mirror of
https://github.com/versity/versitygw.git
synced 2026-09-02 06:06:59 +00:00
* 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>
305 lines
9.7 KiB
Go
305 lines
9.7 KiB
Go
// 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
|
|
}
|