feat(azure): server-side copy with download+reupload fallback

* feat(azure): implement server-side copy with fallback

Add server-side object copy for the Azure backend using StartCopyFromURL, with a
fallback to download+reupload when server-side copy is unavailable. The copy
logic lives in backend/azure/copy.go and handles metadata, tagging and object
lock configurations.

The copy-source SAS service version is configurable via the --copy-sas-version
flag (AZ_COPY_SAS_VERSION) and defaults to the SDK version, so production is
unchanged. Endpoints that lag the SDK's SAS version (e.g. Azurite) cannot verify
a SAS signed with the newer version and can set an older one. On a metadata-COPY,
the internal website-redirect key is dropped from the destination to match the
download+reupload fallback.

Testing:
- Add CopyObject_cross_bucket_server_side_copy, which copies an object with data,
  user metadata, content-type and tags across two buckets and verifies all are
  preserved and an ETag is returned.
- Configure the Azurite functional-test gateway with AZ_COPY_SAS_VERSION and let
  Azurite trust its self-signed test certificate (NODE_EXTRA_CA_CERTS) so it can
  fetch the copy source from its own HTTPS endpoint, ensuring CI exercises the
  real server-side copy path instead of always falling back.

Signed-off-by: Nils Leger <nils.leger@getflip.com>

* docs: update copyright year

Signed-off-by: Nils Leger <nils.leger@getflip.com>

* fix: always fallback to download+upload whenever there is an error building the server-side copy URL

Signed-off-by: Nils Leger <nils.leger@getflip.com>

---------

