fix: follow ListObjectsV2 continuation tokens on every production caller (#958)

* fix: follow ListObjectsV2 continuation tokens on every production caller

Six ListObjectsV2 call sites (DeleteSnapshot, DescribeSnapshots, EKS
etcd-snapshot selection, DescribeTags, and both key-pair listings) issued a
single request and never read IsTruncated or NextContinuationToken. This was
latent while predastore ignored max-keys and always answered IsTruncated:
false; predastore#117 pages listings at 1000 keys, turning a truncated
listing beyond that into a silently wrong answer rather than a short one.
DeleteSnapshot in particular could delete 1000 objects, report success, and
orphan the rest, and the EKS restore path could resolve to a stale snapshot
if the newest keys fell past the first page.

Add objectstore.ListAll, a shared helper that drains a listing to
exhaustion, accumulating Contents and CommonPrefixes across pages and
erroring instead of returning a partial result if the server claims
truncation with no token to resume from or never finishes within a bounded
page budget. All six call sites, plus the ebsfake test double with the same
shape, now use it. Bump the predastore pin to dev head ff6a1dc4 so a real
cluster picks up the server-side pagination fix this depends on.

* fix: use external test packages and exclude test fakes from diff coverage

test-package-check requires new test files to bind to the exported API so
internals can change without rewriting tests. Move list_test.go and
delete_snapshot_pagination_test.go to their _test packages, adding a small
export_test.go for the one unexported constant a test needs to assert
against. Simplify list_test.go's pagingStore to embed MemoryObjectStore
instead of hand-stubbing unused methods.

Exclude spinifex/testutil from diff-coverage.sh: those files are fakes and
harnesses driven only from other packages' _test.go files, so go test
records them as zero-covered under the package they live in however hard
another package's tests exercise them, which is not a real coverage gap.
This commit is contained in:
Josh Siviour
2026-08-31 21:59:12 +10:00
committed by GitHub
parent d028637920
commit 4c948512d1
14 changed files with 459 additions and 23 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ require (
github.com/miekg/dns v1.1.73
github.com/mulgadc/bluebottle v1.18.1-0.20260827045410-e7d5cf022748
github.com/mulgadc/northstar v1.18.0
github.com/mulgadc/predastore v1.18.1-0.20260828045402-1dba12d04866
github.com/mulgadc/predastore v1.18.1-0.20260831064140-ff6a1dc42441
github.com/mulgadc/viperblock v1.18.0
github.com/nats-io/nats-server/v2 v2.14.5
github.com/nats-io/nats.go v1.53.1
+2 -2
View File
@@ -1331,8 +1331,8 @@ github.com/mulgadc/bluebottle v1.18.1-0.20260827045410-e7d5cf022748 h1:kvoYwPtGX
github.com/mulgadc/bluebottle v1.18.1-0.20260827045410-e7d5cf022748/go.mod h1:mvvV4d2w+kGLXxO1ymptNlxqUlNJKB2oAhk6PkpVLj8=
github.com/mulgadc/northstar v1.18.0 h1:ksRjIKE6qtrw/ZoYaJh8JRBh+xdnuDw8FsmFLFCiXbg=
github.com/mulgadc/northstar v1.18.0/go.mod h1:z6iol77R1vsIQyIBc1JQboUPhSJ6eG4bipzfbZZbLEM=
github.com/mulgadc/predastore v1.18.1-0.20260828045402-1dba12d04866 h1:xR8X8FR2vHxjT9y47zT4/ka40d3uLD+qCzwi4huiAzQ=
github.com/mulgadc/predastore v1.18.1-0.20260828045402-1dba12d04866/go.mod h1:Az85JvrDqSk5pIF0A3Y06GwCmx53tktRSXEUHZIlsZ0=
github.com/mulgadc/predastore v1.18.1-0.20260831064140-ff6a1dc42441 h1:5t+iuKpnRWXkKOmh5lLBoziBaWD+H71edWO/O4ceMp8=
github.com/mulgadc/predastore v1.18.1-0.20260831064140-ff6a1dc42441/go.mod h1:ioGP2kHWYpW/opboAqlqQ5C2Jv+a1/z4txtenw7K7Ws=
github.com/mulgadc/viperblock v1.18.0 h1:u6yu5lPfZ+RSmfzlg1OXI3whlRBnGp8t685xUzzxlns=
github.com/mulgadc/viperblock v1.18.0/go.mod h1:OjqMFZba0TJl9zcW2oKmz2/MrApIxRIFNcafiv5vTkM=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+4 -1
View File
@@ -70,7 +70,10 @@ trap 'rm -rf "$TMPDIR"' EXIT
# -C enables copy detection for the same reason (file split = partial copy).
# cmd/installer/ui is excluded: interactive TUI rendering has no meaningful unit
# test, and the installer is validated by the ISO hardware run instead.
git diff "${BASE_REF}" HEAD --unified=0 -M -C --diff-filter=AM -- '*.go' ':!*_test.go' ':!spinifex/migrate/*' ':!cmd/installer/ui/*' | awk '
# spinifex/testutil is excluded because it is fakes and harnesses used only from
# _test.go files. Coverage is recorded per package under test, so a fake another
# package's tests drive reads as zero-covered however hard it is exercised.
git diff "${BASE_REF}" HEAD --unified=0 -M -C --diff-filter=AM -- '*.go' ':!*_test.go' ':!spinifex/migrate/*' ':!cmd/installer/ui/*' ':!spinifex/testutil/*' | awk '
/^\+\+\+ / {
file = substr($2, 3)
next
+6 -6
View File
@@ -388,7 +388,7 @@ func (s *KeyServiceImpl) findKeyPairIdFromKeyName(ctx context.Context, accountID
prefix := fmt.Sprintf("keys/%s/", accountID)
// List all objects with the keys prefix
result, err := s.store.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
objects, _, err := objectstore.ListAll(ctx, s.store, &s3.ListObjectsV2Input{
Bucket: aws.String(s.bucketName),
Prefix: aws.String(prefix),
})
@@ -398,8 +398,8 @@ func (s *KeyServiceImpl) findKeyPairIdFromKeyName(ctx context.Context, accountID
}
// Check each .json metadata file
for _, obj := range result.Contents {
if obj.Key == nil {
for _, obj := range objects {
if obj == nil || obj.Key == nil {
continue
}
@@ -611,7 +611,7 @@ func (s *KeyServiceImpl) DescribeKeyPairs(ctx context.Context, input *ec2.Descri
prefix := fmt.Sprintf("keys/%s/", accountID)
// List all objects with the keys prefix
result, err := s.store.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
objects, _, err := objectstore.ListAll(ctx, s.store, &s3.ListObjectsV2Input{
Bucket: aws.String(s.bucketName),
Prefix: aws.String(prefix),
})
@@ -631,8 +631,8 @@ func (s *KeyServiceImpl) DescribeKeyPairs(ctx context.Context, input *ec2.Descri
var keyPairs []*ec2.KeyPairInfo
// Check each .json metadata file
for _, obj := range result.Contents {
if obj.Key == nil {
for _, obj := range objects {
if obj == nil || obj.Key == nil {
continue
}
@@ -0,0 +1,83 @@
package handlers_ec2_snapshot_test
import (
"context"
"fmt"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/ebsmetadata"
"github.com/mulgadc/spinifex/spinifex/ebsprovider"
handlers_ec2_snapshot "github.com/mulgadc/spinifex/spinifex/handlers/ec2/snapshot"
"github.com/mulgadc/spinifex/spinifex/testutil/pagedstore"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const testAccountID = "111122223333"
// TestDeleteSnapshot_RemovesEveryObjectAcrossMultiplePages proves DeleteSnapshot
// follows ListObjectsV2's continuation token to exhaustion. Before this fix it
// read only the first page: above the page size it deleted a partial set of
// objects, reported success, and left the rest orphaned under the snapshot
// prefix.
func TestDeleteSnapshot_RemovesEveryObjectAcrossMultiplePages(t *testing.T) {
const pageSize = 3
store := pagedstore.New(pageSize)
cfg := &config.Config{
Predastore: config.PredastoreConfig{
Bucket: "test-bucket",
AccessKey: "test-owner-123",
},
}
svc := handlers_ec2_snapshot.NewSnapshotServiceImplWithStore(cfg, store, nil)
provider := ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{})
svc.SetEBSProvider(provider)
_, err := provider.CreateVolume(context.Background(), ebsprovider.CreateVolumeRequest{
Versioned: ebsprovider.NewVersioned(), VolumeID: "vol-1",
CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 10 * 1024 * 1024 * 1024},
AvailabilityZone: "us-east-1a",
})
require.NoError(t, err)
require.NoError(t, ebsmetadata.NewStore(store, "test-bucket").PutVolume(context.Background(), ebsmetadata.Volume{
VolumeID: "vol-1", CapacityGiB: 10, AvailabilityZone: "us-east-1a",
}))
snap, err := svc.CreateSnapshot(context.Background(), &ec2.CreateSnapshotInput{
VolumeId: aws.String("vol-1"),
}, testAccountID)
require.NoError(t, err)
snapshotID := *snap.SnapshotId
// Inflate the snapshot's object prefix well past one page: metadata.json
// (written by CreateSnapshot) plus 10 synthetic block objects.
const extraObjects = 10
for i := range extraObjects {
_, err := store.PutObject(context.Background(), &s3.PutObjectInput{
Bucket: aws.String("test-bucket"),
Key: aws.String(fmt.Sprintf("%s/block-%03d", snapshotID, i)),
Body: strings.NewReader("x"),
})
require.NoError(t, err)
}
store.Calls = 0
_, err = svc.DeleteSnapshot(context.Background(), &ec2.DeleteSnapshotInput{
SnapshotId: snap.SnapshotId,
}, testAccountID)
require.NoError(t, err)
wantPages := (extraObjects + 1 + pageSize - 1) / pageSize
assert.GreaterOrEqual(t, store.Calls, wantPages, "the listing must span more than one page for this test to prove anything")
remaining, err := store.MemoryObjectStore.ListObjectsV2(context.Background(), &s3.ListObjectsV2Input{
Bucket: aws.String("test-bucket"), Prefix: aws.String(snapshotID + "/"),
})
require.NoError(t, err)
assert.Empty(t, remaining.Contents, "every object under the snapshot prefix must be deleted, not just the first page")
}
@@ -508,7 +508,7 @@ func (s *SnapshotServiceImpl) describeSnapshots(ctx context.Context, input *ec2.
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
listResult, err := s.store.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
_, commonPrefixes, err := objectstore.ListAll(ctx, s.store, &s3.ListObjectsV2Input{
Bucket: aws.String(s.config.Predastore.Bucket),
Prefix: aws.String("snap-"),
Delimiter: aws.String("/"),
@@ -526,8 +526,8 @@ func (s *SnapshotServiceImpl) describeSnapshots(ctx context.Context, input *ec2.
}
var wanted []string
for _, prefix := range listResult.CommonPrefixes {
if prefix.Prefix == nil {
for _, prefix := range commonPrefixes {
if prefix == nil || prefix.Prefix == nil {
continue
}
@@ -734,7 +734,7 @@ func (s *SnapshotServiceImpl) DeleteSnapshot(ctx context.Context, input *ec2.Del
return nil, errors.New(awserrors.ErrorServerInternal)
}
listResult, err := s.store.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
objects, _, err := objectstore.ListAll(ctx, s.store, &s3.ListObjectsV2Input{
Bucket: aws.String(s.config.Predastore.Bucket),
Prefix: aws.String(snapshotID + "/"),
})
@@ -743,8 +743,8 @@ func (s *SnapshotServiceImpl) DeleteSnapshot(ctx context.Context, input *ec2.Del
return nil, errors.New(awserrors.ErrorServerInternal)
}
for _, obj := range listResult.Contents {
if obj.Key == nil {
for _, obj := range objects {
if obj == nil || obj.Key == nil {
continue
}
_, err := s.store.DeleteObject(ctx, &s3.DeleteObjectInput{
+3 -3
View File
@@ -230,7 +230,7 @@ func (s *TagsServiceImpl) DescribeTags(ctx context.Context, input *ec2.DescribeT
var tags []*ec2.TagDescription
// List all tag files from S3 scoped to this account
listResult, err := s.store.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
objects, _, err := objectstore.ListAll(ctx, s.store, &s3.ListObjectsV2Input{
Bucket: aws.String(s.config.Predastore.Bucket),
Prefix: aws.String(getTagsPrefix(accountID)),
})
@@ -240,8 +240,8 @@ func (s *TagsServiceImpl) DescribeTags(ctx context.Context, input *ec2.DescribeT
}
// Process each tag file
for _, obj := range listResult.Contents {
if obj.Key == nil {
for _, obj := range objects {
if obj == nil || obj.Key == nil {
continue
}
+5 -2
View File
@@ -82,7 +82,7 @@ func resolveLatestSnapshot(ctx context.Context, store objectstore.ObjectStore, a
return "", errors.New("eks: no snapshot store configured; pass --snapshot explicitly")
}
prefix := accountID + "/" + clusterName + "/"
out, err := store.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
objects, _, err := objectstore.ListAll(ctx, store, &s3.ListObjectsV2Input{
Bucket: aws.String(eksBackupsBucket),
Prefix: aws.String(prefix),
})
@@ -91,7 +91,10 @@ func resolveLatestSnapshot(ctx context.Context, store objectstore.ObjectStore, a
}
var bestFrequent, bestAny etcdSnapshotKey
haveFrequent, haveAny := false, false
for _, obj := range out.Contents {
for _, obj := range objects {
if obj == nil {
continue
}
base := strings.TrimPrefix(aws.StringValue(obj.Key), prefix)
parsed, ok := parseEtcdSnapshotKey(base)
if !ok {
@@ -3,12 +3,14 @@ package handlers_eks
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/mulgadc/spinifex/spinifex/objectstore"
"github.com/mulgadc/spinifex/spinifex/testutil/pagedstore"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -84,6 +86,25 @@ func TestResolveLatestSnapshot_NoSnapshotsFound(t *testing.T) {
require.Error(t, err)
}
// TestResolveLatestSnapshot_MultiPageListingFindsNewest proves the newest
// snapshot is still found when the listing spans more than one page. The
// etcd timestamp format sorts lexicographically, so the newest key sorts
// last and lands on the final page — before this fix, a listing that never
// followed the continuation token would silently pick an older "newest".
func TestResolveLatestSnapshot_MultiPageListingFindsNewest(t *testing.T) {
store := pagedstore.New(2)
const count = 5
for i := 1; i <= count; i++ {
seedSnapshot(t, store, testAccountID, "alpha", fmt.Sprintf("etcd-frequent-202607%02dT000000Z.snap", i))
}
store.Calls = 0
got, err := resolveLatestSnapshot(context.Background(), store, testAccountID, "alpha")
require.NoError(t, err)
assert.Equal(t, "etcd-frequent-20260705T000000Z.snap", got, "the true newest snapshot, on the final page, must win")
assert.Greater(t, store.Calls, 1, "the listing must span more than one page for this test to prove anything")
}
// restoreSnapshotFixtureMeta builds a single-CP ClusterMeta with everything
// RestoreSnapshot needs: a persisted launch template and NLB target-group ARNs.
func restoreSnapshotFixtureMeta(name string) *ClusterMeta {
+8
View File
@@ -0,0 +1,8 @@
//test:in-package — an export_test.go is in-package by definition; that is the
//whole of what it does. The tests using this live in objectstore_test.
package objectstore
// MaxListPages exposes maxListPages for the external test package, which
// needs it to assert ListAll gives up after exactly the documented budget.
const MaxListPages = maxListPages
+58
View File
@@ -0,0 +1,58 @@
package objectstore
import (
"context"
"fmt"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
)
// maxListPages bounds a single listing. It exists only to turn a store that
// keeps claiming truncation into an error rather than a hang; at the default
// page size that is ten million objects, far past any real prefix.
const maxListPages = 10000
// ListAll drains a ListObjectsV2 listing to exhaustion, following
// continuation tokens until the store reports IsTruncated false. It returns
// every object and every common prefix seen across all pages, combined, so a
// delimited listing's CommonPrefixes accumulate the same way Contents does.
//
// input is never mutated; each page is requested from a shallow copy with
// only ContinuationToken replaced.
//
// Reading only the first page returns a prefix of the truth with no error,
// which for a caller deleting or selecting by this listing is worse than a
// failure: a partial deletion or an off-by-a-page "latest" pick both look
// like success without this.
func ListAll(ctx context.Context, store ObjectStore, input *s3.ListObjectsV2Input) ([]*s3.Object, []*s3.CommonPrefix, error) {
var contents []*s3.Object
var commonPrefixes []*s3.CommonPrefix
var token *string
prefix := aws.StringValue(input.Prefix)
for range maxListPages {
page := *input
page.ContinuationToken = token
result, err := store.ListObjectsV2(ctx, &page)
if err != nil {
return nil, nil, err
}
contents = append(contents, result.Contents...)
commonPrefixes = append(commonPrefixes, result.CommonPrefixes...)
if !aws.BoolValue(result.IsTruncated) {
return contents, commonPrefixes, nil
}
// Truncated with nothing to resume from. Returning what arrived is the
// silent short answer this whole loop exists to prevent.
if aws.StringValue(result.NextContinuationToken) == "" {
return nil, nil, fmt.Errorf("listing %s reported truncation with no continuation token", prefix)
}
token = result.NextContinuationToken
}
return nil, nil, fmt.Errorf("listing %s did not finish within %d pages", prefix, maxListPages)
}
+179
View File
@@ -0,0 +1,179 @@
package objectstore_test
import (
"context"
"strconv"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/mulgadc/spinifex/spinifex/objectstore"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// pagingStore replays a fixed sequence of ListObjectsV2 pages, honouring
// continuation tokens the way a real paginating store would: each call
// returns the next page and, while pages remain, a token pointing at it.
//
// This is deliberately separate from testutil/pagedstore, which slices a
// real MemoryObjectStore's filtered, sorted contents into pages of a fixed
// size for consumer-side integration tests. These tests are about ListAll's
// own pagination contract: exact page content (including CommonPrefixes,
// which pagedstore does not paginate at all) and the malformed-truncation
// shapes below, neither of which a real store's paging can produce on
// demand. GetObject/PutObject/etc are unused by ListAll, so they come from
// an embedded MemoryObjectStore rather than being hand-stubbed.
type pagingStore struct {
*objectstore.MemoryObjectStore
pages []*s3.ListObjectsV2Output
calls int
}
func newPagingStore(pages []*s3.ListObjectsV2Output) *pagingStore {
return &pagingStore{MemoryObjectStore: objectstore.NewMemoryObjectStore(), pages: pages}
}
func (p *pagingStore) ListObjectsV2(_ context.Context, input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
wantToken := ""
if p.calls > 0 {
wantToken = tokenFor(p.calls)
}
if aws.StringValue(input.ContinuationToken) != wantToken {
panic("pagingStore: unexpected continuation token")
}
out := p.pages[p.calls]
p.calls++
if p.calls < len(p.pages) {
out.IsTruncated = aws.Bool(true)
out.NextContinuationToken = aws.String(tokenFor(p.calls))
} else {
out.IsTruncated = aws.Bool(false)
out.NextContinuationToken = nil
}
return out, nil
}
func tokenFor(page int) string {
return "page-" + strconv.Itoa(page)
}
var _ objectstore.ObjectStore = (*pagingStore)(nil)
// truncatedNoTokenStore always reports truncation but never supplies a
// continuation token to resume from — the shape a broken or misbehaving
// store could produce.
type truncatedNoTokenStore struct{ *pagingStore }
func (t *truncatedNoTokenStore) ListObjectsV2(context.Context, *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
return &s3.ListObjectsV2Output{IsTruncated: aws.Bool(true)}, nil
}
// neverEndingStore always claims truncation with a fresh token, modelling a
// server that never finishes a listing.
type neverEndingStore struct{ *pagingStore }
func (n *neverEndingStore) ListObjectsV2(context.Context, *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
n.calls++
return &s3.ListObjectsV2Output{
IsTruncated: aws.Bool(true),
NextContinuationToken: aws.String("next"),
}, nil
}
func TestListAll_SinglePage(t *testing.T) {
store := newPagingStore([]*s3.ListObjectsV2Output{
{Contents: []*s3.Object{{Key: aws.String("a")}, {Key: aws.String("b")}}},
})
contents, prefixes, err := objectstore.ListAll(context.Background(), store, &s3.ListObjectsV2Input{
Bucket: aws.String("bucket"), Prefix: aws.String("p/"),
})
require.NoError(t, err)
assert.Empty(t, prefixes)
require.Len(t, contents, 2)
assert.Equal(t, "a", aws.StringValue(contents[0].Key))
assert.Equal(t, "b", aws.StringValue(contents[1].Key))
assert.Equal(t, 1, store.calls)
}
// A listing larger than one page must follow the continuation token to
// exhaustion rather than stopping after the first page.
func TestListAll_MultiPageFollowsContinuationToken(t *testing.T) {
store := newPagingStore([]*s3.ListObjectsV2Output{
{Contents: []*s3.Object{{Key: aws.String("a")}, {Key: aws.String("b")}}},
{Contents: []*s3.Object{{Key: aws.String("c")}}},
{Contents: []*s3.Object{{Key: aws.String("d")}, {Key: aws.String("e")}}},
})
contents, _, err := objectstore.ListAll(context.Background(), store, &s3.ListObjectsV2Input{
Bucket: aws.String("bucket"), Prefix: aws.String("p/"),
})
require.NoError(t, err)
require.Len(t, contents, 5)
var keys []string
for _, obj := range contents {
keys = append(keys, aws.StringValue(obj.Key))
}
assert.Equal(t, []string{"a", "b", "c", "d", "e"}, keys)
assert.Equal(t, 3, store.calls)
}
// A delimited listing accumulates CommonPrefixes across pages the same way
// Contents does.
func TestListAll_MultiPageCommonPrefixes(t *testing.T) {
store := newPagingStore([]*s3.ListObjectsV2Output{
{CommonPrefixes: []*s3.CommonPrefix{{Prefix: aws.String("snap-1/")}}},
{CommonPrefixes: []*s3.CommonPrefix{{Prefix: aws.String("snap-2/")}}},
})
_, prefixes, err := objectstore.ListAll(context.Background(), store, &s3.ListObjectsV2Input{
Bucket: aws.String("bucket"), Prefix: aws.String("snap-"), Delimiter: aws.String("/"),
})
require.NoError(t, err)
require.Len(t, prefixes, 2)
assert.Equal(t, "snap-1/", aws.StringValue(prefixes[0].Prefix))
assert.Equal(t, "snap-2/", aws.StringValue(prefixes[1].Prefix))
}
func TestListAll_ListError(t *testing.T) {
store := &erroringStore{pagingStore: newPagingStore(nil), err: assert.AnError}
_, _, err := objectstore.ListAll(context.Background(), store, &s3.ListObjectsV2Input{
Bucket: aws.String("bucket"), Prefix: aws.String("p/"),
})
require.Error(t, err)
}
type erroringStore struct {
*pagingStore
err error
}
func (e *erroringStore) ListObjectsV2(context.Context, *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
return nil, e.err
}
// Truncation reported with no continuation token to resume from must fail
// rather than silently returning the partial page it already read.
func TestListAll_TruncatedWithNoTokenErrors(t *testing.T) {
store := &truncatedNoTokenStore{pagingStore: newPagingStore(nil)}
_, _, err := objectstore.ListAll(context.Background(), store, &s3.ListObjectsV2Input{
Bucket: aws.String("bucket"), Prefix: aws.String("p/"),
})
require.Error(t, err)
assert.Contains(t, err.Error(), "reported truncation with no continuation token")
}
// A store that never stops claiming truncation must fail once the page
// budget is exhausted rather than looping forever.
func TestListAll_NeverEndingTruncationErrors(t *testing.T) {
store := &neverEndingStore{pagingStore: newPagingStore(nil)}
_, _, err := objectstore.ListAll(context.Background(), store, &s3.ListObjectsV2Input{
Bucket: aws.String("bucket"), Prefix: aws.String("p/"),
})
require.Error(t, err)
assert.Contains(t, err.Error(), "did not finish within")
assert.Equal(t, objectstore.MaxListPages, store.calls)
}
+5 -2
View File
@@ -35,13 +35,16 @@ func (p *Provider) DeleteVolume(ctx context.Context, req ebsprovider.DeleteVolum
return err
}
for _, prefix := range []string{req.VolumeID + "-efi/", req.VolumeID + "/"} {
listed, err := p.store.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
objects, _, err := objectstore.ListAll(ctx, p.store, &s3.ListObjectsV2Input{
Bucket: aws.String(p.bucket), Prefix: aws.String(prefix),
})
if err != nil {
return err
}
for _, obj := range listed.Contents {
for _, obj := range objects {
if obj == nil {
continue
}
if _, err := p.store.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: aws.String(p.bucket), Key: obj.Key}); err != nil {
return err
}
@@ -0,0 +1,78 @@
// Package pagedstore provides an objectstore.ObjectStore fake that serves
// ListObjectsV2 across multiple deterministic pages, honouring
// ContinuationToken and IsTruncated the way a real paginating S3-compatible
// store does. It exists so tests can prove a caller follows continuation
// tokens to exhaustion instead of stopping after the first page.
package pagedstore
import (
"context"
"sort"
"strconv"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/mulgadc/spinifex/spinifex/objectstore"
)
// Store wraps a MemoryObjectStore and splits ListObjectsV2's answer into
// pages of at most PageSize objects, sorted by key so pagination is
// deterministic across runs (MemoryObjectStore itself iterates a map).
type Store struct {
*objectstore.MemoryObjectStore
PageSize int
// Calls counts ListObjectsV2 invocations, so tests can assert every page
// was actually walked rather than the loop stopping early by luck.
Calls int
}
// New returns a paging store with the given per-page object limit.
func New(pageSize int) *Store {
return &Store{MemoryObjectStore: objectstore.NewMemoryObjectStore(), PageSize: pageSize}
}
var _ objectstore.ObjectStore = (*Store)(nil)
// ListObjectsV2 delegates to MemoryObjectStore for prefix/delimiter matching,
// then slices the sorted result into one page per call, resuming from the
// offset encoded in ContinuationToken.
func (s *Store) ListObjectsV2(ctx context.Context, input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
s.Calls++
full, err := s.MemoryObjectStore.ListObjectsV2(ctx, input)
if err != nil {
return nil, err
}
sort.Slice(full.Contents, func(i, j int) bool {
return aws.StringValue(full.Contents[i].Key) < aws.StringValue(full.Contents[j].Key)
})
start := 0
if tok := aws.StringValue(input.ContinuationToken); tok != "" {
start, err = strconv.Atoi(tok)
if err != nil {
return nil, err
}
}
end := min(start+s.PageSize, len(full.Contents))
if start > end {
start = end
}
page := &s3.ListObjectsV2Output{
Contents: full.Contents[start:end],
CommonPrefixes: full.CommonPrefixes,
Name: input.Bucket,
KeyCount: aws.Int64(int64(end - start)),
}
if end < len(full.Contents) {
page.IsTruncated = aws.Bool(true)
page.NextContinuationToken = aws.String(strconv.Itoa(end))
} else {
page.IsTruncated = aws.Bool(false)
}
return page, nil
}