Files
spinifex/tests/e2e/harness/predastore.go
T
Bryn Mailer 15dede64ad feat: predastore compaction E2E (#407)
* feat: add predastore compaction E2E

Inject a short compaction interval into test-cluster predastore config via a
new --predastore-compaction-interval flag on admin init/join, gated so
production output stays byte-identical when unset. Add a harness helper that
drives write-then-delete churn against predastore S3, brackets it with
du-over-SSH usage, and asserts end-of-churn usage settles below 70% of the
churn peak; wire it into the multinode suite as TestPredastoreCompaction.

* fix: compaction E2E gate writes churn to its own bucket

The reclaim gate signed PutObject against the config-defined system
'predastore' bucket, which belongs to account 000000000000 with only a
static [[auth]] grant. The harness admin identity is a different IAM
account, so predastore returned 403 AccessDenied cross-account.

Create a per-run bucket the admin identity owns (AdministratorAccess)
and churn into it. Objects still erasure-code into distributed/nodes/
node-* so the du-based reclaim measurement is unchanged. The gateway
(:9999) does not proxy S3, so the client stays predastore-direct.

* test: run predastore compaction gate before destructive node tests

The compaction reclaim gate was declared last in the multinode suite, so it
measured shard-store usage after NodeFailure/NodeRecovery had torn down and
restarted node2. NodeRecovery only gates on NATS/gateway/EC2 readiness, not
predastore, so node2's shard store was still settling and the du baseline came
back empty. Move the test before the destructive node tests so it runs against
a pristine, fully-converged cluster primed by the volume tests.

* fix: race-free SSH output capture + empty-tolerant du probe

The compaction reclaim gate crashed with ParseInt("") when node1's du
probe returned empty combined output. Harden the probe and the SSH layer:

- ssh.Run used one bytes.Buffer for both session.Stdout and session.Stderr,
  which x/crypto/ssh copies in two concurrent goroutines — a data race on a
  non-concurrent-safe type. Switch to session.CombinedOutput, which guards the
  combined stream with a mutex-wrapped singleWriter.
- shardStoreUsageBytes now terminates the remote command with a sentinel echo.
  A missing sentinel is reported as output lost in SSH retrieval; a present
  sentinel with no number is treated as a 0 measurement (anomalous, logged)
  instead of crashing the gate.

The sentinel lets the next run distinguish a transport-layer drop from a
genuinely empty remote measurement.

* test(e2e): replace predastore du-reclaim gate with object lifecycle contract

The multinode compaction gate measured shard-store disk usage over SSH (du)
and asserted reclaim after churn. It could never pass: segments only seal at
the 4 GiB roll threshold, so test-scale churn produces no compactable segment,
and the du probe read 0 bytes before and after. Reclaim correctness is already
covered deterministically by predastore's store-level unit tests.

Delete AssertPredastoreCompaction and its du/churn/poll/sentinel machinery,
the harness unit tests that exercised them, and the multinode TestPredastore
Compaction entry. Add a single-node TestPredastoreObjectLifecycle that checks
the user-visible contract: an object is readable after PutObject, gone after
DeleteObject (GET returns NoSuchKey and ListObjectsV2 is empty), and the store
keeps serving fresh writes. newPredastoreS3 is retained, refactored to take a
host so it works outside the multinode cluster type.

* test(e2e): drop orphaned multinode TestPredastoreCompaction

Complete the du-reclaim -> object-lifecycle refactor: remove the
multinode TestPredastoreCompaction caller (and its now-unused context
and harness imports) that still referenced the deleted
harness.AssertPredastoreCompaction, which broke e2e-build. Wire the
single-node TestPredastoreObjectLifecycle entry that replaces it.
2026-06-23 16:19:10 +10:00

176 lines
5.9 KiB
Go

//go:build e2e
package harness
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net/http"
"os"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
)
// lifecycleBucketPrefix names the per-run bucket the contract test owns. A
// dedicated bucket the harness admin identity owns sidesteps the config-defined
// system bucket, which belongs to a different account and rejects cross-account
// writes.
const lifecycleBucketPrefix = "predastore-lifecycle-"
// AssertPredastoreObjectLifecycle verifies predastore's user-visible object
// contract end to end against the deployed S3 endpoint on host: an object is
// readable and intact after PutObject, absent after DeleteObject (both GET and
// ListObjectsV2 stop returning it), and the store keeps serving fresh writes
// once the delete has landed.
func AssertPredastoreObjectLifecycle(ctx context.Context, t *testing.T, host string) {
t.Helper()
Phase(t, "Predastore — Object Lifecycle Contract")
cli, err := newPredastoreS3(host)
if err != nil {
t.Fatalf("predastore: s3 client: %v", err)
}
bucket := fmt.Sprintf("%s%d", lifecycleBucketPrefix, time.Now().UnixNano())
if _, err := cli.CreateBucketWithContext(ctx, &s3.CreateBucketInput{Bucket: aws.String(bucket)}); err != nil {
t.Fatalf("predastore: create bucket %s: %v", bucket, err)
}
t.Cleanup(func() {
if _, err := cli.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucket)}); err != nil {
t.Logf("predastore: cleanup delete bucket %s: %v", bucket, err)
}
})
const key = "lifecycle/object"
payload := bytes.Repeat([]byte("p"), 256<<10) // 256 KiB
// Write, then confirm the object is readable and byte-identical.
if _, err := cli.PutObjectWithContext(ctx, &s3.PutObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
Body: bytes.NewReader(payload),
}); err != nil {
t.Fatalf("predastore: put %s: %v", key, err)
}
got, err := getObjectBytes(ctx, cli, bucket, key)
if err != nil {
t.Fatalf("predastore: get %s after put: %v", key, err)
}
if !bytes.Equal(got, payload) {
t.Fatalf("predastore: get %s returned %d bytes, want %d", key, len(got), len(payload))
}
// Delete, then confirm the object is gone from both GET and List.
if _, err := cli.DeleteObjectWithContext(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
}); err != nil {
t.Fatalf("predastore: delete %s: %v", key, err)
}
if _, err := cli.GetObjectWithContext(ctx, &s3.GetObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
}); !isNoSuchKey(err) {
t.Fatalf("predastore: get %s after delete: want NoSuchKey, got %v", key, err)
}
list, err := cli.ListObjectsV2WithContext(ctx, &s3.ListObjectsV2Input{
Bucket: aws.String(bucket),
Prefix: aws.String(key),
})
if err != nil {
t.Fatalf("predastore: list after delete: %v", err)
}
if n := len(list.Contents); n != 0 {
t.Fatalf("predastore: list after delete returned %d objects, want 0", n)
}
// The store must keep serving once the delete has landed: a fresh write
// round-trips, proving the delete did not wedge the bucket.
const key2 = "lifecycle/after-delete"
t.Cleanup(func() {
_, _ = cli.DeleteObject(&s3.DeleteObjectInput{Bucket: aws.String(bucket), Key: aws.String(key2)})
})
if _, err := cli.PutObjectWithContext(ctx, &s3.PutObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key2),
Body: bytes.NewReader(payload),
}); err != nil {
t.Fatalf("predastore: put %s after delete: %v", key2, err)
}
if _, err := getObjectBytes(ctx, cli, bucket, key2); err != nil {
t.Fatalf("predastore: get %s after delete: %v", key2, err)
}
Detail(t, "bucket", bucket, "objectBytes", len(payload))
}
// getObjectBytes fetches an object and returns its full body.
func getObjectBytes(ctx context.Context, cli *s3.S3, bucket, key string) ([]byte, error) {
out, err := cli.GetObjectWithContext(ctx, &s3.GetObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
})
if err != nil {
return nil, err
}
defer func() { _ = out.Body.Close() }()
return io.ReadAll(out.Body)
}
// isNoSuchKey reports whether err is the S3 NoSuchKey error predastore returns
// for a GET on a deleted or missing key.
func isNoSuchKey(err error) bool {
var aerr awserr.Error
if errors.As(err, &aerr) {
return aerr.Code() == s3.ErrCodeNoSuchKey
}
return false
}
// newPredastoreS3 builds an S3 client pointed at a predastore endpoint on host.
// The gateway (:9999) does not proxy S3 object operations, so the client targets
// predastore directly on predastoreHealthPort. Credentials resolve from
// SPINIFEX_AWS_* or the spinifex profile — the admin identity
// (AdministratorAccess), which owns and is authorized for the bucket the test
// creates. TLS verification is skipped (test-only) since the assertion carries
// no Env for CA load.
func newPredastoreS3(host string) (*s3.S3, error) {
if host == "" {
return nil, errors.New("predastore: empty host")
}
endpoint := fmt.Sprintf("https://%s:%d", host, predastoreHealthPort)
cfg := &aws.Config{
Endpoint: aws.String(endpoint),
Region: aws.String(getenv("SPINIFEX_AWS_REGION", "ap-southeast-2")),
S3ForcePathStyle: aws.Bool(true),
HTTPClient: &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // test harness
},
},
}
opts := session.Options{Config: *cfg}
if id, secret := os.Getenv("SPINIFEX_AWS_ACCESS_KEY_ID"), os.Getenv("SPINIFEX_AWS_SECRET_ACCESS_KEY"); id != "" && secret != "" {
cfg.Credentials = credentials.NewStaticCredentials(id, secret, "")
opts.Config = *cfg
} else {
opts.SharedConfigState = session.SharedConfigEnable
opts.Profile = getenv("AWS_PROFILE", "spinifex")
}
sess, err := session.NewSessionWithOptions(opts)
if err != nil {
return nil, fmt.Errorf("predastore: s3 session: %w", err)
}
return s3.New(sess), nil
}