mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-12 01:20:45 +02:00
* s3: give a versioned metadata-only copy its own chunks A self-copy that only rewrites metadata clones the source entry, chunk fids and all, and writes the clone back. With no versioning that is exactly right: the clone replaces the entry it came from, so one entry owns the needles the whole time. Under versioning the clone lands in a new .versions/ file and the source stays live, and nothing refcounts a plain shared chunk list -- deleting either version (a NoncurrentVersionExpiration rule, say) frees needles the other still points at, and the next vacuum makes that permanent. rclone hits this on every upload, since it stamps mtime with exactly this copy. Take the metadata-only path only where the write replaces the entry it read: the bare key of a bucket without versioning. Versioned, suspended, and versionId-pinned copies fall through to the regular copy path, which gives the destination its own chunks. * s3: reencrypt a versioned SSE-KMS key rotation instead of reusing the chunks A same-object copy that changes the KMS key id hands the source chunks straight back, on the assumption that the copy overwrites the entry they came from. A versioned bucket writes a new version beside the source instead, so the two end up sharing needles that nothing refcounts, and deleting either one frees the other's data. Reuse the chunks only when the destination really is the source entry; otherwise fall through to the reencrypt path, which also gives the new version the key it asked for rather than leaving it on the old one. * s3: make one predicate decide whether a copy replaces its source The metadata-only branch and the key-rotation strategy both answer the same question -- does this copy write back to the entry it read -- so let them share one predicate instead of pairing a same-destination check with it separately at each site. * test(s3): fail the copy regression tests when the vacuum does not run The helper swallowed a failed or non-200 request to the master, so a vacuum that never ran turned both chunk-ownership assertions into no-ops: the tombstoned needles were still readable and the surviving version looked fine either way. Require the endpoint, the request, and a 200. * ci(s3): run every versioning test in the regression gate The gate named the tests it wanted, so a new regression test sat there uncovered until someone remembered this file -- it fooled me into thinking two tests added in this PR never ran anywhere, when the comprehensive job had them all along. Invert it: run everything, and name a test only to keep it out. The delete job beside this one already works that way, and the suite costs about two minutes. Only the pagination stress tests are excluded; they build 1500+ versions, skip themselves without ENABLE_STRESS_TESTS, and have their own make target. Go's regexp has no negation, so the pattern is still assembled from a listing, the way the volume-server integration workflow does it. Note the trailing $$: make eats a lone trailing $ and takes the anchor with it.
458 lines
17 KiB
Go
458 lines
17 KiB
Go
package s3api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go-v2/aws"
|
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
|
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
|
"github.com/aws/smithy-go"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func versioningCopySource(bucketName, key string) string {
|
|
return fmt.Sprintf("%s/%s", bucketName, url.PathEscape(key))
|
|
}
|
|
|
|
func suspendVersioning(t *testing.T, client *s3.Client, bucketName string) {
|
|
_, err := client.PutBucketVersioning(context.TODO(), &s3.PutBucketVersioningInput{
|
|
Bucket: aws.String(bucketName),
|
|
VersioningConfiguration: &types.VersioningConfiguration{
|
|
Status: types.BucketVersioningStatusSuspended,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// vacuumVolumes asks the master to compact away the needles a delete tombstoned.
|
|
// Tests that assert a surviving object still has its data need this: deleting an
|
|
// entry only tombstones the needles it points at, so a shared chunk list reads
|
|
// fine right up until the vacuum makes the loss permanent. A vacuum that does not
|
|
// run leaves those tests asserting nothing, so treat every failure as fatal.
|
|
func vacuumVolumes(t *testing.T) {
|
|
t.Helper()
|
|
require.NotEmpty(t, defaultConfig.MasterEndpoint, "vacuum needs a master endpoint; set MASTER_ENDPOINT")
|
|
endpoint := strings.TrimRight(defaultConfig.MasterEndpoint, "/") + "/vol/vacuum?garbageThreshold=0.001"
|
|
httpClient := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := httpClient.Get(endpoint)
|
|
require.NoError(t, err, "vacuum request to %s", endpoint)
|
|
defer resp.Body.Close()
|
|
_, err = io.Copy(io.Discard, resp.Body)
|
|
require.NoError(t, err, "reading the vacuum response")
|
|
require.Equal(t, http.StatusOK, resp.StatusCode, "vacuum request to %s", endpoint)
|
|
}
|
|
|
|
func requireVersionBody(t *testing.T, client *s3.Client, bucketName, objectKey, versionId string, want []byte, msg string) {
|
|
t.Helper()
|
|
getResp, err := client.GetObject(context.TODO(), &s3.GetObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(objectKey),
|
|
VersionId: aws.String(versionId),
|
|
})
|
|
require.NoError(t, err, msg)
|
|
defer getResp.Body.Close()
|
|
body, err := io.ReadAll(getResp.Body)
|
|
require.NoError(t, err, msg)
|
|
require.Equal(t, len(want), len(body), msg)
|
|
require.True(t, bytes.Equal(want, body), msg)
|
|
}
|
|
|
|
// chunkedTestContent returns a body large enough to land in volume needles rather
|
|
// than inline in the filer entry, so a copy that reuses the source fids is visible
|
|
// once those needles are freed.
|
|
func chunkedTestContent(size int) []byte {
|
|
content := make([]byte, size)
|
|
for i := range content {
|
|
content[i] = byte(i * 31 % 251)
|
|
}
|
|
return content
|
|
}
|
|
|
|
// TestVersioningSelfCopyMetadataReplaceKeepsChunksIndependent covers the copy that
|
|
// only rewrites metadata: it used to hand the source version's chunk fids to the
|
|
// new version, so nothing owned those needles and deleting either version freed
|
|
// the survivor's data (silently, once a vacuum ran).
|
|
func TestVersioningSelfCopyMetadataReplaceKeepsChunksIndependent(t *testing.T) {
|
|
client := getS3Client(t)
|
|
bucketName := getNewBucketName()
|
|
|
|
createBucket(t, client, bucketName)
|
|
defer deleteBucket(t, client, bucketName)
|
|
|
|
enableVersioning(t, client, bucketName)
|
|
|
|
objectKey := "self-copy-chunk-ownership.bin"
|
|
content := chunkedTestContent(6 << 20)
|
|
|
|
putResp, err := client.PutObject(context.TODO(), &s3.PutObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(objectKey),
|
|
Body: bytes.NewReader(content),
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, putResp.VersionId)
|
|
|
|
copyResp, err := client.CopyObject(context.TODO(), &s3.CopyObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(objectKey),
|
|
CopySource: aws.String(versioningCopySource(bucketName, objectKey)),
|
|
Metadata: map[string]string{"mtime": "1653465360"},
|
|
MetadataDirective: types.MetadataDirectiveReplace,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, copyResp.VersionId)
|
|
require.NotEqual(t, *putResp.VersionId, *copyResp.VersionId)
|
|
|
|
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(objectKey),
|
|
VersionId: putResp.VersionId,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// The filer frees a deleted entry's chunks asynchronously, so re-check across
|
|
// a few vacuum rounds instead of racing a single one.
|
|
for round := 0; round < 4; round++ {
|
|
time.Sleep(time.Second)
|
|
vacuumVolumes(t)
|
|
requireVersionBody(t, client, bucketName, objectKey, *copyResp.VersionId, content,
|
|
"the surviving version must keep its own data after the other version is deleted")
|
|
}
|
|
}
|
|
|
|
// TestSuspendedSelfCopyMetadataReplaceKeepsChunksIndependent is the same defect on
|
|
// a suspended bucket: the null version the copy writes sits beside a .versions/
|
|
// entry that stays live, so the two must not share needles either.
|
|
func TestSuspendedSelfCopyMetadataReplaceKeepsChunksIndependent(t *testing.T) {
|
|
client := getS3Client(t)
|
|
bucketName := getNewBucketName()
|
|
|
|
createBucket(t, client, bucketName)
|
|
defer deleteBucket(t, client, bucketName)
|
|
|
|
enableVersioning(t, client, bucketName)
|
|
|
|
objectKey := "suspended-self-copy-chunk-ownership.bin"
|
|
content := chunkedTestContent(6 << 20)
|
|
|
|
putResp, err := client.PutObject(context.TODO(), &s3.PutObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(objectKey),
|
|
Body: bytes.NewReader(content),
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, putResp.VersionId)
|
|
|
|
suspendVersioning(t, client, bucketName)
|
|
|
|
_, err = client.CopyObject(context.TODO(), &s3.CopyObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(objectKey),
|
|
CopySource: aws.String(versioningCopySource(bucketName, objectKey)),
|
|
Metadata: map[string]string{"mtime": "1653465360"},
|
|
MetadataDirective: types.MetadataDirectiveReplace,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Drop the version the copy read from; the null version it wrote must survive.
|
|
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(objectKey),
|
|
VersionId: putResp.VersionId,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
for round := 0; round < 4; round++ {
|
|
time.Sleep(time.Second)
|
|
vacuumVolumes(t)
|
|
requireVersionBody(t, client, bucketName, objectKey, "null", content,
|
|
"the null version must keep its own data after the version it was copied from is deleted")
|
|
}
|
|
}
|
|
|
|
func TestVersioningSelfCopyMetadataReplaceCreatesNewVersion(t *testing.T) {
|
|
client := getS3Client(t)
|
|
bucketName := getNewBucketName()
|
|
|
|
createBucket(t, client, bucketName)
|
|
defer deleteBucket(t, client, bucketName)
|
|
|
|
enableVersioning(t, client, bucketName)
|
|
checkVersioningStatus(t, client, bucketName, types.BucketVersioningStatusEnabled)
|
|
|
|
objectKey := "self-copy-versioned.txt"
|
|
initialContent := []byte("copy me without changing the body")
|
|
|
|
putResp, err := client.PutObject(context.TODO(), &s3.PutObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(objectKey),
|
|
Body: bytes.NewReader(initialContent),
|
|
Metadata: map[string]string{"stage": "one"},
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, putResp.VersionId)
|
|
|
|
copyResp, err := client.CopyObject(context.TODO(), &s3.CopyObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(objectKey),
|
|
CopySource: aws.String(versioningCopySource(bucketName, objectKey)),
|
|
Metadata: map[string]string{"stage": "two"},
|
|
MetadataDirective: types.MetadataDirectiveReplace,
|
|
})
|
|
require.NoError(t, err, "Self-copy with metadata replacement should succeed")
|
|
require.NotNil(t, copyResp.VersionId, "Versioned self-copy should create a new version")
|
|
require.NotEqual(t, *putResp.VersionId, *copyResp.VersionId, "Self-copy should create a distinct version")
|
|
|
|
headLatestResp, err := client.HeadObject(context.TODO(), &s3.HeadObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(objectKey),
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "two", headLatestResp.Metadata["stage"], "Latest version should expose replaced metadata")
|
|
|
|
headOriginalResp, err := client.HeadObject(context.TODO(), &s3.HeadObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(objectKey),
|
|
VersionId: putResp.VersionId,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "one", headOriginalResp.Metadata["stage"], "Previous version metadata should remain intact")
|
|
|
|
getResp, err := client.GetObject(context.TODO(), &s3.GetObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(objectKey),
|
|
})
|
|
require.NoError(t, err)
|
|
defer getResp.Body.Close()
|
|
body, err := io.ReadAll(getResp.Body)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, initialContent, body, "Self-copy should not alter the object body")
|
|
|
|
versionsResp, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{
|
|
Bucket: aws.String(bucketName),
|
|
Prefix: aws.String(objectKey),
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, versionsResp.Versions, 2, "Self-copy should append a new current version")
|
|
assert.Equal(t, *copyResp.VersionId, *versionsResp.Versions[0].VersionId, "New copy version should be latest")
|
|
}
|
|
|
|
func TestVersioningSelfCopyMetadataReplaceSuspendedKeepsNullVersion(t *testing.T) {
|
|
client := getS3Client(t)
|
|
bucketName := getNewBucketName()
|
|
|
|
createBucket(t, client, bucketName)
|
|
defer deleteBucket(t, client, bucketName)
|
|
|
|
enableVersioning(t, client, bucketName)
|
|
suspendVersioning(t, client, bucketName)
|
|
checkVersioningStatus(t, client, bucketName, types.BucketVersioningStatusSuspended)
|
|
|
|
objectKey := "self-copy-suspended.txt"
|
|
initialContent := []byte("null version content")
|
|
|
|
_, err := client.PutObject(context.TODO(), &s3.PutObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(objectKey),
|
|
Body: bytes.NewReader(initialContent),
|
|
Metadata: map[string]string{"stage": "one"},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
copyResp, err := client.CopyObject(context.TODO(), &s3.CopyObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(objectKey),
|
|
CopySource: aws.String(versioningCopySource(bucketName, objectKey)),
|
|
Metadata: map[string]string{"stage": "two"},
|
|
MetadataDirective: types.MetadataDirectiveReplace,
|
|
})
|
|
require.NoError(t, err, "Suspended self-copy with metadata replacement should succeed")
|
|
assert.Nil(t, copyResp.VersionId, "Suspended versioning should not return a version header for the current null version")
|
|
|
|
headResp, err := client.HeadObject(context.TODO(), &s3.HeadObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(objectKey),
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "two", headResp.Metadata["stage"], "Null current version should be updated in place")
|
|
|
|
versionsResp, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{
|
|
Bucket: aws.String(bucketName),
|
|
Prefix: aws.String(objectKey),
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, versionsResp.Versions, 1, "Suspended self-copy should keep a single null current version")
|
|
require.NotNil(t, versionsResp.Versions[0].VersionId)
|
|
assert.Equal(t, "null", *versionsResp.Versions[0].VersionId, "Suspended self-copy should preserve null-version semantics")
|
|
assert.True(t, *versionsResp.Versions[0].IsLatest, "Null version should remain latest")
|
|
}
|
|
|
|
func TestVersioningSelfCopyCreatesNewVersion(t *testing.T) {
|
|
client := getS3Client(t)
|
|
bucketName := getNewBucketName()
|
|
|
|
createBucket(t, client, bucketName)
|
|
defer deleteBucket(t, client, bucketName)
|
|
|
|
enableVersioning(t, client, bucketName)
|
|
|
|
objectKey := "self-copy-no-directive.txt"
|
|
firstContent := []byte("first")
|
|
secondContent := []byte("second")
|
|
|
|
firstPut, err := client.PutObject(context.TODO(), &s3.PutObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(objectKey),
|
|
Body: bytes.NewReader(firstContent),
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, firstPut.VersionId)
|
|
|
|
_, err = client.PutObject(context.TODO(), &s3.PutObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(objectKey),
|
|
Body: bytes.NewReader(secondContent),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Copying an earlier version onto its own key is how AWS restores that version.
|
|
copyResp, err := client.CopyObject(context.TODO(), &s3.CopyObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(objectKey),
|
|
CopySource: aws.String(fmt.Sprintf("%s?versionId=%s", versioningCopySource(bucketName, objectKey), *firstPut.VersionId)),
|
|
})
|
|
require.NoError(t, err, "Self-copy of an earlier version should succeed on a versioned bucket")
|
|
require.NotNil(t, copyResp.VersionId)
|
|
assert.NotEqual(t, *firstPut.VersionId, *copyResp.VersionId, "Restore should write a new version")
|
|
|
|
getResp, err := client.GetObject(context.TODO(), &s3.GetObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(objectKey),
|
|
})
|
|
require.NoError(t, err)
|
|
defer getResp.Body.Close()
|
|
body, err := io.ReadAll(getResp.Body)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, firstContent, body, "Restored version should serve the earlier body")
|
|
|
|
versionsResp, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{
|
|
Bucket: aws.String(bucketName),
|
|
Prefix: aws.String(objectKey),
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Len(t, versionsResp.Versions, 3, "Restore should append to the version history")
|
|
}
|
|
|
|
func TestSelfCopyWithoutVersioningIsRejected(t *testing.T) {
|
|
client := getS3Client(t)
|
|
bucketName := getNewBucketName()
|
|
|
|
createBucket(t, client, bucketName)
|
|
defer deleteBucket(t, client, bucketName)
|
|
|
|
objectKey := "self-copy-unversioned.txt"
|
|
putObject(t, client, bucketName, objectKey, "content")
|
|
|
|
_, err := client.CopyObject(context.TODO(), &s3.CopyObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(objectKey),
|
|
CopySource: aws.String(versioningCopySource(bucketName, objectKey)),
|
|
})
|
|
require.Error(t, err, "Self-copy without a metadata change is a no-op on an unversioned bucket")
|
|
var apiErr smithy.APIError
|
|
if assert.True(t, errors.As(err, &apiErr), "Expected a smithy.APIError, but got %T", err) {
|
|
assert.Equal(t, "InvalidRequest", apiErr.ErrorCode())
|
|
}
|
|
}
|
|
|
|
func TestSelfCopyWithSuspendedVersioningIsRejected(t *testing.T) {
|
|
client := getS3Client(t)
|
|
bucketName := getNewBucketName()
|
|
|
|
createBucket(t, client, bucketName)
|
|
defer deleteBucket(t, client, bucketName)
|
|
|
|
enableVersioning(t, client, bucketName)
|
|
suspendVersioning(t, client, bucketName)
|
|
|
|
objectKey := "self-copy-suspended-no-directive.txt"
|
|
putObject(t, client, bucketName, objectKey, "content")
|
|
|
|
_, err := client.CopyObject(context.TODO(), &s3.CopyObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(objectKey),
|
|
CopySource: aws.String(versioningCopySource(bucketName, objectKey)),
|
|
})
|
|
require.Error(t, err, "Suspended versioning overwrites the null version in place, so the copy changes nothing")
|
|
var apiErr smithy.APIError
|
|
if assert.True(t, errors.As(err, &apiErr), "Expected a smithy.APIError, but got %T", err) {
|
|
assert.Equal(t, "InvalidRequest", apiErr.ErrorCode())
|
|
}
|
|
}
|
|
|
|
// A suspended-versioning CopyObject writes the null version at the regular path, so
|
|
// like PutObject and multipart completion it has to retire the null delete marker a
|
|
// preceding DELETE left in .versions. While the regular-path object owns the null
|
|
// slot the leftover marker is shadowed, but it resurfaces as a phantom delete the
|
|
// moment that null version goes away.
|
|
func TestSuspendedCopyRetiresDeleteMarker(t *testing.T) {
|
|
client := getS3Client(t)
|
|
bucketName := getNewBucketName()
|
|
|
|
createBucket(t, client, bucketName)
|
|
defer deleteBucket(t, client, bucketName)
|
|
|
|
sourceKey := "suspended-copy-source.txt"
|
|
objectKey := "suspended-copy-dest.txt"
|
|
|
|
enableVersioning(t, client, bucketName)
|
|
putObject(t, client, bucketName, objectKey, "pre-suspension-content")
|
|
suspendVersioning(t, client, bucketName)
|
|
|
|
putObject(t, client, bucketName, sourceKey, "source-content")
|
|
putObject(t, client, bucketName, objectKey, "null-version-content")
|
|
deleteKey(t, client, bucketName, objectKey)
|
|
|
|
_, err := client.CopyObject(context.TODO(), &s3.CopyObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(objectKey),
|
|
CopySource: aws.String(versioningCopySource(bucketName, sourceKey)),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
getResp, err := client.GetObject(context.TODO(), &s3.GetObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(objectKey),
|
|
})
|
|
require.NoError(t, err)
|
|
defer getResp.Body.Close()
|
|
body, err := io.ReadAll(getResp.Body)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "source-content", string(body))
|
|
|
|
// Drop the null version the copy just wrote; a retired marker leaves nothing behind.
|
|
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(objectKey),
|
|
VersionId: aws.String("null"),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
listResp, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{
|
|
Bucket: aws.String(bucketName),
|
|
Prefix: aws.String(objectKey),
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Empty(t, listResp.DeleteMarkers, "the copy should have retired the null delete marker")
|
|
}
|