Signed-off-by: Nils Leger <nils.leger@getflip.com>
This commit is contained in:
Nils Leger
2026-08-27 16:37:28 -07:00
committed by GitHub
parent 29bf076abc
commit a780f3473a
7 changed files with 674 additions and 4 deletions
+37 -2
View File
@@ -47,6 +47,7 @@ import (
"github.com/google/uuid"
"github.com/versity/versitygw/auth"
"github.com/versity/versitygw/backend"
"github.com/versity/versitygw/debuglogger"
"github.com/versity/versitygw/s3err"
"github.com/versity/versitygw/s3response"
)
@@ -110,11 +111,16 @@ type Azure struct {
serviceURL string
sasToken string
copyObjectThreshold int64
// copySASVersion overrides the service version used to sign the copy-source
// SAS for server-side copy. Empty means use the SDK default. This exists so
// endpoints that lag the SDK's SAS version (e.g. Azurite) can verify the
// signature; production leaves it unset.
copySASVersion string
}
var _ backend.Backend = &Azure{}
func New(accountName, accountKey, serviceURL, sasToken string, copyObjectThreshold int64) (*Azure, error) {
func New(accountName, accountKey, serviceURL, sasToken string, copyObjectThreshold int64, copySASVersion string) (*Azure, error) {
url := serviceURL
if serviceURL == "" && accountName != "" {
// if not otherwise specified, use the typical form:
@@ -132,6 +138,7 @@ func New(accountName, accountKey, serviceURL, sasToken string, copyObjectThresho
serviceURL: serviceURL,
sasToken: sasToken,
copyObjectThreshold: copyObjectThreshold,
copySASVersion: copySASVersion,
}, nil
}
@@ -154,6 +161,7 @@ func New(accountName, accountKey, serviceURL, sasToken string, copyObjectThresho
serviceURL: url,
defaultCreds: cred,
copyObjectThreshold: copyObjectThreshold,
copySASVersion: copySASVersion,
}, nil
}
@@ -172,6 +180,7 @@ func New(accountName, accountKey, serviceURL, sasToken string, copyObjectThresho
serviceURL: url,
sharedkeyCreds: cred,
copyObjectThreshold: copyObjectThreshold,
copySASVersion: copySASVersion,
}, nil
}
@@ -1252,7 +1261,33 @@ func (az *Azure) CopyObject(ctx context.Context, input s3response.CopyObjectInpu
}, nil
}
// Get the source object
// Cross-object copy: try server-side copy first, fall back to download+reupload.
srcClient, err := az.getBlobClient(srcBucket, srcObj)
if err != nil {
return s3response.CopyObjectOutput{}, err
}
srcProps, err := srcClient.GetProperties(ctx, nil)
if err != nil {
return s3response.CopyObjectOutput{}, azureErrToS3Err(err)
}
if srcProps.ContentLength != nil && *srcProps.ContentLength > az.copyObjectThreshold {
return s3response.CopyObjectOutput{}, s3err.GetCopySourceObjectTooLargeErr(az.copyObjectThreshold)
}
out, err := az.serverSideCopyObject(ctx, input, srcBucket, srcObj, dstClient, srcClient, &srcProps)
if err == nil {
return out, nil
}
if !errors.Is(err, errServerSideCopyFallback) {
return s3response.CopyObjectOutput{}, err
}
debuglogger.Logf("falling back to download+reupload (%q/%q -> %q/%q): %v",
srcBucket, srcObj, *input.Bucket, *input.Key, err)
// Fallback: download and re-upload through the gateway.
downloadResp, err := az.client.DownloadStream(ctx, srcBucket, srcObj, nil)
if err != nil {
return s3response.CopyObjectOutput{}, azureErrToS3Err(err)
+304
View File
@@ -0,0 +1,304 @@
// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package azure
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blob"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/sas"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/service"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/versity/versitygw/backend"
"github.com/versity/versitygw/s3err"
"github.com/versity/versitygw/s3response"
)
const copyPollInterval = 500 * time.Millisecond
// errServerSideCopyFallback signals that server-side copy could not be started
// and the caller should fall back to download+reupload. Failures before the
// copy is started are wrapped in this sentinel; once StartCopyFromURL has
// succeeded the destination blob has been written, so later failures are
// returned as-is rather than silently re-copying through the gateway.
var errServerSideCopyFallback = errors.New("server-side copy unavailable")
func (az *Azure) copySourceURL(ctx context.Context, srcBucket, srcObj string) (string, error) {
if az.sharedkeyCreds != nil {
now := time.Now().UTC()
sasQueryParams, err := sas.BlobSignatureValues{
Protocol: sas.ProtocolHTTPS,
Version: az.copySASVersion,
StartTime: now.Add(-10 * time.Second),
ExpiryTime: now.Add(15 * time.Minute),
Permissions: (&sas.BlobPermissions{Read: true}).String(),
ContainerName: srcBucket,
BlobName: srcObj,
}.SignWithSharedKey(az.sharedkeyCreds)
if err != nil {
return "", err
}
return az.getBlobURL(srcBucket, srcObj) + "?" + sasQueryParams.Encode(), nil
}
if az.sasToken != "" {
return az.getBlobURL(srcBucket, srcObj) + "?" + az.sasToken, nil
}
if az.defaultCreds != nil {
svcClient, err := service.NewClient(az.serviceURL, az.defaultCreds, nil)
if err != nil {
return "", fmt.Errorf("init service client: %w", err)
}
now := time.Now().UTC()
info := service.KeyInfo{
Start: backend.GetPtrFromString(now.Add(-10 * time.Second).Format(sas.TimeFormat)),
Expiry: backend.GetPtrFromString(now.Add(48 * time.Hour).Format(sas.TimeFormat)),
}
udc, err := svcClient.GetUserDelegationCredential(ctx, info, nil)
if err != nil {
return "", fmt.Errorf("get user delegation credential: %w", err)
}
perms := &sas.BlobPermissions{Read: true}
sasQueryParams, err := sas.BlobSignatureValues{
Protocol: sas.ProtocolHTTPS,
Version: az.copySASVersion,
StartTime: now.Add(-10 * time.Second),
ExpiryTime: now.Add(15 * time.Minute),
Permissions: perms.String(),
ContainerName: srcBucket,
BlobName: srcObj,
}.SignWithUserDelegation(udc)
if err != nil {
return "", fmt.Errorf("sign user delegation sas: %w", err)
}
return az.getBlobURL(srcBucket, srcObj) + "?" + sasQueryParams.Encode(), nil
}
return "", errors.New("no credentials available")
}
func (az *Azure) serverSideCopyObject(
ctx context.Context,
input s3response.CopyObjectInput,
srcBucket, srcObj string,
dstClient, srcClient *blob.Client,
srcProps *blob.GetPropertiesResponse,
) (s3response.CopyObjectOutput, error) {
srcURL, err := az.copySourceURL(ctx, srcBucket, srcObj)
if err != nil {
// Any failure to build a signed source URL means server-side copy can't
// be started at all, so fall back instead of failing the request. This
// notably covers GetUserDelegationCredential, which fails if the gateway
// identity lacks the Storage Blob Delegator role or the endpoint doesn't
// implement the delegation key API.
return s3response.CopyObjectOutput{}, fmt.Errorf("%w: %v", errServerSideCopyFallback, err)
}
opts := &blob.StartCopyFromURLOptions{}
// Copy Blob reads absent x-ms-meta-* headers as "inherit the source
// metadata", so an empty opts.Metadata cannot express "no metadata". When
// filtering empties the set, the destination metadata has to be cleared
// after the copy instead.
clearMetadata := false
if input.MetadataDirective == types.MetadataDirectiveReplace {
meta := input.Metadata
if meta == nil {
meta = make(map[string]string)
}
if getString(input.Expires) != "" {
meta[string(keyExpires)] = *input.Expires
}
if getString(input.WebsiteRedirectLocation) != "" {
meta[string(keyWebsiteRedirect)] = *input.WebsiteRedirectLocation
}
opts.Metadata = parseMetadata(meta)
} else {
// MetadataDirective COPY: StartCopyFromURL would otherwise copy the
// source blob's metadata verbatim, including the internal website-redirect
// key. Set the metadata explicitly so that key is dropped from the
// destination, matching the download+reupload fallback.
if srcProps == nil {
return s3response.CopyObjectOutput{}, fmt.Errorf(
"%w: source properties required to filter metadata", errServerSideCopyFallback)
}
if meta := parseAzMetadata(srcProps.Metadata); meta != nil {
delete(meta, string(keyWebsiteRedirect))
opts.Metadata = parseMetadata(meta)
clearMetadata = len(meta) == 0
}
}
if input.TaggingDirective == types.TaggingDirectiveReplace {
tags, err := backend.ParseObjectTags(getString(input.Tagging))
if err != nil {
return s3response.CopyObjectOutput{}, err
}
opts.BlobTags = tags
}
startResp, err := dstClient.StartCopyFromURL(ctx, srcURL, opts)
if err != nil {
return s3response.CopyObjectOutput{}, fmt.Errorf("%w: %v", errServerSideCopyFallback, err)
}
finalProps, err := az.waitForCopy(ctx, dstClient, startResp.CopyStatus)
if err != nil {
return s3response.CopyObjectOutput{}, err
}
if clearMetadata {
res, err := dstClient.SetMetadata(ctx, nil, nil)
if err != nil {
return s3response.CopyObjectOutput{}, azureErrToS3Err(err)
}
if res.LastModified != nil {
finalProps.LastModified = res.LastModified
}
if res.ETag != nil {
finalProps.ETag = res.ETag
}
}
if input.MetadataDirective == types.MetadataDirectiveReplace {
res, err := dstClient.SetHTTPHeaders(ctx, blob.HTTPHeaders{
BlobCacheControl: input.CacheControl,
BlobContentDisposition: input.ContentDisposition,
BlobContentEncoding: input.ContentEncoding,
BlobContentLanguage: input.ContentLanguage,
BlobContentType: input.ContentType,
}, nil)
if err != nil {
return s3response.CopyObjectOutput{}, azureErrToS3Err(err)
}
if res.LastModified != nil {
finalProps.LastModified = res.LastModified
}
if res.ETag != nil {
finalProps.ETag = res.ETag
}
}
if input.TaggingDirective == types.TaggingDirectiveCopy {
res, err := srcClient.GetTags(ctx, nil)
if err != nil {
return s3response.CopyObjectOutput{}, azureErrToS3Err(err)
}
_, err = dstClient.SetTags(ctx, parseAzTags(res.BlobTagSet), nil)
if err != nil {
return s3response.CopyObjectOutput{}, azureErrToS3Err(err)
}
}
if err := az.applyCopyObjectLock(ctx, *input.Bucket, *input.Key, input); err != nil {
return s3response.CopyObjectOutput{}, err
}
var etag string
if finalProps.ETag != nil {
etag = convertAzureEtag(finalProps.ETag)
} else if startResp.ETag != nil {
etag = convertAzureEtag(startResp.ETag)
}
lastModified := finalProps.LastModified
if lastModified == nil {
lastModified = startResp.LastModified
}
return s3response.CopyObjectOutput{
CopyObjectResult: &s3response.CopyObjectResult{
LastModified: lastModified,
ETag: backend.GetPtrFromString(etag),
},
}, nil
}
func (az *Azure) waitForCopy(ctx context.Context, dstClient *blob.Client, initialStatus *blob.CopyStatusType) (*blob.GetPropertiesResponse, error) {
status := initialStatus
for status != nil && *status == blob.CopyStatusTypePending {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(copyPollInterval):
}
props, err := dstClient.GetProperties(ctx, nil)
if err != nil {
return nil, azureErrToS3Err(err)
}
status = props.CopyStatus
}
props, err := dstClient.GetProperties(ctx, nil)
if err != nil {
return nil, azureErrToS3Err(err)
}
if props.CopyStatus != nil {
switch *props.CopyStatus {
case blob.CopyStatusTypeFailed, blob.CopyStatusTypeAborted:
return nil, fmt.Errorf("blob copy failed with status %s", *props.CopyStatus)
}
}
return &props, nil
}
func (az *Azure) applyCopyObjectLock(ctx context.Context, bucket, key string, input s3response.CopyObjectInput) error {
if input.ObjectLockLegalHoldStatus != "" {
err := az.PutObjectLegalHold(ctx, bucket, key, "", input.ObjectLockLegalHoldStatus == types.ObjectLockLegalHoldStatusOn)
if err != nil {
if errors.Is(err, s3err.GetAPIError(s3err.ErrMissingObjectLockConfiguration)) {
err = s3err.GetAPIError(s3err.ErrMissingObjectLockConfigurationNoSpaces)
}
return azureErrToS3Err(err)
}
}
if input.ObjectLockMode != "" && input.ObjectLockRetainUntilDate != nil {
retention := s3response.PutObjectRetentionInput{
Mode: types.ObjectLockRetentionMode(input.ObjectLockMode),
RetainUntilDate: s3response.AmzDate{
Time: *input.ObjectLockRetainUntilDate,
},
}
retParsed, err := json.Marshal(retention)
if err != nil {
return fmt.Errorf("parse object retention: %w", err)
}
err = az.PutObjectRetention(ctx, bucket, key, "", retParsed)
if err != nil {
if errors.Is(err, s3err.GetAPIError(s3err.ErrMissingObjectLockConfiguration)) {
err = s3err.GetAPIError(s3err.ErrMissingObjectLockConfigurationNoSpaces)
}
return azureErrToS3Err(err)
}
}
return nil
}
+162
View File
@@ -0,0 +1,162 @@
// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package azure
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"net/url"
"strings"
"testing"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob"
"github.com/versity/versitygw/s3response"
)
const testServiceURL = "https://devstoreaccount1.blob.core.windows.net/devstoreaccount1"
func testSharedKeyAzure(t *testing.T) *Azure {
t.Helper()
// Any valid base64 key will do: the SAS is signed and inspected locally and
// never sent anywhere, so there is no need for a real account key.
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
t.Fatalf("generate account key: %v", err)
}
cred, err := azblob.NewSharedKeyCredential("devstoreaccount1",
base64.StdEncoding.EncodeToString(raw))
if err != nil {
t.Fatalf("NewSharedKeyCredential: %v", err)
}
return &Azure{
serviceURL: testServiceURL,
sharedkeyCreds: cred,
}
}
// copySourceURL returns plain errors: the fallback classification is applied
// once, by its caller. Keeping it in a single place is what puts every failure
// to build a source URL on the fallback path, including the ones that only
// surface in production (GetUserDelegationCredential when the gateway identity
// lacks the Storage Blob Delegator role, or against an endpoint with no
// delegation key API).
func TestCopySourceURLErrorsAreNotPreClassified(t *testing.T) {
az := &Azure{serviceURL: testServiceURL}
_, err := az.copySourceURL(context.Background(), "src-bucket", "src-object")
if err == nil {
t.Fatal("expected an error when no credentials are configured")
}
if errors.Is(err, errServerSideCopyFallback) {
t.Fatal("copySourceURL must not classify its own errors; its caller does")
}
}
// The other half of that contract: an unsignable copy source has to reach the
// caller classified as "server-side copy unavailable", so that CopyObject falls
// back to download+reupload instead of failing the request.
func TestServerSideCopyObjectNoCredentialsFallsBack(t *testing.T) {
az := &Azure{serviceURL: testServiceURL}
_, err := az.serverSideCopyObject(context.Background(),
s3response.CopyObjectInput{}, "src-bucket", "src-object", nil, nil, nil)
if err == nil {
t.Fatal("expected an error when no credentials are configured")
}
if !errors.Is(err, errServerSideCopyFallback) {
t.Fatalf("error must be classified as a fallback, got %v", err)
}
}
// The MetadataDirective COPY path reads the source properties to filter the
// internal website-redirect key out of the destination metadata, so a caller
// that omits them must be turned away rather than panicking. Falling back keeps
// the copy correct, and skipping the filter instead would silently reintroduce
// the leaked redirect.
func TestServerSideCopyObjectNilSourcePropsFallsBack(t *testing.T) {
az := testSharedKeyAzure(t)
_, err := az.serverSideCopyObject(context.Background(),
s3response.CopyObjectInput{}, "src-bucket", "src-object", nil, nil, nil)
if err == nil {
t.Fatal("expected an error when the source properties are missing")
}
if !errors.Is(err, errServerSideCopyFallback) {
t.Fatalf("error must be classified as a fallback, got %v", err)
}
}
// The copy-source SAS has to be signed, read-only and scoped to the source
// blob. Its service version is pinned by configuration for endpoints that lag
// the SDK, and left at the SDK default otherwise; an override that silently
// stopped being applied would not fail the integration suite, because copies
// would fall back to download+reupload rather than error.
func TestCopySourceURLSharedKeySAS(t *testing.T) {
for _, tc := range []struct {
name string
version string
}{
{name: "sdk default version", version: ""},
{name: "pinned version", version: "2025-11-05"},
} {
t.Run(tc.name, func(t *testing.T) {
az := testSharedKeyAzure(t)
az.copySASVersion = tc.version
got, err := az.copySourceURL(context.Background(), "src-bucket", "src-object")
if err != nil {
t.Fatalf("copySourceURL: %v", err)
}
base, query, ok := strings.Cut(got, "?")
if !ok {
t.Fatalf("expected a query string carrying the SAS, got %v", got)
}
if want := az.getBlobURL("src-bucket", "src-object"); base != want {
t.Fatalf("expected blob URL %v, got %v", want, base)
}
vals, err := url.ParseQuery(query)
if err != nil {
t.Fatalf("ParseQuery: %v", err)
}
if vals.Get("sig") == "" {
t.Error("expected a signature in the SAS")
}
if got := vals.Get("sp"); got != "r" {
t.Errorf("expected read-only permissions, got %q", got)
}
if got := vals.Get("sr"); got != "b" {
t.Errorf("expected a blob-scoped SAS, got %q", got)
}
sv := vals.Get("sv")
if tc.version == "" {
if sv == "" {
t.Error("expected the SDK default service version to be filled in")
}
return
}
if sv != tc.version {
t.Errorf("expected service version %q, got %q", tc.version, sv)
}
})
}
}