mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-20 03:11:46 +00:00
227 lines
6.9 KiB
Go
227 lines
6.9 KiB
Go
package vbscan
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"strconv"
|
|
"testing"
|
|
|
|
"github.com/aws/aws-sdk-go/aws"
|
|
"github.com/aws/aws-sdk-go/service/s3"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// fakeObject is one object in fakeStore, keyed by bucket/key.
|
|
type fakeObject struct {
|
|
body []byte
|
|
}
|
|
|
|
// fakeStore is a minimal in-memory ObjectStore for exercising Scanner without
|
|
// a real S3-compatible backend. Pagination is driven by maxKeysPerPage so
|
|
// tests can exercise the ContinuationToken loop without needing thousands of
|
|
// objects.
|
|
type fakeStore struct {
|
|
objects map[string]fakeObject
|
|
maxKeysPerPage int
|
|
listErr error
|
|
getErrForPrefix string
|
|
}
|
|
|
|
func newFakeStore() *fakeStore {
|
|
return &fakeStore{objects: make(map[string]fakeObject), maxKeysPerPage: 0}
|
|
}
|
|
|
|
func (f *fakeStore) put(bucket, key string, body []byte) {
|
|
f.objects[bucket+"/"+key] = fakeObject{body: body}
|
|
}
|
|
|
|
func (f *fakeStore) GetObject(_ context.Context, in *s3.GetObjectInput) (*s3.GetObjectOutput, error) {
|
|
if f.getErrForPrefix != "" && aws.StringValue(in.Key) == f.getErrForPrefix {
|
|
return nil, errors.New("simulated get error")
|
|
}
|
|
obj, ok := f.objects[aws.StringValue(in.Bucket)+"/"+aws.StringValue(in.Key)]
|
|
if !ok {
|
|
return nil, errors.New("NoSuchKey: " + aws.StringValue(in.Key))
|
|
}
|
|
return &s3.GetObjectOutput{Body: io.NopCloser(bytes.NewReader(obj.body))}, nil
|
|
}
|
|
|
|
// listing key/size pairs under a bucket+prefix, in insertion-independent
|
|
// (sorted) order so pagination is deterministic across test runs.
|
|
func (f *fakeStore) matchingKeys(bucket, prefix string) []string {
|
|
var keys []string
|
|
for k := range f.objects {
|
|
b, key, ok := bytes.Cut([]byte(k), []byte("/"))
|
|
_ = ok
|
|
if string(b) != bucket {
|
|
continue
|
|
}
|
|
if len(prefix) == 0 || bytes.HasPrefix(key, []byte(prefix)) {
|
|
keys = append(keys, string(key))
|
|
}
|
|
}
|
|
// Simple insertion sort; test object counts are tiny.
|
|
for i := 1; i < len(keys); i++ {
|
|
for j := i; j > 0 && keys[j-1] > keys[j]; j-- {
|
|
keys[j-1], keys[j] = keys[j], keys[j-1]
|
|
}
|
|
}
|
|
return keys
|
|
}
|
|
|
|
func (f *fakeStore) ListObjectsV2(_ context.Context, in *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
|
|
if f.listErr != nil {
|
|
return nil, f.listErr
|
|
}
|
|
bucket := aws.StringValue(in.Bucket)
|
|
prefix := aws.StringValue(in.Prefix)
|
|
keys := f.matchingKeys(bucket, prefix)
|
|
|
|
start := 0
|
|
if in.ContinuationToken != nil {
|
|
n, err := strconv.Atoi(aws.StringValue(in.ContinuationToken))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
start = n
|
|
}
|
|
|
|
pageSize := len(keys)
|
|
if f.maxKeysPerPage > 0 {
|
|
pageSize = f.maxKeysPerPage
|
|
}
|
|
|
|
end := start + pageSize
|
|
truncated := false
|
|
if end < len(keys) {
|
|
truncated = true
|
|
} else {
|
|
end = len(keys)
|
|
}
|
|
|
|
out := &s3.ListObjectsV2Output{IsTruncated: aws.Bool(truncated)}
|
|
for _, k := range keys[start:end] {
|
|
obj := f.objects[bucket+"/"+k]
|
|
out.Contents = append(out.Contents, &s3.Object{
|
|
Key: aws.String(k),
|
|
Size: aws.Int64(int64(len(obj.body))),
|
|
})
|
|
}
|
|
if truncated {
|
|
out.NextContinuationToken = aws.String(strconv.Itoa(end))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
const testBucket = "test-bucket"
|
|
|
|
// validConfigJSON is a minimal but realistic VBState blob for a freshly
|
|
// created 8 GiB volume, matching the shape CreateVolume actually persists
|
|
// (verified against a real predastore fixture).
|
|
const validConfigJSON = `{
|
|
"VolumeName": "vol-abc123",
|
|
"VolumeSize": 8589934592,
|
|
"BlockSize": 4096,
|
|
"ObjBlockSize": 4194304,
|
|
"SeqNum": 0,
|
|
"ObjectNum": 0,
|
|
"WALNum": 0,
|
|
"Version": 1,
|
|
"VolumeConfig": {
|
|
"VolumeMetadata": {
|
|
"VolumeID": "vol-abc123",
|
|
"TenantID": "000000000000",
|
|
"SizeGiB": 8,
|
|
"State": "available",
|
|
"AvailabilityZone": "us-east-1a",
|
|
"VolumeType": "gp3"
|
|
}
|
|
}
|
|
}`
|
|
|
|
func TestInspect_FreshVolumeNoChunks(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.put(testBucket, "vol-abc123/config.json", []byte(validConfigJSON))
|
|
|
|
scanner := NewScanner(store, testBucket)
|
|
rep, err := scanner.Inspect(context.Background(), "vol-abc123")
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "vol-abc123", rep.Volume)
|
|
assert.Equal(t, uint32(4096), rep.State.BlockSize)
|
|
assert.Equal(t, uint64(8589934592), rep.State.VolumeSize)
|
|
assert.Equal(t, uint64(0), rep.State.ObjectNum)
|
|
assert.Equal(t, "vol-abc123", rep.State.VolumeConfig.VolumeMetadata.VolumeID)
|
|
assert.Equal(t, uint64(8), rep.State.VolumeConfig.VolumeMetadata.SizeGiB)
|
|
assert.Equal(t, "available", rep.State.VolumeConfig.VolumeMetadata.State)
|
|
|
|
assert.Zero(t, rep.LiveChunkCount, "fresh volume should report no live chunks")
|
|
assert.Zero(t, rep.LiveChunkBytes, "fresh volume should report no live chunk bytes")
|
|
}
|
|
|
|
func TestInspect_CountsChunkObjectsAndIgnoresOthers(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.put(testBucket, "vol-xyz/config.json", []byte(validConfigJSON))
|
|
store.put(testBucket, "vol-xyz/chunks/chunk.00000000.bin", make([]byte, 100))
|
|
store.put(testBucket, "vol-xyz/chunks/chunk.00000001.bin", make([]byte, 250))
|
|
// Not a chunk object -- must not be counted.
|
|
store.put(testBucket, "vol-xyz/state.json", []byte(`{}`))
|
|
// Malformed chunk-like key -- must be ignored by the regex, not error.
|
|
store.put(testBucket, "vol-xyz/chunks/not-a-chunk.bin", make([]byte, 999))
|
|
|
|
scanner := NewScanner(store, testBucket)
|
|
rep, err := scanner.Inspect(context.Background(), "vol-xyz")
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, 2, rep.LiveChunkCount)
|
|
assert.Equal(t, int64(350), rep.LiveChunkBytes)
|
|
}
|
|
|
|
func TestInspect_PaginatesChunkListing(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.maxKeysPerPage = 1
|
|
store.put(testBucket, "vol-page/config.json", []byte(validConfigJSON))
|
|
store.put(testBucket, "vol-page/chunks/chunk.00000000.bin", make([]byte, 10))
|
|
store.put(testBucket, "vol-page/chunks/chunk.00000001.bin", make([]byte, 20))
|
|
store.put(testBucket, "vol-page/chunks/chunk.00000002.bin", make([]byte, 30))
|
|
|
|
scanner := NewScanner(store, testBucket)
|
|
rep, err := scanner.Inspect(context.Background(), "vol-page")
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, 3, rep.LiveChunkCount, "pagination must not drop or double count objects")
|
|
assert.Equal(t, int64(60), rep.LiveChunkBytes)
|
|
}
|
|
|
|
func TestInspect_MissingConfigIsError(t *testing.T) {
|
|
store := newFakeStore()
|
|
scanner := NewScanner(store, testBucket)
|
|
|
|
_, err := scanner.Inspect(context.Background(), "vol-missing")
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "config.json")
|
|
}
|
|
|
|
func TestInspect_MalformedConfigIsError(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.put(testBucket, "vol-bad/config.json", []byte("not json"))
|
|
scanner := NewScanner(store, testBucket)
|
|
|
|
_, err := scanner.Inspect(context.Background(), "vol-bad")
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestInspect_ListErrorPropagates(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.put(testBucket, "vol-listerr/config.json", []byte(validConfigJSON))
|
|
store.listErr = errors.New("simulated list failure")
|
|
|
|
scanner := NewScanner(store, testBucket)
|
|
_, err := scanner.Inspect(context.Background(), "vol-listerr")
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "simulated list failure")
|
|
}
|