check / check (push) Waiting to run
s3.part_size was loaded and defaulted but never reached the S3 client, whose uploader used a fixed 10MiB part. The client now takes the part size from the config, for storage_url and for the s3.* fields, and config load rejects a value below 5MiB or above 5GiB, the part sizes S3 accepts. The docs gave the default as 5MB, which the config file reads as 5,000,000 bytes, below the minimum; they now say 5MiB. Judgement call: the 5GiB maximum is enforced along with the 5MiB minimum the issue names. Trap: at the 5MiB default the uploader's 10,000-part limit caps one upload at about 48.8GiB, down from about 97.7GiB; blob_size_limit is not checked against it. Model: opus-5-5
285 lines
7.5 KiB
Go
285 lines
7.5 KiB
Go
package storage_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/johannesboyne/gofakes3"
|
|
"github.com/johannesboyne/gofakes3/backend/s3mem"
|
|
|
|
"sneak.berlin/go/vaultik/internal/config"
|
|
"sneak.berlin/go/vaultik/internal/s3"
|
|
"sneak.berlin/go/vaultik/internal/storage"
|
|
)
|
|
|
|
// s3TestBucket is the bucket created for each in-process S3 server.
|
|
const s3TestBucket = "test-bucket"
|
|
|
|
// Credentials for the tests that build a storer from a config.Config. The
|
|
// in-process S3 server accepts any.
|
|
const (
|
|
s3TestAccessKeyID = "key"
|
|
s3TestSecretAccessKey = "secret"
|
|
)
|
|
|
|
// newS3Storer builds an s3:// backend backed by a fresh in-process
|
|
// S3 server. It reuses the same in-memory S3 harness (gofakes3 + s3mem
|
|
// over httptest) that internal/s3 and the not-found regression test use,
|
|
// so no new mock or dependency is introduced. Each call gets its own
|
|
// server, bucket, and client, so the conformance suite's per-section
|
|
// instances stay isolated.
|
|
//
|
|
//nolint:ireturn // conformance runs against the Storer interface by design
|
|
func newS3Storer(t *testing.T) storage.Storer {
|
|
t.Helper()
|
|
|
|
backend := s3mem.New()
|
|
|
|
err := backend.CreateBucket(s3TestBucket)
|
|
if err != nil {
|
|
t.Fatalf("create bucket: %v", err)
|
|
}
|
|
|
|
srv := httptest.NewServer(gofakes3.New(backend).Server())
|
|
t.Cleanup(srv.Close)
|
|
|
|
client, err := s3.NewClient(context.Background(), s3.Config{
|
|
Endpoint: srv.URL,
|
|
Bucket: s3TestBucket,
|
|
AccessKeyID: "test",
|
|
SecretAccessKey: "test",
|
|
Region: "us-east-1",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("new client: %v", err)
|
|
}
|
|
|
|
return storage.NewS3Storer(client)
|
|
}
|
|
|
|
// TestS3Storer runs the shared Storer contract against the s3:// backend,
|
|
// so it is held to the same round-trip, list, delete, and not-found
|
|
// behaviour as the file:// backend.
|
|
func TestS3Storer(t *testing.T) {
|
|
t.Parallel()
|
|
runStorerConformance(t, newS3Storer)
|
|
}
|
|
|
|
// TestS3StorerMissingKeyMapsToErrNotFound pins the specific contract that a
|
|
// missing object surfaces as storage.ErrNotFound rather than the raw AWS SDK
|
|
// error. Without the mapping, errors.Is(err, storage.ErrNotFound) is false on
|
|
// s3 and callers would branch differently per backend.
|
|
func TestS3StorerMissingKeyMapsToErrNotFound(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
storer := newS3Storer(t)
|
|
ctx := context.Background()
|
|
|
|
_, err := storer.Get(ctx, "does-not-exist")
|
|
if !errors.Is(err, storage.ErrNotFound) {
|
|
t.Errorf("Get on missing key: got %v, want ErrNotFound", err)
|
|
}
|
|
|
|
_, err = storer.Stat(ctx, "does-not-exist")
|
|
if !errors.Is(err, storage.ErrNotFound) {
|
|
t.Errorf("Stat on missing key: got %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
// TestS3URLPrefixKeyLayout pins the bucket keys an s3:// URL reads and
|
|
// writes: the README's remote storage layout, with the prefix joined to
|
|
// each key by one "/". s3://b/p and s3://b/p/ must be the same
|
|
// destination, or a host that writes the URL the other way finds no
|
|
// snapshots. The listed object is put straight into the bucket, as
|
|
// another host would have written it. Both List and ListStream are
|
|
// checked: ListStream is what every snapshot listing goes through.
|
|
func TestS3URLPrefixKeyLayout(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
blobKey = "blobs/aa/bb/aabbccdd"
|
|
listPrefix = "metadata/"
|
|
manifestKey = listPrefix + "snap/manifest.json.zst"
|
|
manifestBody = "manifest"
|
|
)
|
|
|
|
cases := []struct {
|
|
urlPath string // URL path after the bucket name
|
|
keyPrefix string // what every key in the bucket must start with
|
|
}{
|
|
{urlPath: "/p", keyPrefix: "p/"},
|
|
{urlPath: "/p/", keyPrefix: "p/"},
|
|
{urlPath: "", keyPrefix: ""},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
storageURL := "s3://" + s3TestBucket + tc.urlPath
|
|
|
|
t.Run(storageURL, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
backend := s3mem.New()
|
|
|
|
err := backend.CreateBucket(s3TestBucket)
|
|
if err != nil {
|
|
t.Fatalf("create bucket: %v", err)
|
|
}
|
|
|
|
srv := httptest.NewServer(gofakes3.New(backend).Server())
|
|
t.Cleanup(srv.Close)
|
|
|
|
storer, err := storage.NewStorer(&config.Config{
|
|
StorageURL: storageURL + "?endpoint=" + srv.URL,
|
|
S3: config.S3Config{
|
|
AccessKeyID: s3TestAccessKeyID,
|
|
SecretAccessKey: s3TestSecretAccessKey,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewStorer: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
err = storer.Put(ctx, blobKey, strings.NewReader("blob"))
|
|
if err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
|
|
_, err = backend.HeadObject(s3TestBucket, tc.keyPrefix+blobKey)
|
|
if err != nil {
|
|
t.Errorf("blob not stored at %q: %v", tc.keyPrefix+blobKey, err)
|
|
}
|
|
|
|
_, err = backend.PutObject(s3TestBucket, tc.keyPrefix+manifestKey,
|
|
nil, strings.NewReader(manifestBody), int64(len(manifestBody)))
|
|
if err != nil {
|
|
t.Fatalf("seed manifest: %v", err)
|
|
}
|
|
|
|
keys, err := storer.List(ctx, listPrefix)
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
|
|
if !slices.Equal(keys, []string{manifestKey}) {
|
|
t.Errorf("List(%q) = %q, want [%q]", listPrefix, keys, manifestKey)
|
|
}
|
|
|
|
streamed := listStreamKeys(t, storer, listPrefix)
|
|
if !slices.Equal(streamed, []string{manifestKey}) {
|
|
t.Errorf("ListStream(%q) = %q, want [%q]", listPrefix, streamed, manifestKey)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestS3UploadUsesConfiguredPartSize checks that s3.part_size reaches the
|
|
// multipart uploader, through storage_url and through the s3.* fields. An
|
|
// object three parts long must arrive as three parts; at the SDK's default
|
|
// of 5 MiB it would arrive as four.
|
|
func TestS3UploadUsesConfiguredPartSize(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
partSize = 6 * 1024 * 1024
|
|
wantParts = 3
|
|
)
|
|
|
|
backend := s3mem.New()
|
|
|
|
err := backend.CreateBucket(s3TestBucket)
|
|
if err != nil {
|
|
t.Fatalf("create bucket: %v", err)
|
|
}
|
|
|
|
// Every part of a multipart upload is one request with a partNumber.
|
|
var parts atomic.Int32
|
|
|
|
fake := gofakes3.New(backend).Server()
|
|
srv := httptest.NewServer(http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Query().Has("partNumber") {
|
|
parts.Add(1)
|
|
}
|
|
|
|
fake.ServeHTTP(w, r)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
cases := []struct {
|
|
name string
|
|
cfg *config.Config
|
|
}{
|
|
{
|
|
name: "storage_url",
|
|
cfg: &config.Config{
|
|
StorageURL: "s3://" + s3TestBucket + "?endpoint=" + srv.URL,
|
|
S3: config.S3Config{
|
|
AccessKeyID: s3TestAccessKeyID,
|
|
SecretAccessKey: s3TestSecretAccessKey,
|
|
PartSize: partSize,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "s3.endpoint",
|
|
cfg: &config.Config{
|
|
S3: config.S3Config{
|
|
Endpoint: srv.URL,
|
|
Bucket: s3TestBucket,
|
|
AccessKeyID: s3TestAccessKeyID,
|
|
SecretAccessKey: s3TestSecretAccessKey,
|
|
PartSize: partSize,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
parts.Store(0)
|
|
|
|
storer, err := storage.NewStorer(tc.cfg)
|
|
if err != nil {
|
|
t.Fatalf("%s: NewStorer: %v", tc.name, err)
|
|
}
|
|
|
|
data := bytes.NewReader(make([]byte, wantParts*partSize))
|
|
|
|
err = storer.PutWithProgress(
|
|
context.Background(), "blob", data, data.Size(), nil)
|
|
if err != nil {
|
|
t.Fatalf("%s: PutWithProgress: %v", tc.name, err)
|
|
}
|
|
|
|
if got := parts.Load(); got != wantParts {
|
|
t.Errorf("%s: uploaded in %d parts, want %d", tc.name, got, wantParts)
|
|
}
|
|
}
|
|
}
|
|
|
|
// listStreamKeys returns the keys ListStream yields under a prefix, and
|
|
// fails the test on a listing error.
|
|
func listStreamKeys(t *testing.T, s storage.Storer, prefix string) []string {
|
|
t.Helper()
|
|
|
|
var keys []string
|
|
|
|
for obj := range s.ListStream(context.Background(), prefix) {
|
|
if obj.Err != nil {
|
|
t.Fatalf("ListStream %q: %v", prefix, obj.Err)
|
|
}
|
|
|
|
keys = append(keys, obj.Key)
|
|
}
|
|
|
|
return keys
|
|
}
|