mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
392 lines
14 KiB
Go
392 lines
14 KiB
Go
//go:build e2e
|
|
|
|
// Package rds is the RDS E2E suite: the create/describe/connect path against a
|
|
// running cluster. The control-plane orchestration, the validation matrix and
|
|
// the reconciler's status transitions are covered by the handlers/rds unit
|
|
// tests; what only a live cluster can prove is that a DB VM actually boots, the
|
|
// in-guest agent reports healthy, the endpoint resolves and a client can speak
|
|
// the wire protocol to it.
|
|
//
|
|
// Gated on SPINIFEX_E2E alone: every test here deletes the instances it creates,
|
|
// so there is nothing left for an operator to accept.
|
|
package rds
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go/aws"
|
|
"github.com/aws/aws-sdk-go/service/rds"
|
|
"github.com/mulgadc/spinifex/tests/e2e/harness"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/sync/semaphore"
|
|
)
|
|
|
|
// Mirrors the AWS client's own default, since the endpoint name the control
|
|
// plane publishes is region-qualified.
|
|
const defaultRegion = "ap-southeast-2"
|
|
|
|
// The PostgreSQL instance spec most of the suite creates from; the MariaDB
|
|
// cases carry their own engine, database name and credentials and share the
|
|
// class and storage. The class is the floor and the storage is the API's own
|
|
// minimum: nothing here needs more, and each DB VM booted is charged against the
|
|
// phase's instance budget. Only a class-sensitive assertion names a bigger
|
|
// class, and only a grow names a bigger size.
|
|
const (
|
|
dbInstancePfx = "rds-e2e"
|
|
dbEngine = "postgres"
|
|
dbClass = "db.t3.micro"
|
|
dbStorageGiB = 20
|
|
dbName = "orders"
|
|
dbMasterUser = "appuser"
|
|
// No '/', '"', '@' or spaces: the characters the API rejects because they
|
|
// break a connection string or the engine's own role syntax.
|
|
dbMasterPassword = "e2eSup3rSecret1"
|
|
)
|
|
|
|
// The suite's whole guest-memory allowance on the node, in GiB. Each DB instance
|
|
// is a guest with its own data volume on a node that is also running the cluster,
|
|
// and the phase budget — 25 minutes wall clock — is written against four floor
|
|
// instances alive at once.
|
|
//
|
|
// Denominated in memory rather than instances because the node admits a launch
|
|
// on live MemAvailable: a budget counting VMs lets a test holding two db.t3.small
|
|
// through a cap written for four db.t3.micro, and the launch is then refused with
|
|
// InsufficientInstanceCapacity rather than made to wait.
|
|
const totalVMBudgetGiB = 4
|
|
|
|
// The two nano client VMs, which come out of the same node memory but no test's
|
|
// reservation. Deducted up front rather than acquired: a test asking for one
|
|
// while holding its own DB reservation would deadlock against itself.
|
|
const clientVMBudgetGiB = 1
|
|
|
|
// What is left for DB instances. Every instance-owning test runs in parallel and
|
|
// takes its allowance from here.
|
|
const maxConcurrentDBVMGiB = totalVMBudgetGiB - clientVMBudgetGiB
|
|
|
|
// What each class the suite names costs against that budget.
|
|
var dbClassGiB = map[string]int64{
|
|
dbClass: 1,
|
|
grownClass: 2,
|
|
}
|
|
|
|
var (
|
|
pkgFixOnce sync.Once
|
|
pkgFix *Fixture
|
|
pkgFixErr error
|
|
|
|
dbVMSlots = semaphore.NewWeighted(maxConcurrentDBVMGiB)
|
|
)
|
|
|
|
// Fixture carries per-process state shared across every Test* in this package.
|
|
type Fixture struct {
|
|
Env *harness.Env
|
|
AWS *harness.AWSClient
|
|
Account string
|
|
Region string
|
|
BaseDomain string
|
|
|
|
// The Ensure* fixture the client VM and its keypair hang off. Process-scoped
|
|
// rather than bound to a test: the client guest is shared by every test that
|
|
// needs a connection, and must outlive whichever one built it.
|
|
Harness *harness.Fixture
|
|
|
|
// The system-account client, built on first use: it shells to sudo, and only
|
|
// the tests that reach behind a DB instance need one.
|
|
systemOnce sync.Once
|
|
system *harness.AWSClient
|
|
}
|
|
|
|
// TestMain drains the process fixture's cleanup chain after the run, so the
|
|
// client VM and its keypair are reclaimed whichever test built them. A leaked
|
|
// resource fails the run: the suite may have passed, but it left state behind
|
|
// that the next run trips over.
|
|
func TestMain(m *testing.M) {
|
|
// How many tests may run at once is dbVMSlots' business, not the runner's CPU
|
|
// count: -test.parallel defaults to GOMAXPROCS, so a two-core CI VM would
|
|
// quietly halve the concurrency this suite's budget is written against. The
|
|
// budget in GiB is the right ceiling because no test reserves less than a
|
|
// floor instance. An explicit -test.parallel on the command line still wins,
|
|
// since flag parsing happens inside m.Run.
|
|
if err := flag.Set("test.parallel", strconv.Itoa(maxConcurrentDBVMGiB)); err != nil {
|
|
fmt.Fprintf(os.Stderr, "e2e: set test.parallel: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
start := time.Now()
|
|
code := m.Run()
|
|
reportCreateLatencies(time.Since(start))
|
|
if pkgFix != nil && pkgFix.Harness != nil {
|
|
if err := pkgFix.Harness.Close(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "e2e teardown: %v\n", err)
|
|
code = 1
|
|
}
|
|
}
|
|
os.Exit(code)
|
|
}
|
|
|
|
// reserveDBVMs takes a test's whole DB-VM allowance in one acquisition, before
|
|
// it creates anything. classes is the test's peak — the class of every instance
|
|
// it will have alive at once — so a test that creates and deletes one instance at
|
|
// a time names one however many it gets through, and one whose instance ends up
|
|
// on a bigger class names the class it ends on.
|
|
//
|
|
// The acquisition is atomic because acquiring per instance deadlocks: four tests
|
|
// each holding a floor instance's worth and each waiting for a second would never
|
|
// progress.
|
|
func reserveDBVMs(t *testing.T, classes ...string) {
|
|
t.Helper()
|
|
var gib int64
|
|
for _, class := range classes {
|
|
cost, known := dbClassGiB[class]
|
|
require.True(t, known, "class %s has no entry in dbClassGiB", class)
|
|
gib += cost
|
|
}
|
|
require.LessOrEqual(t, gib, int64(maxConcurrentDBVMGiB),
|
|
"a test cannot reserve more DB-VM memory than the whole suite is allowed")
|
|
|
|
start := time.Now()
|
|
if err := dbVMSlots.Acquire(context.Background(), gib); err != nil {
|
|
t.Fatalf("reserve %d GiB of DB-VM budget: %v", gib, err)
|
|
}
|
|
if waited := time.Since(start); waited > time.Second {
|
|
t.Logf("waited %s for %d of %d GiB of DB-VM budget",
|
|
waited.Round(time.Second), gib, maxConcurrentDBVMGiB)
|
|
}
|
|
|
|
// Registered before any instance's teardown, so LIFO hands the budget back
|
|
// only once the last DB VM this test owns is actually gone.
|
|
t.Cleanup(func() { dbVMSlots.Release(gib) })
|
|
}
|
|
|
|
// Subtests report under their parent, so a create issued from one is attributed
|
|
// to the top-level test that reserved the slot it is using.
|
|
func topLevelName(t *testing.T) string {
|
|
name, _, _ := strings.Cut(t.Name(), "/")
|
|
return name
|
|
}
|
|
|
|
var (
|
|
clientVMMu sync.Mutex
|
|
clientVM harness.SSHTarget
|
|
)
|
|
|
|
// rdsClient returns the shared client VM, built under a package lock. The
|
|
// instance itself is memoised by the process fixture, but the keypair, default
|
|
// VPC and security-group rules RDSClientVM readies first are not safe to drive
|
|
// concurrently — and the tests that need a client now run in parallel.
|
|
func rdsClient(t *testing.T, f *Fixture) harness.SSHTarget {
|
|
t.Helper()
|
|
clientVMMu.Lock()
|
|
defer clientVMMu.Unlock()
|
|
if clientVM.Host == "" {
|
|
clientVM = harness.RDSClientVM(t, f.AWS, f.Harness, f.Env)
|
|
}
|
|
return clientVM
|
|
}
|
|
|
|
var (
|
|
latencyMu sync.Mutex
|
|
dbCreateStarted = map[string]time.Time{}
|
|
dbCreateLatencies []dbCreateLatency
|
|
)
|
|
|
|
type dbCreateLatency struct {
|
|
test string
|
|
id string
|
|
took time.Duration
|
|
}
|
|
|
|
// waitForAvailable waits for a DB instance and, the first time it does so for a
|
|
// freshly created one, records how long create → available took. That boot is
|
|
// the suite's dominant cost and the number the phase budget is written against,
|
|
// so it is reported per instance rather than inferred from the run's total.
|
|
func waitForAvailable(t *testing.T, f *Fixture, id string) *rds.DBInstance {
|
|
t.Helper()
|
|
instance := harness.WaitForDBInstanceAvailable(t, f.AWS, id)
|
|
|
|
latencyMu.Lock()
|
|
started, first := dbCreateStarted[id]
|
|
took := time.Since(started)
|
|
if first {
|
|
delete(dbCreateStarted, id)
|
|
dbCreateLatencies = append(dbCreateLatencies,
|
|
dbCreateLatency{test: topLevelName(t), id: id, took: took})
|
|
}
|
|
latencyMu.Unlock()
|
|
|
|
if first {
|
|
t.Logf("%s reached available %s after its create was issued", id, took.Round(time.Second))
|
|
}
|
|
return instance
|
|
}
|
|
|
|
// reportCreateLatencies prints what every DB VM in the run cost, slowest first.
|
|
// A suite that drifts past its budget should say which create got slower.
|
|
func reportCreateLatencies(wall time.Duration) {
|
|
latencyMu.Lock()
|
|
defer latencyMu.Unlock()
|
|
if len(dbCreateLatencies) == 0 {
|
|
return
|
|
}
|
|
sort.Slice(dbCreateLatencies, func(i, j int) bool {
|
|
return dbCreateLatencies[i].took > dbCreateLatencies[j].took
|
|
})
|
|
fmt.Fprintf(os.Stderr, "\nRDS suite: %s wall clock, %d DB instances, ≤%d GiB concurrent\n",
|
|
wall.Round(time.Second), len(dbCreateLatencies), maxConcurrentDBVMGiB)
|
|
for _, l := range dbCreateLatencies {
|
|
fmt.Fprintf(os.Stderr, " create→available %8s %s (%s)\n", l.took.Round(time.Second), l.id, l.test)
|
|
}
|
|
}
|
|
|
|
// requireRDSFixture returns the package-scoped Fixture, building it on first
|
|
// call. Skips the calling test when the suite's gate is unset.
|
|
func requireRDSFixture(t *testing.T) *Fixture {
|
|
t.Helper()
|
|
pkgFixOnce.Do(func() {
|
|
if os.Getenv("SPINIFEX_E2E") == "" {
|
|
return
|
|
}
|
|
env := harness.LoadEnv(t)
|
|
awsCli := harness.NewAWSClient(t, env)
|
|
h, err := harness.NewProcessFixture(awsCli)
|
|
if err != nil {
|
|
pkgFixErr = err
|
|
return
|
|
}
|
|
region := os.Getenv("SPINIFEX_AWS_REGION")
|
|
if region == "" {
|
|
region = defaultRegion
|
|
}
|
|
pkgFix = &Fixture{
|
|
Env: env,
|
|
AWS: awsCli,
|
|
Account: harness.IAMAccountID(t, awsCli),
|
|
Region: region,
|
|
BaseDomain: harness.NorthstarBaseDomain(env),
|
|
Harness: h,
|
|
}
|
|
})
|
|
if pkgFixErr != nil {
|
|
t.Fatalf("rds fixture init failed: %v", pkgFixErr)
|
|
}
|
|
if pkgFix == nil {
|
|
t.Skip("SPINIFEX_E2E is unset")
|
|
}
|
|
return pkgFix
|
|
}
|
|
|
|
// SystemAWS returns the system-account client. The DB VM and its data volume
|
|
// belong to that account and are filtered out of the suite's own describes, so
|
|
// every assertion behind a DB instance goes through here.
|
|
func (f *Fixture) SystemAWS(t *testing.T) *harness.AWSClient {
|
|
t.Helper()
|
|
f.systemOnce.Do(func() { f.system = harness.SystemAWSClient(t, f.Env) })
|
|
return f.system
|
|
}
|
|
|
|
// The clients and output directory a DB-instance diagnostic bundle needs.
|
|
func (f *Fixture) dbDiag(t *testing.T) harness.DBDiag {
|
|
t.Helper()
|
|
return harness.DBDiag{Tenant: f.AWS, System: f.SystemAWS(t), Dir: harness.ArtifactDir(t, f.Env)}
|
|
}
|
|
|
|
// The suite's own create request: valid as it stands, so a caller mutates only
|
|
// the field it cares about.
|
|
func validCreateInput(id string) *rds.CreateDBInstanceInput {
|
|
return &rds.CreateDBInstanceInput{
|
|
DBInstanceIdentifier: aws.String(id),
|
|
Engine: aws.String(dbEngine),
|
|
DBInstanceClass: aws.String(dbClass),
|
|
AllocatedStorage: aws.Int64(dbStorageGiB),
|
|
DBName: aws.String(dbName),
|
|
MasterUsername: aws.String(dbMasterUser),
|
|
MasterUserPassword: aws.String(dbMasterPassword),
|
|
}
|
|
}
|
|
|
|
// createDBInstance creates the suite's standard instance and returns the create
|
|
// response's own view of it — status creating, no endpoint yet.
|
|
func createDBInstance(t *testing.T, f *Fixture, id string, opts ...func(*rds.CreateDBInstanceInput)) *rds.DBInstance {
|
|
t.Helper()
|
|
return createDBInstanceFrom(t, f, validCreateInput(id), opts...)
|
|
}
|
|
|
|
// createDBInstanceFrom is createDBInstance for a request the caller assembled
|
|
// itself, which is what the MariaDB cases need: their engine, database name and
|
|
// credentials are not the suite's PostgreSQL defaults.
|
|
//
|
|
// Every test that owns an instance bottoms out here, because the three things a
|
|
// test that boots a DB VM must not forget are registered here: the teardown, so
|
|
// a failed run does not charge the next one, the failure-only diagnostic bundle,
|
|
// without which "create timed out" names no owning phase, and the charge against
|
|
// the test's DB-VM reservation.
|
|
func createDBInstanceFrom(t *testing.T, f *Fixture, in *rds.CreateDBInstanceInput,
|
|
opts ...func(*rds.CreateDBInstanceInput)) *rds.DBInstance {
|
|
t.Helper()
|
|
for _, opt := range opts {
|
|
opt(in)
|
|
}
|
|
id := aws.StringValue(in.DBInstanceIdentifier)
|
|
markDBCreateStarted(id)
|
|
out, err := f.AWS.RDS.CreateDBInstance(in) //nolint:staticcheck // e2e:allow-create — the instance under test
|
|
require.NoError(t, err, "create-db-instance %s", id)
|
|
require.NotNil(t, out.DBInstance)
|
|
t.Cleanup(func() { deleteInstance(t, f, id) })
|
|
harness.CaptureDBDiagnostics(t, f.dbDiag(t), id)
|
|
return out.DBInstance
|
|
}
|
|
|
|
// A create that must be refused, made with whichever principal the assertion is
|
|
// about. Deletes whatever it created if it was not refused: a create nobody
|
|
// expected to succeed is otherwise a DB VM nobody waits for and nobody tears down.
|
|
func expectCreateRefused(t *testing.T, f *Fixture, c *harness.AWSClient, code string, in *rds.CreateDBInstanceInput) {
|
|
t.Helper()
|
|
out, err := c.RDS.CreateDBInstance(in) //nolint:staticcheck // e2e:allow-create — asserted to be refused
|
|
if err == nil {
|
|
id := aws.StringValue(out.DBInstance.DBInstanceIdentifier)
|
|
deleteInstance(t, f, id)
|
|
t.Fatalf("create of %s was accepted; expected %s", id, code)
|
|
}
|
|
harness.AssertAWSError(t, err, code)
|
|
}
|
|
|
|
// Teardown for one instance: idempotent, and waits for the record to go so a
|
|
// group or a snapshot the next step deletes is no longer held.
|
|
func deleteInstance(t *testing.T, f *Fixture, id string) {
|
|
t.Helper()
|
|
deleteInstanceAs(t, f.AWS, id)
|
|
}
|
|
|
|
// The same teardown for an instance another tenant owns, which only that
|
|
// tenant's credentials can see at all.
|
|
func deleteInstanceAs(t *testing.T, c *harness.AWSClient, id string) {
|
|
t.Helper()
|
|
_, err := c.RDS.DeleteDBInstance(&rds.DeleteDBInstanceInput{
|
|
DBInstanceIdentifier: aws.String(id),
|
|
SkipFinalSnapshot: aws.Bool(true),
|
|
})
|
|
if err != nil {
|
|
if !harness.ErrorCodeIs(err, "DBInstanceNotFound") {
|
|
t.Logf("delete-db-instance %s: %v (left behind for manual teardown)", id, err)
|
|
}
|
|
return
|
|
}
|
|
harness.WaitForDBInstanceGone(t, c, id)
|
|
}
|
|
|
|
// Stamps the create so waitForAvailable can report what the boot cost.
|
|
func markDBCreateStarted(id string) {
|
|
latencyMu.Lock()
|
|
defer latencyMu.Unlock()
|
|
dbCreateStarted[id] = time.Now()
|
|
}
|