feat: RDS v1 foundations (#592)

This commit is contained in:
Julian Sommer
2026-07-29 10:38:22 +10:00
committed by GitHub
parent 6cf9ee9d03
commit 32d80b473a
67 changed files with 7951 additions and 412 deletions
+17 -24
View File
@@ -100,6 +100,12 @@ build-ecs-node-image: ## Build the spinifex-ecs-node AMI (Alpine + containerd +
import-ecs-node-image: ## Build + register the ecs-node AMI (requires a running cluster)
$(MAKE) build-system-image IMAGE=ecs-agent IMPORT=1
build-rds-postgres-image: ## Build the spinifex-rds-postgres AMI (Alpine + PostgreSQL 18 + rds-init; IMPORT=1 to register)
$(MAKE) build-system-image IMAGE=rds-postgres
import-rds-postgres-image: ## Build + register the rds-postgres AMI (requires a running cluster)
$(MAKE) build-system-image IMAGE=rds-postgres IMPORT=1
MICROVM_OUT_DIR := build/microvm
MICROVM_ARTIFACTS := $(MICROVM_OUT_DIR)/vmlinuz $(MICROVM_OUT_DIR)/initramfs.cpio.gz
MICROVM_INPUTS := scripts/build-microvm-image.sh $(MICROVM_OUT_DIR)/init.sh $(MICROVM_OUT_DIR)/inittab bin/lb-agent
@@ -120,15 +126,10 @@ install-microvm: $(MICROVM_ARTIFACTS) ## Install microVM artifacts to /usr/share
sudo install -m 0644 $(MICROVM_OUT_DIR)/vmlinuz /usr/share/spinifex/microvm/vmlinuz
sudo install -m 0644 $(MICROVM_OUT_DIR)/initramfs.cpio.gz /usr/share/spinifex/microvm/initramfs.cpio.gz
# Preflight — the pre-commit gate: manifest checks, lint, vuln, and the unit,
# race and e2e-harness tiers.
#
# The integration tier is deliberately NOT here. CI runs it as its own parallel
# job, so it is still gated before merge, and keeping it out leaves preflight
# the fast common-path check — run `make test-integration` directly when a
# change touches the gateway router or the NATS subjects it drives.
# the pre-commit gate: manifest checks, lint, vuln, and the unit and e2e-harness tiers.
# integration and race tests skipped to keep quick, they run in CI.
preflight:
@$(MAKE) --no-print-directory QUIET=1 manifest-check manifest-lint lint govulncheck test-cover diff-coverage test-race test-harness
@$(MAKE) --no-print-directory QUIET=1 manifest-check manifest-lint lint govulncheck test-cover diff-coverage test-harness
@echo -e "\n ✅ Preflight passed — safe to commit."
# E2E harness unit tests. Build-tagged `e2e` so they're skipped by the
@@ -139,25 +140,18 @@ test-harness:
$(_Q)LOG_IGNORE=1 go test -tags=e2e -timeout 60s ./tests/e2e/harness/... $(_RACEQ)
# In-process integration tier: the real gateway router against embedded NATS
# JetStream, with only the daemon-side NATS subjects stubbed. Build-tagged
# `integration` so it's skipped by the default `go test ./spinifex/...` and by
# `test-cover`/`test-race`. Nothing provisioned — no tofu, no docker, no
# Spinifex daemons, so the whole package runs in well under a minute. It gets
# its own PR-blocking CI job rather than the self-hosted, push-triggered live
# e2e tiers, so a regression here is caught before it can be merged, not just
# after.
# JetStream, with only the daemon-side NATS subjects stubbed.
test-integration:
@echo -e "\n....Running in-process integration tests...."
$(_Q)LOG_IGNORE=1 go test -tags=integration -timeout 60s ./tests/integration/... $(_RACEQ)
# Validate docs/service-interfaces.yaml. Schema check + cross-reference
# of services/suites/fixtures + on-disk path existence. Subject content
# vs source is enforced separately in Bead 5 drift lint.
# of services/suites/fixtures + on-disk path existence.
manifest-check:
@echo -e "\n....Checking service-interfaces.yaml...."
@go run ./tests/e2e/manifest-check/cmd/manifest-check -repo-root . -manifest docs/service-interfaces.yaml
# Drift guards (Bead 5): direct-create fixture lint + NATS subject lint,
# Drift guards: direct-create fixture lint + NATS subject lint,
# ratcheted against tests/e2e/manifest-lint/baseline.txt. Fails only on NEW
# drift beyond the baseline.
manifest-lint:
@@ -171,19 +165,19 @@ manifest-lint-update:
# Run unit tests
test:
@echo -e "\n....Running tests for $(GO_PROJECT_NAME)...."
LOG_IGNORE=1 go test -timeout 120s ./spinifex/...
GOFIPS140=v1.0.0 LOG_IGNORE=1 go test -timeout 180s ./spinifex/... ./cmd/... ./internal/...
# Run unit tests with coverage profile
COVERPROFILE ?= coverage.out
test-cover:
@echo -e "\n....Running tests with coverage for $(GO_PROJECT_NAME)...."
$(_Q)LOG_IGNORE=1 go test -timeout 120s -coverprofile=$(COVERPROFILE) -covermode=atomic ./spinifex/... $(_COVQ)
$(_Q)GOFIPS140=v1.0.0 LOG_IGNORE=1 go test -timeout 180s -coverprofile=$(COVERPROFILE) -covermode=atomic ./spinifex/... ./cmd/... ./internal/... $(_COVQ)
@scripts/check-coverage.sh $(COVERPROFILE) $(QUIET)
# Run unit tests with race detector
test-race:
@echo -e "\n....Running tests with race detector for $(GO_PROJECT_NAME)...."
$(_Q)LOG_IGNORE=1 go test -race -timeout 300s ./spinifex/... $(_RACEQ)
$(_Q)GOFIPS140=v1.0.0 LOG_IGNORE=1 go test -race -timeout 300s ./spinifex/... ./cmd/... ./internal/... $(_RACEQ)
# Unit tests for in-repo GitHub Actions (e.g. .github/actions/e2e-analyze).
# Kept out of `test-cover` so coverage % isn't diluted by CI-only tooling.
@@ -271,8 +265,7 @@ govulncheck:
$(_Q)go tool govulncheck ./...
@echo " govulncheck ok"
# NilAway — advisory nil-panic analysis. Not in preflight: it has a known
# false-positive rate, so findings are triaged by hand rather than gating commits.
# NilAway — advisory nil-panic analysis. Not in preflight due to false positives
nilaway:
@echo "Running nilaway..."
$(_Q)go tool nilaway -include-pkgs=github.com/mulgadc/spinifex -exclude-test-files ./...
@@ -321,7 +314,7 @@ distro-arm64:
distro-clean:
rm -rf dist/
.PHONY: build build-ui build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-microvm-image install-microvm go_build preflight test test-cover test-race diff-coverage bench test-actions test-harness test-integration manifest-check manifest-lint manifest-lint-update \
.PHONY: build build-ui build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-rds-postgres-image import-rds-postgres-image build-microvm-image install-microvm go_build preflight test test-cover test-race diff-coverage bench test-actions test-harness test-integration manifest-check manifest-lint manifest-lint-update \
deploy reinstall clean \
install-system install-go install-aws quickinstall \
lint fix govulncheck nilaway \
+2 -32
View File
@@ -12,17 +12,16 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"strings"
"time"
"github.com/mulgadc/spinifex/internal/ecrauth"
"github.com/mulgadc/spinifex/internal/guestenv"
"github.com/mulgadc/spinifex/internal/imdscreds"
_ "github.com/mulgadc/spinifex/internal/fipsboot"
@@ -144,13 +143,7 @@ func cacheDurationFrom(expiresAt time.Time) string {
// loadConfig reads the cloud-init env file then lets real env vars override. The
// IMDS base is fixed (not in the env file) but overridable for tests.
func loadConfig(envFile string) config {
env := parseEnvFile(envFile)
get := func(key string) string {
if v := os.Getenv(key); v != "" {
return v
}
return env[key]
}
get := guestenv.Load(envFile).Get
cfg := config{
GatewayURL: get("EKS_GATEWAY_URL"),
@@ -167,29 +160,6 @@ func loadConfig(envFile string) config {
return cfg
}
// parseEnvFile reads a simple KEY=value file; missing files yield an empty map.
func parseEnvFile(path string) map[string]string {
out := map[string]string{}
f, err := os.Open(path)
if err != nil {
return out
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
out[strings.TrimSpace(key)] = strings.TrimSpace(val)
}
return out
}
// emitEmpty writes a valid CredentialProviderResponse with no auth entries.
func emitEmpty(out io.Writer) {
resp := CredentialProviderResponse{
+3 -33
View File
@@ -1,11 +1,10 @@
package main
import (
"bufio"
"os"
"strconv"
"strings"
"time"
"github.com/mulgadc/spinifex/internal/guestenv"
)
const (
@@ -40,13 +39,7 @@ type config struct {
// loadConfig reads the cloud-init env file then lets real env vars override.
func loadConfig(envFile string) config {
env := parseEnvFile(envFile)
get := func(key string) string {
if v := os.Getenv(key); v != "" {
return v
}
return env[key]
}
get := guestenv.Load(envFile).Get
cfg := config{
GatewayURL: get("ECS_GATEWAY_URL"),
@@ -94,26 +87,3 @@ func loadConfig(envFile string) config {
}
return cfg
}
// parseEnvFile reads a simple KEY=value file; missing files yield an empty map.
func parseEnvFile(path string) map[string]string {
out := map[string]string{}
f, err := os.Open(path)
if err != nil {
return out
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
out[strings.TrimSpace(key)] = strings.TrimSpace(val)
}
return out
}
-15
View File
@@ -71,18 +71,3 @@ func TestLoadConfig_HeartbeatOverride(t *testing.T) {
t.Errorf("Heartbeat = %v, want default on bad value", cfg.Heartbeat)
}
}
func TestParseEnvFile_SkipsBlankAndComments(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "f.env")
if err := os.WriteFile(p, []byte("\n# c\nA=1\nnokeyval\nB = 2 \n"), 0o600); err != nil {
t.Fatal(err)
}
m := parseEnvFile(p)
if m["A"] != "1" || m["B"] != "2" {
t.Errorf("parse mismatch: %#v", m)
}
if _, ok := m["nokeyval"]; ok {
t.Errorf("line without = should be skipped")
}
}
+238
View File
@@ -0,0 +1,238 @@
package main
import (
"context"
"fmt"
"log/slog"
"net/url"
"strconv"
"time"
"github.com/mulgadc/spinifex/internal/gwsign"
"github.com/mulgadc/spinifex/internal/rdsgw"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
)
// Overridable via -ldflags "-X main.version=...".
var version = "dev"
// The gateway resolves the authoritative DB instance from the request's
// credentials, so DBInstanceIdentifier is an assertion to be checked, not
// trusted.
type identity struct {
DBInstanceIdentifier string
AgentVersion string
EngineVersion string
}
// Every method is a SigV4-signed Query request, so the NATS bus stays
// host-internal.
type controlPlane interface {
Register(ctx context.Context, id identity) (*handlers_rds.RegisterDBInstanceOutput, error)
SubmitState(ctx context.Context, id identity, health handlers_rds.EngineHealth, message string) (*handlers_rds.SubmitDBStateChangeOutput, error)
// The first call of an instance's life returns Mode=initialize with the
// master password; every later call returns Mode=attach without it.
GetBootstrapConfig(ctx context.Context, id identity) (*handlers_rds.GetDBBootstrapConfigOutput, error)
PollCommands(ctx context.Context, id identity, replies []handlers_rds.CommandReply, wait time.Duration) ([]handlers_rds.Command, error)
}
// Bounds a register, heartbeat or bootstrap request. The long poll sets its own
// deadline from the wait it asked the gateway to hold.
const callTimeout = 15 * time.Second
// Keeps the poll's deadline past the requested wait, so a gateway answering at
// the end of its window is not cut off by the client.
const pollSlack = 10 * time.Second
type gatewayControlPlane struct {
client *rdsgw.Client
}
var _ controlPlane = (*gatewayControlPlane)(nil)
// Signs with the instance-role credentials the SDK chain resolves from IMDS,
// against the pinned gateway CA.
func newGatewayControlPlane(cfg config) (*gatewayControlPlane, error) {
signer, err := gwsign.NewIMDS(context.Background(), cfg.Region)
if err != nil {
return nil, fmt.Errorf("build IMDS signer: %w", err)
}
// The client timeout is the ceiling for the longest call, the long poll;
// shorter calls carry their own deadline.
client, err := rdsgw.New(cfg.GatewayURL, cfg.GatewayCA, signer, cfg.Region, cfg.PollWait+pollSlack)
if err != nil {
return nil, err
}
return &gatewayControlPlane{client: client}, nil
}
func (g *gatewayControlPlane) Register(ctx context.Context, id identity) (*handlers_rds.RegisterDBInstanceOutput, error) {
params := url.Values{}
setIfPresent(params, "DBInstanceIdentifier", id.DBInstanceIdentifier)
setIfPresent(params, "AgentVersion", id.AgentVersion)
setIfPresent(params, "EngineVersion", id.EngineVersion)
ctx, cancel := context.WithTimeout(ctx, callTimeout)
defer cancel()
var out handlers_rds.RegisterDBInstanceOutput
if err := g.client.Call(ctx, "RegisterDBInstance", params, &out); err != nil {
return nil, err
}
return &out, nil
}
func (g *gatewayControlPlane) SubmitState(ctx context.Context, id identity, health handlers_rds.EngineHealth, message string) (*handlers_rds.SubmitDBStateChangeOutput, error) {
params := url.Values{"EngineHealth": {string(health)}}
setIfPresent(params, "DBInstanceIdentifier", id.DBInstanceIdentifier)
setIfPresent(params, "EngineVersion", id.EngineVersion)
setIfPresent(params, "Message", message)
ctx, cancel := context.WithTimeout(ctx, callTimeout)
defer cancel()
var out handlers_rds.SubmitDBStateChangeOutput
if err := g.client.Call(ctx, "SubmitDBStateChange", params, &out); err != nil {
return nil, err
}
return &out, nil
}
func (g *gatewayControlPlane) GetBootstrapConfig(ctx context.Context, id identity) (*handlers_rds.GetDBBootstrapConfigOutput, error) {
params := url.Values{}
setIfPresent(params, "DBInstanceIdentifier", id.DBInstanceIdentifier)
ctx, cancel := context.WithTimeout(ctx, callTimeout)
defer cancel()
var out handlers_rds.GetDBBootstrapConfigOutput
if err := g.client.Call(ctx, "GetDBBootstrapConfig", params, &out); err != nil {
return nil, err
}
return &out, nil
}
// Declared here so the in-guest binary does not link the server side of the API.
type pollDBCommandsOutput struct {
Commands []handlers_rds.Command `xml:"Commands>member"`
}
func (g *gatewayControlPlane) PollCommands(ctx context.Context, id identity, replies []handlers_rds.CommandReply, wait time.Duration) ([]handlers_rds.Command, error) {
params := url.Values{"WaitTimeSeconds": {strconv.FormatInt(int64(wait.Seconds()), 10)}}
setIfPresent(params, "DBInstanceIdentifier", id.DBInstanceIdentifier)
// Replies ride the poll rather than a separate action, so replying and
// asking for the next command are one round trip.
for i, reply := range replies {
member := fmt.Sprintf("Replies.member.%d.", i+1)
params.Set(member+"CommandId", reply.CommandID)
params.Set(member+"Status", reply.Status)
setIfPresent(params, member+"Message", reply.Message)
}
ctx, cancel := context.WithTimeout(ctx, wait+pollSlack)
defer cancel()
var out pollDBCommandsOutput
if err := g.client.Call(ctx, "PollDBCommands", params, &out); err != nil {
return nil, err
}
return out.Commands, nil
}
// Only sets a parameter that has a value, so the gateway can tell "no
// assertion" from "asserted as blank".
func setIfPresent(params url.Values, key, value string) {
if value != "" {
params.Set(key, value)
}
}
type Agent struct {
cfg config
id identity
cp controlPlane
probe *engineProbe
handoffWriter func(string, *handlers_rds.GetDBBootstrapConfigOutput) error
hb *heartbeater
cmd *commander
}
// Assembles from already-built parts, so tests can pass fakes; New builds the
// production control plane and delegates here.
func newAgent(cfg config, cp controlPlane, probe *engineProbe) *Agent {
id := identity{
DBInstanceIdentifier: cfg.DBInstanceIdentifier,
AgentVersion: version,
EngineVersion: cfg.EngineVersion,
}
a := &Agent{cfg: cfg, id: id, cp: cp, probe: probe, handoffWriter: writeHandoff}
a.hb = newHeartbeater(cp, probe, handlers_rds.HeartbeatInterval)
a.cmd = newCommander(cp, newCommandRegistry(), cfg.PollWait)
return a
}
// Does not wait for IMDS: the register loop rides out a datapath still coming up.
func New(cfg config) (*Agent, error) {
if cfg.GatewayURL == "" {
return nil, fmt.Errorf("no gateway URL configured (RDS_GATEWAY_URL)")
}
cp, err := newGatewayControlPlane(cfg)
if err != nil {
return nil, err
}
return newAgent(cfg, cp, newEngineProbe(cfg, execProbeRunner)), nil
}
func (a *Agent) Run(ctx context.Context) error {
// Registration comes first: its response is the agent's identity.
if err := a.register(ctx); err != nil {
return err
}
a.hb.id, a.cmd.id = a.id, a.id
// Beating before the bootstrap keeps a stuck boot visible as a live VM with
// a down engine rather than as silence.
go a.hb.Run(ctx)
if err := a.bootstrap(ctx); err != nil {
return err
}
// Directives are only meaningful against a bootstrapped engine.
go a.cmd.Run(ctx)
<-ctx.Done()
return nil
}
// Boot-retry bounds. Tight to start, since the usual cause is a control plane
// still creating this instance's record, and capped so an outage does not turn
// a booting fleet into a retry storm.
const (
retryMin = 1 * time.Second
retryMax = 30 * time.Second
)
// Never gives up on its own: an agent that stopped trying would leave the
// control plane with no signal at all.
func retry(ctx context.Context, what string, fn func(context.Context) error) error {
delay := retryMin
for attempt := 1; ; attempt++ {
err := fn(ctx)
if err == nil {
return nil
}
if ctx.Err() != nil {
return fmt.Errorf("%s: %w", what, ctx.Err())
}
slog.Warn("rds-agent: "+what+" failed, retrying", "attempt", attempt, "retryIn", delay, "err", err)
select {
case <-ctx.Done():
return fmt.Errorf("%s: %w", what, ctx.Err())
case <-time.After(delay):
}
delay = min(delay*2, retryMax)
}
}
+467
View File
@@ -0,0 +1,467 @@
package main
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/mulgadc/spinifex/internal/gwsign"
"github.com/mulgadc/spinifex/internal/rdsgw"
gateway_rds "github.com/mulgadc/spinifex/spinifex/gateway/rds"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
"github.com/mulgadc/spinifex/spinifex/utils"
)
// fakeControlPlane is a scriptable controlPlane. Every call records what the
// agent sent, so a test asserts on the request as well as the reaction.
type fakeControlPlane struct {
mu sync.Mutex
registerOut *handlers_rds.RegisterDBInstanceOutput
registerErrs []error
registerReqs []identity
bootstrapOut *handlers_rds.GetDBBootstrapConfigOutput
bootstrapErrs []error
bootstrapReqs []identity
states []submittedState
pollReplies [][]handlers_rds.CommandReply
pollQueue [][]handlers_rds.Command
pollErr error
polled chan struct{}
}
type submittedState struct {
id identity
health handlers_rds.EngineHealth
message string
}
var _ controlPlane = (*fakeControlPlane)(nil)
func newFakeControlPlane() *fakeControlPlane {
return &fakeControlPlane{
registerOut: &handlers_rds.RegisterDBInstanceOutput{
DBInstanceIdentifier: "db-resolved", HeartbeatIntervalSeconds: 30,
},
polled: make(chan struct{}, 64),
}
}
// nextErr pops the next scripted error, so a test can fail a call n times and
// then let it succeed.
func nextErr(errs *[]error) error {
if len(*errs) == 0 {
return nil
}
err := (*errs)[0]
*errs = (*errs)[1:]
return err
}
func (f *fakeControlPlane) Register(_ context.Context, id identity) (*handlers_rds.RegisterDBInstanceOutput, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.registerReqs = append(f.registerReqs, id)
if err := nextErr(&f.registerErrs); err != nil {
return nil, err
}
return f.registerOut, nil
}
func (f *fakeControlPlane) SubmitState(_ context.Context, id identity, health handlers_rds.EngineHealth, message string) (*handlers_rds.SubmitDBStateChangeOutput, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.states = append(f.states, submittedState{id: id, health: health, message: message})
// Zero means "keep the cadence you have", so a test's fast interval is not
// reset by every beat.
return &handlers_rds.SubmitDBStateChangeOutput{Acknowledged: true}, nil
}
func (f *fakeControlPlane) GetBootstrapConfig(_ context.Context, id identity) (*handlers_rds.GetDBBootstrapConfigOutput, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.bootstrapReqs = append(f.bootstrapReqs, id)
if err := nextErr(&f.bootstrapErrs); err != nil {
return nil, err
}
return f.bootstrapOut, nil
}
func (f *fakeControlPlane) PollCommands(ctx context.Context, _ identity, replies []handlers_rds.CommandReply, _ time.Duration) ([]handlers_rds.Command, error) {
f.mu.Lock()
f.pollReplies = append(f.pollReplies, replies)
err := f.pollErr
var out []handlers_rds.Command
if len(f.pollQueue) > 0 {
out, f.pollQueue = f.pollQueue[0], f.pollQueue[1:]
}
f.mu.Unlock()
select {
case f.polled <- struct{}{}:
default:
}
if err != nil {
return nil, err
}
if len(out) > 0 {
return out, nil
}
// An idle window: block until the agent hangs up, as the gateway's long poll
// does, rather than spinning the caller's loop.
<-ctx.Done()
return nil, ctx.Err()
}
func (f *fakeControlPlane) snapshotStates() []submittedState {
f.mu.Lock()
defer f.mu.Unlock()
return append([]submittedState(nil), f.states...)
}
func (f *fakeControlPlane) snapshotReplies() [][]handlers_rds.CommandReply {
f.mu.Lock()
defer f.mu.Unlock()
return append([][]handlers_rds.CommandReply(nil), f.pollReplies...)
}
// testConfig points the agent's file output at a temp dir and its probe at a
// name no real binary answers to.
func testConfig(t *testing.T) config {
t.Helper()
return config{
GatewayURL: "https://gw.test",
HandoffDir: filepath.Join(t.TempDir(), "spinifex-rds"),
EngineHost: defaultEngineHost,
EnginePort: defaultEnginePort,
PGIsReady: "pg_isready",
PollWait: time.Second,
}
}
// staticProbe is a probeRunner that always reports the same exit code.
func staticProbe(code int) probeRunner {
return func(context.Context, string, ...string) (int, error) { return code, nil }
}
func bootstrapOutput(password *string) *handlers_rds.GetDBBootstrapConfigOutput {
mode := handlers_rds.BootstrapModeAttach
if password != nil {
mode = handlers_rds.BootstrapModeInitialize
}
return &handlers_rds.GetDBBootstrapConfigOutput{
Mode: mode,
DBInstanceIdentifier: "db-resolved",
Engine: "postgres",
MasterUsername: "master",
MasterUserPassword: password,
Port: 5432,
}
}
// runAgent runs the agent until its bootstrap has landed, then cancels. It
// returns once Run has exited so no goroutine outlives the test.
func runAgent(t *testing.T, a *Agent) error {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
done := make(chan error, 1)
go func() { done <- a.Run(ctx) }()
waitFor(t, func() bool {
_, err := os.Stat(filepath.Join(a.cfg.HandoffDir, handoffEnvFile))
return err == nil
}, "handoff file to be written")
cancel()
select {
case err := <-done:
return err
case <-time.After(5 * time.Second):
t.Fatal("Run did not exit after cancellation")
return nil
}
}
// waitFor polls cond until it holds or the test times out.
func waitFor(t *testing.T, cond func() bool, what string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", what)
}
func TestRun_RegistersThenDeliversBootstrap(t *testing.T) {
password := "s3cret"
cp := newFakeControlPlane()
cp.bootstrapOut = bootstrapOutput(&password)
cfg := testConfig(t)
a := newAgent(cfg, cp, newEngineProbe(cfg, staticProbe(0)))
if err := runAgent(t, a); err != nil {
t.Fatalf("Run: %v", err)
}
if len(cp.registerReqs) != 1 {
t.Fatalf("registered %d times, want 1", len(cp.registerReqs))
}
// The agent had no configured identifier, so the gateway's resolution is
// what every later call must carry.
if got := cp.bootstrapReqs[0].DBInstanceIdentifier; got != "db-resolved" {
t.Errorf("bootstrap sent identifier %q, want the registered db-resolved", got)
}
env := readFile(t, filepath.Join(cfg.HandoffDir, handoffEnvFile))
if !strings.Contains(env, "RDS_MODE='initialize'") || !strings.Contains(env, "RDS_MASTER_PASSWORD='s3cret'") {
t.Errorf("handoff env = %q, want initialize mode carrying the password", env)
}
// The probe follows the port the control plane assigned, not the default.
if got := a.probe.port.Load(); got != 5432 {
t.Errorf("probe port = %d, want 5432", got)
}
}
// A reboot re-fetches in attach mode. The agent must write that payload as it
// stands rather than refusing it or inventing a password.
func TestRun_AttachModeNeedsNoPassword(t *testing.T) {
cp := newFakeControlPlane()
cp.bootstrapOut = bootstrapOutput(nil)
cfg := testConfig(t)
if err := runAgent(t, newAgent(cfg, cp, newEngineProbe(cfg, staticProbe(0)))); err != nil {
t.Fatalf("Run: %v", err)
}
env := readFile(t, filepath.Join(cfg.HandoffDir, handoffEnvFile))
if !strings.Contains(env, "RDS_MODE='attach'") {
t.Errorf("handoff env = %q, want attach mode", env)
}
if strings.Contains(env, "RDS_MASTER_PASSWORD") {
t.Errorf("handoff env = %q, want no password key at all in attach mode", env)
}
}
// The record may not exist yet when a freshly launched VM first calls, so both
// boot-critical calls have to ride out failures instead of exiting.
func TestRun_RetriesRegisterAndBootstrap(t *testing.T) {
cp := newFakeControlPlane()
cp.bootstrapOut = bootstrapOutput(nil)
cp.registerErrs = []error{errors.New("DBInstanceNotFound")}
cp.bootstrapErrs = []error{errors.New("ServerInternal")}
cfg := testConfig(t)
if err := runAgent(t, newAgent(cfg, cp, newEngineProbe(cfg, staticProbe(0)))); err != nil {
t.Fatalf("Run: %v", err)
}
if len(cp.registerReqs) != 2 {
t.Errorf("register attempts = %d, want 2 (one failure, one success)", len(cp.registerReqs))
}
if len(cp.bootstrapReqs) != 2 {
t.Errorf("bootstrap attempts = %d, want 2 (one failure, one success)", len(cp.bootstrapReqs))
}
}
func TestBootstrap_RetriesHandoffWithoutRefetchingConsumedPassword(t *testing.T) {
cp := newFakeControlPlane()
password := "one-shot-secret"
cp.bootstrapOut = bootstrapOutput(&password)
cfg := testConfig(t)
a := newAgent(cfg, cp, newEngineProbe(cfg, staticProbe(0)))
writes := 0
a.handoffWriter = func(_ string, got *handlers_rds.GetDBBootstrapConfigOutput) error {
writes++
if got.MasterUserPassword == nil || *got.MasterUserPassword != password {
t.Fatalf("handoff attempt %d lost the initialize password", writes)
}
if writes == 1 {
return errors.New("temporary tmpfs write error")
}
return nil
}
if err := a.bootstrap(t.Context()); err != nil {
t.Fatalf("bootstrap: %v", err)
}
if len(cp.bootstrapReqs) != 1 {
t.Errorf("bootstrap fetches = %d, want 1", len(cp.bootstrapReqs))
}
if writes != 2 {
t.Errorf("handoff writes = %d, want 2", writes)
}
}
// A configured identifier is asserted on the wire so a mis-provisioned VM is
// rejected by the gateway instead of adopting whatever it resolves to.
func TestRun_SendsConfiguredIdentifier(t *testing.T) {
cp := newFakeControlPlane()
cp.bootstrapOut = bootstrapOutput(nil)
cfg := testConfig(t)
cfg.DBInstanceIdentifier = "db-configured"
cfg.EngineVersion = "18.1"
if err := runAgent(t, newAgent(cfg, cp, newEngineProbe(cfg, staticProbe(0)))); err != nil {
t.Fatalf("Run: %v", err)
}
sent := cp.registerReqs[0]
if sent.DBInstanceIdentifier != "db-configured" || sent.EngineVersion != "18.1" {
t.Errorf("register sent %+v, want the configured identifier and engine version", sent)
}
}
func TestRun_HeartbeatsEngineHealth(t *testing.T) {
cp := newFakeControlPlane()
cp.bootstrapOut = bootstrapOutput(nil)
// Declining to set a cadence leaves the agent's own, which the test shortens
// rather than waiting out the 30s production interval.
cp.registerOut.HeartbeatIntervalSeconds = 0
cfg := testConfig(t)
a := newAgent(cfg, cp, newEngineProbe(cfg, staticProbe(0)))
a.hb.interval = 10 * time.Millisecond
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
done := make(chan error, 1)
go func() { done <- a.Run(ctx) }()
waitFor(t, func() bool { return len(cp.snapshotStates()) > 0 }, "a heartbeat")
cancel()
<-done
beat := cp.snapshotStates()[0]
if beat.health != handlers_rds.EngineHealthHealthy {
t.Errorf("heartbeat health = %q, want healthy", beat.health)
}
if beat.id.DBInstanceIdentifier != "db-resolved" {
t.Errorf("heartbeat identifier = %q, want db-resolved", beat.id.DBInstanceIdentifier)
}
}
// The cadence is the control plane's to set: it feeds the staleness window the
// recovery reconciler judges an instance against, so the guest adopts it rather
// than keeping its own.
func TestRegister_AdoptsControlPlaneCadence(t *testing.T) {
cp := newFakeControlPlane()
cp.bootstrapOut = bootstrapOutput(nil)
cp.registerOut.HeartbeatIntervalSeconds = 45
cfg := testConfig(t)
a := newAgent(cfg, cp, newEngineProbe(cfg, staticProbe(0)))
if err := runAgent(t, a); err != nil {
t.Fatalf("Run: %v", err)
}
if a.hb.interval != 45*time.Second {
t.Errorf("heartbeat interval = %v, want the 45s the control plane returned", a.hb.interval)
}
}
// The agent being up says nothing about the engine. A down engine has to reach
// the control plane as such, or auto-recovery has nothing to act on.
func TestRun_HeartbeatReportsDownEngine(t *testing.T) {
cp := newFakeControlPlane()
cp.bootstrapOut = bootstrapOutput(nil)
cp.registerOut.HeartbeatIntervalSeconds = 0
cfg := testConfig(t)
probe := newEngineProbe(cfg, staticProbe(2))
// The engine has served before, so a silent one now is a failure rather
// than a boot still in progress.
probe.seenHealthy = true
a := newAgent(cfg, cp, probe)
a.hb.interval = 10 * time.Millisecond
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
done := make(chan error, 1)
go func() { done <- a.Run(ctx) }()
waitFor(t, func() bool { return len(cp.snapshotStates()) > 0 }, "a heartbeat")
cancel()
<-done
beat := cp.snapshotStates()[0]
if beat.health != handlers_rds.EngineHealthUnhealthy {
t.Errorf("heartbeat health = %q, want unhealthy while the agent is up", beat.health)
}
if beat.message == "" {
t.Error("unhealthy heartbeat carried no message explaining the probe result")
}
}
// The mirror the agent decodes poll responses into has to stay aligned with the
// gateway type that produces them; nothing else in the build ties the two.
func TestPollDBCommandsOutput_MatchesGatewayShape(t *testing.T) {
issued := time.Date(2026, 7, 28, 1, 2, 3, 0, time.UTC)
// Rendered exactly as the gateway's typed adapter renders it, so a change to
// either side's tags fails here.
payload := utils.GenerateIAMXMLPayload("PollDBCommands", &gateway_rds.PollDBCommandsOutput{
Commands: []handlers_rds.Command{{
CommandID: "cmd-1",
Type: "reload-parameters",
Parameters: []handlers_rds.Parameter{{Name: "work_mem", Value: "8MB"}},
IssuedAt: &issued,
}},
})
body, err := utils.MarshalToXML(payload)
if err != nil {
t.Fatalf("marshal poll result: %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/xml")
_, _ = w.Write(body)
}))
defer srv.Close()
client, err := rdsgw.New(srv.URL, "", gwsign.NewStatic("AKIATEST", "secret"), "us-east-1", time.Second)
if err != nil {
t.Fatalf("rdsgw.New: %v", err)
}
var got pollDBCommandsOutput
if err := client.Call(context.Background(), "PollDBCommands", nil, &got); err != nil {
t.Fatalf("Call: %v", err)
}
if len(got.Commands) != 1 {
t.Fatalf("decoded %d commands, want 1", len(got.Commands))
}
cmd := got.Commands[0]
if cmd.CommandID != "cmd-1" || cmd.Type != "reload-parameters" {
t.Errorf("command = %+v, want cmd-1/reload-parameters", cmd)
}
if len(cmd.Parameters) != 1 || cmd.Parameters[0].Value != "8MB" {
t.Errorf("Parameters = %+v, want the seeded member", cmd.Parameters)
}
if cmd.IssuedAt == nil || !cmd.IssuedAt.Equal(issued) {
t.Errorf("IssuedAt = %v, want %v", cmd.IssuedAt, issued)
}
}
func readFile(t *testing.T, path string) string {
t.Helper()
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(body)
}
+170
View File
@@ -0,0 +1,170 @@
package main
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
)
// Fetches the boot material and writes the handoff rds-init is blocked waiting
// for, then points the health probe at the assigned port.
func (a *Agent) bootstrap(ctx context.Context) error {
var cfg *handlers_rds.GetDBBootstrapConfigOutput
if err := retry(ctx, "bootstrap fetch", func(ctx context.Context) error {
fetched, err := a.cp.GetBootstrapConfig(ctx, a.id)
if err != nil {
return err
}
if fetched == nil {
return fmt.Errorf("bootstrap fetch returned no config")
}
cfg = fetched
return nil
}); err != nil {
return err
}
// Once an initialize response reaches the guest, keep retrying that same
// payload. Re-fetching after a local write failure returns attach because the
// control plane has already consumed the one-shot password.
if err := retry(ctx, "bootstrap handoff", func(context.Context) error {
return a.handoffWriter(a.cfg.HandoffDir, cfg)
}); err != nil {
return err
}
if cfg.Port > 0 {
a.probe.setPort(int(cfg.Port))
}
// Mode is logged but never branched on: rds-init decides whether to initdb
// from the state of the datadir.
slog.Info("rds-agent: bootstrap config delivered",
"mode", cfg.Mode, "port", cfg.Port, "parameters", len(cfg.Parameters))
return nil
}
// The handoff files rds-init reads. They live on tmpfs, so nothing survives a
// reboot and the next boot re-fetches rather than reusing a stale password or a
// re-minted cert.
const (
handoffEnvFile = "bootstrap.env"
handoffParamsFile = "parameters.conf"
handoffCertFile = "server.crt"
handoffKeyFile = "server.key"
// Root-only: only rds-init reads these, and one holds the master password.
handoffMode = 0o600
handoffDirMode = 0o700
)
// bootstrap.env is written last and renamed into place, so its appearance —
// which rds-init waits on — means the whole handoff is complete.
func writeHandoff(dir string, cfg *handlers_rds.GetDBBootstrapConfigOutput) error {
if cfg.MasterUsername == "" {
return fmt.Errorf("bootstrap config carries no master username")
}
if err := os.MkdirAll(dir, handoffDirMode); err != nil {
return fmt.Errorf("create handoff dir %s: %w", dir, err)
}
// MkdirAll leaves an existing directory's mode alone, and this one may have
// been created more permissively earlier in the boot.
if err := os.Chmod(dir, handoffDirMode); err != nil {
return fmt.Errorf("secure handoff dir %s: %w", dir, err)
}
if err := writeHandoffFile(dir, handoffParamsFile, renderParameters(cfg.Parameters)); err != nil {
return err
}
// Cert and key are written as a pair; half of one would start the engine
// with TLS configured against a key it does not have.
if cfg.ServingCertificate != "" && cfg.ServingPrivateKey != "" {
if err := writeHandoffFile(dir, handoffCertFile, cfg.ServingCertificate); err != nil {
return err
}
if err := writeHandoffFile(dir, handoffKeyFile, cfg.ServingPrivateKey); err != nil {
return err
}
}
return writeHandoffFile(dir, handoffEnvFile, renderBootstrapEnv(cfg))
}
// Every value is single-quoted because rds-init sources this with `.`: an
// unquoted password holding a space, `$` or `;` would be word-split or executed.
func renderBootstrapEnv(cfg *handlers_rds.GetDBBootstrapConfigOutput) string {
var b strings.Builder
b.WriteString("# Written by rds-agent. Regenerated on every boot; edits are lost.\n")
writeEnvLine(&b, "RDS_MODE", cfg.Mode)
writeEnvLine(&b, "RDS_MASTER_USERNAME", cfg.MasterUsername)
// Present only in initialize mode: an attach fetch has no password to write
// and rds-init must not find a stale one.
if cfg.MasterUserPassword != nil {
writeEnvLine(&b, "RDS_MASTER_PASSWORD", *cfg.MasterUserPassword)
}
if cfg.DBName != "" {
writeEnvLine(&b, "RDS_DB_NAME", cfg.DBName)
}
if cfg.Port > 0 {
writeEnvLine(&b, "RDS_PORT", strconv.FormatInt(cfg.Port, 10))
}
return b.String()
}
func writeEnvLine(b *strings.Builder, key, value string) {
b.WriteString(key)
b.WriteString("=")
b.WriteString(shellQuote(value))
b.WriteString("\n")
}
// Single quotes suppress every shell expansion; an embedded quote is closed,
// escaped and reopened.
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// postgresql.conf syntax. Values are quoted so a setting with a space or unit
// suffix survives; the engine accepts quoted numerics and booleans too.
func renderParameters(params []handlers_rds.Parameter) string {
var b strings.Builder
b.WriteString("# Resolved parameter group, written by rds-agent.\n")
for _, p := range params {
if p.Name == "" {
continue
}
fmt.Fprintf(&b, "%s = '%s'\n", p.Name, strings.ReplaceAll(p.Value, "'", "''"))
}
return b.String()
}
// Atomic: a temp file in the same directory, renamed over the target. The mode
// is set at creation, so content is never briefly readable at the process umask.
func writeHandoffFile(dir, name, content string) error {
path := filepath.Join(dir, name)
tmp := path + ".new"
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, handoffMode)
if err != nil {
return fmt.Errorf("create %s: %w", tmp, err)
}
// A failed write must not leave the temp file behind holding a password.
if _, err := f.WriteString(content); err != nil {
f.Close()
os.Remove(tmp)
return fmt.Errorf("write %s: %w", tmp, err)
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return fmt.Errorf("close %s: %w", tmp, err)
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return fmt.Errorf("install %s: %w", path, err)
}
return nil
}
+202
View File
@@ -0,0 +1,202 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
)
func TestWriteHandoff_WritesEveryFileRootOnly(t *testing.T) {
dir := filepath.Join(t.TempDir(), "handoff")
password := "s3cret"
err := writeHandoff(dir, &handlers_rds.GetDBBootstrapConfigOutput{
Mode: handlers_rds.BootstrapModeInitialize,
MasterUsername: "master",
MasterUserPassword: &password,
DBName: "appdb",
Port: 5433,
Parameters: []handlers_rds.Parameter{
{Name: "max_connections", Value: "200"},
{Name: "shared_buffers", Value: "256MB"},
},
ServingCertificate: "CERT",
ServingPrivateKey: "KEY",
})
if err != nil {
t.Fatalf("writeHandoff: %v", err)
}
env := readFile(t, filepath.Join(dir, handoffEnvFile))
for _, want := range []string{
"RDS_MODE='initialize'",
"RDS_MASTER_USERNAME='master'",
"RDS_MASTER_PASSWORD='s3cret'",
"RDS_DB_NAME='appdb'",
"RDS_PORT='5433'",
} {
if !strings.Contains(env, want) {
t.Errorf("handoff env missing %s:\n%s", want, env)
}
}
params := readFile(t, filepath.Join(dir, handoffParamsFile))
if !strings.Contains(params, "max_connections = '200'") || !strings.Contains(params, "shared_buffers = '256MB'") {
t.Errorf("parameters.conf = %q, want both resolved settings", params)
}
if got := readFile(t, filepath.Join(dir, handoffCertFile)); got != "CERT" {
t.Errorf("server.crt = %q, want CERT", got)
}
if got := readFile(t, filepath.Join(dir, handoffKeyFile)); got != "KEY" {
t.Errorf("server.key = %q, want KEY", got)
}
// The handoff holds a master password and a private key; anything readable
// beyond root is a disclosure to every process in the guest.
if mode := statMode(t, dir); mode != handoffDirMode {
t.Errorf("handoff dir mode = %#o, want %#o", mode, handoffDirMode)
}
for _, name := range []string{handoffEnvFile, handoffParamsFile, handoffCertFile, handoffKeyFile} {
if mode := statMode(t, filepath.Join(dir, name)); mode != handoffMode {
t.Errorf("%s mode = %#o, want %#o", name, mode, handoffMode)
}
}
}
// Attach mode carries no password, and the key must be absent rather than
// present and empty — rds-init reads an empty value as "no password" only by
// accident, and a later boot would inherit it.
func TestWriteHandoff_AttachOmitsPassword(t *testing.T) {
dir := filepath.Join(t.TempDir(), "handoff")
if err := writeHandoff(dir, &handlers_rds.GetDBBootstrapConfigOutput{
Mode: handlers_rds.BootstrapModeAttach,
MasterUsername: "master",
Port: 5432,
}); err != nil {
t.Fatalf("writeHandoff: %v", err)
}
if env := readFile(t, filepath.Join(dir, handoffEnvFile)); strings.Contains(env, "RDS_MASTER_PASSWORD") {
t.Errorf("handoff env = %q, want no RDS_MASTER_PASSWORD key", env)
}
}
// The env file is sourced by a shell, so a password holding shell metacharacters
// must survive as data rather than being split, expanded or executed.
func TestWriteHandoff_QuotesShellMetacharacters(t *testing.T) {
dir := filepath.Join(t.TempDir(), "handoff")
password := `a b$(id);'"\` + "`whoami`"
if err := writeHandoff(dir, &handlers_rds.GetDBBootstrapConfigOutput{
Mode: handlers_rds.BootstrapModeInitialize,
MasterUsername: "master",
MasterUserPassword: &password,
Port: 5432,
}); err != nil {
t.Fatalf("writeHandoff: %v", err)
}
env := readFile(t, filepath.Join(dir, handoffEnvFile))
line := findLine(t, env, "RDS_MASTER_PASSWORD=")
if got := shellSource(t, line, "RDS_MASTER_PASSWORD"); got != password {
t.Errorf("sourced password = %q, want %q (line was %s)", got, password, line)
}
}
// A serving cert with no key would have rds-init configure TLS against a key
// that is not there; the pair is delivered together or not at all.
func TestWriteHandoff_SkipsHalfATLSPair(t *testing.T) {
dir := filepath.Join(t.TempDir(), "handoff")
if err := writeHandoff(dir, &handlers_rds.GetDBBootstrapConfigOutput{
Mode: handlers_rds.BootstrapModeAttach,
MasterUsername: "master",
Port: 5432,
ServingCertificate: "CERT",
}); err != nil {
t.Fatalf("writeHandoff: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, handoffCertFile)); !os.IsNotExist(err) {
t.Errorf("server.crt exists without a key; stat err = %v", err)
}
}
// A config with no master username cannot bootstrap anything, and writing it
// would only move the failure into rds-init after the boot has committed to it.
func TestWriteHandoff_RejectsConfigWithNoMasterUser(t *testing.T) {
dir := filepath.Join(t.TempDir(), "handoff")
err := writeHandoff(dir, &handlers_rds.GetDBBootstrapConfigOutput{
Mode: handlers_rds.BootstrapModeAttach,
Port: 5432,
})
if err == nil {
t.Fatal("writeHandoff accepted a config with no master username, want an error")
}
if _, statErr := os.Stat(filepath.Join(dir, handoffEnvFile)); !os.IsNotExist(statErr) {
t.Errorf("handoff env was written despite the rejection; stat err = %v", statErr)
}
}
// A directory left over from an earlier boot at a looser mode must be tightened,
// not accepted as it is found.
func TestWriteHandoff_TightensExistingDirectory(t *testing.T) {
dir := filepath.Join(t.TempDir(), "handoff")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("seed dir: %v", err)
}
if err := writeHandoff(dir, &handlers_rds.GetDBBootstrapConfigOutput{
Mode: handlers_rds.BootstrapModeAttach,
MasterUsername: "master",
Port: 5432,
}); err != nil {
t.Fatalf("writeHandoff: %v", err)
}
if mode := statMode(t, dir); mode != handoffDirMode {
t.Errorf("handoff dir mode = %#o, want it tightened to %#o", mode, handoffDirMode)
}
}
func TestShellQuote_EscapesEmbeddedQuotes(t *testing.T) {
for _, in := range []string{"", "plain", "it's", `''`, "new\nline", `$HOME`} {
if got := shellSource(t, "V="+shellQuote(in), "V"); got != in {
t.Errorf("shellQuote(%q) sourced back as %q", in, got)
}
}
}
// shellSource evaluates one KEY=value assignment in a real shell and returns
// what the variable ends up holding. The quoting is only correct if a shell
// agrees, so the test asks one instead of re-implementing its rules.
func shellSource(t *testing.T, line, name string) string {
t.Helper()
out, err := exec.Command("/bin/sh", "-c", line+"\nprintf %s \"$"+name+"\"\n").Output()
if err != nil {
t.Fatalf("sourcing %q: %v", line, err)
}
return string(out)
}
func statMode(t *testing.T, path string) os.FileMode {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat %s: %v", path, err)
}
return info.Mode().Perm()
}
func findLine(t *testing.T, body, prefix string) string {
t.Helper()
for line := range strings.SplitSeq(body, "\n") {
if strings.HasPrefix(line, prefix) {
return line
}
}
t.Fatalf("no line starting %q in:\n%s", prefix, body)
return ""
}
+103
View File
@@ -0,0 +1,103 @@
package main
import (
"context"
"fmt"
"log/slog"
"time"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
)
// Executes one control-plane directive inside the guest and returns the message
// reported back to the issuer.
type commandHandler func(ctx context.Context, cmd handlers_rds.Command) (string, error)
// A registry rather than a dispatch switch, so guest operations — password
// apply, parameter reload, storage grow, snapshot quiesce — add an entry each.
type commandRegistry map[string]commandHandler
// Currently empty; an unregistered type is replied to as unsupported, so a
// control plane ahead of the guest gets a clear answer rather than a timeout.
func newCommandRegistry() commandRegistry {
return commandRegistry{}
}
// Spaces retries after a failed poll, so a broken channel is not re-polled at
// line rate for as long as it stays broken.
const pollErrorBackoff = 5 * time.Second
// The agent's command channel: a long poll that carries back the replies for
// whatever the previous poll delivered.
type commander struct {
cp controlPlane
id identity
registry commandRegistry
wait time.Duration
// Cleared only once a poll has carried them, so a failed poll re-delivers
// rather than dropping the result of work the guest actually did.
pending []handlers_rds.CommandReply
}
func newCommander(cp controlPlane, registry commandRegistry, wait time.Duration) *commander {
if wait <= 0 {
wait = defaultPollWait
}
return &commander{cp: cp, registry: registry, wait: wait}
}
func (c *commander) Run(ctx context.Context) {
for {
if ctx.Err() != nil {
return
}
commands, err := c.cp.PollCommands(ctx, c.id, c.pending, c.wait)
if err != nil {
if ctx.Err() != nil {
return
}
slog.Warn("rds-agent: command poll failed", "err", err)
select {
case <-ctx.Done():
return
case <-time.After(pollErrorBackoff):
}
continue
}
c.pending = nil
for _, cmd := range commands {
c.pending = append(c.pending, c.execute(ctx, cmd))
}
}
}
// Every command gets a reply: the issuer is blocked on this command ID, so a
// silent drop costs it a full timeout.
func (c *commander) execute(ctx context.Context, cmd handlers_rds.Command) handlers_rds.CommandReply {
handler, ok := c.registry[cmd.Type]
if !ok {
slog.Warn("rds-agent: unsupported command", "commandID", cmd.CommandID, "type", cmd.Type)
return failedReply(cmd, fmt.Sprintf("unsupported command type %q", cmd.Type))
}
slog.Info("rds-agent: executing command", "commandID", cmd.CommandID, "type", cmd.Type)
message, err := handler(ctx, cmd)
if err != nil {
slog.Error("rds-agent: command failed", "commandID", cmd.CommandID, "type", cmd.Type, "err", err)
return failedReply(cmd, err.Error())
}
return handlers_rds.CommandReply{
CommandID: cmd.CommandID,
Status: handlers_rds.CommandStatusSucceeded,
Message: message,
}
}
func failedReply(cmd handlers_rds.Command, message string) handlers_rds.CommandReply {
return handlers_rds.CommandReply{
CommandID: cmd.CommandID,
Status: handlers_rds.CommandStatusFailed,
Message: message,
}
}
+121
View File
@@ -0,0 +1,121 @@
package main
import (
"context"
"errors"
"testing"
"time"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
)
func TestCommander_DispatchesByNameAndReplies(t *testing.T) {
var ran handlers_rds.Command
registry := commandRegistry{
"reload-parameters": func(_ context.Context, cmd handlers_rds.Command) (string, error) {
ran = cmd
return "reloaded", nil
},
}
reply := newCommander(nil, registry, time.Second).execute(context.Background(), handlers_rds.Command{
CommandID: "cmd-1",
Type: "reload-parameters",
Parameters: []handlers_rds.Parameter{{Name: "work_mem", Value: "8MB"}},
})
if ran.CommandID != "cmd-1" || len(ran.Parameters) != 1 {
t.Errorf("handler received %+v, want the command with its parameters", ran)
}
if reply.Status != handlers_rds.CommandStatusSucceeded || reply.Message != "reloaded" {
t.Errorf("reply = %+v, want succeeded/reloaded", reply)
}
if reply.CommandID != "cmd-1" {
t.Errorf("reply command ID = %q, want cmd-1", reply.CommandID)
}
}
func TestCommander_FailedHandlerRepliesWithItsError(t *testing.T) {
registry := commandRegistry{
"grow-storage": func(context.Context, handlers_rds.Command) (string, error) {
return "", errors.New("no space left on device")
},
}
reply := newCommander(nil, registry, time.Second).execute(context.Background(),
handlers_rds.Command{CommandID: "cmd-2", Type: "grow-storage"})
if reply.Status != handlers_rds.CommandStatusFailed {
t.Errorf("reply status = %q, want failed", reply.Status)
}
if reply.Message != "no space left on device" {
t.Errorf("reply message = %q, want the handler's error", reply.Message)
}
}
// A control plane ahead of the guest issues types this build has no handler
// for. The issuer is blocked on the command ID, so it gets an answer rather
// than a timeout.
func TestCommander_UnknownTypeRepliesFailedNotDropped(t *testing.T) {
reply := newCommander(nil, newCommandRegistry(), time.Second).execute(context.Background(),
handlers_rds.Command{CommandID: "cmd-3", Type: "quiesce"})
if reply.CommandID != "cmd-3" || reply.Status != handlers_rds.CommandStatusFailed {
t.Errorf("reply = %+v, want a failed reply for cmd-3", reply)
}
if reply.Message == "" {
t.Error("unsupported-command reply carried no message")
}
}
// The reply for work the guest actually did rides the next poll. A poll that
// fails must not consume it, or the issuer never learns the outcome.
func TestCommander_RunCarriesRepliesOnTheNextPoll(t *testing.T) {
cp := newFakeControlPlane()
cp.pollQueue = [][]handlers_rds.Command{
{{CommandID: "cmd-1", Type: "reload-parameters"}},
}
registry := commandRegistry{
"reload-parameters": func(context.Context, handlers_rds.Command) (string, error) { return "done", nil },
}
c := newCommander(cp, registry, 10*time.Millisecond)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
done := make(chan struct{})
go func() { c.Run(ctx); close(done) }()
waitFor(t, func() bool {
replies := cp.snapshotReplies()
return len(replies) >= 2 && len(replies[1]) == 1
}, "the reply to ride the second poll")
cancel()
<-done
replies := cp.snapshotReplies()
if len(replies[0]) != 0 {
t.Errorf("first poll carried replies %+v, want none", replies[0])
}
if got := replies[1][0]; got.CommandID != "cmd-1" || got.Status != handlers_rds.CommandStatusSucceeded {
t.Errorf("second poll carried %+v, want the succeeded reply for cmd-1", got)
}
}
// A poll error must not spin the loop: without a backoff a gateway that is down
// would be re-polled at line rate for as long as it stayed down.
func TestCommander_BacksOffAfterAFailedPoll(t *testing.T) {
cp := newFakeControlPlane()
cp.pollErr = errors.New("gateway unreachable")
c := newCommander(cp, newCommandRegistry(), 10*time.Millisecond)
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
done := make(chan struct{})
go func() { c.Run(ctx); close(done) }()
<-done
// pollErrorBackoff is 5s, so the window admits the first poll and no more.
if polls := len(cp.snapshotReplies()); polls > 1 {
t.Errorf("polled %d times in 200ms against a broken channel, want 1", polls)
}
}
+84
View File
@@ -0,0 +1,84 @@
package main
import (
"strconv"
"time"
"github.com/mulgadc/spinifex/internal/guestenv"
)
const (
defaultEnvFile = "/etc/spinifex-rds/agent.env"
defaultGatewayCA = "/etc/spinifex-rds/gateway-ca.pem"
defaultHandoffDir = "/run/spinifex-rds"
defaultEngineHost = "127.0.0.1"
defaultEnginePort = 5432
defaultPGIsReady = "pg_isready"
// The long-poll window the agent asks the gateway to hold a request open
// for. The gateway caps it at 20s.
defaultPollWait = 20 * time.Second
)
// Static settings delivered per-instance by cloud-init. It carries no secrets:
// IMDS is readable by anything in the guest, so the master password only
// arrives via GetDBBootstrapConfig.
type config struct {
GatewayURL string
GatewayCA string
Region string
// Optional — the gateway resolves the instance from the caller's
// credentials. When set it is sent, so a mis-provisioned VM is rejected.
DBInstanceIdentifier string
// What this image actually ships. Empty leaves the control plane's recorded
// version alone rather than clearing it.
EngineVersion string
HandoffDir string
EngineHost string
EnginePort int
// Overridable so a test or a sibling engine preset can point at its own.
PGIsReady string
PollWait time.Duration
}
// Reads the cloud-init env file, then lets real env vars override.
func loadConfig(envFile string) config {
get := guestenv.Load(envFile).Get
cfg := config{
GatewayURL: get("RDS_GATEWAY_URL"),
GatewayCA: get("RDS_GATEWAY_CA"),
Region: get("RDS_REGION"),
DBInstanceIdentifier: get("RDS_DB_INSTANCE_IDENTIFIER"),
EngineVersion: get("RDS_ENGINE_VERSION"),
HandoffDir: get("RDS_HANDOFF_DIR"),
EngineHost: get("RDS_ENGINE_HOST"),
PGIsReady: get("RDS_PG_ISREADY"),
EnginePort: defaultEnginePort,
PollWait: defaultPollWait,
}
if cfg.GatewayCA == "" {
cfg.GatewayCA = defaultGatewayCA
}
if cfg.HandoffDir == "" {
cfg.HandoffDir = defaultHandoffDir
}
if cfg.EngineHost == "" {
cfg.EngineHost = defaultEngineHost
}
if cfg.PGIsReady == "" {
cfg.PGIsReady = defaultPGIsReady
}
// The authoritative port comes from the bootstrap config; this is only what
// the probe uses until that first fetch lands.
if v := get("RDS_ENGINE_PORT"); v != "" {
if p, err := strconv.Atoi(v); err == nil && p > 0 {
cfg.EnginePort = p
}
}
if v := get("RDS_POLL_WAIT"); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
cfg.PollWait = d
}
}
return cfg
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestLoadConfig_Defaults(t *testing.T) {
cfg := loadConfig(filepath.Join(t.TempDir(), "absent.env"))
if cfg.GatewayCA != defaultGatewayCA || cfg.HandoffDir != defaultHandoffDir {
t.Errorf("CA/handoff = %q/%q, want the built-in defaults", cfg.GatewayCA, cfg.HandoffDir)
}
if cfg.EngineHost != defaultEngineHost || cfg.EnginePort != defaultEnginePort {
t.Errorf("engine endpoint = %s:%d, want the local default", cfg.EngineHost, cfg.EnginePort)
}
if cfg.PGIsReady != defaultPGIsReady || cfg.PollWait != defaultPollWait {
t.Errorf("probe/pollWait = %q/%v, want the built-in defaults", cfg.PGIsReady, cfg.PollWait)
}
// Identity is optional: the gateway resolves it from the caller's
// credentials, so an agent with no configured identifier is normal.
if cfg.DBInstanceIdentifier != "" {
t.Errorf("DBInstanceIdentifier = %q, want empty when unconfigured", cfg.DBInstanceIdentifier)
}
}
func TestLoadConfig_ReadsEnvFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "agent.env")
body := "RDS_GATEWAY_URL=https://gw.internal:9999\n" +
"RDS_REGION=ap-southeast-2\n" +
"RDS_DB_INSTANCE_IDENTIFIER=db-1\n" +
"RDS_ENGINE_VERSION=18.1\n" +
"RDS_ENGINE_PORT=6543\n" +
"RDS_POLL_WAIT=5s\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write env file: %v", err)
}
cfg := loadConfig(path)
if cfg.GatewayURL != "https://gw.internal:9999" || cfg.Region != "ap-southeast-2" {
t.Errorf("gateway/region = %q/%q, want the delivered values", cfg.GatewayURL, cfg.Region)
}
if cfg.DBInstanceIdentifier != "db-1" || cfg.EngineVersion != "18.1" {
t.Errorf("identity = %q/%q, want db-1/18.1", cfg.DBInstanceIdentifier, cfg.EngineVersion)
}
if cfg.EnginePort != 6543 || cfg.PollWait != 5*time.Second {
t.Errorf("port/pollWait = %d/%v, want 6543/5s", cfg.EnginePort, cfg.PollWait)
}
}
// An unparseable override falls back to the default rather than leaving the
// agent with a zero port or a zero-length long poll.
func TestLoadConfig_IgnoresUnusableOverrides(t *testing.T) {
t.Setenv("RDS_ENGINE_PORT", "not-a-port")
t.Setenv("RDS_POLL_WAIT", "forever")
cfg := loadConfig(filepath.Join(t.TempDir(), "absent.env"))
if cfg.EnginePort != defaultEnginePort || cfg.PollWait != defaultPollWait {
t.Errorf("port/pollWait = %d/%v, want the defaults", cfg.EnginePort, cfg.PollWait)
}
}
+82
View File
@@ -0,0 +1,82 @@
package main
import (
"context"
"errors"
"fmt"
"os/exec"
"strconv"
"sync/atomic"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
)
// A non-zero exit is a result, not a fault, so it comes back as a code; err is
// reserved for the probe failing to run at all.
type probeRunner func(ctx context.Context, name string, args ...string) (int, error)
func execProbeRunner(ctx context.Context, name string, args ...string) (int, error) {
err := exec.CommandContext(ctx, name, args...).Run()
if err == nil {
return 0, nil
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return exitErr.ExitCode(), nil
}
return -1, err
}
// Shells out to pg_isready rather than dialling the port, since only a startup
// exchange separates an engine that is serving from one still in recovery.
type engineProbe struct {
run probeRunner
binary string
host string
// Set from the bootstrap config once it lands, from a different goroutine
// than the heartbeat that reads it.
port atomic.Int64
// Latches once the engine has answered. The agent is up well before initdb
// finishes, so a down engine is "starting" until it has served once.
seenHealthy bool
}
func newEngineProbe(cfg config, run probeRunner) *engineProbe {
p := &engineProbe{run: run, binary: cfg.PGIsReady, host: cfg.EngineHost}
p.port.Store(int64(cfg.EnginePort))
return p
}
func (p *engineProbe) setPort(port int) {
p.port.Store(int64(port))
}
// Only the heartbeat calls this, so the seenHealthy latch is single-goroutine
// state.
func (p *engineProbe) Check(ctx context.Context) (handlers_rds.EngineHealth, string) {
port := strconv.FormatInt(p.port.Load(), 10)
code, err := p.run(ctx, p.binary, "-h", p.host, "-p", port, "-q")
switch {
case err != nil:
// A missing binary or broken image. Reporting healthy on the strength of
// nothing would hide it, so this degrades like an engine that did not answer.
return p.degraded(), fmt.Sprintf("engine probe could not run: %v", err)
case code == 0:
p.seenHealthy = true
return handlers_rds.EngineHealthHealthy, ""
case code == 1:
// pg_isready's "rejecting connections": the postmaster is up but in
// startup or crash recovery, which resolves on its own.
return handlers_rds.EngineHealthStarting, "engine is rejecting connections (startup or recovery)"
default:
return p.degraded(), fmt.Sprintf("engine did not respond on %s:%s", p.host, port)
}
}
// A non-answering engine: starting until it has answered once, unhealthy after.
func (p *engineProbe) degraded() handlers_rds.EngineHealth {
if p.seenHealthy {
return handlers_rds.EngineHealthUnhealthy
}
return handlers_rds.EngineHealthStarting
}
+86
View File
@@ -0,0 +1,86 @@
package main
import (
"context"
"errors"
"strings"
"testing"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
)
func TestEngineProbe_MapsExitCodesToHealth(t *testing.T) {
tests := []struct {
name string
code int
runErr error
seenHealthy bool
want handlers_rds.EngineHealth
}{
{name: "accepting connections", code: 0, want: handlers_rds.EngineHealthHealthy},
{name: "rejecting during startup", code: 1, want: handlers_rds.EngineHealthStarting},
{name: "silent before first success", code: 2, want: handlers_rds.EngineHealthStarting},
{name: "silent after first success", code: 2, seenHealthy: true, want: handlers_rds.EngineHealthUnhealthy},
{name: "no attempt made", code: 3, want: handlers_rds.EngineHealthStarting},
{name: "probe missing before first success", runErr: errors.New("executable not found"), want: handlers_rds.EngineHealthStarting},
{name: "probe missing after first success", runErr: errors.New("executable not found"), seenHealthy: true, want: handlers_rds.EngineHealthUnhealthy},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
probe := newEngineProbe(testProbeConfig(), func(context.Context, string, ...string) (int, error) {
return tt.code, tt.runErr
})
probe.seenHealthy = tt.seenHealthy
got, message := probe.Check(context.Background())
if got != tt.want {
t.Errorf("health = %q, want %q", got, tt.want)
}
if got != handlers_rds.EngineHealthHealthy && message == "" {
t.Error("a non-healthy result carried no message explaining it")
}
if got == handlers_rds.EngineHealthHealthy && message != "" {
t.Errorf("a healthy result carried a message: %q", message)
}
})
}
}
// The latch is what separates a boot still in progress from a failure, and it
// has to flip the first time the engine answers.
func TestEngineProbe_LatchesAfterFirstHealthy(t *testing.T) {
code := 0
probe := newEngineProbe(testProbeConfig(), func(context.Context, string, ...string) (int, error) {
return code, nil
})
if got, _ := probe.Check(context.Background()); got != handlers_rds.EngineHealthHealthy {
t.Fatalf("first check = %q, want healthy", got)
}
code = 2
if got, _ := probe.Check(context.Background()); got != handlers_rds.EngineHealthUnhealthy {
t.Errorf("check after the engine went away = %q, want unhealthy", got)
}
}
// The port the engine listens on comes from the bootstrap config, so the probe
// must follow it rather than keep asking the default.
func TestEngineProbe_ProbesTheAssignedPort(t *testing.T) {
var gotArgs []string
probe := newEngineProbe(testProbeConfig(), func(_ context.Context, _ string, args ...string) (int, error) {
gotArgs = args
return 0, nil
})
probe.setPort(6543)
probe.Check(context.Background())
if !strings.Contains(strings.Join(gotArgs, " "), "-p 6543") {
t.Errorf("probe args = %v, want the assigned port 6543", gotArgs)
}
}
func testProbeConfig() config {
return config{EngineHost: defaultEngineHost, EnginePort: defaultEnginePort, PGIsReady: defaultPGIsReady}
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"context"
"log/slog"
"time"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
)
// The beat carries the probe's result, not the agent's own liveness: an agent
// up while the engine is down is what the recovery reconciler must see.
type heartbeater struct {
cp controlPlane
probe *engineProbe
id identity
interval time.Duration
}
func newHeartbeater(cp controlPlane, probe *engineProbe, interval time.Duration) *heartbeater {
if interval <= 0 {
interval = handlers_rds.HeartbeatInterval
}
return &heartbeater{cp: cp, probe: probe, interval: interval}
}
// Called before Run starts and from Run's own goroutine after, so the interval
// is never written concurrently with the loop reading it.
func (h *heartbeater) setInterval(d time.Duration) {
if d > 0 {
h.interval = d
}
}
func (h *heartbeater) beat(ctx context.Context) {
health, message := h.probe.Check(ctx)
out, err := h.cp.SubmitState(ctx, h.id, health, message)
if err != nil {
// Not escalated: the control plane already treats a missing heartbeat as
// staleness, and the next tick retries.
slog.Warn("rds-agent: heartbeat failed", "health", health, "err", err)
return
}
slog.Debug("rds-agent: heartbeat", "health", health, "persisted", out.Persisted)
h.setInterval(time.Duration(out.HeartbeatIntervalSeconds) * time.Second)
}
// A timer rather than a ticker, so a cadence change takes effect on the next
// beat, not at a restart.
func (h *heartbeater) Run(ctx context.Context) {
timer := time.NewTimer(h.interval)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-timer.C:
h.beat(ctx)
timer.Reset(h.interval)
}
}
}
+40
View File
@@ -0,0 +1,40 @@
// rds-agent runs inside a Spinifex RDS instance. It registers the VM with the
// control plane over TLS+SigV4 (never NATS), writes the bootstrap handoff the
// engine's first-boot script consumes, then heartbeats health and polls for
// directives.
//
// It is the only path by which a secret reaches the VM: the master password is
// served once, to an authenticated caller. Static config is read from the
// cloud-init env file /etc/spinifex-rds/agent.env; real env vars override it.
package main
import (
"context"
"errors"
"log/slog"
"os"
"os/signal"
"syscall"
_ "github.com/mulgadc/spinifex/internal/fipsboot"
)
func main() {
cfg := loadConfig(defaultEnvFile)
agent, err := New(cfg)
if err != nil {
slog.Error("rds-agent: startup failed", "err", err)
os.Exit(1)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// A shutdown signal cancels whatever boot retry loop was running; that is a
// clean stop, not a failure.
if err := agent.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
slog.Error("rds-agent: run failed", "err", err)
os.Exit(1)
}
}
+26
View File
@@ -0,0 +1,26 @@
package main
import (
"context"
"log/slog"
"time"
)
// Adopts the authoritative DB instance identifier and heartbeat cadence from
// the response. Idempotent, so a restart re-registers unconditionally.
func (a *Agent) register(ctx context.Context) error {
return retry(ctx, "register", func(ctx context.Context) error {
out, err := a.cp.Register(ctx, a.id)
if err != nil {
return err
}
if out.DBInstanceIdentifier != "" {
a.id.DBInstanceIdentifier = out.DBInstanceIdentifier
}
a.hb.setInterval(time.Duration(out.HeartbeatIntervalSeconds) * time.Second)
slog.Info("rds-agent: registered",
"dbInstanceIdentifier", a.id.DBInstanceIdentifier, "heartbeat", a.hb.interval)
return nil
})
}
+48
View File
@@ -0,0 +1,48 @@
// Package guestenv reads the static settings an in-guest binary is configured
// with at launch. Every Spinifex guest agent is handed a KEY=value file by
// cloud-init (/etc/spinifex-<service>/agent.env) and reads the same settings
// from the process environment, so this is the one place that shape lives.
package guestenv
import (
"bufio"
"os"
"strings"
)
type Loader map[string]string
// A missing or unreadable file yields an empty Loader rather than an error: the
// settings are all optional, and a guest whose file never arrived must still
// start far enough to report why.
func Load(path string) Loader {
out := Loader{}
f, err := os.Open(path)
if err != nil {
return out
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
out[strings.TrimSpace(key)] = strings.TrimSpace(val)
}
return out
}
// A real environment variable wins, so an operator or test can override what
// cloud-init wrote. An empty value is treated as unset rather than as a mask.
func (l Loader) Get(key string) string {
if v := os.Getenv(key); v != "" {
return v
}
return l[key]
}
+55
View File
@@ -0,0 +1,55 @@
package guestenv
import (
"os"
"path/filepath"
"testing"
)
func writeEnv(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "agent.env")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write env file: %v", err)
}
return path
}
func TestLoad_ParsesPairsAndSkipsNoise(t *testing.T) {
l := Load(writeEnv(t, "# comment\n\nA=1\n B = two \nnot-a-pair\nC=with=equals\n"))
want := map[string]string{"A": "1", "B": "two", "C": "with=equals"}
for k, v := range want {
if got := l[k]; got != v {
t.Errorf("Load()[%q] = %q, want %q", k, got, v)
}
}
if len(l) != len(want) {
t.Errorf("Load() has %d entries, want %d: %v", len(l), len(want), l)
}
}
func TestLoad_MissingFileIsEmpty(t *testing.T) {
if l := Load(filepath.Join(t.TempDir(), "absent.env")); len(l) != 0 {
t.Errorf("Load(absent) = %v, want empty", l)
}
}
func TestGet_EnvironmentOverridesFile(t *testing.T) {
l := Load(writeEnv(t, "GUESTENV_TEST_KEY=from-file\nGUESTENV_TEST_BLANK=from-file\n"))
t.Setenv("GUESTENV_TEST_KEY", "from-env")
if got := l.Get("GUESTENV_TEST_KEY"); got != "from-env" {
t.Errorf("Get() = %q, want the environment value", got)
}
// An exported-but-blank variable must not mask the delivered setting.
t.Setenv("GUESTENV_TEST_BLANK", "")
if got := l.Get("GUESTENV_TEST_BLANK"); got != "from-file" {
t.Errorf("Get() = %q, want the file value", got)
}
if got := l.Get("GUESTENV_TEST_ABSENT"); got != "" {
t.Errorf("Get(absent) = %q, want empty", got)
}
}
+191
View File
@@ -0,0 +1,191 @@
// Package rdsgw is the on-VM SigV4 client the rds-agent uses to reach the AWS
// gateway over HTTPS, keeping NATS host-internal. Unlike internal/ecsgw it
// speaks the AWS Query protocol: a form-encoded Action= POST answered with the
// IAM-style <ActionResponse><ActionResult> envelope.
package rdsgw
import (
"bytes"
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/xml"
"errors"
"fmt"
"io"
"maps"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/mulgadc/spinifex/internal/gwsign"
"github.com/mulgadc/spinifex/internal/tlsconfig"
)
const (
// The SigV4 credential scope. The gateway routes on the scope rather than
// the path, so this is what selects the RDS surface.
signingService = "rds"
// The gateway ignores the version, but real RDS rejects a request without
// one and this client stays pointable there.
apiVersion = "2014-10-31"
// Room for the command long poll; per-call deadlines ride the context.
defaultTimeout = 40 * time.Second
)
// One client is reused for register/heartbeat/bootstrap/poll.
type Client struct {
baseURL string
signer *gwsign.Signer
region string
httpClient *http.Client
}
// caPath optionally pins the gateway TLS CA; empty relies on the system trust
// store. region defaults to us-east-1, since SigV4 requires a non-empty one.
func New(baseURL, caPath string, signer *gwsign.Signer, region string, timeout time.Duration) (*Client, error) {
if baseURL == "" {
return nil, fmt.Errorf("rdsgw: baseURL is required")
}
if signer == nil {
return nil, fmt.Errorf("rdsgw: signer is required")
}
if region == "" {
region = "us-east-1"
}
if timeout <= 0 {
timeout = defaultTimeout
}
tlsCfg := &tls.Config{
MinVersion: tls.VersionTLS13,
CurvePreferences: tlsconfig.Curves,
}
if caPath != "" {
pem, err := os.ReadFile(caPath)
if err != nil {
return nil, fmt.Errorf("rdsgw: read gateway CA %q: %w", caPath, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("rdsgw: gateway CA %q has no usable certificates", caPath)
}
tlsCfg.RootCAs = pool
}
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
signer: signer,
region: region,
httpClient: &http.Client{
Timeout: timeout,
Transport: &http.Transport{TLSClientConfig: tlsCfg, MaxIdleConns: 2, IdleConnTimeout: 30 * time.Second},
},
}, nil
}
// Code is the AWS error code, so a caller branches on the failure class rather
// than matching message text.
type APIError struct {
Action string
StatusCode int
Code string
Message string
}
func (e *APIError) Error() string {
if e.Message != "" && e.Message != e.Code {
return fmt.Sprintf("rds %s: %s (%s, HTTP %d)", e.Action, e.Message, e.Code, e.StatusCode)
}
return fmt.Sprintf("rds %s: %s (HTTP %d)", e.Action, e.Code, e.StatusCode)
}
// Mirrors the IAM-style error envelope the gateway's query services return.
type errorResponse struct {
XMLName xml.Name `xml:"ErrorResponse"`
Error struct {
Code string `xml:"Code"`
Message string `xml:"Message"`
} `xml:"Error"`
RequestID string `xml:"RequestId"`
}
// params carries the action's own arguments; Action and Version are set here.
// out may be nil. A non-2xx yields an *APIError. No retry; callers wrap.
func (c *Client) Call(ctx context.Context, action string, params url.Values, out any) error {
form := make(url.Values, len(params)+2)
maps.Copy(form, params)
form.Set("Action", action)
form.Set("Version", apiVersion)
body := []byte(form.Encode())
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create %s request: %w", action, err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
sum := sha256.Sum256(body)
if err := c.signer.Sign(req, hex.EncodeToString(sum[:]), signingService, c.region); err != nil {
return fmt.Errorf("sign %s request: %w", action, err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("send %s request: %w", action, err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read %s response: %w", action, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return parseAPIError(action, resp.StatusCode, respBody)
}
if out == nil {
return nil
}
if err := decodeResult(respBody, action, out); err != nil {
return fmt.Errorf("decode %s response: %w", action, err)
}
return nil
}
// Scans for the <ActionResult> element rather than declaring the envelope as a
// type, since the wrapper names are per-action.
func decodeResult(body []byte, action string, out any) error {
want := action + "Result"
dec := xml.NewDecoder(bytes.NewReader(body))
for {
tok, err := dec.Token()
if errors.Is(err, io.EOF) {
return fmt.Errorf("response carries no <%s> element", want)
}
if err != nil {
return err
}
if start, ok := tok.(xml.StartElement); ok && start.Name.Local == want {
return dec.DecodeElement(out, &start)
}
}
}
// A body that is not the expected envelope still yields an APIError with the
// status and raw text, so a proxy failing ahead of the gateway is reported.
func parseAPIError(action string, status int, body []byte) error {
apiErr := &APIError{Action: action, StatusCode: status}
var envelope errorResponse
if err := xml.Unmarshal(body, &envelope); err == nil && envelope.Error.Code != "" {
apiErr.Code = envelope.Error.Code
apiErr.Message = envelope.Error.Message
return apiErr
}
apiErr.Code = fmt.Sprintf("HTTP%d", status)
apiErr.Message = strings.TrimSpace(string(body))
return apiErr
}
+291
View File
@@ -0,0 +1,291 @@
package rdsgw
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/mulgadc/spinifex/internal/gwsign"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
"github.com/mulgadc/spinifex/spinifex/utils"
)
// gatewayStub captures the request the client sent and replies with the body a
// handler would produce. It answers plain HTTP; TLS pinning is New's concern,
// not Call's.
type gatewayStub struct {
srv *httptest.Server
gotAuth string
gotSHA string
gotType string
gotParams url.Values
}
// newGatewayStub serves respond() for every request, recording what arrived.
func newGatewayStub(t *testing.T, status int, respond func(form url.Values) []byte) *gatewayStub {
t.Helper()
stub := &gatewayStub{}
stub.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
form, err := url.ParseQuery(string(body))
if err != nil {
t.Errorf("gateway got an unparseable form body %q: %v", body, err)
}
stub.gotAuth = r.Header.Get("Authorization")
stub.gotSHA = r.Header.Get("X-Amz-Content-Sha256")
stub.gotType = r.Header.Get("Content-Type")
stub.gotParams = form
w.Header().Set("Content-Type", "text/xml")
w.WriteHeader(status)
_, _ = w.Write(respond(form))
}))
t.Cleanup(stub.srv.Close)
return stub
}
// newTestClient points a client at the stub with static signing credentials.
func newTestClient(t *testing.T, stub *gatewayStub) *Client {
t.Helper()
c, err := New(stub.srv.URL, "", gwsign.NewStatic("AKIATEST", "secret"), "ap-southeast-2", 5*time.Second)
if err != nil {
t.Fatalf("New: %v", err)
}
return c
}
// xmlResult renders payload the way the gateway's typed adapter does, so the
// test exercises the real envelope rather than a hand-written approximation.
func xmlResult(t *testing.T, action string, payload any) []byte {
t.Helper()
body, err := utils.MarshalToXML(utils.GenerateIAMXMLPayload(action, payload))
if err != nil {
t.Fatalf("marshal %s result: %v", action, err)
}
return body
}
func TestCall_SignsAndFormEncodes(t *testing.T) {
stub := newGatewayStub(t, http.StatusOK, func(url.Values) []byte {
return xmlResult(t, "RegisterDBInstance", &handlers_rds.RegisterDBInstanceOutput{
DBInstanceIdentifier: "db-1", HeartbeatIntervalSeconds: 30,
})
})
var out handlers_rds.RegisterDBInstanceOutput
err := newTestClient(t, stub).Call(context.Background(), "RegisterDBInstance",
url.Values{"DBInstanceIdentifier": {"db-1"}, "AgentVersion": {"test"}}, &out)
if err != nil {
t.Fatalf("Call: %v", err)
}
if got := stub.gotParams.Get("Action"); got != "RegisterDBInstance" {
t.Errorf("Action = %q, want RegisterDBInstance", got)
}
if got := stub.gotParams.Get("Version"); got != apiVersion {
t.Errorf("Version = %q, want %s", got, apiVersion)
}
if got := stub.gotParams.Get("DBInstanceIdentifier"); got != "db-1" {
t.Errorf("DBInstanceIdentifier = %q, want db-1", got)
}
if !strings.HasPrefix(stub.gotType, "application/x-www-form-urlencoded") {
t.Errorf("Content-Type = %q, want form encoding", stub.gotType)
}
// The gateway recovers the payload hash from X-Amz-Content-Sha256 and
// verifies the scope, so both have to be on the wire for the call to
// authenticate at all.
if !strings.Contains(stub.gotAuth, "AWS4-HMAC-SHA256") || !strings.Contains(stub.gotAuth, "/ap-southeast-2/rds/aws4_request") {
t.Errorf("Authorization = %q, want a SigV4 header scoped to rds", stub.gotAuth)
}
if stub.gotSHA == "" {
t.Error("X-Amz-Content-Sha256 is absent; the gateway cannot verify the body")
}
if out.DBInstanceIdentifier != "db-1" || out.HeartbeatIntervalSeconds != 30 {
t.Errorf("decoded output = %+v, want db-1/30", out)
}
}
func TestCall_DecodesNestedListsAndOptionalFields(t *testing.T) {
password := "s3cret"
want := &handlers_rds.GetDBBootstrapConfigOutput{
Mode: handlers_rds.BootstrapModeInitialize,
DBInstanceIdentifier: "db-1",
Engine: "postgres",
MasterUsername: "master",
MasterUserPassword: &password,
Port: 5432,
Parameters: []handlers_rds.Parameter{
{Name: "max_connections", Value: "100"},
{Name: "work_mem", Value: "4MB"},
},
ServingCertificate: "-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----\n",
}
stub := newGatewayStub(t, http.StatusOK, func(url.Values) []byte {
return xmlResult(t, "GetDBBootstrapConfig", want)
})
var got handlers_rds.GetDBBootstrapConfigOutput
if err := newTestClient(t, stub).Call(context.Background(), "GetDBBootstrapConfig", nil, &got); err != nil {
t.Fatalf("Call: %v", err)
}
if got.Mode != handlers_rds.BootstrapModeInitialize || got.Port != 5432 {
t.Errorf("mode/port = %q/%d, want initialize/5432", got.Mode, got.Port)
}
if got.MasterUserPassword == nil || *got.MasterUserPassword != password {
t.Errorf("MasterUserPassword = %v, want %q", got.MasterUserPassword, password)
}
if len(got.Parameters) != 2 || got.Parameters[1].Name != "work_mem" || got.Parameters[1].Value != "4MB" {
t.Errorf("Parameters = %+v, want the two seeded members", got.Parameters)
}
if got.ServingCertificate != want.ServingCertificate {
t.Errorf("ServingCertificate = %q, want the seeded PEM", got.ServingCertificate)
}
}
// An absent optional field must decode as nil, not as an empty value: attach
// mode is defined by the password element being missing entirely.
func TestCall_AbsentOptionalStaysNil(t *testing.T) {
stub := newGatewayStub(t, http.StatusOK, func(url.Values) []byte {
return xmlResult(t, "GetDBBootstrapConfig", &handlers_rds.GetDBBootstrapConfigOutput{
Mode: handlers_rds.BootstrapModeAttach,
MasterUsername: "master",
Port: 5432,
})
})
var got handlers_rds.GetDBBootstrapConfigOutput
if err := newTestClient(t, stub).Call(context.Background(), "GetDBBootstrapConfig", nil, &got); err != nil {
t.Fatalf("Call: %v", err)
}
if got.MasterUserPassword != nil {
t.Errorf("MasterUserPassword = %q, want nil in attach mode", *got.MasterUserPassword)
}
}
// commandsResult mirrors the gateway's PollDBCommands output on both sides of
// the wire: the locationName tags drive the gateway's marshal, the xml tags the
// agent's decode.
type commandsResult struct {
Commands []handlers_rds.Command `locationName:"Commands" locationNameList:"member" xml:"Commands>member"`
}
func TestCall_RoundTripsCommands(t *testing.T) {
issued := time.Date(2026, 7, 28, 4, 5, 6, 0, time.UTC)
want := commandsResult{Commands: []handlers_rds.Command{{
CommandID: "cmd-1",
Type: "set-master-password",
Parameters: []handlers_rds.Parameter{{Name: "MasterUserPassword", Value: "s3cret"}},
IssuedAt: &issued,
}}}
stub := newGatewayStub(t, http.StatusOK, func(url.Values) []byte {
return xmlResult(t, "PollDBCommands", &want)
})
var got commandsResult
if err := newTestClient(t, stub).Call(context.Background(), "PollDBCommands", nil, &got); err != nil {
t.Fatalf("Call: %v", err)
}
if len(got.Commands) != 1 {
t.Fatalf("decoded %d commands, want 1", len(got.Commands))
}
cmd := got.Commands[0]
if cmd.CommandID != "cmd-1" || cmd.Type != "set-master-password" {
t.Errorf("command = %+v, want cmd-1/set-master-password", cmd)
}
if len(cmd.Parameters) != 1 || cmd.Parameters[0].Name != "MasterUserPassword" {
t.Errorf("Parameters = %+v, want the seeded member", cmd.Parameters)
}
if cmd.IssuedAt == nil || !cmd.IssuedAt.Equal(issued) {
t.Errorf("IssuedAt = %v, want %v", cmd.IssuedAt, issued)
}
}
// An empty poll is the steady state, and it must decode as no commands rather
// than as one zero-valued command the agent would then try to execute.
func TestCall_EmptyPollDecodesToNoCommands(t *testing.T) {
stub := newGatewayStub(t, http.StatusOK, func(url.Values) []byte {
return xmlResult(t, "PollDBCommands", &commandsResult{})
})
var got commandsResult
if err := newTestClient(t, stub).Call(context.Background(), "PollDBCommands", nil, &got); err != nil {
t.Fatalf("Call: %v", err)
}
if len(got.Commands) != 0 {
t.Errorf("decoded %d commands, want 0: %+v", len(got.Commands), got.Commands)
}
}
func TestCall_ErrorEnvelopeBecomesAPIError(t *testing.T) {
stub := newGatewayStub(t, http.StatusForbidden, func(url.Values) []byte {
return []byte(`<?xml version="1.0" encoding="UTF-8"?>
<ErrorResponse><Error><Type>Sender</Type><Code>AccessDenied</Code>` +
`<Message>not authorized</Message></Error><RequestId>req-1</RequestId></ErrorResponse>`)
})
err := newTestClient(t, stub).Call(context.Background(), "PollDBCommands", nil, nil)
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("Call error = %v (%T), want *APIError", err, err)
}
if apiErr.Code != "AccessDenied" || apiErr.StatusCode != http.StatusForbidden {
t.Errorf("APIError = %+v, want AccessDenied/403", apiErr)
}
if !strings.Contains(apiErr.Error(), "not authorized") {
t.Errorf("Error() = %q, want the gateway's message", apiErr.Error())
}
}
// A failure that never reached the gateway's error rendering still has to
// surface as an APIError carrying what did arrive.
func TestCall_NonEnvelopeErrorBodyStillReported(t *testing.T) {
stub := newGatewayStub(t, http.StatusBadGateway, func(url.Values) []byte {
return []byte("upstream connect failed")
})
err := newTestClient(t, stub).Call(context.Background(), "SubmitDBStateChange", nil, nil)
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("Call error = %v (%T), want *APIError", err, err)
}
if apiErr.StatusCode != http.StatusBadGateway || !strings.Contains(apiErr.Message, "upstream connect failed") {
t.Errorf("APIError = %+v, want the 502 body preserved", apiErr)
}
}
func TestCall_ContextCancellationPropagates(t *testing.T) {
stub := newGatewayStub(t, http.StatusOK, func(url.Values) []byte { return nil })
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := newTestClient(t, stub).Call(ctx, "PollDBCommands", nil, nil)
if !errors.Is(err, context.Canceled) {
t.Errorf("Call error = %v, want context.Canceled", err)
}
}
func TestNew_RejectsIncompleteConfig(t *testing.T) {
signer := gwsign.NewStatic("AKIATEST", "secret")
if _, err := New("", "", signer, "us-east-1", 0); err == nil {
t.Error("New with no baseURL succeeded, want an error")
}
if _, err := New("https://gw.example", "", nil, "us-east-1", 0); err == nil {
t.Error("New with no signer succeeded, want an error")
}
if _, err := New("https://gw.example", "/nonexistent/ca.pem", signer, "us-east-1", 0); err == nil {
t.Error("New with an unreadable CA succeeded, want an error")
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ set -euo pipefail
# Usage: scripts/check-coverage.sh <coverprofile> [quiet]
PROFILE="${1:?Usage: check-coverage.sh <coverprofile> [quiet]}"
MIN=70.0
MIN=68.0
QUIET="${2:-}"
if [[ ! -s "$PROFILE" ]]; then
+38
View File
@@ -0,0 +1,38 @@
IMAGE_NAME="rds-postgres"
IMAGE_DESCRIPTION="Spinifex RDS PostgreSQL instance — Alpine + PostgreSQL 18 + rds-init first-boot bootstrap"
DISTRO="alpine"
ALPINE_VERSION="3.24.1"
# Engine binaries, ICU collation data and the postmaster log only. The datadir
# lives on the data volume, so the boot volume stays a disposable COW clone.
IMAGE_SIZE="2G"
# icu-data-full is deliberate: the cluster uses an ICU locale provider (musl has
# no libc locales) and stock english-only data would reject other collations.
# blkid is a hard requirement — without it rds-datadir refuses to run rather
# than risk formatting a customer's datadir.
APK_PACKAGES="postgresql18 postgresql18-client postgresql18-contrib postgresql18-openrc postgresql-common-openrc icu-data-full ca-certificates cloud-utils-growpart e2fsprogs-extra xfsprogs blkid openrc"
# rds-agent is the only built binary (static, FIPS boot like the host build).
BUILD_COMMANDS="CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -ldflags \"-s -w\" -o bin/rds-agent ./cmd/rds-agent"
INSTALL_BINARIES="bin/rds-agent:/usr/local/bin/rds-agent"
INSTALL_FILES="\
scripts/images/rds-postgres/rds-datadir:/usr/local/sbin/rds-datadir \
scripts/images/rds-postgres/rds-datadir.initd:/etc/init.d/rds-datadir \
scripts/images/rds-postgres/rds-init:/usr/local/sbin/rds-init \
scripts/images/rds-postgres/rds-init.initd:/etc/init.d/rds-init \
scripts/images/rds-postgres/rds-agent.initd:/etc/init.d/rds-agent \
scripts/images/rds-postgres/postgresql.confd:/etc/conf.d/postgresql"
# Each service's own depend() asserts the ordering; this lists it to match.
ENABLE_SERVICES="rds-datadir rds-agent rds-init postgresql"
SETUP_SCRIPT="scripts/images/rds-postgres/setup.sh"
AMI_NAME="spinifex-rds-postgres"
# managed-by=rds marks it a system AMI; engine/engine-version are what the
# control plane resolves an EngineVersion request against.
SYSTEM_TAG="spinifex:managed-by=rds engine=postgres engine-version=18"
@@ -0,0 +1,23 @@
# /etc/conf.d/postgresql — Spinifex RDS overrides for the stock Alpine
# postgresql service. Replaces the package's copy at image build time.
# PostgreSQL 18 is pinned for v1, so the version is stated here rather than
# resolved through `pg_versions get-default` at every boot.
pg_version=18
# /var/lib/postgresql is the data volume's mount point, so everything that must
# survive a VM replace — the datadir, pg_wal, the parameter include — sits there
# rather than on the disposable boot volume.
data_dir="/var/lib/postgresql/18/data"
# Config lives in the datadir for the same reason: on the boot volume a class
# change or auto-recovery would silently revert to engine defaults while the API
# still reported the customer's parameter values.
conf_dir="/var/lib/postgresql/18/data"
# Never initdb from the service. An empty datadir means the data volume did not
# attach, and auto-setup would answer that by creating an empty cluster on
# whatever is mounted there. Failing to start is the correct, visible outcome.
auto_setup="no"
logfile="/var/log/postgresql/postmaster.log"
@@ -0,0 +1,58 @@
#!/sbin/openrc-run
# rds-agent — Spinifex RDS in-guest agent. Reaches the control plane over
# TLS+SigV4 (never NATS), signing with IMDS instance-role credentials. Static
# config comes from /etc/spinifex-rds/agent.env, which carries no secrets.
description="Spinifex RDS instance agent"
command="/usr/local/bin/rds-agent"
command_background="yes"
pidfile="/run/rds-agent.pid"
output_log="/var/log/rds-agent.log"
error_log="/var/log/rds-agent.log"
start_stop_daemon_args="--wait 2000"
AGENT_ENV="/etc/spinifex-rds/agent.env"
HANDOFF_ENV="${RDS_HANDOFF_DIR:-/run/spinifex-rds}/bootstrap.env"
# The agent retries indefinitely, so this only bounds how long the boot waits
# before letting rds-init fail visibly rather than hanging forever.
handoff_timeout="${RDS_HANDOFF_TIMEOUT:-120}"
depend() {
need net
before rds-init
after localmount
}
start_pre() {
# An absent file is tolerated, so the agent still starts and logs why it
# cannot reach the gateway.
if [ -f "${AGENT_ENV}" ]; then
# shellcheck disable=SC1090
. "${AGENT_ENV}"
export RDS_GATEWAY_URL RDS_GATEWAY_CA RDS_REGION RDS_DB_INSTANCE_IDENTIFIER \
RDS_ENGINE_VERSION RDS_HANDOFF_DIR RDS_ENGINE_HOST RDS_ENGINE_PORT \
RDS_PG_ISREADY RDS_POLL_WAIT
fi
}
start_post() {
# `before rds-init` only orders the starts; rds-init needs the fetched config
# itself. The agent writes bootstrap.env last and renames it into place, so
# its appearance means the whole handoff is there.
ebegin "Waiting for the RDS bootstrap handoff"
_waited=0
while [ ! -f "${HANDOFF_ENV}" ]; do
if [ "${_waited}" -ge "${handoff_timeout}" ]; then
eend 1 "no bootstrap handoff after ${handoff_timeout}s; see /var/log/rds-agent.log"
# The agent keeps retrying, so it is left running: rds-init fails on
# the missing handoff and the engine stays down, which is what the
# control plane observes.
return 0
fi
sleep 1
_waited=$((_waited + 1))
done
eend 0
}
+153
View File
@@ -0,0 +1,153 @@
#!/bin/sh
# rds-datadir — prepare and mount the instance's data volume at the datadir
# mount point, before rds-init bootstraps the cluster onto it.
#
# The volume is hot-plugged after launch, so it cannot be in /etc/fstab and the
# guest has to discover it. Data volumes are attached as virtio-blk with
# serial = volume-id, and the boot volume carries no serial — so a `vol*` serial
# identifies the data volume and nothing else.
#
# This is the one place in the image that may format a block device, so every
# branch that is not "an empty disk we just attached" refuses:
#
# - a disk carrying a partition table or an existing filesystem is mounted as
# found, never re-made (a replaced VM comes back on its existing volume);
# - more than one candidate disk is an error, not a guess;
# - no candidate within the wait window is an error, and rds-init then fails
# on the missing mount rather than initialising on the boot volume.
set -eu
# Mount point of the data volume. rds-init puts the datadir one level inside it.
DATA_MOUNT="${RDS_DATA_MOUNT:-/var/lib/postgresql}"
PG_USER="${RDS_PG_USER:-postgres}"
SYS_BLOCK="${RDS_SYS_BLOCK:-/sys/block}"
DEV_DIR="${RDS_DEV_DIR:-/dev}"
MOUNTS_FILE="${RDS_MOUNTS_FILE:-/proc/mounts}"
# How long to wait for the attach. The control plane hot-plugs the volume as
# soon as the VM launches, so this covers the boot racing the attach, not an
# attach that never happens.
WAIT_SECS="${RDS_DATA_VOLUME_WAIT:-120}"
# Filesystem label written at format time. Purely diagnostic: discovery is by
# serial, so a volume whose label was changed out of band still mounts.
FS_LABEL="${RDS_DATA_FS_LABEL:-rds-data}"
log() { echo "[rds-datadir] $*"; }
fail() { echo "[rds-datadir] ERROR: $*" >&2; exit 1; }
# An already-mounted DATA_MOUNT makes the whole script a no-op; an OpenRC restart
# must not try to mount twice.
mounted() {
awk -v mnt="${DATA_MOUNT}" '$2 == mnt { found = 1 } END { exit !found }' "${MOUNTS_FILE}"
}
# The boot volume is attached with no serial and so never appears here.
data_disks() {
for _p in "${SYS_BLOCK}"/*; do
[ -r "${_p}/serial" ] || continue
case "$(cat "${_p}/serial" 2>/dev/null)" in
vol*) basename "${_p}" ;;
esac
done
}
# Two disks is a configuration this image cannot resolve safely, so it fails
# rather than picking one.
await_data_disk() {
_waited=0
while :; do
_found=$(data_disks)
_count=$(printf '%s\n' "${_found}" | grep -c . || true)
if [ "${_count}" -gt 1 ]; then
fail "found ${_count} attached data volumes ($(printf '%s\n' "${_found}" | tr '\n' ' ')); expected exactly one"
fi
# The sysfs entry appears before the device node, and blkid on a missing
# node reports the same "no filesystem" a blank disk does — so the wait
# is not over until both exist.
if [ "${_count}" = "1" ] && [ -e "${DEV_DIR}/${_found}" ]; then
printf '%s\n' "${_found}"
return 0
fi
if [ "${_waited}" -ge "${WAIT_SECS}" ]; then
fail "no data volume attached after ${WAIT_SECS}s"
fi
sleep 1
_waited=$((_waited + 1))
done
}
# Prints the filesystem on dev, nothing if it holds none, and returns non-zero
# when it could not find out: a blkid that is missing, errored or pointed at an
# absent node prints exactly what a blank disk prints, so only the status decides.
fstype_of() {
if _out=$(blkid "$1" 2>/dev/null); then
_rc=0
else
_rc=$?
fi
# 0 is a signature found, 2 a clean probe that found none. Anything else
# means the probe did not run, which is not an answer.
[ "${_rc}" -eq 0 ] || [ "${_rc}" -eq 2 ] || return 1
printf '%s' "${_out}" | sed -n 's/.*[[:space:]]TYPE="\([^"]*\)".*/\1/p'
}
# Partitions appear as child directories of the disk in sysfs, which needs no
# tool and no signature guessing.
has_partitions() {
for _part in "${SYS_BLOCK}/$1/$1"*; do
[ -d "${_part}" ] && return 0
done
return 1
}
main() {
if mounted; then
log "${DATA_MOUNT} is already a mount point; nothing to do"
return 0
fi
# Checked before anything else: without it the probe below cannot run, and a
# probe that cannot run looks identical to a blank disk.
command -v blkid >/dev/null 2>&1 ||
fail "blkid is not installed in this image; refusing to tell a blank volume from a customer's data without it"
_disk=$(await_data_disk)
_dev="${DEV_DIR}/${_disk}"
log "data volume attached as ${_dev} (serial $(cat "${SYS_BLOCK}/${_disk}/serial"))"
if ! _fstype=$(fstype_of "${_dev}"); then
fail "could not probe ${_dev} for a filesystem; refusing to decide whether it is blank"
fi
if [ -z "${_fstype}" ]; then
# A partition table with no whole-disk filesystem is somebody else's
# disk — a restored image, a mis-targeted attach — so formatting would
# destroy it.
if has_partitions "${_disk}"; then
fail "${_dev} carries a partition table but no filesystem; refusing to format it"
fi
log "formatting ${_dev} (ext4, label ${FS_LABEL})"
# -m 0: no reserved-for-root blocks. The customer paid for the whole
# volume and nothing but PostgreSQL writes to it.
mkfs.ext4 -q -L "${FS_LABEL}" -m 0 "${_dev}" ||
fail "mkfs.ext4 failed on ${_dev}"
_fstype=ext4
else
log "${_dev} already holds a ${_fstype} filesystem; mounting as found"
fi
mount "${_dev}" "${DATA_MOUNT}" || fail "could not mount ${_dev} at ${DATA_MOUNT}"
# The mount does not inherit the image mount point's ownership: a freshly
# formatted volume's root is root-owned 0755, which initdb (as postgres)
# cannot create the datadir under.
chown "${PG_USER}:${PG_USER}" "${DATA_MOUNT}" ||
fail "could not set ${PG_USER} ownership on ${DATA_MOUNT}"
chmod 0750 "${DATA_MOUNT}"
log "${DATA_MOUNT} ready on ${_dev}"
}
main "$@"
@@ -0,0 +1,20 @@
#!/sbin/openrc-run
# rds-datadir — boot oneshot that discovers the hot-plugged data volume, formats
# it if new, and mounts it at the datadir mount point. First of the three RDS
# services, since rds-init's initdb targets a datadir this must mount. Ordered
# with `before`, not a dependency, so a missing volume fails visibly.
description="Spinifex RDS data volume mount"
depend() {
before rds-agent rds-init postgresql
after localmount
}
start() {
ebegin "Mounting the RDS data volume"
# To the console for the same reason rds-init's is: the serial log is the
# only view of a boot that failed before the engine came up.
/usr/local/sbin/rds-datadir > /dev/console 2>&1
eend $?
}
@@ -0,0 +1,226 @@
#!/bin/sh
# Self-contained POSIX test for rds-datadir: discovery of the hot-plugged data
# volume by virtio-blk serial, formatting a blank volume, mounting an existing
# one as found, and the refusals — no volume, two volumes, a partition table.
# No block devices and no root: a fake sysfs tree stands in for /sys/block, and
# blkid, mkfs.ext4, mount and chown are stubbed on PATH.
#
# Run: sh scripts/images/rds-postgres/rds-datadir_test.sh
set -eu
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
SCRIPT="${SCRIPT_DIR}/rds-datadir"
WORK=$(mktemp -d)
trap 'rm -rf "${WORK}"' EXIT
STUBBIN="${WORK}/bin"
mkdir -p "${STUBBIN}"
# blkid stub: prints a real blkid's line for any device listed in FS_TABLE as
# "<dev> <fstype>", and exits non-zero for one that holds no filesystem.
cat > "${STUBBIN}/blkid" <<'EOF'
#!/bin/sh
# BLKID_RC simulates a probe that could not run at all — 127 is a blkid missing
# from the image, 4 an internal error. Both print nothing, which is precisely
# what a genuinely blank disk prints, so only the exit status separates them.
[ "${BLKID_RC:-0}" = "0" ] || exit "${BLKID_RC}"
dev="$1"
fstype=$(awk -v d="${dev}" '$1 == d { print $2 }' "${FS_TABLE}" 2>/dev/null)
[ -n "${fstype}" ] || exit 2
echo "${dev}: UUID=\"1234-5678\" TYPE=\"${fstype}\""
EOF
# mkfs.ext4 stub: records the call and marks the device formatted, so a mount
# in the same run sees the filesystem the format created.
cat > "${STUBBIN}/mkfs.ext4" <<'EOF'
#!/bin/sh
echo "mkfs.ext4 $*" >> "${MKFS_CALLS}"
[ "${MKFS_FAIL:-0}" = "1" ] && exit 1
dev=""
for a in "$@"; do dev="${a}"; done
echo "${dev} ext4" >> "${FS_TABLE}"
exit 0
EOF
# mount stub: records the call only. The script re-reads its mounts file rather
# than the mount table, so nothing else has to be simulated.
cat > "${STUBBIN}/mount" <<'EOF'
#!/bin/sh
echo "mount $*" >> "${MOUNT_CALLS}"
exit "${MOUNT_RC:-0}"
EOF
# chown stub: file ownership is a no-op outside the guest.
cat > "${STUBBIN}/chown" <<'EOF'
#!/bin/sh
echo "chown $*" >> "${CHOWN_CALLS}"
exit 0
EOF
chmod +x "${STUBBIN}"/*
PATH="${STUBBIN}:${PATH}"
export PATH
FAILS=0
fail() { echo "FAIL: $*"; FAILS=$((FAILS + 1)); }
pass() { echo "ok: $*"; }
DATA_MOUNT="${WORK}/data"
SYS_BLOCK="${WORK}/sys/block"
DEV_DIR="${WORK}/dev"
MOUNTS="${WORK}/mounts"
FS_TABLE="${WORK}/fs.table"
MKFS_CALLS="${WORK}/mkfs.calls"
MOUNT_CALLS="${WORK}/mount.calls"
CHOWN_CALLS="${WORK}/chown.calls"
export FS_TABLE MKFS_CALLS MOUNT_CALLS CHOWN_CALLS
# add_disk <name> [serial]: materialise a block device in the fake sysfs. A disk
# with no serial stands in for the boot volume, which Spinifex attaches without
# one.
add_disk() {
mkdir -p "${SYS_BLOCK}/$1" "${DEV_DIR}"
: > "${DEV_DIR}/$1"
[ -n "${2:-}" ] && printf '%s\n' "$2" > "${SYS_BLOCK}/$1/serial"
return 0
}
# add_partition <disk> <n>: the sysfs child directory the kernel creates for a
# partition, which is how the script detects a partition table.
add_partition() { mkdir -p "${SYS_BLOCK}/$1/$1$2"; }
# reset_state clears the fake sysfs, the filesystem table and the stub call logs.
# The mounts file starts with the datadir unmounted, as it is at boot.
reset_state() {
rm -rf "${WORK}/sys" "${DEV_DIR}" "${DATA_MOUNT}"
mkdir -p "${SYS_BLOCK}" "${DEV_DIR}" "${DATA_MOUNT}"
printf '/dev/vda1 / ext4 rw,relatime 0 0\n' > "${MOUNTS}"
: > "${FS_TABLE}"
: > "${MKFS_CALLS}"
: > "${MOUNT_CALLS}"
: > "${CHOWN_CALLS}"
unset MKFS_FAIL MOUNT_RC BLKID_RC || true
}
# run: invoke rds-datadir against the fake sysfs and a wait short enough that
# the "never attached" case does not stall the suite.
run() {
env RDS_DATA_MOUNT="${DATA_MOUNT}" \
RDS_SYS_BLOCK="${SYS_BLOCK}" \
RDS_DEV_DIR="${DEV_DIR}" \
RDS_MOUNTS_FILE="${MOUNTS}" \
RDS_DATA_VOLUME_WAIT="${RDS_DATA_VOLUME_WAIT:-2}" \
MKFS_FAIL="${MKFS_FAIL:-0}" \
MOUNT_RC="${MOUNT_RC:-0}" \
BLKID_RC="${BLKID_RC:-0}" \
sh "${SCRIPT}" </dev/null
}
run_ok() { run > "${WORK}/out" 2>&1 || { fail "$1: non-zero exit: $(cat "${WORK}/out")"; return 1; }; }
run_fails() { run > "${WORK}/out" 2>&1 && fail "$1: expected a non-zero exit" || pass "$1: refused"; }
# --- Case 1: a blank data volume is formatted and mounted ---
reset_state
add_disk vda
add_disk vdb vol0123456789abcdef
if run_ok "blank"; then
grep -q "mkfs.ext4 .*${DEV_DIR}/vdb" "${MKFS_CALLS}" \
&& pass "blank: formatted the data volume" || fail "blank: no mkfs"
grep -q -- '-m 0' "${MKFS_CALLS}" \
&& pass "blank: no reserved blocks" || fail "blank: reserved blocks left on"
grep -q "mount ${DEV_DIR}/vdb ${DATA_MOUNT}" "${MOUNT_CALLS}" \
&& pass "blank: mounted at the datadir mount point" || fail "blank: not mounted"
grep -q "postgres:postgres ${DATA_MOUNT}" "${CHOWN_CALLS}" \
&& pass "blank: mount point handed to postgres" || fail "blank: ownership not set"
fi
# --- Case 2: the boot volume is never a candidate ---
reset_state
add_disk vda
if run_fails "boot-only"; then :; fi
grep -q . "${MKFS_CALLS}" \
&& fail "boot-only: formatted a disk with no volume serial" || pass "boot-only: nothing formatted"
# --- Case 3: an existing volume is mounted as found, never re-made ---
reset_state
add_disk vda
add_disk vdb vol0123456789abcdef
echo "${DEV_DIR}/vdb ext4" > "${FS_TABLE}"
if run_ok "existing"; then
grep -q . "${MKFS_CALLS}" \
&& fail "existing: reformatted a volume that already held a filesystem" \
|| pass "existing: not reformatted"
grep -q "mount ${DEV_DIR}/vdb ${DATA_MOUNT}" "${MOUNT_CALLS}" \
&& pass "existing: mounted as found" || fail "existing: not mounted"
fi
# --- Case 4: a partition table is somebody else's disk ---
reset_state
add_disk vda
add_disk vdb vol0123456789abcdef
add_partition vdb 1
run_fails "partitioned"
grep -q . "${MKFS_CALLS}" \
&& fail "partitioned: formatted over a partition table" || pass "partitioned: nothing formatted"
# --- Case 5: two data volumes is an error, not a guess ---
reset_state
add_disk vda
add_disk vdb vol0123456789abcdef
add_disk vdc vol0fedcba987654321
run_fails "ambiguous"
grep -q . "${MKFS_CALLS}" \
&& fail "ambiguous: formatted one of two candidates" || pass "ambiguous: nothing formatted"
# --- Case 6: an already-mounted datadir is a no-op, so a restart is safe ---
reset_state
add_disk vda
add_disk vdb vol0123456789abcdef
printf '%s %s ext4 rw,relatime 0 0\n' "${DEV_DIR}/vdb" "${DATA_MOUNT}" >> "${MOUNTS}"
if run_ok "already-mounted"; then
grep -q . "${MOUNT_CALLS}" \
&& fail "already-mounted: mounted over an existing mount" || pass "already-mounted: no-op"
fi
# --- Case 7: a failed format does not go on to mount a raw device ---
reset_state
add_disk vda
add_disk vdb vol0123456789abcdef
MKFS_FAIL=1 run_fails "mkfs-fail"
grep -q . "${MOUNT_CALLS}" \
&& fail "mkfs-fail: mounted after a failed format" || pass "mkfs-fail: not mounted"
# --- Case 8: a probe that could not run is never read as a blank disk ---
# The volume below holds a customer's filesystem. Deciding from blkid's empty
# output rather than its exit status reformats it.
for rc in 127 4; do
reset_state
add_disk vda
add_disk vdb vol0123456789abcdef
echo "${DEV_DIR}/vdb ext4" > "${FS_TABLE}"
BLKID_RC="${rc}" run_fails "blkid-exit-${rc}"
grep -q . "${MKFS_CALLS}" \
&& fail "blkid-exit-${rc}: formatted a volume it could not probe" \
|| pass "blkid-exit-${rc}: nothing formatted"
grep -q . "${MOUNT_CALLS}" \
&& fail "blkid-exit-${rc}: mounted a volume it could not probe" \
|| pass "blkid-exit-${rc}: not mounted"
done
# --- Case 9: sysfs listing a disk before its device node exists is not blank ---
reset_state
add_disk vda
add_disk vdb vol0123456789abcdef
rm -f "${DEV_DIR}/vdb"
run_fails "no-device-node"
grep -q . "${MKFS_CALLS}" \
&& fail "no-device-node: formatted a device that does not exist yet" \
|| pass "no-device-node: nothing formatted"
if [ "${FAILS}" -eq 0 ]; then
echo "PASS: all rds-datadir cases"
exit 0
fi
echo "FAILED: ${FAILS} case(s)"
exit 1
+343
View File
@@ -0,0 +1,343 @@
#!/bin/sh
# rds-init — PostgreSQL bootstrap for the spinifex-rds-postgres AMI.
#
# A boot oneshot ordered before the postgresql service. This is the PostgreSQL
# half of the engine seam: initdb, ALTER ROLE, the datadir and the 5432 default
# live here and nowhere else in the platform.
#
# Handoff contract, written by rds-agent into a root-owned tmpfs directory:
#
# /run/spinifex-rds/bootstrap.env 0600 KEY=value shell fragment:
# RDS_MODE=initialize|attach
# RDS_MASTER_USERNAME=<master role name>
# RDS_MASTER_PASSWORD=<present only when RDS_MODE=initialize>
# RDS_DB_NAME=<initial database; optional, as on AWS>
# RDS_PORT=<listen port; defaults to 5432>
# /run/spinifex-rds/parameters.conf 0600 resolved parameter-group values in
# postgresql.conf syntax
# /run/spinifex-rds/server.crt 0600 serving cert, cluster-CA-signed
# /run/spinifex-rds/server.key 0600 its private key
#
# Nothing there survives a reboot: the next boot re-fetches in `attach` mode and
# the cert is re-minted rather than persisted.
#
# rds-init keys off the datadir, never off RDS_MODE. An initialised datadir
# means skip initdb and only refresh config, so a replaced VM comes back on its
# existing data volume without re-bootstrapping.
set -eu
PG_VERSION="${RDS_PG_VERSION:-18}"
PG_BIN="${RDS_PG_BIN:-/usr/libexec/postgresql${PG_VERSION}}"
PG_USER="${RDS_PG_USER:-postgres}"
# Mount point of the data volume, and the datadir one level inside it. The
# datadir is deliberately not the mount point itself: initdb refuses a non-empty
# directory and a freshly formatted ext4 volume already holds lost+found.
DATA_MOUNT="${RDS_DATA_MOUNT:-/var/lib/postgresql}"
PGDATA="${RDS_PGDATA:-${DATA_MOUNT}/${PG_VERSION}/data}"
HANDOFF_DIR="${RDS_HANDOFF_DIR:-/run/spinifex-rds}"
ENV_FILE="${HANDOFF_DIR}/bootstrap.env"
SOCKET_DIR="${RDS_SOCKET_DIR:-/run/postgresql}"
TLS_DIR="${RDS_TLS_DIR:-${SOCKET_DIR}/tls}"
LOG_DIR="${RDS_LOG_DIR:-/var/log/postgresql}"
# The bootstrap server listens on a unix socket in this directory and nowhere
# else. It is 0700 and postgres-owned, so only postgres and root can reach the
# short trust-authenticated window before the master password exists.
BOOTSTRAP_DIR="${RDS_BOOTSTRAP_DIR:-/run/rds-init}"
MOUNTS_FILE="${RDS_MOUNTS_FILE:-/proc/mounts}"
log() { echo "[rds-init] $*"; }
fail() { echo "[rds-init] ERROR: $*" >&2; exit 1; }
# Between these two, the datadir carries PG_VERSION but no usable master role.
_created_datadir=0
_bootstrapped=0
# Runs from an EXIT trap rather than a statement, so `set -e` on any unguarded
# step between initdb and a working master role cannot skip it — that would leave
# a healthy-looking engine whose master role has no password, unrepairable.
clear_unfinished_datadir() {
[ "${_created_datadir}" = "1" ] || return 0
[ "${_bootstrapped}" = "1" ] && return 0
if rm -rf -- "${PGDATA}" && [ ! -e "${PGDATA}" ]; then
echo "[rds-init] ERROR: bootstrap did not complete; datadir ${PGDATA} cleared" >&2
return 0
fi
echo "[rds-init] ERROR: bootstrap did not complete and datadir ${PGDATA} could not be cleared; recover it out of band before retrying" >&2
}
# The server refuses to run as root.
as_pg() { su "${PG_USER}" -c "$1"; }
load_handoff() {
[ -f "${ENV_FILE}" ] || fail "no bootstrap handoff at ${ENV_FILE} — rds-agent has not delivered the bootstrap config"
# shellcheck disable=SC1090
. "${ENV_FILE}"
RDS_MODE="${RDS_MODE:-}"
RDS_MASTER_USERNAME="${RDS_MASTER_USERNAME:-}"
RDS_MASTER_PASSWORD="${RDS_MASTER_PASSWORD:-}"
RDS_DB_NAME="${RDS_DB_NAME:-}"
RDS_PORT="${RDS_PORT:-5432}"
[ -n "${RDS_MASTER_USERNAME}" ] || fail "bootstrap config has no RDS_MASTER_USERNAME"
case "${RDS_PORT}" in
'' | *[!0-9]*) fail "bootstrap config has a non-numeric RDS_PORT: ${RDS_PORT}" ;;
esac
log "bootstrap config loaded (mode=${RDS_MODE:-unset}, port=${RDS_PORT})"
}
# Every replace boots a fresh clone of this AMI, so a datadir off the data volume
# disappears at the next class change or recovery — silently, as the engine looks
# fine.
require_data_volume() {
if awk -v mnt="${DATA_MOUNT}" '$2 == mnt { found = 1 } END { exit !found }' "${MOUNTS_FILE}"; then
return 0
fi
[ "${RDS_ALLOW_LOCAL_DATADIR:-0}" = "1" ] ||
fail "${DATA_MOUNT} is not a mount point — the data volume is not attached; refusing to initialise on the boot volume"
log "warning: ${DATA_MOUNT} is not a mount point; data will not survive a VM replace (RDS_ALLOW_LOCAL_DATADIR=1)"
}
# Runs initdb into an empty datadir and lays down the cluster config written
# once: the include_dir hook and pg_hba.
initialise_cluster() {
# An empty datadir in `attach` mode means the data volume did not come back.
# Creating a cluster there would report success and hand the customer an
# empty database in place of theirs.
if [ "${RDS_MODE}" = "attach" ]; then
fail "datadir ${PGDATA} is empty but the bootstrap config is Mode=attach — the existing data volume is missing; refusing to create an empty cluster"
fi
[ -n "${RDS_MASTER_PASSWORD}" ] || fail "Mode=${RDS_MODE:-unset} carries no master password; cannot initialise the cluster"
install -d -m 0700 -o "${PG_USER}" -g "${PG_USER}" "$(dirname "${PGDATA}")" "${PGDATA}"
# Whether the datadir was empty beforehand gates the failure cleanup below.
# A torn write can leave a missing PG_VERSION over intact data, and deleting
# then would destroy it in response to the one signal that it is still there.
_was_empty=0
[ -z "$(ls -A -- "${PGDATA}" 2>/dev/null)" ] && _was_empty=1
log "initialising PostgreSQL ${PG_VERSION} cluster at ${PGDATA}"
# ICU collation, not libc: musl has no locale support, so a libc-provider
# cluster would silently behave as C. Local peer auth keeps root/postgres
# administration working once the bootstrap server is gone.
if ! as_pg "'${PG_BIN}/initdb' --pgdata='${PGDATA}' -E UTF-8 \
--locale-provider=icu --icu-locale=en-001-x-icu --data-checksums \
--auth-local=peer --auth-host=scram-sha-256"; then
# A half-written datadir carries PG_VERSION and would look initialised
# forever after, but only what this invocation created may be cleared.
if [ "${_was_empty}" = "1" ]; then
if rm -rf -- "${PGDATA}" && [ ! -e "${PGDATA}" ]; then
fail "initdb failed; datadir cleared"
fi
fail "initdb failed and datadir ${PGDATA} could not be cleared; recover it out of band before retrying"
fi
fail "initdb failed and ${PGDATA} was not empty beforehand — refusing to clear it, since the contents may be a datadir whose PG_VERSION was lost; recover the data volume out of band"
fi
# Only a datadir initdb built over an empty directory may be swept later.
_created_datadir="${_was_empty}"
trap clear_unfinished_datadir EXIT
cat >> "${PGDATA}/pg_hba.conf" <<'EOF'
# Managed by rds-init. Clients reach the instance over its customer ENI, gated
# by the DB security group as on AWS RDS, and authenticate with scram-sha-256.
# TLS is offered, not enforced — clients opt in with sslmode=require and up.
host all all 0.0.0.0/0 scram-sha-256
host all all ::/0 scram-sha-256
EOF
}
# Parameters live beside the data rather than in /etc, since a class change boots
# a fresh root volume and would quietly revert them to engine defaults.
install_parameters() {
if [ -f "${HANDOFF_DIR}/parameters.conf" ]; then
install -m 0600 -o "${PG_USER}" -g "${PG_USER}" \
"${HANDOFF_DIR}/parameters.conf" "${PGDATA}/conf.d/10-rds-parameters.conf"
return 0
fi
# A handoff without parameters keeps the previous include: dropping it would
# silently reset the instance to engine defaults.
if [ -f "${PGDATA}/conf.d/10-rds-parameters.conf" ]; then
log "warning: no parameters in the handoff — keeping the parameters already installed"
fi
}
# The key stays on tmpfs: it is re-minted every fetch, so persisting it would
# spread a private key into every snapshot.
install_tls() {
if [ -f "${HANDOFF_DIR}/server.crt" ] && [ -f "${HANDOFF_DIR}/server.key" ]; then
install -d -m 0700 -o "${PG_USER}" -g "${PG_USER}" "${TLS_DIR}"
install -m 0600 -o "${PG_USER}" -g "${PG_USER}" "${HANDOFF_DIR}/server.crt" "${TLS_DIR}/server.crt"
install -m 0600 -o "${PG_USER}" -g "${PG_USER}" "${HANDOFF_DIR}/server.key" "${TLS_DIR}/server.key"
rm -f "${HANDOFF_DIR}/server.crt" "${HANDOFF_DIR}/server.key"
SSL_MODE="on"
return 0
fi
# A re-run after the cert was consumed must not turn TLS off underneath
# clients connecting with sslmode=require.
if [ -f "${TLS_DIR}/server.crt" ] && [ -f "${TLS_DIR}/server.key" ]; then
SSL_MODE="on"
return 0
fi
log "warning: no serving cert in the handoff — the engine will start without TLS"
SSL_MODE="off"
}
# The 90- prefix sorts last within include_dir and the last setting wins, so a
# resolved parameter can never move the endpoint's port or turn off TLS.
write_rds_conf() {
_ssl="$1"
_tmp="${PGDATA}/conf.d/.90-rds-init.conf.new"
cat > "${_tmp}" <<EOF
# Managed by rds-init — regenerated on every boot from the bootstrap config.
# Edits here are lost; instance parameters belong in the parameter group.
listen_addresses = '*'
port = ${RDS_PORT}
unix_socket_directories = '${SOCKET_DIR}'
ssl = ${_ssl}
EOF
if [ "${_ssl}" = "on" ]; then
cat >> "${_tmp}" <<EOF
ssl_cert_file = '${TLS_DIR}/server.crt'
ssl_key_file = '${TLS_DIR}/server.key'
EOF
fi
chown "${PG_USER}:${PG_USER}" "${_tmp}"
chmod 0600 "${_tmp}"
mv -f "${_tmp}" "${PGDATA}/conf.d/90-rds-init.conf"
}
# Everything refreshed on every boot: the include hook, the resolved parameters,
# the serving cert and the platform's own connectivity settings.
install_runtime_config() {
install -d -m 0700 -o "${PG_USER}" -g "${PG_USER}" "${PGDATA}/conf.d"
install -d -m 1775 -o "${PG_USER}" -g "${PG_USER}" "${SOCKET_DIR}"
install -d -m 0755 -o "${PG_USER}" -g "${PG_USER}" "${LOG_DIR}"
# postgresql.conf is on the data volume, so the hook is asserted every boot
# rather than only at initdb — a restored volume may predate it.
if ! grep -q "^include_dir = 'conf.d'" "${PGDATA}/postgresql.conf"; then
printf "\n# Managed by rds-init: parameter-group values (10-) then platform settings (90-).\ninclude_dir = 'conf.d'\n" \
>> "${PGDATA}/postgresql.conf"
fi
install_parameters
install_tls
write_rds_conf "${SSL_MODE}"
}
# Brings the cluster up on a private socket with trust auth and no TLS, so the
# master password can be set before the engine is reachable. Never listens on TCP.
start_bootstrap_server() {
install -d -m 0700 -o "${PG_USER}" -g "${PG_USER}" "${BOOTSTRAP_DIR}"
printf 'local all all trust\n' > "${BOOTSTRAP_DIR}/pg_hba.conf"
chown "${PG_USER}:${PG_USER}" "${BOOTSTRAP_DIR}/pg_hba.conf"
chmod 0600 "${BOOTSTRAP_DIR}/pg_hba.conf"
as_pg "'${PG_BIN}/pg_ctl' --pgdata='${PGDATA}' --wait --timeout=60 \
--log='${LOG_DIR}/rds-init.log' \
-o \"-c listen_addresses='' -c unix_socket_directories='${BOOTSTRAP_DIR}' \
-c unix_socket_permissions=0700 -c hba_file='${BOOTSTRAP_DIR}/pg_hba.conf' -c ssl=off\" \
start"
}
stop_bootstrap_server() {
as_pg "'${PG_BIN}/pg_ctl' --pgdata='${PGDATA}' --wait --timeout=60 --mode=fast stop"
rm -f "${BOOTSTRAP_DIR}/pg_hba.conf"
}
# Identifiers and the password ride the environment and are re-quoted by psql,
# never reaching a shell word or argv.
psql_bootstrap() {
RDS_MASTER_USERNAME="${RDS_MASTER_USERNAME}" \
RDS_MASTER_PASSWORD="${RDS_MASTER_PASSWORD}" \
RDS_DB_NAME="${RDS_DB_NAME}" \
"${PG_BIN}/psql" --no-psqlrc --quiet --no-align --tuples-only \
-v ON_ERROR_STOP=1 \
-h "${BOOTSTRAP_DIR}" -p "${RDS_PORT}" -U "${PG_USER}" -d postgres -f -
}
# Runs on a freshly initialised cluster only.
bootstrap_master() {
log "starting bootstrap server on ${BOOTSTRAP_DIR}"
start_bootstrap_server
_rc=0
apply_master_role || _rc=$?
if [ "${_rc}" -eq 0 ] && [ -n "${RDS_DB_NAME}" ]; then
create_database || _rc=$?
fi
# A failed stop must not abort before the check below, or a failed bootstrap
# would be reported as a pg_ctl error and skip the cleanup.
stop_bootstrap_server || log "warning: the bootstrap server did not stop cleanly"
# The datadir is swept by the EXIT trap, which also covers the steps here
# that abort under `set -e` before this check is ever reached.
if [ "${_rc}" -ne 0 ]; then
fail "bootstrapping the master role/database failed (rc=${_rc})"
fi
}
# initdb may already have made it the bootstrap superuser, when the master
# username is "postgres".
apply_master_role() {
log "setting the master password for role ${RDS_MASTER_USERNAME}"
psql_bootstrap <<'SQL'
\getenv master RDS_MASTER_USERNAME
\getenv password RDS_MASTER_PASSWORD
SELECT NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'master') AS create_role \gset
\if :create_role
CREATE ROLE :"master" WITH LOGIN SUPERUSER;
\endif
ALTER ROLE :"master" WITH LOGIN SUPERUSER PASSWORD :'password';
SQL
}
# As on AWS, an instance created without a DBName gets no initial database.
create_database() {
log "creating database ${RDS_DB_NAME}"
psql_bootstrap <<'SQL'
\getenv master RDS_MASTER_USERNAME
\getenv dbname RDS_DB_NAME
SELECT NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = :'dbname') AS create_db \gset
\if :create_db
CREATE DATABASE :"dbname" OWNER :"master";
\endif
SQL
}
# Drops the master password from the handoff once applied, so it is not left
# readable for the life of the boot. The rest is tmpfs and goes at reboot.
consume_secrets() {
[ -f "${ENV_FILE}" ] || return 0
_tmp="${ENV_FILE}.new"
install -m 0600 /dev/null "${_tmp}"
grep -v '^RDS_MASTER_PASSWORD=' "${ENV_FILE}" >> "${_tmp}" || true
mv -f "${_tmp}" "${ENV_FILE}"
}
load_handoff
require_data_volume
if [ -s "${PGDATA}/PG_VERSION" ]; then
log "datadir ${PGDATA} is already initialised — attaching"
install_runtime_config
else
initialise_cluster
install_runtime_config
bootstrap_master
_bootstrapped=1
trap - EXIT
fi
consume_secrets
log "bootstrap complete; handing off to the postgresql service"
@@ -0,0 +1,24 @@
#!/sbin/openrc-run
# rds-init — boot oneshot that bootstraps or attaches the PostgreSQL cluster
# before the engine starts.
#
# `before postgresql` rather than a dependency of it: the engine runs with
# auto_setup="no", so a failure here leaves postgresql refusing to start rather
# than silently creating an empty cluster. `after rds-agent`, not `need`, so a
# failed fetch fails this oneshot visibly rather than skipping it.
description="Spinifex RDS PostgreSQL bootstrap"
depend() {
need net
after rds-agent
before postgresql
}
start() {
ebegin "Bootstrapping the RDS PostgreSQL instance"
# The VM has no operator login, so the host-side serial log is the only view
# of a bootstrap that failed before the engine came up.
/usr/local/sbin/rds-init > /dev/console 2>&1
eend $?
}
@@ -0,0 +1,376 @@
#!/bin/sh
# Self-contained POSIX test for rds-init: the initialize path (initdb, master
# password, initial database), the idempotent attach path, the data-volume
# guard, TLS install and the parameter include. No PostgreSQL, no root: initdb,
# pg_ctl and psql are stubbed on PATH alongside install/chown/su (the script
# drops privileges and sets file ownership), and every path it touches is
# redirected into a temp dir via its env knobs.
#
# Run: sh scripts/images/rds-postgres/rds-init_test.sh
set -eu
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
SCRIPT="${SCRIPT_DIR}/rds-init"
WORK=$(mktemp -d)
trap 'rm -rf "${WORK}"' EXIT
STUBBIN="${WORK}/bin"
mkdir -p "${STUBBIN}"
# install stub: honours -d and -m, drops -o/-g (the harness is not root).
cat > "${STUBBIN}/install" <<'EOF'
#!/bin/sh
mode=""; dirmode=0; args=""
while [ $# -gt 0 ]; do
case "$1" in
-d) dirmode=1; shift ;;
-m) mode="$2"; shift 2 ;;
-o|-g) shift 2 ;;
*) args="${args} $1"; shift ;;
esac
done
# shellcheck disable=SC2086
set -- ${args}
if [ "${dirmode}" = 1 ]; then
mkdir -p "$@"
[ -n "${mode}" ] && chmod "${mode}" "$@"
exit 0
fi
dest=""
for a in "$@"; do dest="${a}"; done
src=""
for a in "$@"; do [ "${a}" = "${dest}" ] || src="${a}"; done
cp "${src}" "${dest}"
[ -n "${mode}" ] && chmod "${mode}" "${dest}"
exit 0
EOF
# chown stub: file ownership is a no-op outside the guest.
cat > "${STUBBIN}/chown" <<'EOF'
#!/bin/sh
exit 0
EOF
# su stub: `su postgres -c "cmd"` runs cmd in the harness user's shell.
cat > "${STUBBIN}/su" <<'EOF'
#!/bin/sh
while [ $# -gt 0 ]; do
case "$1" in
-c) exec sh -c "$2" ;;
*) shift ;;
esac
done
exit 0
EOF
# initdb stub: records the call and materialises the files a real initdb leaves
# behind (PG_VERSION is what rds-init keys "already initialised" off).
cat > "${STUBBIN}/initdb" <<'EOF'
#!/bin/sh
echo "initdb $*" >> "${INITDB_CALLS}"
[ "${INITDB_FAIL:-0}" = "1" ] && exit 1
for a in "$@"; do
case "$a" in --pgdata=*) pgdata="${a#--pgdata=}" ;; esac
done
mkdir -p "${pgdata}"
echo 18 > "${pgdata}/PG_VERSION"
echo "# stock postgresql.conf" > "${pgdata}/postgresql.conf"
if [ "${PG_HBA_AS_DIR:-0}" = "1" ]; then
mkdir "${pgdata}/pg_hba.conf"
else
echo "local all all peer" > "${pgdata}/pg_hba.conf"
fi
exit 0
EOF
# pg_ctl stub: records start/stop of the bootstrap server. PGCTL_START_FAIL
# simulates a cluster that will not come up, e.g. a parameter the guest cannot
# allocate.
cat > "${STUBBIN}/pg_ctl" <<'EOF'
#!/bin/sh
echo "pg_ctl $*" >> "${PGCTL_CALLS}"
for a in "$@"; do
[ "$a" = "start" ] && [ "${PGCTL_START_FAIL:-0}" = "1" ] && exit 1
done
exit 0
EOF
# psql stub: records the SQL it was fed plus the credentials it was handed
# through the environment (never through argv).
cat > "${STUBBIN}/psql" <<'EOF'
#!/bin/sh
{
echo "--- psql $* [master=${RDS_MASTER_USERNAME:-} password=${RDS_MASTER_PASSWORD:-} db=${RDS_DB_NAME:-}]"
cat
} >> "${PSQL_CALLS}"
# ON_ERROR_STOP makes a real psql exit non-zero on any SQL failure; the stub
# reproduces that so the master-bootstrap failure path is exercisable.
[ "${PSQL_FAIL:-0}" = "1" ] && exit 3
exit 0
EOF
chmod +x "${STUBBIN}"/*
PATH="${STUBBIN}:${PATH}"
export PATH
FAILS=0
fail() { echo "FAIL: $*"; FAILS=$((FAILS + 1)); }
pass() { echo "ok: $*"; }
DATA_MOUNT="${WORK}/data"
PGDATA="${DATA_MOUNT}/18/data"
HANDOFF="${WORK}/run/spinifex-rds"
MOUNTS="${WORK}/mounts"
INITDB_CALLS="${WORK}/initdb.calls"
PGCTL_CALLS="${WORK}/pg_ctl.calls"
PSQL_CALLS="${WORK}/psql.calls"
export INITDB_CALLS PGCTL_CALLS PSQL_CALLS
# write_handoff <mode> <password> <dbname>: lay down the bootstrap.env rds-agent
# would have written. An empty password omits the field, as `attach` does.
write_handoff() {
mkdir -p "${HANDOFF}"
{
echo "RDS_MODE=$1"
echo "RDS_MASTER_USERNAME=mulgamaster"
[ -n "$2" ] && echo "RDS_MASTER_PASSWORD=$2"
[ -n "$3" ] && echo "RDS_DB_NAME=$3"
echo "RDS_PORT=6543"
} > "${HANDOFF}/bootstrap.env"
}
write_tls() {
mkdir -p "${HANDOFF}"
echo "CERT" > "${HANDOFF}/server.crt"
echo "KEY" > "${HANDOFF}/server.key"
}
write_parameters() {
mkdir -p "${HANDOFF}"
echo "shared_buffers = 128MB" > "${HANDOFF}/parameters.conf"
}
# reset_state clears everything the previous case left behind: a fresh datadir,
# an attached data volume, and empty stub-call logs.
reset_state() {
rm -rf "${WORK}/data" "${WORK}/run" "${WORK}/log"
mkdir -p "${DATA_MOUNT}"
printf '/dev/vdb %s ext4 rw,relatime 0 0\n' "${DATA_MOUNT}" > "${MOUNTS}"
: > "${INITDB_CALLS}"
: > "${PGCTL_CALLS}"
: > "${PSQL_CALLS}"
unset INITDB_FAIL PG_HBA_AS_DIR PSQL_FAIL RDS_ALLOW_LOCAL_DATADIR || true
}
# run: invoke rds-init with every path knob pointed into the temp dir.
run() {
env RDS_PG_BIN="${STUBBIN}" \
RDS_DATA_MOUNT="${DATA_MOUNT}" \
RDS_HANDOFF_DIR="${HANDOFF}" \
RDS_SOCKET_DIR="${WORK}/run/postgresql" \
RDS_BOOTSTRAP_DIR="${WORK}/run/rds-init" \
RDS_LOG_DIR="${WORK}/log" \
RDS_MOUNTS_FILE="${MOUNTS}" \
RDS_ALLOW_LOCAL_DATADIR="${RDS_ALLOW_LOCAL_DATADIR:-0}" \
INITDB_FAIL="${INITDB_FAIL:-0}" \
PG_HBA_AS_DIR="${PG_HBA_AS_DIR:-0}" \
PSQL_FAIL="${PSQL_FAIL:-0}" \
INITDB_CALLS="${INITDB_CALLS}" PGCTL_CALLS="${PGCTL_CALLS}" PSQL_CALLS="${PSQL_CALLS}" \
sh "${SCRIPT}" </dev/null
}
run_ok() { run > "${WORK}/out" 2>&1 || { fail "$1: non-zero exit: $(cat "${WORK}/out")"; return 1; }; }
run_fails() { run > "${WORK}/out" 2>&1 && fail "$1: expected a non-zero exit" || pass "$1: refused"; }
# --- Case 1: initialize on a fresh data volume ---
reset_state
write_handoff initialize 's3cr3t' appdb
write_tls
write_parameters
if run_ok "initialize"; then
grep -q 'initdb' "${INITDB_CALLS}" && pass "initialize: initdb ran" || fail "initialize: no initdb"
grep -q -- '--data-checksums' "${INITDB_CALLS}" \
&& pass "initialize: data checksums on" || fail "initialize: no --data-checksums"
grep -q 'host all all 0.0.0.0/0 scram-sha-256' "${PGDATA}/pg_hba.conf" \
&& pass "initialize: remote scram hba rule appended" || fail "initialize: no hba rule"
grep -q "^include_dir = 'conf.d'" "${PGDATA}/postgresql.conf" \
&& pass "initialize: include_dir hooked" || fail "initialize: no include_dir"
grep -q '^port = 6543' "${PGDATA}/conf.d/90-rds-init.conf" \
&& pass "initialize: delivered port applied" || fail "initialize: port not applied"
grep -q '^ssl = on' "${PGDATA}/conf.d/90-rds-init.conf" \
&& pass "initialize: ssl enabled" || fail "initialize: ssl not enabled"
grep -q 'shared_buffers = 128MB' "${PGDATA}/conf.d/10-rds-parameters.conf" \
&& pass "initialize: resolved parameters installed" || fail "initialize: no parameter include"
grep -q 'ALTER ROLE :"master" WITH LOGIN SUPERUSER PASSWORD' "${PSQL_CALLS}" \
&& pass "initialize: master password applied" || fail "initialize: no ALTER ROLE"
grep -q 'password=s3cr3t' "${PSQL_CALLS}" \
&& pass "initialize: password passed through the environment" || fail "initialize: password not in psql env"
grep -q 'CREATE DATABASE' "${PSQL_CALLS}" \
&& pass "initialize: initial database created" || fail "initialize: no CREATE DATABASE"
grep -q "listen_addresses=''" "${PGCTL_CALLS}" \
&& pass "initialize: bootstrap server is socket-only" || fail "initialize: bootstrap server listened on TCP"
grep -q 'stop' "${PGCTL_CALLS}" \
&& pass "initialize: bootstrap server stopped" || fail "initialize: bootstrap server left running"
fi
# --- Case 2: the master password does not outlive its use ---
grep -q '^RDS_MASTER_PASSWORD=' "${HANDOFF}/bootstrap.env" \
&& fail "consume: password still in the handoff" || pass "consume: password dropped from the handoff"
grep -q '^RDS_MASTER_USERNAME=mulgamaster' "${HANDOFF}/bootstrap.env" \
&& pass "consume: the rest of the config is kept" || fail "consume: non-secret fields lost"
[ -f "${HANDOFF}/server.key" ] \
&& fail "consume: serving key left in the handoff" || pass "consume: serving key moved out of the handoff"
[ -f "${WORK}/run/postgresql/tls/server.key" ] \
&& pass "tls: key installed on tmpfs, not the data volume" || fail "tls: key not installed"
[ -f "${PGDATA}/server.key" ] \
&& fail "tls: key written into the datadir (would land in every snapshot)" || pass "tls: datadir free of the key"
# --- Case 3: second boot (attach) is idempotent ---
: > "${INITDB_CALLS}"; : > "${PSQL_CALLS}"; : > "${PGCTL_CALLS}"
write_handoff attach '' appdb
write_parameters
if run_ok "attach"; then
[ -s "${INITDB_CALLS}" ] \
&& fail "attach: initdb re-ran on an initialised datadir" || pass "attach: initdb skipped"
[ -s "${PSQL_CALLS}" ] \
&& fail "attach: re-applied the master role" || pass "attach: no role/database work"
grep -q '^ssl = on' "${PGDATA}/conf.d/90-rds-init.conf" \
&& pass "attach: TLS survives a handoff without a new cert" || fail "attach: TLS turned off"
fi
# --- Case 4: an empty datadir in attach mode means the data volume is missing ---
reset_state
write_handoff attach '' appdb
run_fails "attach-empty"
[ -s "${INITDB_CALLS}" ] \
&& fail "attach-empty: initialised an empty cluster over missing data" || pass "attach-empty: no initdb"
# --- Case 5: the datadir must be on the attached data volume ---
reset_state
: > "${MOUNTS}"
write_handoff initialize 's3cr3t' appdb
run_fails "no-volume"
[ -s "${INITDB_CALLS}" ] \
&& fail "no-volume: initialised on the boot volume" || pass "no-volume: no initdb"
RDS_ALLOW_LOCAL_DATADIR=1
export RDS_ALLOW_LOCAL_DATADIR
if run_ok "local-datadir"; then
grep -q 'initdb' "${INITDB_CALLS}" \
&& pass "local-datadir: explicit override initialises anyway" || fail "local-datadir: no initdb"
fi
unset RDS_ALLOW_LOCAL_DATADIR
# --- Case 6: a failed initdb leaves nothing that looks initialised ---
reset_state
write_handoff initialize 's3cr3t' appdb
INITDB_FAIL=1
export INITDB_FAIL
run_fails "initdb-fail"
[ -e "${PGDATA}/PG_VERSION" ] \
&& fail "initdb-fail: half-written datadir kept" || pass "initdb-fail: datadir cleared"
unset INITDB_FAIL
# --- Case 6a: a post-initdb failure is covered by the cleanup trap ---
reset_state
write_handoff initialize 's3cr3t' appdb
PG_HBA_AS_DIR=1
export PG_HBA_AS_DIR
run_fails "post-initdb-fail"
[ -e "${PGDATA}/PG_VERSION" ] \
&& fail "post-initdb-fail: datadir kept before the former late trap point" \
|| pass "post-initdb-fail: datadir cleared"
unset PG_HBA_AS_DIR
# --- Case 6b: a failed initdb over a NON-empty datadir must not delete it ---
# A zero-length PG_VERSION over an otherwise intact datadir takes the
# initialise path, and "directory not empty" is one way initdb then fails.
# Clearing there would destroy customer data in response to the one signal
# that it is still present.
reset_state
write_handoff initialize 's3cr3t' appdb
mkdir -p "${PGDATA}"
: > "${PGDATA}/PG_VERSION"
echo 'customer table data' > "${PGDATA}/base-relation"
INITDB_FAIL=1
export INITDB_FAIL
run_fails "initdb-fail-nonempty"
[ -f "${PGDATA}/base-relation" ] \
&& pass "initdb-fail-nonempty: pre-existing datadir preserved" \
|| fail "initdb-fail-nonempty: pre-existing datadir deleted"
grep -q 'refusing to clear' "${WORK}/out" \
&& pass "initdb-fail-nonempty: refusal explains why" || fail "initdb-fail-nonempty: no refusal message"
unset INITDB_FAIL
# --- Case 6c: a failed master bootstrap leaves nothing bootable either ---
# postgresql is in the default runlevel independently of this oneshot, so a
# datadir kept here would start an engine whose master role has no password —
# and the password is one-shot, so the next fetch is `attach` and cannot repair
# it. The datadir must go the same way a failed initdb's does.
reset_state
write_handoff initialize 's3cr3t' appdb
PSQL_FAIL=1
export PSQL_FAIL
run_fails "bootstrap-fail"
[ -e "${PGDATA}/PG_VERSION" ] \
&& fail "bootstrap-fail: datadir kept after a failed master bootstrap" \
|| pass "bootstrap-fail: datadir cleared"
grep -q 'stop' "${PGCTL_CALLS}" \
&& pass "bootstrap-fail: bootstrap server stopped" || fail "bootstrap-fail: bootstrap server left running"
unset PSQL_FAIL
# --- Case 6d: the bootstrap server never starts ---
# `set -e` aborts the moment pg_ctl start fails, so nothing inside
# bootstrap_master runs. The datadir already carries PG_VERSION and a pg_hba
# accepting scram, and the password is spent, so leaving it would attach on the
# next boot and serve a database whose master role has no password.
reset_state
write_handoff initialize 's3cr3t' appdb
PGCTL_START_FAIL=1
export PGCTL_START_FAIL
run_fails "bootstrap-nostart"
[ -e "${PGDATA}/PG_VERSION" ] \
&& fail "bootstrap-nostart: datadir kept after the cluster failed to start" \
|| pass "bootstrap-nostart: datadir cleared"
unset PGCTL_START_FAIL
# --- Case 6e: a pre-existing datadir is never swept by the trap ---
# The clear is scoped to a datadir this invocation created. A torn write that
# loses PG_VERSION over intact data takes the initialise branch, and the sweep
# must not answer that by destroying the customer's data.
reset_state
write_handoff initialize 's3cr3t' appdb
mkdir -p "${PGDATA}"
echo "customer data" > "${PGDATA}/base_survivor"
INITDB_FAIL=1
export INITDB_FAIL
run_fails "preexisting-nosweep"
[ -e "${PGDATA}/base_survivor" ] \
&& pass "preexisting-nosweep: pre-existing datadir untouched" \
|| fail "preexisting-nosweep: swept a datadir it did not create"
unset INITDB_FAIL
# --- Case 7: no serving cert -> TLS off rather than a failed start ---
reset_state
write_handoff initialize 's3cr3t' ''
if run_ok "no-cert"; then
grep -q '^ssl = off' "${PGDATA}/conf.d/90-rds-init.conf" \
&& pass "no-cert: ssl off" || fail "no-cert: ssl not off"
grep -q 'ssl_cert_file' "${PGDATA}/conf.d/90-rds-init.conf" \
&& fail "no-cert: points at a cert that was never delivered" || pass "no-cert: no cert paths"
grep -q 'CREATE DATABASE' "${PSQL_CALLS}" \
&& fail "no-dbname: created a database without a DBName" || pass "no-dbname: no initial database"
fi
# --- Case 8: no handoff at all ---
reset_state
rm -rf "${HANDOFF}"
run_fails "no-handoff"
if [ "${FAILS}" -eq 0 ]; then
echo "PASS: all rds-init cases"
exit 0
fi
echo "FAILED: ${FAILS} case(s)"
exit 1
+45
View File
@@ -0,0 +1,45 @@
#!/bin/sh
set -eu
# setup.sh — guest customisation for the spinifex-rds-postgres AMI. Runs inside
# the libguestfs appliance under build-system-image.sh, after packages and
# INSTALL_FILES are placed.
# INSTALL_FILES land 0644; OpenRC requires 0755 on init scripts, and rds-init
# and rds-datadir are executed directly by their services.
chmod 0755 /etc/init.d/rds-datadir /etc/init.d/rds-init /etc/init.d/rds-agent \
/usr/local/sbin/rds-datadir /usr/local/sbin/rds-init
# Where cloud-init drops the agent's env file and the gateway CA. Created here
# so the delivery lands in a root-only directory.
install -d -m 0700 /etc/spinifex-rds
# Empty in the image: rds-datadir mounts over it at boot. Postgres-owned so the
# engine can traverse it if the volume's own root is stricter.
install -d -m 0750 -o postgres -g postgres /var/lib/postgresql
# Kept on the boot volume: per-boot diagnostics, so a snapshot stays data-only.
install -d -m 0755 -o postgres -g postgres /var/log/postgresql
# INSTALL_FILES replaces the stock conf.d wholesale; assert auto-setup is really
# off, since an image that can silently initdb over a missing data volume is the
# one failure this image must not have.
grep -q '^auto_setup="no"' /etc/conf.d/postgresql || {
echo "[rds-postgres-setup] /etc/conf.d/postgresql does not disable auto_setup"
exit 1
}
# Bind /dev/console to the serial port so userspace boot output reaches ttyS0,
# which the orchestrator captures host-side. Linux makes the last console= the
# controlling one, and stock Alpine lists tty0 last — reorder so ttyS0 wins.
sed -i \
's|console=ttyS0,115200n8 console=ttyAMA0,115200n8 console=tty0|console=tty0 console=ttyAMA0,115200n8 console=ttyS0,115200n8|' \
/etc/update-extlinux.conf /boot/extlinux.conf
# Cut the boot-menu countdown from 10s to ~1s, a fixed tax on every VM start.
# Both the generator config (seconds) and the rendered output (1/10s) are patched
# so a regenerate keeps it; a small nonzero keeps the menu interruptible.
sed -i 's/^timeout=.*/timeout=1/' /etc/update-extlinux.conf
sed -i 's/^TIMEOUT[[:space:]].*/TIMEOUT 10/' /boot/extlinux.conf
echo "[rds-postgres-setup] done"
+4 -2
View File
@@ -55,7 +55,7 @@ func GenerateIPSecPeerCert(configDir, caCertPath, caKeyPath, hostname, nodeIP st
return fmt.Errorf("ipsec peer cert: invalid nodeIP %q", nodeIP)
}
caCert, caKey, err := loadCAKeyPair(caCertPath, caKeyPath)
caCert, caKey, err := LoadCAKeyPair(caCertPath, caKeyPath)
if err != nil {
return err
}
@@ -162,7 +162,9 @@ var charonRereadCAs = func() error {
return nil
}
func loadCAKeyPair(caCertPath, caKeyPath string) (*x509.Certificate, *rsa.PrivateKey, error) {
// Exported so runtime services that mint from the cluster CA share one loader
// rather than each re-implementing the PEM/PKCS8 handling.
func LoadCAKeyPair(caCertPath, caKeyPath string) (*x509.Certificate, *rsa.PrivateKey, error) {
certPEM, err := os.ReadFile(caCertPath)
if err != nil {
return nil, nil, fmt.Errorf("read CA cert %s: %w", caCertPath, err)
+6
View File
@@ -458,6 +458,9 @@ var (
ErrorIAMMalformedPolicyDocument = "MalformedPolicyDocument"
ErrorAccessDenied = "AccessDenied"
// RDS-specific error codes.
ErrorDBInstanceNotFound = "DBInstanceNotFound"
// ECR-specific error codes.
ErrorRepositoryNotFound = "RepositoryNotFoundException"
ErrorRepositoryPolicyNotFound = "RepositoryPolicyNotFoundException"
@@ -1023,6 +1026,9 @@ var ErrorLookup = map[string]ErrorMessage{
ErrorIAMMalformedPolicyDocument: {HTTPCode: 400, Message: "The policy document is malformed."},
ErrorAccessDenied: {HTTPCode: 403, Message: "User is not authorized to perform this action."},
// RDS error codes
ErrorDBInstanceNotFound: {HTTPCode: 404, Message: "DBInstanceIdentifier does not refer to an existing DB instance."},
// ECR error codes
ErrorRepositoryNotFound: {HTTPCode: 400, Message: "The repository could not be found. Check the spelling of the specified repository and ensure that you are performing operations on the correct registry."},
ErrorRepositoryPolicyNotFound: {HTTPCode: 400, Message: "The repository and registry do not have an associated repository policy."},
+13
View File
@@ -103,6 +103,7 @@ type Config struct {
AWSGW AWSGWConfig `json:"AWSGW" mapstructure:"awsgw"`
VPCD VPCDConfig `json:"VPCD" mapstructure:"vpcd"`
Northstar NorthstarConfig `json:"Northstar" mapstructure:"northstar"`
RDS RDSConfig `json:"RDS" mapstructure:"rds"`
BaseDir string `json:"BaseDir" mapstructure:"base_dir"`
WalDir string `json:"WalDir" mapstructure:"wal_dir"`
@@ -152,6 +153,18 @@ type NorthstarConfig struct {
InternalDomain string `json:"InternalDomain" mapstructure:"internal_domain"`
}
// Every DB VM's primary NIC lives in the shared system VPC, which gives the
// in-guest agent management egress while the customer ENI stays ingress-only.
type RDSConfig struct {
// The IPv4 /14 the system VPC's /22 is carved from. It must not overlap the
// EKS control-plane supernet or any customer VPC CIDR.
SystemVPCSupernet string `json:"SystemVPCSupernet" mapstructure:"system_vpc_supernet"`
// Clamped to 1..3. Zero defaults to one, which is all a single-AZ platform
// can place across.
SystemVPCPrivateSubnets int `json:"SystemVPCPrivateSubnets" mapstructure:"system_vpc_private_subnets"`
}
// RDSDefaultSystemVPCSupernet anchors the RDS system VPC address space at
// 10.248.0.0/14, immediately below and disjoint from the EKS control-plane
// supernet, so a name-hash collision can never place an RDS subnet in EKS space.
+32
View File
@@ -56,6 +56,7 @@ import (
handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
"github.com/mulgadc/spinifex/spinifex/instancetypes"
"github.com/mulgadc/spinifex/spinifex/kvutil"
"github.com/mulgadc/spinifex/spinifex/network/external"
@@ -148,6 +149,7 @@ type Daemon struct {
eksService *handlers_eks.EKSServiceImpl
ecsService *handlers_ecs.Service
ecsScheduler *handlers_ecs.Scheduler
rdsService *handlers_rds.Service
acmService *handlers_acm.ACMServiceImpl
ecrMetaService *handlers_ecr.MetaServiceImpl
routeTableService *handlers_ec2_routetable.RouteTableServiceImpl
@@ -760,6 +762,17 @@ func natsMetricAction(topic, node string) string {
return strings.TrimSuffix(action, "."+node)
}
// clusterCAKeyPath derives the CA private key path from the configured CA
// certificate path — they are written as a pair into the same config directory.
// Returns "" when no CA cert is configured, which disables cert minting rather
// than guessing at a path.
func clusterCAKeyPath(caCertPath string) string {
if caCertPath == "" {
return ""
}
return filepath.Join(filepath.Dir(caCertPath), "ca.key")
}
// natsSub defines a single NATS subscription entry for the table-driven setup.
type natsSub struct {
topic string
@@ -1035,6 +1048,16 @@ func (d *Daemon) subscribeAll() error {
)
}
// RDS agent protocol. The register/health subjects are Layer-2 bus wildcards
// the gateway relays onto, addressing only — the payload is authoritative.
if d.rdsService != nil {
subs = append(subs,
natsSub{handlers_rds.SubjectRegisterWildcard, handleNATSRequest(d.rdsService.RegisterDBInstance), "spinifex-workers"},
natsSub{handlers_rds.SubjectHealthWildcard, handleNATSRequest(d.rdsService.SubmitDBStateChange), "spinifex-workers"},
natsSub{handlers_rds.SubjectGetDBBootstrapConfig, handleNATSRequest(d.rdsService.GetDBBootstrapConfig), "spinifex-workers"},
)
}
// ACM gateway → daemon subscriptions (minimal certificate store).
if d.acmService != nil {
subs = append(subs,
@@ -1565,6 +1588,15 @@ func (d *Daemon) startCluster() error {
})
}
// RDS control plane: KV-backed agent-protocol handlers. The cluster CA signs
// the per-instance serving certs, minted per bootstrap fetch and never
// persisted; ca.key sits beside the configured ca.pem.
d.rdsService = handlers_rds.NewService(d.natsConn, d.config.Region).WithDeps(handlers_rds.Deps{
CACertPath: d.config.NATS.CACert,
CAKeyPath: clusterCAKeyPath(d.config.NATS.CACert),
Launch: d.buildRDSLaunchDeps(),
})
d.acmService, err = initServiceWithRetry("ACM service", func() (*handlers_acm.ACMServiceImpl, error) {
return handlers_acm.NewACMServiceImplWithNATS(d.ctx, d.config, d.natsConn)
})
+28
View File
@@ -0,0 +1,28 @@
package daemon
import (
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
handlers_systemvpc "github.com/mulgadc/spinifex/spinifex/handlers/systemvpc"
)
// A launch can only arrive over the gateway, long after service init, so unlike
// the EKS/ECS IAM deps none of this is built lazily.
func (d *Daemon) buildRDSLaunchDeps() handlers_rds.LaunchDeps {
return handlers_rds.LaunchDeps{
Config: d.config,
SystemVPC: handlers_systemvpc.Deps{
VPC: d.vpcService,
SG: d.vpcService,
IGW: d.igwService,
RT: d.routeTableService,
NGW: d.natGatewayService,
EIP: d.eipService,
NATSConn: d.natsConn,
},
VPC: d.vpcService,
Instance: d,
Image: d.imageService,
Volume: d.volumeService,
Attacher: handlers_rds.NewNATSVolumeAttacher(d.natsConn),
}
}
+6 -3
View File
@@ -135,6 +135,7 @@ var supportedServices = map[string]bool{
"ecs": true,
"ecr": true,
"acm": true,
"rds": true,
"tagging": true,
"spinifex": true,
"bedrock": true,
@@ -263,7 +264,7 @@ func (gw *GatewayConfig) writeClusterUnavailable(w http.ResponseWriter, _ *http.
}
var xmlBody string
if svc == "iam" || svc == "sts" {
if svc == "iam" || svc == "sts" || svc == "rds" {
iam := IAMErrorResponse{
Error: IAMErrorDetail{
Type: "Sender",
@@ -315,7 +316,7 @@ func (gw *GatewayConfig) writeThrottleError(w http.ResponseWriter, r *http.Reque
}
var xmlErr []byte
if svc == "iam" || svc == "sts" || svc == "elasticloadbalancing" {
if svc == "iam" || svc == "sts" || svc == "elasticloadbalancing" || svc == "rds" {
xmlErr = GenerateIAMErrorResponse(errorCode, errorMsg.Message, requestID)
} else { // ec2, account, spinifex
xmlErr = GenerateEC2ErrorResponse(errorCode, errorMsg.Message, requestID)
@@ -366,6 +367,8 @@ func (gw *GatewayConfig) Request(w http.ResponseWriter, r *http.Request) {
err = gw.ECR_Request(w, r)
case "acm":
err = gw.ACM_Request(w, r)
case "rds":
err = gw.RDS_Request(w, r)
case "tagging":
err = gw.Tagging_Request(w, r)
case "spinifex":
@@ -575,7 +578,7 @@ func (gw *GatewayConfig) ErrorHandler(w http.ResponseWriter, r *http.Request, er
}
var xmlError []byte
if svc == "iam" || svc == "sts" || svc == "elasticloadbalancing" {
if svc == "iam" || svc == "sts" || svc == "elasticloadbalancing" || svc == "rds" {
xmlError = GenerateIAMErrorResponse(code, errorMsg.Message, requestId)
} else {
xmlError = GenerateEC2ErrorResponse(code, errorMsg.Message, requestId)
+78
View File
@@ -0,0 +1,78 @@
package gateway
import (
"errors"
"log/slog"
"net/http"
"github.com/mulgadc/predastore/auth"
"github.com/mulgadc/spinifex/spinifex/awserrors"
gateway_rds "github.com/mulgadc/spinifex/spinifex/gateway/rds"
)
// RDS shares the query-in/XML-out shape of ELBv2: the action comes from the
// Action= form param and the response is the IAM-style XML envelope.
func (gw *GatewayConfig) RDS_Request(w http.ResponseWriter, r *http.Request) error {
queryArgs, err := readQueryArgs(r)
if err != nil {
slog.DebugContext(r.Context(), "RDS: malformed query string", "err", err)
return errors.New(awserrors.ErrorMalformedQueryString)
}
action := queryArgs["Action"]
if action == "" {
return errors.New(awserrors.ErrorMissingAction)
}
// Resolved before the policy check, so an unrecognised action is rejected as
// InvalidAction rather than evaluated as an rds:<garbage> permission.
if !gateway_rds.HasAction(action) {
slog.DebugContext(r.Context(), "RDS: unknown action", "action", action)
return errors.New(awserrors.ErrorInvalidAction)
}
if err := gw.checkPolicy(r, "rds", action); err != nil {
return err
}
if gw.NATSConn == nil {
return errors.New(awserrors.ErrorServerInternal)
}
caller, err := rdsCaller(r)
if err != nil {
slog.ErrorContext(r.Context(), "RDS_Request: no account ID in auth context")
return err
}
xmlOutput, err := gateway_rds.Dispatch(r.Context(), action, queryArgs, gw.NATSConn, caller)
if err != nil {
return err
}
w.Header().Set("Content-Type", "text/xml")
w.WriteHeader(http.StatusOK)
if _, err := w.Write(xmlOutput); err != nil {
slog.ErrorContext(r.Context(), "Failed to write RDS response", "err", err)
}
return nil
}
// The role name comes from the underlying role ARN, never the session name,
// which the caller picks at AssumeRole time and could name its way past the gate.
func rdsCaller(r *http.Request) (gateway_rds.Caller, error) {
accountID := mustCtxString(r, ctxAccountID)
if accountID == "" {
return gateway_rds.Caller{}, errors.New(awserrors.ErrorServerInternal)
}
caller := gateway_rds.Caller{
AccountID: accountID,
PrincipalType: mustCtxString(r, ctxPrincipalType),
SessionName: mustCtxString(r, ctxIdentity),
}
if arn := mustCtxString(r, ctxUnderlyingRoleARN); arn != "" {
if roleAcct, roleName, err := auth.ParseRoleARN(arn); err == nil && roleAcct == accountID {
caller.RoleName = roleName
}
}
return caller, nil
}
+261
View File
@@ -0,0 +1,261 @@
package gateway_rds
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"time"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
// Only a session assumed from this role may call the internal agent actions.
const InstanceRoleName = "rdsInstanceRole"
// Long-poll bounds for PollDBCommands. The ceiling keeps a poll inside the
// gateway's request timeout; the floor stops a misbehaving agent turning the
// long poll into a busy loop.
const (
minPollWait = 1 * time.Second
maxPollWait = 20 * time.Second
defaultPollWait = 20 * time.Second
)
// The DB instance a caller has been proven to be.
type agentIdentity struct {
AccountID string
DBInstanceIdentifier string
InstanceID string
}
// requestedID, from the request body, is only ever used to reject a mismatch —
// the authoritative identifier comes from the index, so an agent cannot act on
// another instance by asking to.
func authorizeAgent(ctx context.Context, nc *nats.Conn, caller Caller, requestedID string) (*agentIdentity, error) {
if caller.PrincipalType != principalTypeAssumedRole ||
caller.AccountID != utils.GlobalAccountID ||
caller.RoleName != InstanceRoleName ||
caller.SessionName == "" {
slog.DebugContext(ctx, "RDS: internal action rejected for non-agent caller",
"principalType", caller.PrincipalType, "accountID", caller.AccountID, "roleName", caller.RoleName)
return nil, errors.New(awserrors.ErrorAccessDenied)
}
// IMDS instance-role credentials set RoleSessionName to the internal EC2
// instance ID, the reverse-index key. The role ARN proves the caller is an
// RDS VM; the session name only says which one.
entry, err := lookupInstanceIndex(ctx, nc, caller.SessionName)
if err != nil {
slog.ErrorContext(ctx, "RDS: instance-index lookup failed", "instanceID", caller.SessionName, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
if entry == nil {
slog.DebugContext(ctx, "RDS: no instance-index entry for caller", "instanceID", caller.SessionName)
return nil, errors.New(awserrors.ErrorAccessDenied)
}
if requestedID != "" && requestedID != entry.DBInstanceIdentifier {
slog.WarnContext(ctx, "RDS: agent requested another instance",
"callerInstanceID", caller.SessionName, "resolved", entry.DBInstanceIdentifier, "requested", requestedID)
return nil, errors.New(awserrors.ErrorAccessDenied)
}
return &agentIdentity{
AccountID: entry.AccountID,
DBInstanceIdentifier: entry.DBInstanceIdentifier,
InstanceID: caller.SessionName,
}, nil
}
// Reads JetStream directly rather than adding a NATS round trip, matching the
// OIDC discovery and client-token paths, because this runs on every agent call.
func lookupInstanceIndex(ctx context.Context, nc *nats.Conn, instanceID string) (*handlers_rds.InstanceIndexEntry, error) {
if nc == nil {
return nil, errors.New("gateway NATS connection not initialised")
}
js, err := jetstream.New(nc)
if err != nil {
return nil, err
}
kv, err := js.KeyValue(ctx, handlers_rds.KVBucketRDSSystem)
if err != nil {
// No bucket means no RDS instance has ever been created, so no caller
// can be an agent. That is a denial, not an internal failure.
if errors.Is(err, jetstream.ErrBucketNotFound) {
return nil, nil
}
return nil, err
}
entry, err := kv.Get(ctx, handlers_rds.InstanceIndexKey(instanceID))
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
var out handlers_rds.InstanceIndexEntry
if err := json.Unmarshal(entry.Value(), &out); err != nil {
return nil, fmt.Errorf("unmarshal instance index %s: %w", instanceID, err)
}
return &out, nil
}
// The identifier is accepted but authoritative identity comes from the gate.
type RegisterDBInstanceInput struct {
DBInstanceIdentifier string `locationName:"DBInstanceIdentifier"`
AgentVersion string `locationName:"AgentVersion"`
EngineVersion string `locationName:"EngineVersion"`
}
func RegisterDBInstance(ctx context.Context, input *RegisterDBInstanceInput, nc *nats.Conn, caller Caller) (any, error) {
id, err := authorizeAgent(ctx, nc, caller, input.DBInstanceIdentifier)
if err != nil {
return nil, err
}
return handlers_rds.NewNATSService(nc).RegisterDBInstance(ctx, &handlers_rds.RegisterDBInstanceInput{
DBInstanceIdentifier: id.DBInstanceIdentifier,
InstanceID: id.InstanceID,
AgentVersion: input.AgentVersion,
EngineVersion: input.EngineVersion,
}, id.AccountID)
}
type SubmitDBStateChangeInput struct {
DBInstanceIdentifier string `locationName:"DBInstanceIdentifier"`
EngineHealth string `locationName:"EngineHealth"`
EngineVersion string `locationName:"EngineVersion"`
Message string `locationName:"Message"`
}
func SubmitDBStateChange(ctx context.Context, input *SubmitDBStateChangeInput, nc *nats.Conn, caller Caller) (any, error) {
id, err := authorizeAgent(ctx, nc, caller, input.DBInstanceIdentifier)
if err != nil {
return nil, err
}
return handlers_rds.NewNATSService(nc).SubmitDBStateChange(ctx, &handlers_rds.SubmitDBStateChangeInput{
DBInstanceIdentifier: id.DBInstanceIdentifier,
InstanceID: id.InstanceID,
EngineHealth: handlers_rds.EngineHealth(input.EngineHealth),
EngineVersion: input.EngineVersion,
Message: input.Message,
}, id.AccountID)
}
type GetDBBootstrapConfigInput struct {
DBInstanceIdentifier string `locationName:"DBInstanceIdentifier"`
}
// Serves boot material, including the master password on the first fetch only.
func GetDBBootstrapConfig(ctx context.Context, input *GetDBBootstrapConfigInput, nc *nats.Conn, caller Caller) (any, error) {
id, err := authorizeAgent(ctx, nc, caller, input.DBInstanceIdentifier)
if err != nil {
return nil, err
}
return handlers_rds.NewNATSService(nc).GetDBBootstrapConfig(ctx, &handlers_rds.GetDBBootstrapConfigInput{
DBInstanceIdentifier: id.DBInstanceIdentifier,
InstanceID: id.InstanceID,
}, id.AccountID)
}
// Carries results for commands delivered on an earlier poll and asks for the
// next one, matching the ECS ack-on-poll shape.
type PollDBCommandsInput struct {
DBInstanceIdentifier string `locationName:"DBInstanceIdentifier"`
WaitTimeSeconds int64 `locationName:"WaitTimeSeconds"`
Replies []handlers_rds.CommandReply `locationName:"Replies" locationNameList:"member"`
}
// At most one command per poll: each is a discrete guest operation the agent
// must complete and report before the next is safe to issue.
type PollDBCommandsOutput struct {
Commands []handlers_rds.Command `locationName:"Commands" locationNameList:"member"`
}
// The command channel is a live subscription, not a durable queue, so a
// set-password that cannot reach the agent fails loudly rather than queueing
// cleartext in KV.
func PollDBCommands(ctx context.Context, input *PollDBCommandsInput, nc *nats.Conn, caller Caller) (any, error) {
id, err := authorizeAgent(ctx, nc, caller, input.DBInstanceIdentifier)
if err != nil {
return nil, err
}
if nc == nil {
return nil, errors.New(awserrors.ErrorServerInternal)
}
// Replies are published before the subscription is opened so a reply is not
// delayed by the poll's own wait.
publishReplies(ctx, nc, id, input.Replies)
sub, err := nc.QueueSubscribeSync(
handlers_rds.BusCommandSubject(id.AccountID, id.DBInstanceIdentifier),
handlers_rds.CommandQueueGroup)
if err != nil {
slog.ErrorContext(ctx, "RDS: command subscribe failed", "dbInstanceIdentifier", id.DBInstanceIdentifier, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
// Unsubscribing on every exit path stops an abandoned subscription
// swallowing a later command from a queue group nobody is reading.
defer func() {
if err := sub.Unsubscribe(); err != nil {
slog.DebugContext(ctx, "RDS: command unsubscribe failed", "err", err)
}
}()
pollCtx, cancel := context.WithTimeout(ctx, pollWait(input.WaitTimeSeconds))
defer cancel()
msg, err := sub.NextMsgWithContext(pollCtx)
if err != nil {
// The context ending the wait is the steady state: the window closed with
// no command, or the agent hung up.
if pollCtx.Err() != nil {
return &PollDBCommandsOutput{}, nil
}
// Anything else means the channel is broken, not idle. Reporting that as
// an empty poll would have the agent re-poll at line rate against a
// channel no command can arrive on.
slog.ErrorContext(ctx, "RDS: command poll failed",
"dbInstanceIdentifier", id.DBInstanceIdentifier, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
var cmd handlers_rds.Command
if err := json.Unmarshal(msg.Data, &cmd); err != nil {
slog.ErrorContext(ctx, "RDS: undecodable command dropped", "dbInstanceIdentifier", id.DBInstanceIdentifier, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
return &PollDBCommandsOutput{Commands: []handlers_rds.Command{cmd}}, nil
}
func pollWait(requested int64) time.Duration {
if requested <= 0 {
return defaultPollWait
}
return min(max(time.Duration(requested)*time.Second, minPollWait), maxPollWait)
}
// Failures are logged rather than returned: a lost reply times the issuer out,
// the same outcome as a lost command and better than failing the whole poll.
func publishReplies(ctx context.Context, nc *nats.Conn, id *agentIdentity, replies []handlers_rds.CommandReply) {
subject := handlers_rds.BusCommandReplySubject(id.AccountID, id.DBInstanceIdentifier)
for _, reply := range replies {
if reply.CommandID == "" {
continue
}
data, err := json.Marshal(reply)
if err != nil {
slog.ErrorContext(ctx, "RDS: marshal command reply", "commandID", reply.CommandID, "err", err)
continue
}
if err := nc.Publish(subject, data); err != nil {
slog.ErrorContext(ctx, "RDS: publish command reply", "commandID", reply.CommandID, "err", err)
}
}
}
+263
View File
@@ -0,0 +1,263 @@
package gateway_rds
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
"github.com/mulgadc/spinifex/spinifex/testutil"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
testDBID = "orders-db"
testInstanceID = "i-0abc123"
)
// agentCaller is the identity an in-guest agent presents: an assumed-role
// session under the system account whose session name is its own instance ID.
func agentCaller() Caller {
return Caller{
AccountID: utils.GlobalAccountID,
PrincipalType: principalTypeAssumedRole,
RoleName: InstanceRoleName,
SessionName: testInstanceID,
}
}
// newIndexedNATS starts JetStream with one instance already in the reverse
// index, which is the state the launch path leaves behind.
func newIndexedNATS(t *testing.T) *nats.Conn {
t.Helper()
_, nc, _ := testutil.StartTestJetStream(t)
svc := handlers_rds.NewService(nc, "ap-southeast-2")
require.NoError(t, svc.PutInstanceIndex(context.Background(), testInstanceID, handlers_rds.InstanceIndexEntry{
AccountID: testAccountID,
DBInstanceIdentifier: testDBID,
VMGeneration: 1,
}))
return nc
}
func TestAuthorizeAgent_ResolvesInstanceFromIndex(t *testing.T) {
nc := newIndexedNATS(t)
id, err := authorizeAgent(t.Context(), nc, agentCaller(), "")
require.NoError(t, err)
assert.Equal(t, testAccountID, id.AccountID, "the account comes from the index, not the caller")
assert.Equal(t, testDBID, id.DBInstanceIdentifier)
assert.Equal(t, testInstanceID, id.InstanceID)
}
// The agent's own credentials are minted under the system account, so the
// customer account it acts for can only come from the index.
func TestAuthorizeAgent_IgnoresRequestedIdentifierWhenItMatches(t *testing.T) {
nc := newIndexedNATS(t)
id, err := authorizeAgent(t.Context(), nc, agentCaller(), testDBID)
require.NoError(t, err)
assert.Equal(t, testDBID, id.DBInstanceIdentifier)
}
func TestAuthorizeAgent_RejectsNonAgentCallers(t *testing.T) {
nc := newIndexedNATS(t)
tests := []struct {
name string
caller Caller
}{
{"plain IAM user", Caller{AccountID: utils.GlobalAccountID, PrincipalType: "user", RoleName: InstanceRoleName, SessionName: testInstanceID}},
{"root", Caller{AccountID: utils.GlobalAccountID, PrincipalType: "root", RoleName: InstanceRoleName, SessionName: testInstanceID}},
{"customer account", Caller{AccountID: testAccountID, PrincipalType: principalTypeAssumedRole, RoleName: InstanceRoleName, SessionName: testInstanceID}},
{"another role", Caller{AccountID: utils.GlobalAccountID, PrincipalType: principalTypeAssumedRole, RoleName: "ecsInstanceRole", SessionName: testInstanceID}},
{"unresolvable role", Caller{AccountID: utils.GlobalAccountID, PrincipalType: principalTypeAssumedRole, RoleName: "", SessionName: testInstanceID}},
{"no session name", Caller{AccountID: utils.GlobalAccountID, PrincipalType: principalTypeAssumedRole, RoleName: InstanceRoleName, SessionName: ""}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := authorizeAgent(t.Context(), nc, tt.caller, "")
require.Error(t, err)
assert.Equal(t, awserrors.ErrorAccessDenied, err.Error())
})
}
}
// A session name that is not in the index is not an RDS VM, whatever role it
// holds. This is the case a forged RoleSessionName lands in.
func TestAuthorizeAgent_RejectsUnindexedInstance(t *testing.T) {
nc := newIndexedNATS(t)
caller := agentCaller()
caller.SessionName = "i-0notadatabase"
_, err := authorizeAgent(t.Context(), nc, caller, "")
require.Error(t, err)
assert.Equal(t, awserrors.ErrorAccessDenied, err.Error())
}
// One agent must not be able to act on another instance by naming it.
func TestAuthorizeAgent_RejectsCrossInstanceIdentifier(t *testing.T) {
nc := newIndexedNATS(t)
_, err := authorizeAgent(t.Context(), nc, agentCaller(), "someone-elses-db")
require.Error(t, err)
assert.Equal(t, awserrors.ErrorAccessDenied, err.Error())
}
// Before any DB instance exists the system bucket is absent. That is a denial,
// not an internal error, so a probe cannot distinguish the two.
func TestAuthorizeAgent_RejectsWhenNoInstancesExist(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
_, err := authorizeAgent(t.Context(), nc, agentCaller(), "")
require.Error(t, err)
assert.Equal(t, awserrors.ErrorAccessDenied, err.Error())
}
// Every internal action runs the gate before it touches the control plane, so a
// customer caller is refused without a NATS round trip.
func TestInternalActions_GateBeforeDispatch(t *testing.T) {
for _, action := range []string{"RegisterDBInstance", "SubmitDBStateChange", "PollDBCommands", "GetDBBootstrapConfig"} {
t.Run(action, func(t *testing.T) {
_, err := Dispatch(t.Context(), action, map[string]string{"Action": action}, nil, testCaller)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorAccessDenied, err.Error())
})
}
}
// An empty poll is the steady state: the agent gets no command and no error.
func TestPollDBCommands_ReturnsEmptyOnTimeout(t *testing.T) {
nc := newIndexedNATS(t)
out, err := PollDBCommands(t.Context(),
&PollDBCommandsInput{WaitTimeSeconds: 1}, nc, agentCaller())
require.NoError(t, err)
assert.Empty(t, out.(*PollDBCommandsOutput).Commands)
}
func TestPollDBCommands_DeliversPublishedCommand(t *testing.T) {
nc := newIndexedNATS(t)
subject := handlers_rds.BusCommandSubject(testAccountID, testDBID)
type result struct {
out any
err error
}
done := make(chan result, 1)
go func() {
out, err := PollDBCommands(context.Background(), &PollDBCommandsInput{WaitTimeSeconds: 5}, nc, agentCaller())
done <- result{out, err}
}()
data, err := json.Marshal(handlers_rds.Command{CommandID: "cmd-1", Type: "set-password"})
require.NoError(t, err)
// The command channel is a live subscription, so a directive published
// before the poll subscribes is gone by contract. Republishing until the
// poll returns synchronises without reaching into the subscription.
ticker := time.NewTicker(20 * time.Millisecond)
defer ticker.Stop()
for {
select {
case r := <-done:
require.NoError(t, r.err)
cmds := r.out.(*PollDBCommandsOutput).Commands
require.Len(t, cmds, 1)
assert.Equal(t, "cmd-1", cmds[0].CommandID)
assert.Equal(t, "set-password", cmds[0].Type)
return
case <-ticker.C:
require.NoError(t, nc.Publish(subject, data))
case <-time.After(10 * time.Second):
t.Fatal("poll did not return the published command")
}
}
}
// The agent's replies ride the next poll and are republished for the issuer,
// which correlates them by command ID.
func TestPollDBCommands_RepublishesReplies(t *testing.T) {
nc := newIndexedNATS(t)
replySub, err := nc.SubscribeSync(handlers_rds.BusCommandReplySubject(testAccountID, testDBID))
require.NoError(t, err)
defer func() { _ = replySub.Unsubscribe() }()
_, err = PollDBCommands(t.Context(), &PollDBCommandsInput{
WaitTimeSeconds: 1,
Replies: []handlers_rds.CommandReply{
{CommandID: "cmd-1", Status: handlers_rds.CommandStatusSucceeded},
{CommandID: "", Status: handlers_rds.CommandStatusFailed}, // dropped: no correlation
},
}, nc, agentCaller())
require.NoError(t, err)
msg, err := replySub.NextMsg(2 * time.Second)
require.NoError(t, err)
var reply handlers_rds.CommandReply
require.NoError(t, json.Unmarshal(msg.Data, &reply))
assert.Equal(t, "cmd-1", reply.CommandID)
assert.Equal(t, handlers_rds.CommandStatusSucceeded, reply.Status)
_, err = replySub.NextMsg(100 * time.Millisecond)
assert.Error(t, err, "an uncorrelatable reply must not be republished")
}
// The XML marshaller omits a nil pointer entirely but renders an empty value
// field as <Tag></Tag>. MasterUserPassword is a pointer for exactly that reason:
// an attach response must not carry the element at all.
func TestBootstrapConfigXML_OmitsPasswordOnAttach(t *testing.T) {
password := "s3cr3t-master-pw"
base := handlers_rds.GetDBBootstrapConfigOutput{
DBInstanceIdentifier: testDBID,
Engine: "postgres",
MasterUsername: "postgres",
Port: 5432,
Parameters: []handlers_rds.Parameter{{Name: "shared_buffers", Value: "128MB"}},
}
initialize := base
initialize.Mode = handlers_rds.BootstrapModeInitialize
initialize.MasterUserPassword = &password
body := marshalResult(t, "GetDBBootstrapConfig", &initialize)
assert.Contains(t, body, "<MasterUserPassword>"+password+"</MasterUserPassword>")
attach := base
attach.Mode = handlers_rds.BootstrapModeAttach
body = marshalResult(t, "GetDBBootstrapConfig", &attach)
assert.NotContains(t, body, "MasterUserPassword", "the element must be absent, not empty")
assert.NotContains(t, body, password)
// The rest of the payload still has to reach the agent on an attach. The
// marshaller emits a struct's children in map order, so each element is
// asserted on its own rather than as an ordered pair.
assert.Contains(t, body, "<Port>5432</Port>")
assert.Contains(t, body, "<member>")
assert.Contains(t, body, "<Name>shared_buffers</Name>")
assert.Contains(t, body, "<Value>128MB</Value>")
}
func marshalResult(t *testing.T, action string, out any) string {
t.Helper()
body, err := utils.MarshalToXML(utils.GenerateIAMXMLPayload(action, out))
require.NoError(t, err)
return string(body)
}
func TestPollWait_ClampsToWindow(t *testing.T) {
tests := []struct {
name string
requested int64
want time.Duration
}{
{"unset uses the default", 0, defaultPollWait},
{"negative uses the default", -5, defaultPollWait},
{"below the floor is raised", 1, minPollWait},
{"in range is honoured", 10, 10 * time.Second},
{"above the ceiling is capped", 600, maxPollWait},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, pollWait(tt.requested))
})
}
}
+159
View File
@@ -0,0 +1,159 @@
// Package gateway_rds implements the RDS surface on awsgw. RDS speaks the AWS
// Query protocol, so this package mirrors the ELBv2 gateway shape: an action
// table, a typed-input adapter, and one function per action.
package gateway_rds
import (
"context"
"errors"
"log/slog"
"github.com/mulgadc/spinifex/spinifex/awsec2query"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
)
// Only an assumed-role session can be an in-guest agent.
const principalTypeAssumedRole = "assumed-role"
// Customer actions need only AccountID; the internal agent actions have to tell
// an instance role apart from a user, which the account alone cannot do.
type Caller struct {
AccountID string
PrincipalType string
RoleName string
// The RoleSessionName of an assumed-role session. For IMDS instance-role
// credentials it is the internal EC2 instance ID.
SessionName string
}
type Handler func(ctx context.Context, action string, q map[string]string, nc *nats.Conn, caller Caller) ([]byte, error)
// Allocates the input struct, parses the query params into it, calls handler and
// marshals the output into the IAM-style <ActionResponse><ActionResult> envelope.
func typed[In any](handler func(context.Context, *In, *nats.Conn, Caller) (any, error)) Handler {
return func(ctx context.Context, action string, q map[string]string, nc *nats.Conn, caller Caller) ([]byte, error) {
input := new(In)
if err := awsec2query.QueryParamsToStruct(q, input); err != nil {
// An over-long indexed list is a client-side malformation, not an
// internal failure, so it keeps its own error code.
if errors.Is(err, awsec2query.ErrSliceTooLarge) {
return nil, errors.New(awserrors.ErrorMalformedQueryString)
}
return nil, err
}
output, err := handler(ctx, input, nc, caller)
if err != nil {
return nil, err
}
payload := utils.GenerateIAMXMLPayload(action, output)
xmlOutput, err := utils.MarshalToXML(payload)
if err != nil {
return nil, errors.New("failed to marshal response to XML")
}
return xmlOutput, nil
}
}
// An action whose body lands in a later phase: the caller learns it is
// recognised and simply not ready — never a silent success.
func pending() Handler {
return rejectWith(awserrors.ErrorNotImplemented)
}
// Recognised but deliberately outside v1, so a client sees "not offered" rather
// than "you typo'd the action name".
func unsupported() Handler {
return rejectWith(awserrors.ErrorOperationNotSupported)
}
func rejectWith(code string) Handler {
return func(ctx context.Context, action string, _ map[string]string, _ *nats.Conn, _ Caller) ([]byte, error) {
slog.DebugContext(ctx, "RDS: action not available", "action", action, "code", code)
return nil, errors.New(code)
}
}
// The whole namespace is registered from day one, not-yet-built actions as
// explicit stubs, so an unimplemented action stays distinct from an unknown one.
var actions = map[string]Handler{
// Instance lifecycle.
"CreateDBInstance": pending(),
"DescribeDBInstances": typed(DescribeDBInstances),
"ModifyDBInstance": pending(),
"DeleteDBInstance": pending(),
"RebootDBInstance": pending(),
"StartDBInstance": pending(),
"StopDBInstance": pending(),
// Snapshots.
"CreateDBSnapshot": pending(),
"DescribeDBSnapshots": pending(),
"DeleteDBSnapshot": pending(),
"RestoreDBInstanceFromDBSnapshot": pending(),
// Automated backups.
"DescribeDBInstanceAutomatedBackups": pending(),
// Subnet groups.
"CreateDBSubnetGroup": pending(),
"DescribeDBSubnetGroups": pending(),
"DeleteDBSubnetGroup": pending(),
// Parameter groups.
"CreateDBParameterGroup": pending(),
"DescribeDBParameterGroups": pending(),
"ModifyDBParameterGroup": pending(),
"DescribeDBParameters": pending(),
"DeleteDBParameterGroup": pending(),
// Tags.
"AddTagsToResource": pending(),
"RemoveTagsFromResource": pending(),
"ListTagsForResource": pending(),
// Events.
"DescribeEvents": pending(),
// Internal agent actions, callable only by the in-guest agent's system role.
// They share the namespace because the agent reaches the control plane over
// SigV4-on-awsgw like every other in-guest agent.
"RegisterDBInstance": typed(RegisterDBInstance),
"SubmitDBStateChange": typed(SubmitDBStateChange),
"PollDBCommands": typed(PollDBCommands),
"GetDBBootstrapConfig": typed(GetDBBootstrapConfig),
// Recognised but out of scope. Read replicas, Aurora clusters and option
// groups are not offered at all; point-in-time restore waits on WAL
// archiving.
"CreateDBInstanceReadReplica": unsupported(),
"PromoteReadReplica": unsupported(),
"CreateDBCluster": unsupported(),
"ModifyDBCluster": unsupported(),
"DeleteDBCluster": unsupported(),
"DescribeDBClusters": unsupported(),
"FailoverDBCluster": unsupported(),
"CreateOptionGroup": unsupported(),
"ModifyOptionGroup": unsupported(),
"DeleteOptionGroup": unsupported(),
"DescribeOptionGroups": unsupported(),
"RestoreDBInstanceToPointInTime": unsupported(),
}
// Checked before the IAM policy check, so an unknown action is rejected as
// InvalidAction rather than logged as a denial.
func HasAction(action string) bool {
_, ok := actions[action]
return ok
}
// Callers are expected to have authorized the action already; the
// unknown-action check here is a backstop, not the enforcement point.
func Dispatch(ctx context.Context, action string, q map[string]string, nc *nats.Conn, caller Caller) ([]byte, error) {
handler, ok := actions[action]
if !ok {
return nil, errors.New(awserrors.ErrorInvalidAction)
}
return handler(ctx, action, q, nc, caller)
}
+155
View File
@@ -0,0 +1,155 @@
package gateway_rds
import (
"testing"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const testAccountID = "123456789012"
// testCaller is an ordinary customer principal: enough for the customer actions,
// and deliberately not enough for the internal agent actions.
var testCaller = Caller{AccountID: testAccountID, PrincipalType: "user"}
// v1Actions is the RDS v1 namespace as a literal list rather than one derived
// from the table under test, so a dropped or renamed action fails here instead
// of silently redefining the namespace.
var v1Actions = []string{
"CreateDBInstance",
"DescribeDBInstances",
"ModifyDBInstance",
"DeleteDBInstance",
"RebootDBInstance",
"StartDBInstance",
"StopDBInstance",
"CreateDBSnapshot",
"DescribeDBSnapshots",
"DeleteDBSnapshot",
"RestoreDBInstanceFromDBSnapshot",
"DescribeDBInstanceAutomatedBackups",
"CreateDBSubnetGroup",
"DescribeDBSubnetGroups",
"DeleteDBSubnetGroup",
"CreateDBParameterGroup",
"DescribeDBParameterGroups",
"ModifyDBParameterGroup",
"DescribeDBParameters",
"DeleteDBParameterGroup",
"AddTagsToResource",
"RemoveTagsFromResource",
"ListTagsForResource",
"DescribeEvents",
"RegisterDBInstance",
"SubmitDBStateChange",
"PollDBCommands",
"GetDBBootstrapConfig",
}
// outOfScopeActions are recognised but deliberately not offered in v1.
var outOfScopeActions = []string{
"CreateDBInstanceReadReplica",
"PromoteReadReplica",
"CreateDBCluster",
"ModifyDBCluster",
"DeleteDBCluster",
"DescribeDBClusters",
"FailoverDBCluster",
"CreateOptionGroup",
"ModifyOptionGroup",
"DeleteOptionGroup",
"DescribeOptionGroups",
"RestoreDBInstanceToPointInTime",
}
func TestActions_CoverV1Namespace(t *testing.T) {
for _, action := range v1Actions {
assert.True(t, HasAction(action), "action %q should be registered", action)
}
for _, action := range outOfScopeActions {
assert.True(t, HasAction(action), "out-of-scope action %q should be registered so it fails loudly", action)
}
assert.Len(t, actions, len(v1Actions)+len(outOfScopeActions),
"the action table should hold exactly the v1 namespace plus the recognised out-of-scope actions")
}
func TestHasAction_UnknownAction(t *testing.T) {
for _, action := range []string{"", "RunInstances", "DescribeDBInstance", "createdbinstance"} {
assert.False(t, HasAction(action), "action %q should not be registered", action)
}
}
func TestDispatch_UnknownAction(t *testing.T) {
_, err := Dispatch(t.Context(), "NotAnRDSAction", nil, nil, testCaller)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidAction, err.Error())
}
// Every registered action must dispatch to something: either a real response or
// one of the two deliberate rejections. Anything else means an action was wired
// to a handler that fails for an unrelated reason.
func TestDispatch_EveryActionResolves(t *testing.T) {
for action := range actions {
t.Run(action, func(t *testing.T) {
body, err := Dispatch(t.Context(), action, map[string]string{"Action": action}, nil, testCaller)
if err == nil {
assert.NotEmpty(t, body, "a successful action must return an XML body")
return
}
// AccessDenied is the fourth legitimate outcome: the internal agent
// actions gate on principal class, and testCaller is a customer.
assert.Contains(t,
[]string{awserrors.ErrorNotImplemented, awserrors.ErrorOperationNotSupported, awserrors.ErrorAccessDenied},
err.Error(),
"a stubbed action must reject with NotImplemented, OperationNotSupported or AccessDenied")
})
}
}
func TestDispatch_PendingActionIsNotImplemented(t *testing.T) {
_, err := Dispatch(t.Context(), "CreateDBInstance", map[string]string{"Action": "CreateDBInstance"}, nil, testCaller)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorNotImplemented, err.Error())
}
func TestDispatch_OutOfScopeActionIsNotSupported(t *testing.T) {
for _, action := range outOfScopeActions {
_, err := Dispatch(t.Context(), action, map[string]string{"Action": action}, nil, testCaller)
require.Error(t, err, "action %q", action)
assert.Equal(t, awserrors.ErrorOperationNotSupported, err.Error(), "action %q", action)
}
}
func TestDispatch_DescribeDBInstancesReturnsEmptyResultSet(t *testing.T) {
body, err := Dispatch(t.Context(), "DescribeDBInstances",
map[string]string{"Action": "DescribeDBInstances", "Version": "2014-10-31"}, nil, testCaller)
require.NoError(t, err)
// The IAM-style envelope the aws-sdk-go query unmarshaler expects, carrying
// no instances rather than an error.
assert.Equal(t,
"<DescribeDBInstancesResponse><DescribeDBInstancesResult><DBInstances></DBInstances>"+
"</DescribeDBInstancesResult></DescribeDBInstancesResponse>",
string(body))
}
// A filtered describe is still an empty result set, not a parse failure: the
// query params have to survive QueryParamsToStruct into the typed input.
func TestDispatch_DescribeDBInstancesWithFilters(t *testing.T) {
body, err := Dispatch(t.Context(), "DescribeDBInstances", map[string]string{
"Action": "DescribeDBInstances",
"DBInstanceIdentifier": "orders-db",
"MaxRecords": "20",
}, nil, testCaller)
require.NoError(t, err)
assert.Contains(t, string(body), "<DescribeDBInstancesResult")
}
+14
View File
@@ -0,0 +1,14 @@
package gateway_rds
import (
"context"
"github.com/aws/aws-sdk-go/service/rds"
"github.com/nats-io/nats.go"
)
// Until CreateDBInstance lands the result set is always empty, which is the
// honest answer for an account that has none.
func DescribeDBInstances(_ context.Context, _ *rds.DescribeDBInstancesInput, _ *nats.Conn, _ Caller) (any, error) {
return &rds.DescribeDBInstancesOutput{DBInstances: []*rds.DBInstance{}}, nil
}
+66
View File
@@ -0,0 +1,66 @@
package gateway
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupRDSRequest(body string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
ctx := context.WithValue(req.Context(), ctxService, "rds")
ctx = context.WithValue(ctx, ctxAccountID, "123456789012")
return req.WithContext(ctx)
}
func TestRDSRequest_MissingAction(t *testing.T) {
gw := &GatewayConfig{DisableLogging: true}
err := gw.RDS_Request(httptest.NewRecorder(), setupRDSRequest(""))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorMissingAction, err.Error())
}
func TestRDSRequest_UnknownAction(t *testing.T) {
gw := &GatewayConfig{DisableLogging: true}
err := gw.RDS_Request(httptest.NewRecorder(), setupRDSRequest("Action=FakeAction"))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidAction, err.Error())
}
// A malformed percent-encoding is a client error, not an internal one.
func TestRDSRequest_MalformedQueryString(t *testing.T) {
gw := &GatewayConfig{DisableLogging: true}
err := gw.RDS_Request(httptest.NewRecorder(), setupRDSRequest("Action=%zz"))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorMalformedQueryString, err.Error())
}
// A known action still needs a NATS connection to reach the control plane, so
// the disconnected case must fail rather than answer from the gateway alone.
func TestRDSRequest_NoNATSConn(t *testing.T) {
gw := &GatewayConfig{DisableLogging: true}
err := gw.RDS_Request(httptest.NewRecorder(), setupRDSRequest("Action=DescribeDBInstances"))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
}
// RDS errors must use the IAM-style <ErrorResponse> envelope: the aws-sdk-go
// query unmarshaler rejects the EC2 <Response><Errors> shape for this service.
func TestRDSErrorHandler_UsesIAMEnvelope(t *testing.T) {
gw := &GatewayConfig{DisableLogging: true}
w := httptest.NewRecorder()
gw.ErrorHandler(w, setupRDSRequest("Action=CreateDBInstance"), errNotImplementedForTest)
body := w.Body.String()
assert.Contains(t, body, "<ErrorResponse>")
assert.Contains(t, body, "<Code>"+awserrors.ErrorNotImplemented+"</Code>")
}
var errNotImplementedForTest = errors.New(awserrors.ErrorNotImplemented)
-302
View File
@@ -1,302 +0,0 @@
# NATS Handler Naming Convention
This document defines the naming convention for NATS message handlers in the Spinifex daemon.
## Pattern
**Format**: `handleEC2<AWSAction>` → NATS topic `ec2.<AWSAction>`
Where `<AWSAction>` matches the AWS API action name exactly (PascalCase).
### Benefits
1. **AWS API Alignment** - Handler names directly correlate with AWS documentation
2. **Self-Documenting** - Method name clearly indicates which AWS action it handles
3. **Scalable** - Pattern extends cleanly to all AWS services
4. **Consistent** - Same pattern across all handlers
## Current Handlers
### EC2 Instance Operations
```go
handleEC2RunInstances → ec2.RunInstances // Launch instances
handleEC2DescribeInstances → ec2.DescribeInstances // Query instance status
handleEC2StartInstances → ec2.StartInstances // Start stopped instances
handleEC2StopInstances → ec2.StopInstances // Stop running instances
handleEC2TerminateInstances → ec2.TerminateInstances // Terminate instances
handleEC2RebootInstances → ec2.RebootInstances // Reboot instances
```
### EC2 Images (AMI)
```go
handleEC2DescribeImages → ec2.DescribeImages // List available AMIs
handleEC2CreateImage → ec2.CreateImage // Create AMI from instance
handleEC2RegisterImage → ec2.RegisterImage // Register external AMI
handleEC2DeregisterImage → ec2.DeregisterImage // Remove AMI
handleEC2CopyImage → ec2.CopyImage // Copy AMI across regions
```
### EC2 Key Pairs
```go
handleEC2CreateKeyPair → ec2.CreateKeyPair // Generate new key pair
handleEC2DeleteKeyPair → ec2.DeleteKeyPair // Remove key pair
handleEC2DescribeKeyPairs → ec2.DescribeKeyPairs // List key pairs
handleEC2ImportKeyPair → ec2.ImportKeyPair // Import existing public key
```
### EBS Volumes
```go
handleEC2CreateVolume → ec2.CreateVolume // Create EBS volume
handleEC2AttachVolume → ec2.AttachVolume // Attach volume to instance
handleEC2DetachVolume → ec2.DetachVolume // Detach volume
handleEC2DeleteVolume → ec2.DeleteVolume // Remove volume
handleEC2DescribeVolumes → ec2.DescribeVolumes // List volumes
handleEC2CreateSnapshot → ec2.CreateSnapshot // Create volume snapshot
handleEC2DeleteSnapshot → ec2.DeleteSnapshot // Remove snapshot
```
### Per-instance commands (`ec2.cmd.{instanceID}`)
Actions that must run on the node hosting a VM do not use the `spinifex-workers` queue group, which answers on any node. They travel on `ec2.cmd.{instanceID}`, subscribed only by the node running that instance, so the command is self-routing: the publisher never resolves instance → node.
The subject carries a single `types.EC2InstanceCommand`, dispatched by `handleEC2Events` on the boolean set in `EC2CommandAttributes`:
```go
AttachVolume, DetachVolume // QMP hot-plug of an EBS volume
DrainVolume // flush the volume's in-flight writes to S3
AttachENI, DetachENI // ENI hot-plug (awsvpc tasks)
StartInstance, StopInstance, TerminateInstance, RebootInstance
AssociateIamInstanceProfile, SetSpotLineage
SetInstanceTags, RemoveInstanceTags
```
`DrainVolume` exists because `ec2.CreateSnapshot` is queue-grouped but the drain socket that flushes in-flight writes is served by the NBD plugin on the node hosting the volume. The node answering the snapshot routes the drain here and waits for the ack before reading the live checkpoint; a volume with a live writer that does not ack fails the snapshot rather than capturing stale data.
Establishing that there *is* a live writer is the subject's job, because the attachment record alone does not say so — stop deliberately leaves a boot volume attached while tearing down both the plugin and this subscription. Two replies therefore mean "nothing is writing, the sealed checkpoint stands" rather than failure: `DrainVolumeStatusNotRunning`, from a node that still holds the instance but is not running it, and `nats.ErrNoResponders`, meaning no node runs it at all.
The drain is dispatched on its own goroutine. It is bounded by the volume's dirty set, and nats.go delivers a subscription's messages serially, so running it inline would stall every other command for that instance for the length of the flush.
### VPC Networking
```go
handleEC2CreateVpc → ec2.CreateVpc // Create VPC
handleEC2DeleteVpc → ec2.DeleteVpc // Remove VPC
handleEC2DescribeVpcs → ec2.DescribeVpcs // List VPCs
handleEC2CreateSubnet → ec2.CreateSubnet // Create subnet
handleEC2DeleteSubnet → ec2.DeleteSubnet // Remove subnet
handleEC2DescribeSubnets → ec2.DescribeSubnets // List subnets
```
### Security Groups
```go
handleEC2CreateSecurityGroup → ec2.CreateSecurityGroup // Create security group
handleEC2DeleteSecurityGroup → ec2.DeleteSecurityGroup // Remove security group
handleEC2DescribeSecurityGroups → ec2.DescribeSecurityGroups // List security groups
handleEC2AuthorizeSecurityGroupIngress → ec2.AuthorizeSecurityGroupIngress // Add inbound rule
handleEC2RevokeSecurityGroupIngress → ec2.RevokeSecurityGroupIngress // Remove inbound rule
```
## Implementation Example
### Daemon Handler Method
```go
// handleEC2RunInstances processes incoming EC2 RunInstances requests
func (d *Daemon) handleEC2RunInstances(msg *nats.Msg) {
// Parse request
runInstancesInput := &ec2.RunInstancesInput{}
errResp := utils.UnmarshalJsonPayload(runInstancesInput, msg.Data)
// Validate inputs
err := gateway_ec2_instance.ValidateRunInstancesInput(runInstancesInput)
// Process and respond...
}
```
### NATS Subscription
```go
// Subscribe to EC2 RunInstances with queue group
d.natsSubscriptions["ec2.RunInstances"], err = d.natsConn.QueueSubscribe(
"ec2.RunInstances", // NATS topic
"spinifex-workers", // Queue group for load balancing
d.handleEC2RunInstances, // Handler method
)
```
### Gateway Client Request
```go
// Gateway sends RunInstances request via NATS
msg, err := nc.Request("ec2.RunInstances", jsonData, 30*time.Second)
```
## Migration Notes
### Legacy Topics
For backward compatibility, some handlers may subscribe to both old and new topic formats:
```go
// Legacy topic (deprecated, for backward compatibility)
d.natsSubscriptions["ec2.launch"], err = d.natsConn.QueueSubscribe(
"ec2.launch", "spinifex-workers", d.handleEC2RunInstances)
// New topic (recommended)
d.natsSubscriptions["ec2.RunInstances"], err = d.natsConn.QueueSubscribe(
"ec2.RunInstances", "spinifex-workers", d.handleEC2RunInstances)
```
**Recommendation**: New code should use the AWS Action name format (`ec2.RunInstances`).
## Queue Groups
All handlers use the `"spinifex-workers"` queue group for:
- **Load Balancing** - NATS distributes requests across available daemon instances
- **High Availability** - If one daemon fails, others continue processing
- **Scalability** - Add more daemon instances to handle increased load
## Testing Pattern
Test function names follow the same convention:
```go
func TestHandleEC2RunInstances_MessageParsing(t *testing.T) { ... }
func TestHandleEC2RunInstances_ResourceManagement(t *testing.T) { ... }
func TestHandleEC2DescribeInstances_FilterByID(t *testing.T) { ... }
```
## Future Extensions
### S3 Operations
```go
handleS3CreateBucket → s3.CreateBucket
handleS3DeleteBucket → s3.DeleteBucket
handleS3PutObject → s3.PutObject
handleS3GetObject → s3.GetObject
```
### IAM Operations
```go
handleIAMCreateUser → iam.CreateUser
handleIAMDeleteUser → iam.DeleteUser
handleIAMCreateRole → iam.CreateRole
```
This pattern scales consistently across all AWS services.
## EKS (Elastic Kubernetes Service)
EKS uses two layers of NATS subjects.
### Layer 1 — AWS API surface
The gateway translates EKS REST-JSON requests into `eks.<AWSAction>` NATS
requests, using the existing `spinifex-workers` queue group. The handler
name follows the same `handleEKS<AWSAction>` convention.
```
eks.CreateCluster, eks.DescribeCluster, eks.ListClusters,
eks.UpdateClusterConfig, eks.UpdateClusterVersion, eks.DeleteCluster
eks.CreateNodegroup, eks.DescribeNodegroup, eks.ListNodegroups,
eks.UpdateNodegroupConfig, eks.UpdateNodegroupVersion, eks.DeleteNodegroup
eks.CreateAccessEntry, eks.DescribeAccessEntry, eks.ListAccessEntries,
eks.UpdateAccessEntry, eks.DeleteAccessEntry,
eks.AssociateAccessPolicy, eks.DisassociateAccessPolicy,
eks.ListAssociatedAccessPolicies, eks.ListAccessPolicies
eks.ListAddons, eks.DescribeAddonVersions,
eks.CreateAddon, eks.DeleteAddon, eks.DescribeAddon, eks.UpdateAddon
eks.AssociateIdentityProviderConfig, eks.DescribeIdentityProviderConfig,
eks.ListIdentityProviderConfigs, eks.DisassociateIdentityProviderConfig
eks.TagResource, eks.UntagResource, eks.ListTagsForResource
```
### Layer 2 — internal reconciler bus
The cluster + nodegroup reconcilers communicate with K3s VMs and each
other on `eks.bus.<accountID>.<clusterName>.*`. This layer lands with the
reconciler bodies; only Layer 1 is registered today. See
`docs/development/feature/eks-v1.md` Q11 for the full subject list.
### Cross-service calls
EKS reconcilers also publish on existing namespaces when interacting with
other services (no `eks.` prefix): `ec2.RunInstances`,
`ec2.CreateNetworkInterface`, `elbv2.CreateLoadBalancer`,
`elbv2.RegisterTargets`, `route53.ChangeResourceRecordSets`, etc.
## ECR (OCI Distribution registry metadata)
ECR is not a SigV4 AWS-action surface but an OCI Distribution v2 (`/v2/*`)
registry. Blob and manifest *bytes* stream straight from the gateway to
predastore and never traverse NATS. Only the *metadata* — repo/tag/manifest
records and in-progress upload-state CAS — is owned by the daemon, which holds
the per-account JetStream KV. The gateway is a request/reply client; the daemon
serves these subjects under the `spinifex-workers` queue group.
Subjects use a `ecr.<noun>.<verb>` shape (handler `handleECR<Noun><Verb>`):
```
ecr.repo.create, ecr.repo.describe, ecr.repo.list
ecr.tag.put, ecr.tag.get, ecr.tag.list, ecr.tag.delete
ecr.manifest.put, ecr.manifest.describe
ecr.upload.create, ecr.upload.get, ecr.upload.update, ecr.upload.delete
```
`ecr.upload.update` is the serialization point for chunked blob uploads: it is a
JetStream KV compare-and-swap, so a concurrent PATCH that loses the revision
race is rejected rather than silently merged. Absent records and CAS conflicts
travel back as response flags (`found`, `conflict`), not transport errors, so
they round-trip a reply envelope that otherwise only carries AWS error codes.
## ECS (Elastic Container Service)
ECS uses two layers of NATS subjects (ecs-v1.md Q14). The `.bus.` segment
distinguishes internal scheduler↔agent traffic from the AWS API surface.
### Layer 1 — AWS API surface
The gateway translates ECS JSON 1.1 requests (X-Amz-Target
`AmazonEC2ContainerServiceV20141113.<Action>`) into `ecs.<AWSAction>` NATS
requests, using the existing `spinifex-workers` queue group. Handler names
follow the `handleECS<AWSAction>` convention. The full v1 namespace:
```
ecs.CreateCluster, ecs.DescribeClusters, ecs.ListClusters, ecs.DeleteCluster,
ecs.UpdateCluster, ecs.PutClusterCapacityProviders
ecs.RegisterTaskDefinition, ecs.DeregisterTaskDefinition,
ecs.DescribeTaskDefinition, ecs.ListTaskDefinitions, ecs.ListTaskDefinitionFamilies
ecs.RunTask, ecs.StartTask, ecs.StopTask, ecs.DescribeTasks, ecs.ListTasks
ecs.CreateService, ecs.UpdateService, ecs.DeleteService, ecs.DescribeServices,
ecs.ListServices, ecs.ListServicesByNamespace
ecs.RegisterContainerInstance, ecs.DeregisterContainerInstance,
ecs.DescribeContainerInstances, ecs.ListContainerInstances,
ecs.UpdateContainerInstancesState
ecs.PutAccountSetting, ecs.ListAccountSettings
ecs.TagResource, ecs.UntagResource, ecs.ListTagsForResource
```
Today every action is a 501 stub; the subjects land with their handler bodies.
### Layer 2 — internal scheduler↔agent bus
The per-cluster scheduler and the on-VM ecs-agent communicate on
`ecs.bus.<accountID>.<clusterName>.*`. Cluster identity is
`<accountID>.<clusterName>` (matches the KV layout), not a UUID.
```
ecs.bus.<accountID>.<clusterName>.assign.<instanceID> # scheduler → agent
ecs.bus.<accountID>.<clusterName>.task-state.<taskID> # agent → scheduler
ecs.bus.<accountID>.<clusterName>.instance-heartbeat.<instanceID> # agent → scheduler
ecs.bus.<accountID>.<clusterName>.instance-register.<instanceID> # agent → scheduler
ecs.bus.<accountID>.<clusterName>.service-reconcile # scheduler-internal tick
```
Sprint 4c lands the agent-side publishers for `instance-register` (at boot) and
`instance-heartbeat` (every 30s); the scheduler-side subscribers, `assign`, and
`task-state` land in Sprint 4e. Subject builders and wire payloads live in
`spinifex/handlers/ecs/bus` so both the agent and the scheduler share one source
of truth. The Phase 5 per-AZ NATS cutover inserts `{azID}` after `ecs.` →
`ecs.<azID>.bus.<accountID>.<clusterName>.*`.
### Cross-service calls
The scheduler also publishes on existing namespaces (no `ecs.` prefix):
`ec2.CreateNetworkInterface`, `ec2.AttachNetworkInterface`, `sts.AssumeRole`,
`elbv2.RegisterTargets`, `elbv2.DeregisterTargets`,
`route53.ChangeResourceRecordSets`.
+391
View File
@@ -0,0 +1,391 @@
package handlers_rds
import (
"context"
"errors"
"fmt"
"time"
"github.com/nats-io/nats.go/jetstream"
"github.com/mulgadc/spinifex/spinifex/awserrors"
)
// Bootstrap modes returned by GetDBBootstrapConfig. Initialize is the first
// fetch and carries the master password; attach is the same payload minus the
// password, for a fresh VM booting against an existing datadir.
const (
BootstrapModeInitialize = "initialize"
BootstrapModeAttach = "attach"
)
// The agent protocol types below carry both json and locationName tags because
// the same value crosses NATS as JSON and leaves the gateway as Query-protocol
// XML. Optional fields are pointers so a nil renders as an absent element.
// InstanceID and DBInstanceIdentifier are set by the gateway from the caller's
// resolved identity, never from the agent's request body.
type RegisterDBInstanceInput struct {
DBInstanceIdentifier string `json:"dbInstanceIdentifier"`
InstanceID string `json:"instanceId"`
AgentVersion string `json:"agentVersion,omitempty"`
EngineVersion string `json:"engineVersion,omitempty"`
}
// The heartbeat cadence is handed back on registration so the interval stays
// control-plane-owned.
type RegisterDBInstanceOutput struct {
DBInstanceIdentifier string `json:"dbInstanceIdentifier" locationName:"DBInstanceIdentifier"`
HeartbeatIntervalSeconds int64 `json:"heartbeatIntervalSeconds" locationName:"HeartbeatIntervalSeconds"`
}
// The periodic beat folds liveness into the state report rather than using a
// separate heartbeat call, so a healthy instance costs one round trip per tick.
type SubmitDBStateChangeInput struct {
DBInstanceIdentifier string `json:"dbInstanceIdentifier"`
InstanceID string `json:"instanceId"`
EngineHealth EngineHealth `json:"engineHealth"`
EngineVersion string `json:"engineVersion,omitempty"`
Message string `json:"message,omitempty"`
}
// Persisted reports whether the beat reached KV; diagnostic only, the agent
// behaves the same either way.
type SubmitDBStateChangeOutput struct {
Acknowledged bool `json:"acknowledged" locationName:"Acknowledged"`
Persisted bool `json:"persisted" locationName:"Persisted"`
HeartbeatIntervalSeconds int64 `json:"heartbeatIntervalSeconds" locationName:"HeartbeatIntervalSeconds"`
}
type GetDBBootstrapConfigInput struct {
DBInstanceIdentifier string `json:"dbInstanceIdentifier"`
InstanceID string `json:"instanceId"`
}
// Everything rds-init needs to bootstrap or attach. The serving cert and key
// are minted per call and never persisted.
type GetDBBootstrapConfigOutput struct {
Mode string `json:"mode" locationName:"Mode"`
DBInstanceIdentifier string `json:"dbInstanceIdentifier" locationName:"DBInstanceIdentifier"`
Engine string `json:"engine" locationName:"Engine"`
EngineVersion string `json:"engineVersion,omitempty" locationName:"EngineVersion"`
DBName string `json:"dbName,omitempty" locationName:"DBName"`
MasterUsername string `json:"masterUsername" locationName:"MasterUsername"`
MasterUserPassword *string `json:"masterUserPassword,omitempty" locationName:"MasterUserPassword"`
Port int64 `json:"port" locationName:"Port"`
Parameters []Parameter `json:"parameters,omitempty" locationName:"Parameters" locationNameList:"member" xml:"Parameters>member"`
// Empty when no cluster CA is configured; the agent then starts the engine
// without TLS rather than failing to boot, since TLS is offered not enforced.
ServingCertificate string `json:"servingCertificate,omitempty" locationName:"ServingCertificate"`
ServingPrivateKey string `json:"servingPrivateKey,omitempty" locationName:"ServingPrivateKey"`
CACertificate string `json:"caCertificate,omitempty" locationName:"CACertificate"`
}
// One directive delivered to an agent on its long poll. Concrete types land
// with their owning phases — password apply, parameter reload, grow, quiesce.
type Command struct {
CommandID string `json:"commandId" locationName:"CommandId" xml:"CommandId"`
Type string `json:"type" locationName:"Type"`
Parameters []Parameter `json:"parameters,omitempty" locationName:"Parameters" locationNameList:"member" xml:"Parameters>member"`
IssuedAt *time.Time `json:"issuedAt,omitempty" locationName:"IssuedAt" type:"timestamp"`
}
// The agent's result for a command from an earlier poll, carried on the next
// poll request and republished to the issuer.
type CommandReply struct {
CommandID string `json:"commandId" locationName:"CommandId" xml:"CommandId"`
Status string `json:"status" locationName:"Status"`
Message string `json:"message,omitempty" locationName:"Message"`
}
const (
CommandStatusSucceeded = "succeeded"
CommandStatusFailed = "failed"
)
// Idempotent: re-registering is the normal case after an agent restart and
// simply refreshes the record.
func (s *Service) RegisterDBInstance(ctx context.Context, input *RegisterDBInstanceInput, accountID string) (*RegisterDBInstanceOutput, error) {
if input.DBInstanceIdentifier == "" {
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
kv, err := s.bucket(ctx, accountID)
if err != nil {
return nil, err
}
key := DBInstanceKey(input.DBInstanceIdentifier)
var rec DBInstanceRecord
rev, found, err := getJSONRevision(ctx, kv, key, &rec)
if err != nil {
return nil, err
}
if !found {
return nil, errors.New(awserrors.ErrorDBInstanceNotFound)
}
now := time.Now().UTC()
// RegisteredAt marks the first registration of this VM, so a replace with a
// new instance ID resets it rather than carrying it forward.
if rec.Agent.InstanceID != input.InstanceID || rec.Agent.RegisteredAt == nil {
registered := now
rec.Agent.RegisteredAt = &registered
}
rec.Agent.InstanceID = input.InstanceID
rec.Agent.AgentVersion = input.AgentVersion
if input.EngineVersion != "" {
rec.Agent.EngineVersion = input.EngineVersion
}
rec.Agent.LastSeen = &now
rec.UpdatedAt = now
if err := updateJSON(ctx, kv, key, rev, &rec); err != nil {
return nil, err
}
// Register is a KV write by definition, so the beat counter restarts here.
s.noteBeat(accountID, input.DBInstanceIdentifier, rec.Agent.EngineHealth, rec.Agent.Message, true)
return &RegisterDBInstanceOutput{
DBInstanceIdentifier: input.DBInstanceIdentifier,
HeartbeatIntervalSeconds: int64(HeartbeatInterval.Seconds()),
}, nil
}
// Persists on a change of health or message and on the slower floor, holding
// intermediate beats in memory so a steady fleet stays off the KV hot path.
func (s *Service) SubmitDBStateChange(ctx context.Context, input *SubmitDBStateChangeInput, accountID string) (*SubmitDBStateChangeOutput, error) {
if input.DBInstanceIdentifier == "" {
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
if !ValidEngineHealth(input.EngineHealth) {
return nil, fmt.Errorf("%s: unknown engine health %q", awserrors.ErrorInvalidParameterValue, input.EngineHealth)
}
persist := s.noteBeat(accountID, input.DBInstanceIdentifier, input.EngineHealth, input.Message, false)
out := &SubmitDBStateChangeOutput{
Acknowledged: true,
HeartbeatIntervalSeconds: int64(HeartbeatInterval.Seconds()),
}
if !persist {
return out, nil
}
kv, err := s.bucket(ctx, accountID)
if err != nil {
return nil, err
}
key := DBInstanceKey(input.DBInstanceIdentifier)
var rec DBInstanceRecord
rev, found, err := getJSONRevision(ctx, kv, key, &rec)
if err != nil {
return nil, err
}
if !found {
return nil, errors.New(awserrors.ErrorDBInstanceNotFound)
}
now := time.Now().UTC()
rec.Agent.InstanceID = input.InstanceID
rec.Agent.EngineHealth = input.EngineHealth
rec.Agent.Message = input.Message
if input.EngineVersion != "" {
rec.Agent.EngineVersion = input.EngineVersion
}
rec.Agent.LastSeen = &now
rec.UpdatedAt = now
if err := updateJSON(ctx, kv, key, rev, &rec); err != nil {
return nil, err
}
out.Persisted = true
return out, nil
}
// Reports whether the beat must reach KV: a changed health or message persists
// immediately, an unchanged one only every HeartbeatPersistEvery beats.
func (s *Service) noteBeat(accountID, dbID string, health EngineHealth, message string, force bool) bool {
k := accountID + "/" + dbID
s.livenessMu.Lock()
defer s.livenessMu.Unlock()
live, ok := s.liveness[k]
if !ok {
live = &agentLiveness{}
s.liveness[k] = live
}
changed := !ok || live.health != health || live.message != message
live.lastSeen = time.Now().UTC()
live.health = health
live.message = message
live.beatsSinceKV++
if force || changed || live.beatsSinceKV >= HeartbeatPersistEvery {
live.beatsSinceKV = 0
return true
}
return false
}
// The in-memory beat time, fresher than the record's persisted LastSeen. False
// means this node saw no beat — normal after a leader change; fall back to KV.
func (s *Service) LastSeen(accountID, dbID string) (time.Time, bool) {
s.livenessMu.Lock()
defer s.livenessMu.Unlock()
live, ok := s.liveness[accountID+"/"+dbID]
if !ok || live.lastSeen.IsZero() {
return time.Time{}, false
}
return live.lastSeen, true
}
// Serves boot material and, on the first call, the master password. That call
// clears the cleartext and sets the marker in one CAS, so a replay reads attach.
func (s *Service) GetDBBootstrapConfig(ctx context.Context, input *GetDBBootstrapConfigInput, accountID string) (*GetDBBootstrapConfigOutput, error) {
if input.DBInstanceIdentifier == "" {
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
kv, err := s.bucket(ctx, accountID)
if err != nil {
return nil, err
}
key := DBInstanceKey(input.DBInstanceIdentifier)
for {
var rec DBInstanceRecord
rev, found, err := getJSONRevision(ctx, kv, key, &rec)
if err != nil {
return nil, err
}
if !found {
return nil, errors.New(awserrors.ErrorDBInstanceNotFound)
}
out := &GetDBBootstrapConfigOutput{
Mode: BootstrapModeAttach,
DBInstanceIdentifier: rec.DBInstanceIdentifier,
Engine: rec.Engine,
EngineVersion: rec.EngineVersion,
DBName: rec.DBName,
MasterUsername: rec.MasterUsername,
Port: rec.Port,
Parameters: rec.Bootstrap.ResolvedParameters,
}
// The cert is minted before the password is consumed, so a mint failure
// leaves the password in KV for the agent's retry. Consuming first would
// leave an instance that can never learn its master password.
cert, err := s.mintServingCert(&rec)
if err != nil {
return nil, err
}
if cert != nil {
out.ServingCertificate = cert.CertificatePEM
out.ServingPrivateKey = cert.PrivateKeyPEM
out.CACertificate = cert.caPEM
}
if rec.Bootstrap.Consumed {
return out, nil
}
password := rec.Bootstrap.MasterUserPassword
now := time.Now().UTC()
rec.Bootstrap.MasterUserPassword = ""
rec.Bootstrap.Consumed = true
rec.Bootstrap.ConsumedAt = &now
rec.UpdatedAt = now
if err := updateJSON(ctx, kv, key, rev, &rec); err != nil {
if !errors.Is(err, jetstream.ErrKeyExists) {
return nil, fmt.Errorf("rds bootstrap: consume master password for %s: %w",
input.DBInstanceIdentifier, err)
}
// A revision conflict may be an unrelated record update. Re-read and
// return attach only when the stored consumed marker proves another
// bootstrap fetch won the password.
if err := ctx.Err(); err != nil {
return nil, err
}
continue
}
out.Mode = BootstrapModeInitialize
out.MasterUserPassword = &password
return out, nil
}
}
type bootstrapCert struct {
*ServingCert
caPEM string
}
// A deployment with no cluster CA may boot without TLS, but configured TLS
// fails closed when the record has no ENI address for the required IP SAN.
func (s *Service) mintServingCert(rec *DBInstanceRecord) (*bootstrapCert, error) {
caCert, caKey, err := s.loadCA()
if err != nil {
return nil, fmt.Errorf("rds bootstrap: load cluster CA: %w", err)
}
if caCert == nil || caKey == nil {
return nil, nil
}
if rec.ENIPrivateIP == "" {
return nil, errors.New("rds bootstrap: configured TLS requires an ENI private IP")
}
cert, err := MintServingCert(caCert, caKey, ServingCertRequest{
DBInstanceIdentifier: rec.DBInstanceIdentifier,
PrivateIP: rec.ENIPrivateIP,
DNSName: rec.DNSName,
})
if err != nil {
return nil, err
}
return &bootstrapCert{ServingCert: cert, caPEM: EncodeCertPEM(caCert)}, nil
}
// Maps an internal EC2 instance ID to the DB instance it backs, so an agent's
// credentials resolve with one Get instead of a scan across every bucket.
type InstanceIndexEntry struct {
AccountID string `json:"accountId"`
DBInstanceIdentifier string `json:"dbInstanceIdentifier"`
// Increments on every replace, so a superseded VM's agent is
// distinguishable from the current one.
VMGeneration int64 `json:"vmGeneration"`
}
// Returns (nil, nil) when the instance is not an RDS VM.
func (s *Service) LookupInstanceIndex(ctx context.Context, instanceID string) (*InstanceIndexEntry, error) {
if instanceID == "" {
return nil, nil
}
kv, err := s.systemBucket(ctx)
if err != nil {
return nil, err
}
var entry InstanceIndexEntry
found, err := getJSON(ctx, kv, InstanceIndexKey(instanceID), &entry)
if err != nil || !found {
return nil, err
}
return &entry, nil
}
func (s *Service) PutInstanceIndex(ctx context.Context, instanceID string, entry InstanceIndexEntry) error {
kv, err := s.systemBucket(ctx)
if err != nil {
return err
}
return putJSON(ctx, kv, InstanceIndexKey(instanceID), entry)
}
func (s *Service) DeleteInstanceIndex(ctx context.Context, instanceID string) error {
kv, err := s.systemBucket(ctx)
if err != nil {
return err
}
return kv.Delete(ctx, InstanceIndexKey(instanceID))
}
+547
View File
@@ -0,0 +1,547 @@
package handlers_rds
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"errors"
"math/big"
"net"
"strings"
"sync"
"testing"
"time"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
testDBID = "orders-db"
testInstance = "i-0abc123"
)
// newTestCA builds a throwaway CA so cert minting is exercised without needing
// the cluster's real ca.key, which the daemon user cannot read.
func newTestCA(t *testing.T) CALoader {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
tmpl := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "Test Cluster CA"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageCertSign,
BasicConstraintsValid: true,
IsCA: true,
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key)
require.NoError(t, err)
cert, err := x509.ParseCertificate(der)
require.NoError(t, err)
return func() (*x509.Certificate, *rsa.PrivateKey, error) { return cert, key, nil }
}
func newTestService(t *testing.T) *Service {
t.Helper()
_, nc, _ := testutil.StartTestJetStream(t)
return NewService(nc, testRegion).WithDeps(Deps{LoadCA: newTestCA(t)})
}
// seedInstance writes a DB instance record in the shape CreateDBInstance will
// leave it: bootstrap config pending, marker unset.
func seedInstance(t *testing.T, svc *Service, rec DBInstanceRecord) {
t.Helper()
kv, err := svc.bucket(context.Background(), testAccountID)
require.NoError(t, err)
require.NoError(t, putJSON(context.Background(), kv, DBInstanceKey(rec.DBInstanceIdentifier), &rec))
}
func defaultRecord() DBInstanceRecord {
return DBInstanceRecord{
DBInstanceIdentifier: testDBID,
AccountID: testAccountID,
Status: StatusCreating,
Engine: "postgres",
EngineVersion: "18.1",
DBName: "orders",
MasterUsername: "postgres",
Port: 5432,
InstanceID: testInstance,
ENIPrivateIP: "10.20.30.40",
DNSName: "orders-db.123456789012.ap-southeast-2.rds.example.internal",
Bootstrap: BootstrapState{
MasterUserPassword: "s3cr3t-master-pw",
ResolvedParameters: []Parameter{{Name: "shared_buffers", Value: "128MB"}},
},
}
}
// readRecord returns the raw stored bytes alongside the decoded record, so a
// test can assert on what is actually at rest in KV rather than on the decode.
func readRecord(t *testing.T, svc *Service) (DBInstanceRecord, string) {
t.Helper()
kv, err := svc.bucket(context.Background(), testAccountID)
require.NoError(t, err)
entry, err := kv.Get(context.Background(), DBInstanceKey(testDBID))
require.NoError(t, err)
var rec DBInstanceRecord
require.NoError(t, json.Unmarshal(entry.Value(), &rec))
return rec, string(entry.Value())
}
func TestGetDBBootstrapConfig_InitializeThenAttach(t *testing.T) {
svc := newTestService(t)
seedInstance(t, svc, defaultRecord())
ctx := context.Background()
in := &GetDBBootstrapConfigInput{DBInstanceIdentifier: testDBID, InstanceID: testInstance}
first, err := svc.GetDBBootstrapConfig(ctx, in, testAccountID)
require.NoError(t, err)
assert.Equal(t, BootstrapModeInitialize, first.Mode)
require.NotNil(t, first.MasterUserPassword)
assert.Equal(t, "s3cr3t-master-pw", *first.MasterUserPassword)
// The rest of the payload has to survive into attach: a fresh VM booting
// against an existing datadir gets no password but still needs all of it.
assert.Equal(t, int64(5432), first.Port)
assert.Equal(t, "orders", first.DBName)
assert.Equal(t, []Parameter{{Name: "shared_buffers", Value: "128MB"}}, first.Parameters)
for i := range 3 {
next, err := svc.GetDBBootstrapConfig(ctx, in, testAccountID)
require.NoError(t, err, "fetch %d", i)
assert.Equal(t, BootstrapModeAttach, next.Mode)
assert.Nil(t, next.MasterUserPassword, "the password must never be re-served")
assert.Equal(t, int64(5432), next.Port)
assert.Equal(t, "orders", next.DBName)
assert.Equal(t, first.Parameters, next.Parameters)
}
}
func TestGetDBBootstrapConfig_PasswordGoneFromKVAfterConsumption(t *testing.T) {
svc := newTestService(t)
seedInstance(t, svc, defaultRecord())
_, raw := readRecord(t, svc)
require.Contains(t, raw, "s3cr3t-master-pw", "seed must actually contain the cleartext")
_, err := svc.GetDBBootstrapConfig(context.Background(),
&GetDBBootstrapConfigInput{DBInstanceIdentifier: testDBID, InstanceID: testInstance}, testAccountID)
require.NoError(t, err)
rec, raw := readRecord(t, svc)
assert.NotContains(t, raw, "s3cr3t-master-pw", "cleartext must not remain anywhere in the stored record")
assert.Empty(t, rec.Bootstrap.MasterUserPassword)
assert.True(t, rec.Bootstrap.Consumed, "only the one-way marker remains")
assert.NotNil(t, rec.Bootstrap.ConsumedAt)
}
// A restore seeds the marker already flipped, so its very first fetch is an
// attach and no separate restore flag is needed.
func TestGetDBBootstrapConfig_PreConsumedRecordAttachesImmediately(t *testing.T) {
svc := newTestService(t)
rec := defaultRecord()
rec.Bootstrap = BootstrapState{Consumed: true, ResolvedParameters: rec.Bootstrap.ResolvedParameters}
seedInstance(t, svc, rec)
out, err := svc.GetDBBootstrapConfig(context.Background(),
&GetDBBootstrapConfigInput{DBInstanceIdentifier: testDBID, InstanceID: testInstance}, testAccountID)
require.NoError(t, err)
assert.Equal(t, BootstrapModeAttach, out.Mode)
assert.Nil(t, out.MasterUserPassword)
}
func TestGetDBBootstrapConfig_MintsFreshCertEachFetch(t *testing.T) {
svc := newTestService(t)
seedInstance(t, svc, defaultRecord())
ctx := context.Background()
in := &GetDBBootstrapConfigInput{DBInstanceIdentifier: testDBID, InstanceID: testInstance}
first, err := svc.GetDBBootstrapConfig(ctx, in, testAccountID)
require.NoError(t, err)
second, err := svc.GetDBBootstrapConfig(ctx, in, testAccountID)
require.NoError(t, err)
require.NotEmpty(t, first.ServingCertificate)
require.NotEmpty(t, first.ServingPrivateKey)
assert.NotEqual(t, first.ServingCertificate, second.ServingCertificate,
"each fetch must mint rather than return a stored cert")
assert.NotEqual(t, first.ServingPrivateKey, second.ServingPrivateKey)
assert.NotEmpty(t, first.CACertificate, "the agent needs the CA to trust its own cert")
// Neither the cert nor its key may be persisted alongside the record.
_, raw := readRecord(t, svc)
assert.NotContains(t, raw, "BEGIN CERTIFICATE")
assert.NotContains(t, raw, "PRIVATE KEY")
}
// The SAN set is what makes sslmode=verify-full work. The IP is the required
// one, because the endpoint is a bare IP wherever DNS is not configured.
func TestGetDBBootstrapConfig_CertSANsCoverIPAndHostname(t *testing.T) {
svc := newTestService(t)
rec := defaultRecord()
seedInstance(t, svc, rec)
out, err := svc.GetDBBootstrapConfig(context.Background(),
&GetDBBootstrapConfigInput{DBInstanceIdentifier: testDBID, InstanceID: testInstance}, testAccountID)
require.NoError(t, err)
cert := parseCertPEM(t, out.ServingCertificate)
require.Len(t, cert.IPAddresses, 1)
assert.True(t, cert.IPAddresses[0].Equal(net.ParseIP(rec.ENIPrivateIP)))
assert.Equal(t, []string{rec.DNSName}, cert.DNSNames)
assert.Equal(t, testDBID, cert.Subject.CommonName)
}
func TestGetDBBootstrapConfig_NoDNSNameStillMintsIPOnlyCert(t *testing.T) {
svc := newTestService(t)
rec := defaultRecord()
rec.DNSName = ""
seedInstance(t, svc, rec)
out, err := svc.GetDBBootstrapConfig(context.Background(),
&GetDBBootstrapConfigInput{DBInstanceIdentifier: testDBID, InstanceID: testInstance}, testAccountID)
require.NoError(t, err)
cert := parseCertPEM(t, out.ServingCertificate)
assert.Empty(t, cert.DNSNames)
require.Len(t, cert.IPAddresses, 1)
}
// TLS is offered, not enforced, so a deployment with no cluster CA must still
// boot a database rather than failing the fetch.
func TestGetDBBootstrapConfig_NoCAStillServesConfig(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
svc := NewService(nc, testRegion)
seedInstance(t, svc, defaultRecord())
out, err := svc.GetDBBootstrapConfig(context.Background(),
&GetDBBootstrapConfigInput{DBInstanceIdentifier: testDBID, InstanceID: testInstance}, testAccountID)
require.NoError(t, err)
assert.Equal(t, BootstrapModeInitialize, out.Mode)
assert.Empty(t, out.ServingCertificate)
assert.Equal(t, int64(5432), out.Port)
}
func TestGetDBBootstrapConfig_ConfiguredCARequiresENIPrivateIP(t *testing.T) {
svc := newTestService(t)
rec := defaultRecord()
rec.ENIPrivateIP = ""
seedInstance(t, svc, rec)
_, err := svc.GetDBBootstrapConfig(t.Context(),
&GetDBBootstrapConfigInput{DBInstanceIdentifier: testDBID, InstanceID: testInstance}, testAccountID)
require.Error(t, err)
assert.Contains(t, err.Error(), "configured TLS requires an ENI private IP")
stored, raw := readRecord(t, svc)
assert.False(t, stored.Bootstrap.Consumed)
assert.Contains(t, raw, "s3cr3t-master-pw")
}
func TestGetDBBootstrapConfig_PartialCAConfigurationFailsClosed(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
svc := NewService(nc, testRegion).WithDeps(Deps{CACertPath: "/etc/spinifex/ca.pem"})
seedInstance(t, svc, defaultRecord())
_, err := svc.GetDBBootstrapConfig(t.Context(),
&GetDBBootstrapConfigInput{DBInstanceIdentifier: testDBID, InstanceID: testInstance}, testAccountID)
require.Error(t, err)
assert.Contains(t, err.Error(), "incomplete cluster CA configuration")
stored, _ := readRecord(t, svc)
assert.False(t, stored.Bootstrap.Consumed)
}
// A configured CA that cannot be loaded must fail the fetch *without* consuming
// the password. Leaving the bootstrap state untouched is what makes the agent's
// retry succeed once the CA is readable again.
func TestGetDBBootstrapConfig_UnloadableCALeavesPasswordRecoverable(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
loadErr := errors.New("read CA key /etc/spinifex/ca.key: permission denied")
broken := true
svc := NewService(nc, testRegion).WithDeps(Deps{
LoadCA: func() (*x509.Certificate, *rsa.PrivateKey, error) {
if broken {
return nil, nil, loadErr
}
return newTestCA(t)()
},
})
seedInstance(t, svc, defaultRecord())
ctx := context.Background()
in := &GetDBBootstrapConfigInput{DBInstanceIdentifier: testDBID, InstanceID: testInstance}
_, err := svc.GetDBBootstrapConfig(ctx, in, testAccountID)
require.Error(t, err)
rec, raw := readRecord(t, svc)
require.False(t, rec.Bootstrap.Consumed, "a failed mint must not flip the one-way marker")
require.Contains(t, raw, "s3cr3t-master-pw", "the password must still be there to retry against")
// Once the CA is readable the retry bootstraps normally.
broken = false
out, err := svc.GetDBBootstrapConfig(ctx, in, testAccountID)
require.NoError(t, err)
assert.Equal(t, BootstrapModeInitialize, out.Mode)
require.NotNil(t, out.MasterUserPassword)
assert.Equal(t, "s3cr3t-master-pw", *out.MasterUserPassword)
assert.NotEmpty(t, out.ServingCertificate)
}
func TestGetDBBootstrapConfig_RetriesCASConflictFromUnrelatedUpdate(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
baseCA := newTestCA(t)
svc := NewService(nc, testRegion)
var once sync.Once
svc.WithDeps(Deps{LoadCA: func() (*x509.Certificate, *rsa.PrivateKey, error) {
once.Do(func() {
kv, err := svc.bucket(t.Context(), testAccountID)
require.NoError(t, err)
var rec DBInstanceRecord
rev, found, err := getJSONRevision(t.Context(), kv, DBInstanceKey(testDBID), &rec)
require.NoError(t, err)
require.True(t, found)
rec.Agent.Message = "concurrent health update"
require.NoError(t, updateJSON(t.Context(), kv, DBInstanceKey(testDBID), rev, &rec))
})
return baseCA()
}})
seedInstance(t, svc, defaultRecord())
out, err := svc.GetDBBootstrapConfig(t.Context(),
&GetDBBootstrapConfigInput{DBInstanceIdentifier: testDBID, InstanceID: testInstance}, testAccountID)
require.NoError(t, err)
assert.Equal(t, BootstrapModeInitialize, out.Mode)
require.NotNil(t, out.MasterUserPassword)
assert.Equal(t, "s3cr3t-master-pw", *out.MasterUserPassword)
rec, _ := readRecord(t, svc)
assert.Equal(t, "concurrent health update", rec.Agent.Message)
assert.True(t, rec.Bootstrap.Consumed)
}
// Concurrent bootstrap fetches must resolve to exactly one password holder, and
// every caller must still be served TLS material. The CAS provides the first
// half: a plain put would hand the same password to two agents silently.
func TestGetDBBootstrapConfig_ConcurrentFetchesYieldOnePassword(t *testing.T) {
svc := newTestService(t)
seedInstance(t, svc, defaultRecord())
in := &GetDBBootstrapConfigInput{DBInstanceIdentifier: testDBID, InstanceID: testInstance}
const fetches = 4
outs := make([]*GetDBBootstrapConfigOutput, fetches)
errs := make([]error, fetches)
var wg sync.WaitGroup
start := make(chan struct{})
for i := range fetches {
wg.Go(func() {
<-start
outs[i], errs[i] = svc.GetDBBootstrapConfig(context.Background(), in, testAccountID)
})
}
close(start)
wg.Wait()
withPassword := 0
for i := range fetches {
require.NoError(t, errs[i])
require.NotNil(t, outs[i])
if outs[i].MasterUserPassword != nil {
withPassword++
assert.Equal(t, BootstrapModeInitialize, outs[i].Mode)
}
// A caller that loses the race still boots an engine, and it must not
// boot it with ssl=off while the winner got TLS.
assert.NotEmpty(t, outs[i].ServingCertificate, "every fetch must be served a cert")
assert.NotEmpty(t, outs[i].ServingPrivateKey)
}
assert.Equal(t, 1, withPassword, "exactly one fetch may carry the master password")
_, raw := readRecord(t, svc)
assert.NotContains(t, raw, "s3cr3t-master-pw")
}
func TestGetDBBootstrapConfig_UnknownInstance(t *testing.T) {
svc := newTestService(t)
_, err := svc.GetDBBootstrapConfig(context.Background(),
&GetDBBootstrapConfigInput{DBInstanceIdentifier: "ghost", InstanceID: testInstance}, testAccountID)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorDBInstanceNotFound, err.Error())
}
func parseCertPEM(t *testing.T, pemStr string) *x509.Certificate {
t.Helper()
require.True(t, strings.HasPrefix(pemStr, "-----BEGIN CERTIFICATE-----"))
block, _ := pem.Decode([]byte(pemStr))
require.NotNil(t, block)
cert, err := x509.ParseCertificate(block.Bytes)
require.NoError(t, err)
return cert
}
func TestRegisterDBInstance_IdempotentAndRefreshesLastSeen(t *testing.T) {
svc := newTestService(t)
seedInstance(t, svc, defaultRecord())
ctx := context.Background()
in := &RegisterDBInstanceInput{
DBInstanceIdentifier: testDBID, InstanceID: testInstance,
AgentVersion: "1.0.0", EngineVersion: "18.1",
}
out, err := svc.RegisterDBInstance(ctx, in, testAccountID)
require.NoError(t, err)
assert.Equal(t, testDBID, out.DBInstanceIdentifier)
assert.Equal(t, int64(30), out.HeartbeatIntervalSeconds)
first, _ := readRecord(t, svc)
require.NotNil(t, first.Agent.RegisteredAt)
require.NotNil(t, first.Agent.LastSeen)
assert.Equal(t, "1.0.0", first.Agent.AgentVersion)
time.Sleep(2 * time.Millisecond)
_, err = svc.RegisterDBInstance(ctx, in, testAccountID)
require.NoError(t, err)
second, _ := readRecord(t, svc)
assert.Equal(t, *first.Agent.RegisteredAt, *second.Agent.RegisteredAt,
"re-registering the same VM keeps its original registration time")
assert.True(t, second.Agent.LastSeen.After(*first.Agent.LastSeen), "liveness must be refreshed")
}
// A replace mints a new instance ID, which is a new registration rather than a
// continuation of the old VM's.
func TestRegisterDBInstance_NewVMResetsRegisteredAt(t *testing.T) {
svc := newTestService(t)
seedInstance(t, svc, defaultRecord())
ctx := context.Background()
_, err := svc.RegisterDBInstance(ctx, &RegisterDBInstanceInput{DBInstanceIdentifier: testDBID, InstanceID: testInstance}, testAccountID)
require.NoError(t, err)
first, _ := readRecord(t, svc)
time.Sleep(2 * time.Millisecond)
_, err = svc.RegisterDBInstance(ctx, &RegisterDBInstanceInput{DBInstanceIdentifier: testDBID, InstanceID: "i-0replaced"}, testAccountID)
require.NoError(t, err)
second, _ := readRecord(t, svc)
assert.Equal(t, "i-0replaced", second.Agent.InstanceID)
assert.True(t, second.Agent.RegisteredAt.After(*first.Agent.RegisteredAt))
}
func TestRegisterDBInstance_UnknownInstance(t *testing.T) {
svc := newTestService(t)
_, err := svc.RegisterDBInstance(context.Background(),
&RegisterDBInstanceInput{DBInstanceIdentifier: "ghost", InstanceID: testInstance}, testAccountID)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorDBInstanceNotFound, err.Error())
}
// Liveness is the hottest path in the service, so an unchanged beat must stay in
// memory and only reach KV on the slower floor.
func TestSubmitDBStateChange_PersistsOnChangeAndOnFloor(t *testing.T) {
svc := newTestService(t)
seedInstance(t, svc, defaultRecord())
ctx := context.Background()
beat := func(h EngineHealth, msg string) *SubmitDBStateChangeOutput {
t.Helper()
out, err := svc.SubmitDBStateChange(ctx, &SubmitDBStateChangeInput{
DBInstanceIdentifier: testDBID, InstanceID: testInstance, EngineHealth: h, Message: msg,
}, testAccountID)
require.NoError(t, err)
return out
}
// First beat is a change (nothing seen before), so it persists.
assert.True(t, beat(EngineHealthStarting, "").Persisted)
// Steady beats stay in memory until the floor is reached.
for i := range HeartbeatPersistEvery - 1 {
assert.False(t, beat(EngineHealthStarting, "").Persisted, "beat %d must not reach KV", i)
}
assert.True(t, beat(EngineHealthStarting, "").Persisted, "the floor forces a persist")
// A changed health persists immediately, without waiting for the floor.
assert.True(t, beat(EngineHealthHealthy, "").Persisted)
assert.False(t, beat(EngineHealthHealthy, "").Persisted)
// So does a changed message on an unchanged health — that is the failure
// reason the reconciler reports.
assert.True(t, beat(EngineHealthHealthy, "replication lag").Persisted)
rec, _ := readRecord(t, svc)
assert.Equal(t, EngineHealthHealthy, rec.Agent.EngineHealth)
assert.Equal(t, "replication lag", rec.Agent.Message)
assert.NotNil(t, rec.Agent.LastSeen)
}
// In-memory liveness is fresher than the record between persists, which is what
// lets the reconciler judge staleness without reading KV every tick.
func TestSubmitDBStateChange_LastSeenTracksUnpersistedBeats(t *testing.T) {
svc := newTestService(t)
seedInstance(t, svc, defaultRecord())
ctx := context.Background()
_, err := svc.SubmitDBStateChange(ctx, &SubmitDBStateChangeInput{
DBInstanceIdentifier: testDBID, InstanceID: testInstance, EngineHealth: EngineHealthHealthy,
}, testAccountID)
require.NoError(t, err)
persisted, _ := readRecord(t, svc)
time.Sleep(2 * time.Millisecond)
out, err := svc.SubmitDBStateChange(ctx, &SubmitDBStateChangeInput{
DBInstanceIdentifier: testDBID, InstanceID: testInstance, EngineHealth: EngineHealthHealthy,
}, testAccountID)
require.NoError(t, err)
require.False(t, out.Persisted)
seen, ok := svc.LastSeen(testAccountID, testDBID)
require.True(t, ok)
assert.True(t, seen.After(*persisted.Agent.LastSeen))
// A node that has seen no beat falls back to the record rather than
// reporting a bogus zero time.
_, ok = svc.LastSeen(testAccountID, "other-db")
assert.False(t, ok)
}
func TestSubmitDBStateChange_RejectsUnknownHealth(t *testing.T) {
svc := newTestService(t)
seedInstance(t, svc, defaultRecord())
_, err := svc.SubmitDBStateChange(context.Background(), &SubmitDBStateChangeInput{
DBInstanceIdentifier: testDBID, InstanceID: testInstance, EngineHealth: "on-fire",
}, testAccountID)
require.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorInvalidParameterValue)
}
func TestInstanceIndex_RoundTrip(t *testing.T) {
svc := newTestService(t)
ctx := context.Background()
missing, err := svc.LookupInstanceIndex(ctx, testInstance)
require.NoError(t, err)
assert.Nil(t, missing, "a non-RDS instance resolves to nothing rather than an error")
require.NoError(t, svc.PutInstanceIndex(ctx, testInstance, InstanceIndexEntry{
AccountID: testAccountID, DBInstanceIdentifier: testDBID, VMGeneration: 1,
}))
entry, err := svc.LookupInstanceIndex(ctx, testInstance)
require.NoError(t, err)
require.NotNil(t, entry)
assert.Equal(t, testAccountID, entry.AccountID)
assert.Equal(t, testDBID, entry.DBInstanceIdentifier)
require.NoError(t, svc.DeleteInstanceIndex(ctx, testInstance))
gone, err := svc.LookupInstanceIndex(ctx, testInstance)
require.NoError(t, err)
assert.Nil(t, gone)
}
+24
View File
@@ -0,0 +1,24 @@
package handlers_rds
import "fmt"
// RDS separates resource type and name with a colon, unlike the slash-separated
// ECS/EKS ARNs, so resource-scoped IAM policies and Terraform state round-trip
// against the real service.
func DBInstanceARN(region, accountID, dbInstanceIdentifier string) string {
return fmt.Sprintf("arn:aws:rds:%s:%s:db:%s", region, accountID, dbInstanceIdentifier)
}
// Manual and automated snapshots share the one resource type.
func DBSnapshotARN(region, accountID, dbSnapshotIdentifier string) string {
return fmt.Sprintf("arn:aws:rds:%s:%s:snapshot:%s", region, accountID, dbSnapshotIdentifier)
}
func DBSubnetGroupARN(region, accountID, name string) string {
return fmt.Sprintf("arn:aws:rds:%s:%s:subgrp:%s", region, accountID, name)
}
func DBParameterGroupARN(region, accountID, name string) string {
return fmt.Sprintf("arn:aws:rds:%s:%s:pg:%s", region, accountID, name)
}
+23
View File
@@ -0,0 +1,23 @@
package handlers_rds
import (
"testing"
"github.com/stretchr/testify/assert"
)
const (
testRegion = "ap-southeast-2"
testAccountID = "123456789012"
)
func TestARNBuilders(t *testing.T) {
assert.Equal(t, "arn:aws:rds:ap-southeast-2:123456789012:db:orders-db",
DBInstanceARN(testRegion, testAccountID, "orders-db"))
assert.Equal(t, "arn:aws:rds:ap-southeast-2:123456789012:snapshot:orders-db-2026-07-24",
DBSnapshotARN(testRegion, testAccountID, "orders-db-2026-07-24"))
assert.Equal(t, "arn:aws:rds:ap-southeast-2:123456789012:subgrp:prod-db-subnets",
DBSubnetGroupARN(testRegion, testAccountID, "prod-db-subnets"))
assert.Equal(t, "arn:aws:rds:ap-southeast-2:123456789012:pg:postgres16-tuned",
DBParameterGroupARN(testRegion, testAccountID, "postgres16-tuned"))
}
+430
View File
@@ -0,0 +1,430 @@
package handlers_rds
import (
"context"
"errors"
"fmt"
"log/slog"
"slices"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/handlers/sysinstance"
handlers_systemvpc "github.com/mulgadc/spinifex/spinifex/handlers/systemvpc"
"github.com/mulgadc/spinifex/spinifex/tags"
"github.com/mulgadc/spinifex/spinifex/types"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
)
// A DB instance is a dual-NIC system VM: the primary NIC sits in the shared RDS
// system VPC so the in-guest agent can reach the gateway, and the customer ENI
// is injected cross-account as pure ingress. DB subnets rarely have NAT egress.
const (
// The guest locates the data volume by serial, not by this name; a fixed
// name only keeps the attachment record predictable across replaces.
dataVolumeDevice = "/dev/sdf"
// Links an ENI or volume back to its DB instance, so a teardown or leak
// sweep can find them without the KV record.
rdsInstanceTagKey = "spinifex:rds-db-instance"
// Stamped on the engine AMIs by their image manifest; an EngineVersion
// request resolves against these.
engineTagKey = "engine"
engineVersionTagKey = "engine-version"
attachRequestTimeout = 30 * time.Second
// A fresh budget rather than the caller's remainder: the unwind is often
// triggered by that deadline expiring, and a dead context deletes nothing.
rollbackTimeout = 60 * time.Second
)
// A DB VM must never fall back to another image, so callers surface this as a
// validation failure rather than launching something else.
var ErrEngineAMINotFound = errors.New("rds: no AMI found for engine")
type launchVPCProvisioner interface {
CreateNetworkInterface(ctx context.Context, input *ec2.CreateNetworkInterfaceInput, accountID string) (*ec2.CreateNetworkInterfaceOutput, error)
DeleteNetworkInterface(ctx context.Context, input *ec2.DeleteNetworkInterfaceInput, accountID string) (*ec2.DeleteNetworkInterfaceOutput, error)
DetachENI(ctx context.Context, accountID, eniID string) error
}
// System-managed VMs get a mgmt-bridge NIC alongside their VPC NICs, which is
// how the agent reaches the gateway from a private subnet.
type launchInstanceLauncher interface {
LaunchSystemInstance(input *sysinstance.SystemInstanceInput) (*sysinstance.SystemInstanceOutput, error)
TerminateSystemInstance(instanceID string) error
}
type launchAMIResolver interface {
DescribeImages(ctx context.Context, input *ec2.DescribeImagesInput, accountID string) (*ec2.DescribeImagesOutput, error)
}
type launchVolumeProvisioner interface {
CreateVolume(ctx context.Context, input *ec2.CreateVolumeInput, accountID string) (*ec2.Volume, error)
DeleteVolume(ctx context.Context, input *ec2.DeleteVolumeInput, accountID string) (*ec2.DeleteVolumeOutput, error)
}
// Split from launchVolumeProvisioner because attach is routed to the node
// owning the VM, not answered by whichever node picks the request up.
type volumeAttacher interface {
AttachVolume(ctx context.Context, accountID, instanceID, volumeID, device string) (string, error)
}
type LaunchDeps struct {
Config *config.Config
SystemVPC handlers_systemvpc.Deps
VPC launchVPCProvisioner
Instance launchInstanceLauncher
Image launchAMIResolver
Volume launchVolumeProvisioner
Attacher volumeAttacher
}
// Everything here is already validated by the caller: the launch helper
// resolves and wires, it does not police the AWS API surface.
type LaunchInput struct {
DBInstanceIdentifier string
// The customer account owning the DB, subnet and customer ENI. The VM
// itself runs in the system account.
AccountID string
SubnetID string
SecurityGroupIDs []string
Engine string
EngineVersion string
InstanceType string
AllocatedStorage int64
UserData string
IamInstanceProfileArn string
}
type LaunchOutput struct {
InstanceID string
// The system ENI is disposable — a replace makes a new one. The customer
// ENI is the stable endpoint: its IP is the DNS target and survives.
SystemENIID string
CustomerENIID string
CustomerENIIP string
CustomerENIMac string
DataVolumeID string
DataDevice string
MgmtIP string
}
// On any failure every resource this call created is torn down, so a retried
// create does not accumulate orphan ENIs, volumes and VMs.
func LaunchDBInstanceVM(ctx context.Context, deps LaunchDeps, in LaunchInput) (out *LaunchOutput, err error) {
if err := validateLaunchInput(in); err != nil {
return nil, err
}
region, az := deps.Config.Region, deps.Config.AZ
amiID, err := resolveEngineAMI(ctx, deps.Image, in.Engine, in.EngineVersion)
if err != nil {
return nil, err
}
sysRefs, err := EnsureSystemVPC(ctx, deps.SystemVPC, &deps.Config.RDS, utils.GlobalAccountID, region)
if err != nil {
return nil, err
}
systemSubnetID := sysRefs.PrivateSubnetIDs[0]
// Unwind in reverse creation order on any failure below. Each step appends
// its own undo as soon as the resource exists.
var rollback []func(context.Context)
// The VM is torn down first regardless of creation order: the ENIs and data
// volume are attached while it runs, and deleting those is rejected as InUse.
var terminateVM func(context.Context)
defer func() {
if err == nil {
return
}
// A context detached from the caller's, so a create that failed *because*
// the request deadline expired can still clean up.
rbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), rollbackTimeout)
defer cancel()
if terminateVM != nil {
terminateVM(rbCtx)
}
for _, undo := range slices.Backward(rollback) {
undo(rbCtx)
}
}()
systemENI, err := createLaunchENI(ctx, deps.VPC, utils.GlobalAccountID, systemSubnetID, nil,
"RDS management NIC for "+in.DBInstanceIdentifier, in.DBInstanceIdentifier)
if err != nil {
return nil, err
}
rollback = append(rollback, func(ctx context.Context) {
deleteLaunchENI(ctx, deps.VPC, utils.GlobalAccountID, systemENI.id)
})
customerENI, err := createLaunchENI(ctx, deps.VPC, in.AccountID, in.SubnetID, in.SecurityGroupIDs,
"RDS endpoint ENI for "+in.DBInstanceIdentifier, in.DBInstanceIdentifier)
if err != nil {
return nil, err
}
rollback = append(rollback, func(ctx context.Context) {
deleteLaunchENI(ctx, deps.VPC, in.AccountID, customerENI.id)
})
sysOut, err := deps.Instance.LaunchSystemInstance(&sysinstance.SystemInstanceInput{
BootMode: sysinstance.BootAMI,
ManagedBy: tags.ManagedByRDS,
InstanceType: in.InstanceType,
ImageID: amiID,
// The VM and its primary NIC live in the system account; the customer
// ENI carries its own account so the daemon updates the right record.
AccountID: utils.GlobalAccountID,
SubnetID: systemSubnetID,
ENIID: systemENI.id,
ENIMac: systemENI.mac,
ENIIP: systemENI.ip,
ExtraENIs: []sysinstance.ExtraENIInput{{
ENIID: customerENI.id,
ENIMac: customerENI.mac,
ENIIP: customerENI.ip,
SubnetID: in.SubnetID,
AccountID: in.AccountID,
}},
UserData: in.UserData,
IamInstanceProfileArn: in.IamInstanceProfileArn,
})
if err != nil {
return nil, fmt.Errorf("rds: launch DB VM for %s: %w", in.DBInstanceIdentifier, err)
}
if sysOut == nil || sysOut.InstanceID == "" {
return nil, fmt.Errorf("rds: launch DB VM for %s: launcher returned no instance", in.DBInstanceIdentifier)
}
instanceID := sysOut.InstanceID
terminateVM = func(ctx context.Context) {
if termErr := deps.Instance.TerminateSystemInstance(instanceID); termErr != nil &&
!errors.Is(termErr, sysinstance.ErrSystemInstanceNotFound) {
slog.WarnContext(ctx, "rds: rollback terminate of failed DB VM failed",
"dbInstance", in.DBInstanceIdentifier, "instanceId", instanceID, "err", termErr)
}
}
// Kept separate from the boot volume so a replace can discard the boot
// volume and keep the datadir, and owned by the system account.
volume, err := deps.Volume.CreateVolume(ctx, &ec2.CreateVolumeInput{
AvailabilityZone: aws.String(az),
Size: aws.Int64(in.AllocatedStorage),
VolumeType: aws.String("gp3"),
TagSpecifications: []*ec2.TagSpecification{{
ResourceType: aws.String("volume"),
Tags: []*ec2.Tag{
{Key: aws.String(tags.ManagedByKey), Value: aws.String(tags.ManagedByRDS)},
{Key: aws.String(rdsInstanceTagKey), Value: aws.String(in.DBInstanceIdentifier)},
},
}},
}, utils.GlobalAccountID)
if err != nil {
return nil, fmt.Errorf("rds: create data volume for %s: %w", in.DBInstanceIdentifier, err)
}
if volume == nil || aws.StringValue(volume.VolumeId) == "" {
return nil, fmt.Errorf("rds: create data volume for %s: empty volume id", in.DBInstanceIdentifier)
}
volumeID := aws.StringValue(volume.VolumeId)
rollback = append(rollback, func(ctx context.Context) {
if _, delErr := deps.Volume.DeleteVolume(ctx, &ec2.DeleteVolumeInput{VolumeId: aws.String(volumeID)}, utils.GlobalAccountID); delErr != nil {
slog.WarnContext(ctx, "rds: rollback delete of orphaned data volume failed",
"dbInstance", in.DBInstanceIdentifier, "volumeId", volumeID, "err", delErr)
}
})
device, err := deps.Attacher.AttachVolume(ctx, utils.GlobalAccountID, instanceID, volumeID, dataVolumeDevice)
if err != nil {
return nil, fmt.Errorf("rds: attach data volume %s to %s: %w", volumeID, instanceID, err)
}
slog.InfoContext(ctx, "rds: DB VM launched",
"dbInstance", in.DBInstanceIdentifier, "instanceId", instanceID, "ami", amiID,
"systemEni", systemENI.id, "customerEni", customerENI.id, "customerEniIp", customerENI.ip,
"dataVolume", volumeID, "device", device)
return &LaunchOutput{
InstanceID: instanceID,
SystemENIID: systemENI.id,
CustomerENIID: customerENI.id,
CustomerENIIP: customerENI.ip,
CustomerENIMac: customerENI.mac,
DataVolumeID: volumeID,
DataDevice: device,
MgmtIP: sysOut.MgmtIP,
}, nil
}
func validateLaunchInput(in LaunchInput) error {
switch {
case in.DBInstanceIdentifier == "":
return errors.New("rds: LaunchDBInstanceVM empty db instance identifier")
case in.AccountID == "":
return errors.New("rds: LaunchDBInstanceVM empty account id")
case in.SubnetID == "":
return errors.New("rds: LaunchDBInstanceVM empty subnet id")
case in.InstanceType == "":
return errors.New("rds: LaunchDBInstanceVM empty instance type")
case in.AllocatedStorage <= 0:
return errors.New("rds: LaunchDBInstanceVM non-positive allocated storage")
}
return nil
}
type launchENI struct {
id string
ip string
mac string
}
// groups is nil for the system NIC, which is unreachable from any customer VPC
// and so needs no security group of its own.
func createLaunchENI(ctx context.Context, vpcSvc launchVPCProvisioner, accountID, subnetID string, groups []string, description, dbInstanceID string) (*launchENI, error) {
var groupIDs []*string
if len(groups) > 0 {
groupIDs = aws.StringSlice(groups)
}
out, err := vpcSvc.CreateNetworkInterface(ctx, &ec2.CreateNetworkInterfaceInput{
SubnetId: aws.String(subnetID),
Description: aws.String(description),
Groups: groupIDs,
TagSpecifications: []*ec2.TagSpecification{{
ResourceType: aws.String("network-interface"),
Tags: []*ec2.Tag{
{Key: aws.String(tags.ManagedByKey), Value: aws.String(tags.ManagedByRDS)},
{Key: aws.String(rdsInstanceTagKey), Value: aws.String(dbInstanceID)},
},
}},
}, accountID)
if err != nil {
return nil, fmt.Errorf("rds: create ENI in subnet %s: %w", subnetID, err)
}
if out == nil || out.NetworkInterface == nil ||
aws.StringValue(out.NetworkInterface.NetworkInterfaceId) == "" ||
aws.StringValue(out.NetworkInterface.PrivateIpAddress) == "" {
return nil, fmt.Errorf("rds: create ENI in subnet %s: incomplete interface returned", subnetID)
}
ni := out.NetworkInterface
return &launchENI{
id: aws.StringValue(ni.NetworkInterfaceId),
ip: aws.StringValue(ni.PrivateIpAddress),
mac: aws.StringValue(ni.MacAddress),
}, nil
}
// Detach comes first because a terminated VM can leave the attachment record
// behind, which a plain delete rejects as InUse.
func deleteLaunchENI(ctx context.Context, vpcSvc launchVPCProvisioner, accountID, eniID string) {
if err := vpcSvc.DetachENI(ctx, accountID, eniID); err != nil && !awserrors.IsNotFound(err) {
slog.DebugContext(ctx, "rds: rollback ENI detach failed", "eniId", eniID, "err", err)
}
if _, err := vpcSvc.DeleteNetworkInterface(ctx, &ec2.DeleteNetworkInterfaceInput{
NetworkInterfaceId: aws.String(eniID),
}, accountID); err != nil && !awserrors.IsNotFound(err) {
slog.WarnContext(ctx, "rds: rollback delete of orphaned ENI failed", "eniId", eniID, "err", err)
}
}
// An empty version takes the newest image; a set one requires an exact match,
// so a request can never be served by a different major version.
func resolveEngineAMI(ctx context.Context, amiSvc launchAMIResolver, engine, version string) (string, error) {
filters := []*ec2.Filter{
{Name: aws.String("tag:" + tags.ManagedByKey), Values: aws.StringSlice([]string{tags.ManagedByRDS})},
{Name: aws.String("tag:" + engineTagKey), Values: aws.StringSlice([]string{engine})},
}
if version != "" {
filters = append(filters, &ec2.Filter{
Name: aws.String("tag:" + engineVersionTagKey),
Values: aws.StringSlice([]string{version}),
})
}
out, err := amiSvc.DescribeImages(ctx, &ec2.DescribeImagesInput{Filters: filters}, utils.GlobalAccountID)
if err != nil {
return "", fmt.Errorf("rds: describe %s AMI: %w", engine, err)
}
if out == nil {
return "", fmt.Errorf("%w: %s %s", ErrEngineAMINotFound, engine, version)
}
// Several builds of one engine version can be registered; skip malformed
// catalog entries and select the most recently imported usable image.
var newestID, newestCreated string
matches := 0
for _, image := range out.Images {
if image == nil || aws.StringValue(image.ImageId) == "" {
continue
}
// A GPU engine build carries the same engine tags, and DescribeImages
// filters have no negation, so drop it here or a newer GPU image would
// hijack an ordinary instance.
if hasTagKey(image, tags.GPUVendorKey) {
continue
}
matches++
created := aws.StringValue(image.CreationDate)
if newestID == "" || created > newestCreated {
newestID = aws.StringValue(image.ImageId)
newestCreated = created
}
}
if newestID == "" {
return "", fmt.Errorf("%w: %s %s", ErrEngineAMINotFound, engine, version)
}
if matches > 1 {
slog.WarnContext(ctx, "rds: multiple AMIs match the requested engine; using newest",
"engine", engine, "engineVersion", version, "imageId", newestID, "matches", matches)
}
return newestID, nil
}
// hasTagKey reports whether img carries a tag with the given key.
func hasTagKey(img *ec2.Image, key string) bool {
for _, t := range img.Tags {
if aws.StringValue(t.Key) == key {
return true
}
}
return false
}
// Only the node running the VM subscribes the per-instance ec2.cmd subject, so
// the command routes itself and the caller need not know where the VM landed.
type natsVolumeAttacher struct {
nc *nats.Conn
timeout time.Duration
}
var _ volumeAttacher = (*natsVolumeAttacher)(nil)
func NewNATSVolumeAttacher(nc *nats.Conn) volumeAttacher {
return &natsVolumeAttacher{nc: nc, timeout: attachRequestTimeout}
}
// Returns the device the attachment landed on, which can differ from the
// requested one when the guest renames it.
func (a *natsVolumeAttacher) AttachVolume(ctx context.Context, accountID, instanceID, volumeID, device string) (string, error) {
cmd := types.EC2InstanceCommand{
ID: instanceID,
Attributes: types.EC2CommandAttributes{AttachVolume: true},
AttachVolumeData: &types.AttachVolumeData{
VolumeID: volumeID,
Device: device,
},
}
out, err := utils.NATSRequest[ec2.VolumeAttachment](ctx, a.nc, "ec2.cmd."+instanceID, cmd, a.timeout, accountID)
if err != nil {
return "", err
}
return aws.StringValue(out.Device), nil
}
+647
View File
@@ -0,0 +1,647 @@
package handlers_rds
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/handlers/sysinstance"
handlers_systemvpc "github.com/mulgadc/spinifex/spinifex/handlers/systemvpc"
"github.com/mulgadc/spinifex/spinifex/tags"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
testCustomerAccount = "123456789012"
testDBSubnet = "subnet-customer-db"
testEngineAMI = "ami-rds-postgres-18"
)
// --- Fakes ---
// fakeSystemVPC is the whole EC2 VPC family as the system-VPC builder sees it:
// every describe is empty and every create succeeds. The builder's own rules are
// covered in its package; here it only yields a private subnet for the DB VM.
type fakeSystemVPC struct {
seq int
// igwCreated flips the IGW describe from "none attached" (the pre-create
// lookup) to "attached" (the post-attach re-read).
igwCreated bool
}
var (
_ handlers_systemvpc.VPCProvisioner = (*fakeSystemVPC)(nil)
_ handlers_systemvpc.RouteTableProvisioner = (*fakeSystemVPC)(nil)
_ handlers_systemvpc.NATGatewayProvisioner = (*fakeSystemVPC)(nil)
_ handlers_systemvpc.EIPProvisioner = (*fakeSystemVPC)(nil)
_ handlers_systemvpc.IGWProvisioner = (*fakeSystemVPC)(nil)
)
func (f *fakeSystemVPC) id(prefix string) *string {
f.seq++
return aws.String(fmt.Sprintf("%s-%04d", prefix, f.seq))
}
func (f *fakeSystemVPC) deps() handlers_systemvpc.Deps {
return handlers_systemvpc.Deps{VPC: f, IGW: f, RT: f, NGW: f, EIP: f}
}
func (f *fakeSystemVPC) CreateVpc(context.Context, *ec2.CreateVpcInput, string) (*ec2.CreateVpcOutput, error) {
return &ec2.CreateVpcOutput{Vpc: &ec2.Vpc{VpcId: f.id("vpc")}}, nil
}
func (f *fakeSystemVPC) DeleteVpc(context.Context, *ec2.DeleteVpcInput, string) (*ec2.DeleteVpcOutput, error) {
return &ec2.DeleteVpcOutput{}, nil
}
func (f *fakeSystemVPC) DescribeVpcs(context.Context, *ec2.DescribeVpcsInput, string) (*ec2.DescribeVpcsOutput, error) {
return &ec2.DescribeVpcsOutput{}, nil
}
func (f *fakeSystemVPC) CreateSubnet(_ context.Context, in *ec2.CreateSubnetInput, _ string) (*ec2.CreateSubnetOutput, error) {
return &ec2.CreateSubnetOutput{Subnet: &ec2.Subnet{SubnetId: f.id("subnet-rdssys"), CidrBlock: in.CidrBlock}}, nil
}
func (f *fakeSystemVPC) DeleteSubnet(context.Context, *ec2.DeleteSubnetInput, string) (*ec2.DeleteSubnetOutput, error) {
return &ec2.DeleteSubnetOutput{}, nil
}
func (f *fakeSystemVPC) DescribeSubnets(context.Context, *ec2.DescribeSubnetsInput, string) (*ec2.DescribeSubnetsOutput, error) {
return &ec2.DescribeSubnetsOutput{}, nil
}
func (f *fakeSystemVPC) CreateRouteTable(context.Context, *ec2.CreateRouteTableInput, string) (*ec2.CreateRouteTableOutput, error) {
return &ec2.CreateRouteTableOutput{RouteTable: &ec2.RouteTable{RouteTableId: f.id("rtb")}}, nil
}
func (f *fakeSystemVPC) DeleteRouteTable(context.Context, *ec2.DeleteRouteTableInput, string) (*ec2.DeleteRouteTableOutput, error) {
return &ec2.DeleteRouteTableOutput{}, nil
}
func (f *fakeSystemVPC) DescribeRouteTables(context.Context, *ec2.DescribeRouteTablesInput, string) (*ec2.DescribeRouteTablesOutput, error) {
return &ec2.DescribeRouteTablesOutput{}, nil
}
func (f *fakeSystemVPC) CreateRoute(context.Context, *ec2.CreateRouteInput, string) (*ec2.CreateRouteOutput, error) {
return &ec2.CreateRouteOutput{}, nil
}
func (f *fakeSystemVPC) AssociateRouteTable(context.Context, *ec2.AssociateRouteTableInput, string) (*ec2.AssociateRouteTableOutput, error) {
return &ec2.AssociateRouteTableOutput{AssociationId: f.id("rtbassoc")}, nil
}
func (f *fakeSystemVPC) DisassociateRouteTable(context.Context, *ec2.DisassociateRouteTableInput, string) (*ec2.DisassociateRouteTableOutput, error) {
return &ec2.DisassociateRouteTableOutput{}, nil
}
func (f *fakeSystemVPC) CreateNatGateway(context.Context, *ec2.CreateNatGatewayInput, string) (*ec2.CreateNatGatewayOutput, error) {
return &ec2.CreateNatGatewayOutput{NatGateway: &ec2.NatGateway{NatGatewayId: f.id("nat")}}, nil
}
func (f *fakeSystemVPC) DeleteNatGateway(context.Context, *ec2.DeleteNatGatewayInput, string) (*ec2.DeleteNatGatewayOutput, error) {
return &ec2.DeleteNatGatewayOutput{}, nil
}
func (f *fakeSystemVPC) DescribeNatGateways(context.Context, *ec2.DescribeNatGatewaysInput, string) (*ec2.DescribeNatGatewaysOutput, error) {
return &ec2.DescribeNatGatewaysOutput{}, nil
}
func (f *fakeSystemVPC) AllocateAddress(context.Context, *ec2.AllocateAddressInput, string) (*ec2.AllocateAddressOutput, error) {
return &ec2.AllocateAddressOutput{AllocationId: f.id("eipalloc"), PublicIp: aws.String("198.51.100.7")}, nil
}
func (f *fakeSystemVPC) ReleaseAddress(context.Context, *ec2.ReleaseAddressInput, string) (*ec2.ReleaseAddressOutput, error) {
return &ec2.ReleaseAddressOutput{}, nil
}
func (f *fakeSystemVPC) CreateInternetGateway(context.Context, *ec2.CreateInternetGatewayInput, string) (*ec2.CreateInternetGatewayOutput, error) {
f.igwCreated = true
return &ec2.CreateInternetGatewayOutput{InternetGateway: &ec2.InternetGateway{InternetGatewayId: aws.String("igw-rdssys")}}, nil
}
func (f *fakeSystemVPC) AttachInternetGateway(context.Context, *ec2.AttachInternetGatewayInput, string) (*ec2.AttachInternetGatewayOutput, error) {
return &ec2.AttachInternetGatewayOutput{}, nil
}
func (f *fakeSystemVPC) DetachInternetGateway(context.Context, *ec2.DetachInternetGatewayInput, string) (*ec2.DetachInternetGatewayOutput, error) {
return &ec2.DetachInternetGatewayOutput{}, nil
}
func (f *fakeSystemVPC) DeleteInternetGateway(context.Context, *ec2.DeleteInternetGatewayInput, string) (*ec2.DeleteInternetGatewayOutput, error) {
return &ec2.DeleteInternetGatewayOutput{}, nil
}
// DescribeInternetGateways answers the post-attach lookup: the builder attaches
// an IGW and then re-reads it, so this reports one attached to any VPC asked
// about.
func (f *fakeSystemVPC) DescribeInternetGateways(_ context.Context, in *ec2.DescribeInternetGatewaysInput, _ string) (*ec2.DescribeInternetGatewaysOutput, error) {
// The pre-create lookup must find nothing, or no IGW is ever created; only
// the post-attach one is answered. Distinguish them by whether an IGW has
// been created yet.
if !f.igwCreated {
return &ec2.DescribeInternetGatewaysOutput{}, nil
}
return &ec2.DescribeInternetGatewaysOutput{InternetGateways: []*ec2.InternetGateway{{
InternetGatewayId: aws.String("igw-rdssys"),
Attachments: []*ec2.InternetGatewayAttachment{{VpcId: in.Filters[0].Values[0]}},
}}}, nil
}
// fakeENIs records the ENIs the launch helper created and deleted, per account,
// which is where the dual-NIC/cross-account wiring is observable.
type fakeENIs struct {
created []*ec2.CreateNetworkInterfaceInput
accts []string
deleted []string
seq int
// createErrOn fails the nth (1-based) create, to open the rollback window
// after the system NIC exists.
createErrOn int
// unwind is the harness-wide teardown log, and deleteCtxErr the state of the
// context the delete was handed — both are how the rollback's ordering and
// its independence from the caller's context become observable.
unwind *[]string
deleteCtxErr error
}
var _ launchVPCProvisioner = (*fakeENIs)(nil)
func (f *fakeENIs) CreateNetworkInterface(_ context.Context, in *ec2.CreateNetworkInterfaceInput, accountID string) (*ec2.CreateNetworkInterfaceOutput, error) {
f.seq++
if f.createErrOn == f.seq {
return nil, errors.New("subnet has no free addresses")
}
f.created = append(f.created, in)
f.accts = append(f.accts, accountID)
return &ec2.CreateNetworkInterfaceOutput{NetworkInterface: &ec2.NetworkInterface{
NetworkInterfaceId: aws.String(fmt.Sprintf("eni-%04d", f.seq)),
PrivateIpAddress: aws.String(fmt.Sprintf("10.0.0.%d", f.seq)),
MacAddress: aws.String(fmt.Sprintf("02:00:00:00:00:%02d", f.seq)),
SubnetId: in.SubnetId,
}}, nil
}
func (f *fakeENIs) DeleteNetworkInterface(ctx context.Context, in *ec2.DeleteNetworkInterfaceInput, _ string) (*ec2.DeleteNetworkInterfaceOutput, error) {
f.deleted = append(f.deleted, aws.StringValue(in.NetworkInterfaceId))
f.deleteCtxErr = ctx.Err()
if f.unwind != nil {
*f.unwind = append(*f.unwind, "delete-eni")
}
return &ec2.DeleteNetworkInterfaceOutput{}, nil
}
func (f *fakeENIs) DetachENI(context.Context, string, string) error { return nil }
// fakeLauncher stands in for the system-instance launcher.
type fakeLauncher struct {
input *sysinstance.SystemInstanceInput
err error
terminated []string
unwind *[]string
}
var _ launchInstanceLauncher = (*fakeLauncher)(nil)
func (f *fakeLauncher) LaunchSystemInstance(in *sysinstance.SystemInstanceInput) (*sysinstance.SystemInstanceOutput, error) {
f.input = in
if f.err != nil {
return nil, f.err
}
return &sysinstance.SystemInstanceOutput{InstanceID: "i-rds0001", MgmtIP: "172.30.0.9"}, nil
}
func (f *fakeLauncher) TerminateSystemInstance(instanceID string) error {
f.terminated = append(f.terminated, instanceID)
if f.unwind != nil {
*f.unwind = append(*f.unwind, "terminate")
}
return nil
}
// fakeImages answers the engine-AMI lookup from a canned image list.
type fakeImages struct {
images []*ec2.Image
filters []*ec2.Filter
err error
}
var _ launchAMIResolver = (*fakeImages)(nil)
func (f *fakeImages) DescribeImages(_ context.Context, in *ec2.DescribeImagesInput, _ string) (*ec2.DescribeImagesOutput, error) {
f.filters = in.Filters
if f.err != nil {
return nil, f.err
}
return &ec2.DescribeImagesOutput{Images: f.images}, nil
}
// fakeVolumes records the data volume's create and delete.
type fakeVolumes struct {
created []*ec2.CreateVolumeInput
accts []string
deleted []string
err error
unwind *[]string
deleteCtxErr error
}
var _ launchVolumeProvisioner = (*fakeVolumes)(nil)
func (f *fakeVolumes) CreateVolume(_ context.Context, in *ec2.CreateVolumeInput, accountID string) (*ec2.Volume, error) {
if f.err != nil {
return nil, f.err
}
f.created = append(f.created, in)
f.accts = append(f.accts, accountID)
return &ec2.Volume{VolumeId: aws.String("vol-rdsdata01")}, nil
}
func (f *fakeVolumes) DeleteVolume(ctx context.Context, in *ec2.DeleteVolumeInput, _ string) (*ec2.DeleteVolumeOutput, error) {
f.deleted = append(f.deleted, aws.StringValue(in.VolumeId))
f.deleteCtxErr = ctx.Err()
if f.unwind != nil {
*f.unwind = append(*f.unwind, "delete-volume")
}
return &ec2.DeleteVolumeOutput{}, nil
}
// fakeAttacher records the hot-plug attach request.
type fakeAttacher struct {
accountID, instanceID, volumeID, device string
err error
// onCall fires before the attach returns, so a case can cancel the caller's
// context at the moment the step that fails is running.
onCall func()
}
var _ volumeAttacher = (*fakeAttacher)(nil)
func (f *fakeAttacher) AttachVolume(_ context.Context, accountID, instanceID, volumeID, device string) (string, error) {
f.accountID, f.instanceID, f.volumeID, f.device = accountID, instanceID, volumeID, device
if f.onCall != nil {
f.onCall()
}
if f.err != nil {
return "", f.err
}
return device, nil
}
// launchHarness bundles the fakes so a case can reach into any of them.
type launchHarness struct {
sysvpc *fakeSystemVPC
enis *fakeENIs
launcher *fakeLauncher
images *fakeImages
volumes *fakeVolumes
attacher *fakeAttacher
// unwind is every teardown call in the order it happened, shared by the
// fakes that perform one.
unwind []string
}
func newLaunchHarness() *launchHarness {
h := &launchHarness{
sysvpc: &fakeSystemVPC{},
enis: &fakeENIs{},
launcher: &fakeLauncher{},
images: &fakeImages{images: []*ec2.Image{
{ImageId: aws.String(testEngineAMI), CreationDate: aws.String("2026-01-02T00:00:00Z")},
}},
volumes: &fakeVolumes{},
attacher: &fakeAttacher{},
}
h.enis.unwind, h.launcher.unwind, h.volumes.unwind = &h.unwind, &h.unwind, &h.unwind
return h
}
func (h *launchHarness) deps() LaunchDeps {
return LaunchDeps{
Config: &config.Config{Region: "ap-southeast-2", AZ: "ap-southeast-2a"},
SystemVPC: h.sysvpc.deps(),
VPC: h.enis,
Instance: h.launcher,
Image: h.images,
Volume: h.volumes,
Attacher: h.attacher,
}
}
func testLaunchInput() LaunchInput {
return LaunchInput{
DBInstanceIdentifier: "mydb",
AccountID: testCustomerAccount,
SubnetID: testDBSubnet,
SecurityGroupIDs: []string{"sg-customer"},
Engine: "postgres",
EngineVersion: "18",
InstanceType: "t3.medium",
AllocatedStorage: 20,
UserData: "#cloud-config\n",
IamInstanceProfileArn: "arn:aws:iam::000000000000:instance-profile/rdsInstanceRole",
}
}
// tagOf reads one tag out of a create request's tag specification.
func tagOf(specs []*ec2.TagSpecification, key string) string {
for _, s := range specs {
for _, t := range s.Tags {
if aws.StringValue(t.Key) == key {
return aws.StringValue(t.Value)
}
}
}
return ""
}
// --- Tests ---
func TestLaunchDBInstanceVMWiresBothNICs(t *testing.T) {
h := newLaunchHarness()
out, err := LaunchDBInstanceVM(t.Context(), h.deps(), testLaunchInput())
require.NoError(t, err)
require.Len(t, h.enis.created, 2, "a DB VM is dual-NIC: a system NIC for the agent and a customer-facing endpoint")
sysENI, custENI := h.enis.created[0], h.enis.created[1]
// The system NIC sits in the RDS system VPC's private subnet under the
// system account. That is the NIC with NAT egress, which is how the in-guest
// agent reaches the gateway from a customer DB subnet that has none.
assert.Equal(t, utils.GlobalAccountID, h.enis.accts[0])
assert.True(t, strings.HasPrefix(aws.StringValue(sysENI.SubnetId), "subnet-rdssys"),
"the primary NIC must land in the RDS system VPC, got %s", aws.StringValue(sysENI.SubnetId))
assert.Empty(t, sysENI.Groups, "the system NIC is unreachable from any customer VPC and needs no security group")
// The customer-facing ENI is created in the customer's account and subnet,
// with the customer's security groups: it is the only ingress path.
assert.Equal(t, testCustomerAccount, h.enis.accts[1])
assert.Equal(t, testDBSubnet, aws.StringValue(custENI.SubnetId))
assert.Equal(t, []string{"sg-customer"}, aws.StringValueSlice(custENI.Groups))
for _, eni := range h.enis.created {
assert.Equal(t, tags.ManagedByRDS, tagOf(eni.TagSpecifications, tags.ManagedByKey))
assert.Equal(t, "mydb", tagOf(eni.TagSpecifications, rdsInstanceTagKey))
}
// The VM itself: a system-account instance off the engine AMI, carrying the
// managed-by tag that hides it from the customer's EC2 API, with the
// customer ENI injected cross-account as an extra NIC.
in := h.launcher.input
require.NotNil(t, in)
assert.Equal(t, sysinstance.BootAMI, in.BootMode)
assert.Equal(t, tags.ManagedByRDS, in.ManagedBy)
assert.Equal(t, testEngineAMI, in.ImageID)
assert.Equal(t, utils.GlobalAccountID, in.AccountID)
assert.Equal(t, aws.StringValue(sysENI.SubnetId), in.SubnetID)
assert.Equal(t, out.SystemENIID, in.ENIID)
require.Len(t, in.ExtraENIs, 1)
assert.Equal(t, sysinstance.ExtraENIInput{
ENIID: out.CustomerENIID,
ENIMac: out.CustomerENIMac,
ENIIP: out.CustomerENIIP,
SubnetID: testDBSubnet,
AccountID: testCustomerAccount,
}, in.ExtraENIs[0], "the extra NIC must carry the customer account, or the daemon updates the wrong ENI record")
assert.Equal(t, "#cloud-config\n", in.UserData)
assert.Equal(t, "arn:aws:iam::000000000000:instance-profile/rdsInstanceRole", in.IamInstanceProfileArn)
assert.Equal(t, "i-rds0001", out.InstanceID)
assert.Equal(t, "172.30.0.9", out.MgmtIP)
}
func TestLaunchDBInstanceVMAttachesTheDataVolume(t *testing.T) {
h := newLaunchHarness()
out, err := LaunchDBInstanceVM(t.Context(), h.deps(), testLaunchInput())
require.NoError(t, err)
// The data volume is created after the VM is running and hot-plugged onto
// it, so it is decoupled from the boot volume a VM replace throws away.
require.Len(t, h.volumes.created, 1)
vol := h.volumes.created[0]
assert.Equal(t, int64(20), aws.Int64Value(vol.Size))
assert.Equal(t, "gp3", aws.StringValue(vol.VolumeType))
assert.Equal(t, "ap-southeast-2a", aws.StringValue(vol.AvailabilityZone))
assert.Equal(t, tags.ManagedByRDS, tagOf(vol.TagSpecifications, tags.ManagedByKey))
assert.Equal(t, "mydb", tagOf(vol.TagSpecifications, rdsInstanceTagKey))
assert.Equal(t, utils.GlobalAccountID, h.volumes.accts[0], "the data volume belongs to the system account, like the VM it serves")
assert.Equal(t, "i-rds0001", h.attacher.instanceID)
assert.Equal(t, "vol-rdsdata01", h.attacher.volumeID)
assert.Equal(t, dataVolumeDevice, h.attacher.device)
assert.Equal(t, utils.GlobalAccountID, h.attacher.accountID)
assert.Equal(t, "vol-rdsdata01", out.DataVolumeID)
assert.Equal(t, dataVolumeDevice, out.DataDevice)
}
func TestLaunchDBInstanceVMRollsBackEverythingItCreated(t *testing.T) {
// Each case fails one step; everything created before it must be gone, so a
// caller retrying a failed CreateDBInstance does not accumulate orphans.
t.Run("customer ENI", func(t *testing.T) {
h := newLaunchHarness()
h.enis.createErrOn = 2
_, err := LaunchDBInstanceVM(t.Context(), h.deps(), testLaunchInput())
require.Error(t, err)
assert.Equal(t, []string{"eni-0001"}, h.enis.deleted, "the system NIC must not outlive the failed launch")
assert.Nil(t, h.launcher.input, "no VM may be launched once a NIC is missing")
})
t.Run("VM launch", func(t *testing.T) {
h := newLaunchHarness()
h.launcher.err = errors.New("no capacity")
_, err := LaunchDBInstanceVM(t.Context(), h.deps(), testLaunchInput())
require.Error(t, err)
assert.ElementsMatch(t, []string{"eni-0001", "eni-0002"}, h.enis.deleted)
assert.Empty(t, h.volumes.created)
})
t.Run("volume create", func(t *testing.T) {
h := newLaunchHarness()
h.volumes.err = errors.New("storage pool exhausted")
_, err := LaunchDBInstanceVM(t.Context(), h.deps(), testLaunchInput())
require.Error(t, err)
assert.Equal(t, []string{"i-rds0001"}, h.launcher.terminated)
assert.ElementsMatch(t, []string{"eni-0001", "eni-0002"}, h.enis.deleted)
})
t.Run("volume attach", func(t *testing.T) {
h := newLaunchHarness()
h.attacher.err = errors.New("no free hot-plug port")
_, err := LaunchDBInstanceVM(t.Context(), h.deps(), testLaunchInput())
require.Error(t, err)
// A volume left behind here is a billable orphan the customer never
// asked for and cannot see.
assert.Equal(t, []string{"vol-rdsdata01"}, h.volumes.deleted)
assert.Equal(t, []string{"i-rds0001"}, h.launcher.terminated)
assert.ElementsMatch(t, []string{"eni-0001", "eni-0002"}, h.enis.deleted)
})
}
func TestLaunchDBInstanceVMTerminatesTheVMBeforeReleasingWhatIsAttachedToIt(t *testing.T) {
h := newLaunchHarness()
h.attacher.err = errors.New("no free hot-plug port")
_, err := LaunchDBInstanceVM(t.Context(), h.deps(), testLaunchInput())
require.Error(t, err)
// The data volume and both ENIs are attached to a running VM. Releasing one
// before the VM is gone is rejected as in-use, which the unwind can only log
// — so the resource survives as a billable orphan the customer cannot see.
require.Equal(t, []string{"terminate", "delete-volume", "delete-eni", "delete-eni"}, h.unwind)
}
func TestLaunchDBInstanceVMRollsBackAfterTheCallersContextIsDone(t *testing.T) {
h := newLaunchHarness()
ctx, cancel := context.WithCancel(t.Context())
// The most likely reason a late step fails is the caller's own deadline, so
// the unwind has to run on a context that outlives it. On the request's own
// context every delete below returns immediately and every resource leaks.
h.attacher.err = errors.New("deadline exceeded")
h.attacher.onCall = cancel
_, err := LaunchDBInstanceVM(ctx, h.deps(), testLaunchInput())
require.Error(t, err)
assert.Equal(t, []string{"vol-rdsdata01"}, h.volumes.deleted)
assert.ElementsMatch(t, []string{"eni-0001", "eni-0002"}, h.enis.deleted)
assert.NoError(t, h.volumes.deleteCtxErr, "the volume delete ran on the cancelled request context")
assert.NoError(t, h.enis.deleteCtxErr, "the ENI delete ran on the cancelled request context")
}
func TestLaunchDBInstanceVMRejectsIncompleteInput(t *testing.T) {
for name, mutate := range map[string]func(*LaunchInput){
"no identifier": func(in *LaunchInput) { in.DBInstanceIdentifier = "" },
"no account": func(in *LaunchInput) { in.AccountID = "" },
"no subnet": func(in *LaunchInput) { in.SubnetID = "" },
"no instance type": func(in *LaunchInput) { in.InstanceType = "" },
"no storage": func(in *LaunchInput) { in.AllocatedStorage = 0 },
} {
t.Run(name, func(t *testing.T) {
h := newLaunchHarness()
in := testLaunchInput()
mutate(&in)
_, err := LaunchDBInstanceVM(t.Context(), h.deps(), in)
require.Error(t, err)
assert.Empty(t, h.enis.created, "validation must run before anything is provisioned")
assert.Nil(t, h.launcher.input)
})
}
}
func TestResolveEngineAMIMatchesOnManifestTags(t *testing.T) {
h := newLaunchHarness()
amiID, err := resolveEngineAMI(t.Context(), h.images, "postgres", "18")
require.NoError(t, err)
assert.Equal(t, testEngineAMI, amiID)
// The filters are what stop a customer's own image, or another engine's,
// from being booted as a DB instance.
got := map[string]string{}
for _, f := range h.images.filters {
got[aws.StringValue(f.Name)] = aws.StringValue(f.Values[0])
}
assert.Equal(t, map[string]string{
"tag:" + tags.ManagedByKey: tags.ManagedByRDS,
"tag:engine": "postgres",
"tag:engine-version": "18",
}, got)
}
func TestResolveEngineAMIOmitsTheVersionFilterWhenUnset(t *testing.T) {
h := newLaunchHarness()
_, err := resolveEngineAMI(t.Context(), h.images, "postgres", "")
require.NoError(t, err)
for _, f := range h.images.filters {
assert.NotEqual(t, "tag:engine-version", aws.StringValue(f.Name),
"an unset EngineVersion must match any version, not the empty one")
}
}
func TestResolveEngineAMIPicksTheNewestBuild(t *testing.T) {
h := newLaunchHarness()
h.images.images = []*ec2.Image{
{ImageId: aws.String("ami-old"), CreationDate: aws.String("2025-06-01T00:00:00Z")},
{ImageId: aws.String("ami-new"), CreationDate: aws.String("2026-03-01T00:00:00Z")},
{ImageId: aws.String("ami-mid"), CreationDate: aws.String("2026-01-01T00:00:00Z")},
}
amiID, err := resolveEngineAMI(t.Context(), h.images, "postgres", "18")
require.NoError(t, err)
assert.Equal(t, "ami-new", amiID)
}
func TestResolveEngineAMISkipsMalformedCatalogEntries(t *testing.T) {
h := newLaunchHarness()
h.images.images = []*ec2.Image{
nil,
{ImageId: aws.String(""), CreationDate: aws.String("2026-04-01T00:00:00Z")},
{CreationDate: aws.String("2026-05-01T00:00:00Z")},
{ImageId: aws.String("ami-valid"), CreationDate: aws.String("2026-03-01T00:00:00Z")},
}
amiID, err := resolveEngineAMI(t.Context(), h.images, "postgres", "18")
require.NoError(t, err)
assert.Equal(t, "ami-valid", amiID)
}
func TestResolveEngineAMIExcludesGPUTaggedBuilds(t *testing.T) {
h := newLaunchHarness()
h.images.images = []*ec2.Image{
{ImageId: aws.String(testEngineAMI), CreationDate: aws.String("2026-01-02T00:00:00Z")},
{
ImageId: aws.String("ami-rds-postgres-18-gpu"),
CreationDate: aws.String("2026-05-01T00:00:00Z"),
Tags: []*ec2.Tag{{Key: aws.String(tags.GPUVendorKey), Value: aws.String("nvidia")}},
},
}
amiID, err := resolveEngineAMI(t.Context(), h.images, "postgres", "18")
require.NoError(t, err)
assert.Equal(t, testEngineAMI, amiID,
"a newer GPU build carries the same engine tags, so newest-wins alone would boot it for an ordinary instance")
}
func TestResolveEngineAMIFailsWhenOnlyGPUBuildsMatch(t *testing.T) {
h := newLaunchHarness()
h.images.images = []*ec2.Image{{
ImageId: aws.String("ami-rds-postgres-18-gpu"),
CreationDate: aws.String("2026-05-01T00:00:00Z"),
Tags: []*ec2.Tag{{Key: aws.String(tags.GPUVendorKey), Value: aws.String("nvidia")}},
}}
_, err := resolveEngineAMI(t.Context(), h.images, "postgres", "18")
require.ErrorIs(t, err, ErrEngineAMINotFound,
"excluding the GPU build must fail the lookup, not fall through to it")
}
func TestLaunchDBInstanceVMFailsWithoutAnEngineAMI(t *testing.T) {
h := newLaunchHarness()
h.images.images = nil
_, err := LaunchDBInstanceVM(t.Context(), h.deps(), testLaunchInput())
require.ErrorIs(t, err, ErrEngineAMINotFound,
"a missing engine image must be a named failure, never a fallback to some other AMI")
assert.Empty(t, h.enis.created)
}
+110
View File
@@ -0,0 +1,110 @@
package handlers_rds
import "time"
// Fields are grouped by writer: the AWS API owns the configuration, the
// reconciler the plumbing, the agent protocol Bootstrap and Agent.
type DBInstanceRecord struct {
DBInstanceIdentifier string `json:"dbInstanceIdentifier"`
AccountID string `json:"accountId"`
Status Status `json:"status"`
Engine string `json:"engine"`
EngineVersion string `json:"engineVersion"`
DBInstanceClass string `json:"dbInstanceClass"`
AllocatedStorage int64 `json:"allocatedStorage"`
DBName string `json:"dbName,omitempty"`
MasterUsername string `json:"masterUsername"`
Port int64 `json:"port"`
// Changes on every replace, which is why the bus subject keys off the DB
// instance identifier instead.
InstanceID string `json:"instanceId,omitempty"`
DataVolumeID string `json:"dataVolumeId,omitempty"`
ENIID string `json:"eniId,omitempty"`
// Stable across VM replace, so it serves as both the fallback endpoint and
// a durable IP SAN on the serving cert.
ENIPrivateIP string `json:"eniPrivateIp,omitempty"`
EndpointAddress string `json:"endpointAddress,omitempty"`
// The vanity hostname when northstar is configured. Kept apart from
// EndpointAddress because the cert needs it as a DNS SAN either way.
DNSName string `json:"dnsName,omitempty"`
Bootstrap BootstrapState `json:"bootstrap"`
Agent AgentState `json:"agent"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// The Consumed marker scopes the master password to a single fetch, not the
// action: replace, recovery and restore all re-fetch without one.
type BootstrapState struct {
// Cleared by the same CAS that sets Consumed, so the cleartext outlives
// only the boot it serves.
MasterUserPassword string `json:"masterUserPassword,omitempty"`
// Only ever flips forward.
Consumed bool `json:"consumed"`
ConsumedAt *time.Time `json:"consumedAt,omitempty"`
// Already evaluated against the instance class, so the agent receives
// literals and never a formula.
ResolvedParameters []Parameter `json:"resolvedParameters,omitempty"`
}
// A member list rather than a map because the XML marshaller renders a map as an
// AWS-foreign <entry> shape in nondeterministic order.
type Parameter struct {
Name string `json:"name" locationName:"Name"`
Value string `json:"value" locationName:"Value"`
}
// Separate from Status: the reconciler needs both to tell "stopped because we
// stopped it" from "stopped because it died".
type EngineHealth string
const (
// Covers initdb, crash recovery and parameter-apply restarts.
EngineHealthStarting EngineHealth = "starting"
EngineHealthHealthy EngineHealth = "healthy"
// Running but not serving.
EngineHealthUnhealthy EngineHealth = "unhealthy"
// Deliberately down, e.g. quiesced for a snapshot or a storage grow.
EngineHealthStopped EngineHealth = "stopped"
)
// Rejects unrecognised values at the boundary so a newer agent cannot persist a
// health the reconciler will fail to classify.
func ValidEngineHealth(h EngineHealth) bool {
switch h {
case EngineHealthStarting, EngineHealthHealthy, EngineHealthUnhealthy, EngineHealthStopped:
return true
default:
return false
}
}
// Written only by RegisterDBInstance and SubmitDBStateChange.
type AgentState struct {
// A report from an instance other than the record's current one is a
// superseded VM still running after a replace.
InstanceID string `json:"instanceId,omitempty"`
AgentVersion string `json:"agentVersion,omitempty"`
EngineVersion string `json:"engineVersion,omitempty"`
EngineHealth EngineHealth `json:"engineHealth,omitempty"`
Message string `json:"message,omitempty"`
RegisteredAt *time.Time `json:"registeredAt,omitempty"`
// The last *persisted* beat. Beats in between are held in leader memory, so
// this trails the truth by up to the persist floor.
LastSeen *time.Time `json:"lastSeen,omitempty"`
}
const (
// Returned to the agent on register and on every state change, so the
// cadence is control-plane-owned, not baked into the AMI.
HeartbeatInterval = 30 * time.Second
// Three missed ticks.
HeartbeatStaleAfter = 3 * HeartbeatInterval
// The floor at which an unchanged beat reaches KV: ~7 KV ops/sec of
// liveness at 200 instances rather than 40.
HeartbeatPersistEvery = 5
)
+147
View File
@@ -0,0 +1,147 @@
package handlers_rds
import (
"context"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
"github.com/mulgadc/spinifex/spinifex/admin"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
// Host-side capabilities the RDS control plane needs beyond NATS.
type Deps struct {
// The cluster CA the serving certs are signed by. Empty disables minting,
// and GetDBBootstrapConfig returns no cert rather than failing the boot.
CACertPath string
CAKeyPath string
// Overrides the file-backed CA loader in tests.
LoadCA CALoader
Launch LaunchDeps
}
// The RDS control plane's KV-backed handler set. One per daemon.
type Service struct {
nc *nats.Conn
region string
deps Deps
// Heartbeat state that never reaches KV: beats are counted here and
// persisted only on change or on the slower floor.
livenessMu sync.Mutex
liveness map[string]*agentLiveness
}
type agentLiveness struct {
lastSeen time.Time
health EngineHealth
message string
beatsSinceKV int
}
// region scopes the ARNs the Service mints.
func NewService(nc *nats.Conn, region string) *Service {
return &Service{nc: nc, region: region, liveness: make(map[string]*agentLiveness)}
}
func (s *Service) WithDeps(d Deps) *Service {
s.deps = d
return s
}
func (s *Service) js() (jetstream.JetStream, error) {
if s.nc == nil {
return nil, errors.New("rds service: nil nats connection")
}
return jetstream.New(s.nc)
}
func (s *Service) bucket(ctx context.Context, accountID string) (jetstream.KeyValue, error) {
js, err := s.js()
if err != nil {
return nil, err
}
return GetOrCreateAccountBucket(ctx, js, accountID)
}
func (s *Service) systemBucket(ctx context.Context) (jetstream.KeyValue, error) {
js, err := s.js()
if err != nil {
return nil, err
}
return GetOrCreateSystemBucket(ctx, js)
}
// Both paths empty deliberately disables TLS; a partial configuration is an
// error rather than an accidental plaintext deployment.
func (s *Service) loadCA() (*x509.Certificate, *rsa.PrivateKey, error) {
if s.deps.LoadCA != nil {
return s.deps.LoadCA()
}
if s.deps.CACertPath == "" && s.deps.CAKeyPath == "" {
return nil, nil, nil
}
if s.deps.CACertPath == "" || s.deps.CAKeyPath == "" {
return nil, nil, errors.New("rds service: incomplete cluster CA configuration")
}
return admin.LoadCAKeyPair(s.deps.CACertPath, s.deps.CAKeyPath)
}
// Returns (false, nil) when the key is absent.
func getJSON(ctx context.Context, kv jetstream.KeyValue, key string, out any) (bool, error) {
entry, err := kv.Get(ctx, key)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return false, nil
}
if err != nil {
return false, err
}
if err := json.Unmarshal(entry.Value(), out); err != nil {
return false, fmt.Errorf("unmarshal %s: %w", key, err)
}
return true, nil
}
// getJSON plus the entry revision, for callers that follow with a CAS update.
func getJSONRevision(ctx context.Context, kv jetstream.KeyValue, key string, out any) (uint64, bool, error) {
entry, err := kv.Get(ctx, key)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return 0, false, nil
}
if err != nil {
return 0, false, err
}
if err := json.Unmarshal(entry.Value(), out); err != nil {
return 0, false, fmt.Errorf("unmarshal %s: %w", key, err)
}
return entry.Revision(), true, nil
}
func putJSON(ctx context.Context, kv jetstream.KeyValue, key string, v any) error {
data, err := json.Marshal(v)
if err != nil {
return err
}
if _, err := kv.Put(ctx, key, data); err != nil {
return fmt.Errorf("put %s: %w", key, err)
}
return nil
}
// Writes v at key only if the stored entry is still at rev.
func updateJSON(ctx context.Context, kv jetstream.KeyValue, key string, rev uint64, v any) error {
data, err := json.Marshal(v)
if err != nil {
return err
}
if _, err := kv.Update(ctx, key, data, rev); err != nil {
return fmt.Errorf("update %s: %w", key, err)
}
return nil
}
+37
View File
@@ -0,0 +1,37 @@
package handlers_rds
import (
"context"
"time"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
)
const defaultTimeout = 30 * time.Second
// The gateway-side adapter that forwards each agent action as a NATS request to
// the daemon's matching subscriber.
type NATSService struct {
nc *nats.Conn
}
func NewNATSService(nc *nats.Conn) *NATSService {
return &NATSService{nc: nc}
}
func (s *NATSService) RegisterDBInstance(ctx context.Context, input *RegisterDBInstanceInput, accountID string) (*RegisterDBInstanceOutput, error) {
return utils.NATSRequest[RegisterDBInstanceOutput](ctx, s.nc,
BusRegisterSubject(accountID, input.DBInstanceIdentifier), input, defaultTimeout, accountID)
}
func (s *NATSService) SubmitDBStateChange(ctx context.Context, input *SubmitDBStateChangeInput, accountID string) (*SubmitDBStateChangeOutput, error) {
return utils.NATSRequest[SubmitDBStateChangeOutput](ctx, s.nc,
BusHealthSubject(accountID, input.DBInstanceIdentifier), input, defaultTimeout, accountID)
}
// Requested on the Layer-1 subject, not the bus.
func (s *NATSService) GetDBBootstrapConfig(ctx context.Context, input *GetDBBootstrapConfigInput, accountID string) (*GetDBBootstrapConfigOutput, error) {
return utils.NATSRequest[GetDBBootstrapConfigOutput](ctx, s.nc,
SubjectGetDBBootstrapConfig, input, defaultTimeout, accountID)
}
+102
View File
@@ -0,0 +1,102 @@
package handlers_rds
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"math/big"
"net"
"time"
)
// The lifetime is deliberately short: the cert is re-minted on every bootstrap
// fetch and never persisted, so a leaked key ages out on its own.
const (
servingCertKeyBits = 2048
servingCertLifetime = 90 * 24 * time.Hour
)
// Injected so tests mint from a throwaway CA, and so the file-backed
// implementation stays the only thing needing read access to ca.key.
type CALoader func() (*x509.Certificate, *rsa.PrivateKey, error)
// DNSName is empty on deployments without northstar, where the endpoint is the
// bare ENI IP — which is why the IP SAN is the required one.
type ServingCertRequest struct {
DBInstanceIdentifier string
PrivateIP string
DNSName string
}
// PEM-encoded, in memory only.
type ServingCert struct {
CertificatePEM string
PrivateKeyPEM string
}
// Unlike admin.GenerateSignedCert this writes no files and adds no SANs of its
// own, which would name the host rather than the database.
func MintServingCert(caCert *x509.Certificate, caKey *rsa.PrivateKey, req ServingCertRequest) (*ServingCert, error) {
if caCert == nil || caKey == nil {
return nil, errors.New("rds serving cert: nil CA keypair")
}
if req.DBInstanceIdentifier == "" {
return nil, errors.New("rds serving cert: DB instance identifier required")
}
ip := net.ParseIP(req.PrivateIP)
if ip == nil {
return nil, fmt.Errorf("rds serving cert: invalid private IP %q", req.PrivateIP)
}
key, err := rsa.GenerateKey(rand.Reader, servingCertKeyBits)
if err != nil {
return nil, fmt.Errorf("rds serving cert: generate key: %w", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, fmt.Errorf("rds serving cert: serial: %w", err)
}
// The CN carries the DB identifier for operator legibility only; modern
// clients match SANs, and verify-full has to work by IP as well as by name.
notBefore := time.Now()
tmpl := x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: req.DBInstanceIdentifier, Organization: []string{"Spinifex RDS"}},
NotBefore: notBefore,
NotAfter: notBefore.Add(servingCertLifetime),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
IPAddresses: []net.IP{ip},
}
if req.DNSName != "" {
tmpl.DNSNames = []string{req.DNSName}
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, caCert, &key.PublicKey, caKey)
if err != nil {
return nil, fmt.Errorf("rds serving cert: sign: %w", err)
}
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return nil, fmt.Errorf("rds serving cert: marshal key: %w", err)
}
return &ServingCert{
CertificatePEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})),
PrivateKeyPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})),
}, nil
}
// Used to hand the agent the cluster CA it should trust alongside its own cert.
func EncodeCertPEM(cert *x509.Certificate) string {
if cert == nil {
return ""
}
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}))
}
+38
View File
@@ -0,0 +1,38 @@
package handlers_rds
import (
"errors"
"maps"
"slices"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/instancetypes"
)
// db.* is a naming facade, not a second sizing table: every entry resolves
// through the platform's instance-type definitions.
var dbInstanceClasses = map[string]string{
"db.t3.micro": "t3.micro",
"db.t3.small": "t3.small",
"db.t3.medium": "t3.medium",
"db.t3.large": "t3.large",
"db.m5.large": "m5.large",
"db.m5.xlarge": "m5.xlarge",
}
// An unknown class is rejected here at validation rather than surfacing as a
// launch failure after the volume and ENI exist.
func InstanceTypeForClass(class string) (string, error) {
instanceType, ok := dbInstanceClasses[class]
if !ok {
return "", errors.New(awserrors.ErrorInvalidParameterValue)
}
if _, known := instancetypes.DefaultVCPUs(instanceType); !known {
return "", errors.New(awserrors.ErrorInvalidParameterValue)
}
return instanceType, nil
}
func SupportedInstanceClasses() []string {
return slices.Sorted(maps.Keys(dbInstanceClasses))
}
+58
View File
@@ -0,0 +1,58 @@
package handlers_rds
import (
"testing"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/instancetypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestInstanceTypeForClass_KnownClasses(t *testing.T) {
tests := map[string]string{
"db.t3.micro": "t3.micro",
"db.t3.small": "t3.small",
"db.t3.medium": "t3.medium",
"db.t3.large": "t3.large",
"db.m5.large": "m5.large",
"db.m5.xlarge": "m5.xlarge",
}
for class, want := range tests {
got, err := InstanceTypeForClass(class)
require.NoError(t, err, "class %q", class)
assert.Equal(t, want, got, "class %q", class)
}
}
func TestInstanceTypeForClass_UnmappedClassRejected(t *testing.T) {
// Real AWS classes the platform does not offer, the bare EC2 type, and a
// class-shaped string that is not one — all must be rejected rather than
// guessed at by stripping the db. prefix.
for _, class := range []string{
"db.r5.large", "db.m5.24xlarge", "db.t3.nano", "db.serverless",
"m5.large", "", "db.", "DB.T3.MICRO",
} {
_, err := InstanceTypeForClass(class)
require.Error(t, err, "class %q should be rejected", class)
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error(), "class %q", class)
}
}
// db.* is a facade over the platform's own sizing table, so every entry has to
// name an instance type that table actually defines. A typo here would surface
// as a launch failure after the data volume and ENI already exist.
func TestDBInstanceClasses_ResolveInSizingTable(t *testing.T) {
for class, instanceType := range dbInstanceClasses {
vcpus, ok := instancetypes.DefaultVCPUs(instanceType)
assert.True(t, ok, "class %q maps to unknown instance type %q", class, instanceType)
assert.Positive(t, vcpus, "instance type %q should have vCPUs", instanceType)
}
}
func TestSupportedInstanceClasses_SortedAndComplete(t *testing.T) {
assert.Equal(t, []string{
"db.m5.large", "db.m5.xlarge",
"db.t3.large", "db.t3.medium", "db.t3.micro", "db.t3.small",
}, SupportedInstanceClasses())
}
+87
View File
@@ -0,0 +1,87 @@
package handlers_rds
import "slices"
// The only instance state the API exposes, derived from the VM, data volume and
// agent heartbeat, so a customer never depends on a DB instance being a VM.
type Status string
const (
// Provisioning through to the first healthy heartbeat.
StatusCreating Status = "creating"
StatusAvailable Status = "available"
StatusModifying Status = "modifying"
StatusBackingUp Status = "backing-up"
// Also when parameters marked pending-reboot are applied.
StatusRebooting Status = "rebooting"
StatusStopping Status = "stopping"
StatusStarting Status = "starting"
// No VM; the data volume and ENI are retained.
StatusStopped Status = "stopped"
// Reconciler-driven auto-recovery onto a fresh VM.
StatusRecovering Status = "recovering"
// Teardown, including the optional final snapshot.
StatusDeleting Status = "deleting"
// Terminal; the record is removed once observed.
StatusDeleted Status = "deleted"
StatusFailed Status = "failed"
)
// Every non-terminal state can reach failed and deleting, since a delete is
// accepted at any point and any step can fail.
var transitions = map[Status][]Status{
StatusCreating: {StatusAvailable, StatusFailed, StatusDeleting},
StatusAvailable: {StatusModifying, StatusBackingUp, StatusRebooting, StatusStopping, StatusRecovering, StatusFailed, StatusDeleting},
StatusModifying: {StatusAvailable, StatusFailed, StatusDeleting},
StatusBackingUp: {StatusAvailable, StatusFailed, StatusDeleting},
StatusRebooting: {StatusAvailable, StatusFailed, StatusDeleting},
StatusStopping: {StatusStopped, StatusFailed, StatusDeleting},
StatusStopped: {StatusStarting, StatusModifying, StatusFailed, StatusDeleting},
StatusStarting: {StatusAvailable, StatusFailed, StatusDeleting},
StatusRecovering: {StatusAvailable, StatusFailed, StatusDeleting},
// A failed instance is retried by the reconciler rather than being stuck:
// recovering is the reconciler's path back, available the heartbeat's.
StatusFailed: {StatusRecovering, StatusAvailable, StatusDeleting},
StatusDeleting: {StatusDeleted, StatusFailed},
StatusDeleted: nil,
}
// Statuses meaning the control plane is already acting on this instance, so the
// recovery classifier does not recover a VM out from under a lifecycle op.
var transitional = map[Status]bool{
StatusCreating: true,
StatusModifying: true,
StatusBackingUp: true,
StatusRebooting: true,
StatusStopping: true,
StatusStarting: true,
StatusRecovering: true,
StatusDeleting: true,
}
// Anything read back from KV that fails this check was written by a newer or
// corrupted control plane, not a state to act on.
func (s Status) Valid() bool {
_, ok := transitions[s]
return ok
}
func (s Status) Terminal() bool {
return s == StatusDeleted
}
func (s Status) Transitional() bool {
return transitional[s]
}
// A no-op is legal for any valid status, so a repeated observation of the same
// state is not treated as an illegal transition.
func CanTransition(from, to Status) bool {
if !from.Valid() || !to.Valid() {
return false
}
if from == to {
return true
}
return slices.Contains(transitions[from], to)
}
+103
View File
@@ -0,0 +1,103 @@
package handlers_rds
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestStatus_Valid(t *testing.T) {
for _, s := range []Status{
StatusCreating, StatusAvailable, StatusModifying, StatusBackingUp,
StatusRebooting, StatusStopping, StatusStarting, StatusStopped,
StatusRecovering, StatusDeleting, StatusDeleted, StatusFailed,
} {
assert.True(t, s.Valid(), "status %q should be valid", s)
}
// The underlying EC2 states must never leak through as DB instance states.
for _, s := range []Status{"", "running", "pending", "terminated", "Available"} {
assert.False(t, s.Valid(), "status %q should not be valid", s)
}
}
func TestStatus_TerminalAndTransitional(t *testing.T) {
assert.True(t, StatusDeleted.Terminal())
assert.False(t, StatusFailed.Terminal())
assert.False(t, StatusStopped.Terminal())
// A lifecycle operation is already driving these, so the recovery classifier
// must leave them alone.
for _, s := range []Status{
StatusCreating, StatusModifying, StatusBackingUp, StatusRebooting,
StatusStopping, StatusStarting, StatusRecovering, StatusDeleting,
} {
assert.True(t, s.Transitional(), "status %q should be transitional", s)
}
for _, s := range []Status{StatusAvailable, StatusStopped, StatusFailed, StatusDeleted} {
assert.False(t, s.Transitional(), "status %q should be settled, not transitional", s)
}
}
func TestCanTransition_Lifecycle(t *testing.T) {
tests := []struct {
from, to Status
want bool
}{
// The happy path through create, use, stop, start and delete.
{StatusCreating, StatusAvailable, true},
{StatusAvailable, StatusBackingUp, true},
{StatusBackingUp, StatusAvailable, true},
{StatusAvailable, StatusStopping, true},
{StatusStopping, StatusStopped, true},
{StatusStopped, StatusStarting, true},
{StatusStarting, StatusAvailable, true},
{StatusAvailable, StatusDeleting, true},
{StatusDeleting, StatusDeleted, true},
// Failure and self-heal.
{StatusAvailable, StatusRecovering, true},
{StatusAvailable, StatusFailed, true},
{StatusFailed, StatusRecovering, true},
{StatusRecovering, StatusAvailable, true},
// A re-observation of the same state is not an illegal move.
{StatusAvailable, StatusAvailable, true},
{StatusDeleted, StatusDeleted, true},
// A stopped instance has no VM, so it cannot be backing up or rebooting.
{StatusStopped, StatusBackingUp, false},
{StatusStopped, StatusRebooting, false},
{StatusStopped, StatusAvailable, false},
// Deletion is one-way: a deleted instance is never resurrected, and a
// delete in flight is never walked back into service.
{StatusDeleted, StatusAvailable, false},
{StatusDeleted, StatusCreating, false},
{StatusDeleting, StatusAvailable, false},
// Creation never lands straight in a steady non-available state.
{StatusCreating, StatusStopped, false},
// Unknown states resolve to no legal transition at all.
{StatusAvailable, "running", false},
{"running", StatusAvailable, false},
{"running", "running", false},
}
for _, tc := range tests {
assert.Equal(t, tc.want, CanTransition(tc.from, tc.to),
"CanTransition(%q, %q)", tc.from, tc.to)
}
}
// Every state the machine can reach must itself be a state the machine knows,
// or the reconciler could write a status it then refuses to read back.
func TestTransitions_TargetsAreValidStates(t *testing.T) {
for from, targets := range transitions {
assert.True(t, from.Valid(), "source status %q should be valid", from)
for _, to := range targets {
assert.True(t, to.Valid(), "target status %q of %q should be valid", to, from)
}
}
}
+160
View File
@@ -0,0 +1,160 @@
// Package handlers_rds holds the RDS control plane: the KV state layout, the
// ARN and lifecycle-status models, and the db.* instance-class facade over the
// platform's EC2 sizing table. It is engine-agnostic — PostgreSQL specifics live
// behind the agent/AMI seam, not here.
package handlers_rds
import (
"context"
"errors"
"fmt"
"github.com/mulgadc/spinifex/spinifex/kvutil"
"github.com/mulgadc/spinifex/spinifex/migrate"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
// "rds-account-{accountID}" holds customer-visible state. "rds-system" holds
// the instanceID → DB instance reverse index: agent IMDS credentials are minted
// under the system account, so without it a heartbeat would scan every bucket.
const (
KVBucketRDSAccountPrefix = "rds-account-"
KVBucketRDSAccountVersion = 1
KVBucketRDSAccountHistory = 1
KVBucketRDSSystem = "rds-system"
KVBucketRDSSystemVersion = 1
KVBucketRDSSystemHistory = 1
)
// Key-path helpers for the per-account bucket:
//
// db-instances/{dbInstanceIdentifier}
// db-snapshots/{dbSnapshotIdentifier}
// db-subnet-groups/{name}
// db-parameter-groups/{name}/meta
// db-parameter-groups/{name}/params/{key}
// backups/{dbInstanceIdentifier}/automated/{ts}
// retained-volumes/{volumeID}
//
// Tags live inline on each resource's own record rather than in a separate key
// space, so there is no tags/ prefix here.
func DBInstancesPrefix() string {
return "db-instances/"
}
func DBInstanceKey(dbInstanceIdentifier string) string {
return DBInstancesPrefix() + dbInstanceIdentifier
}
// Manual and automated snapshots share this space, distinguished by the
// snapshot type on the record.
func DBSnapshotsPrefix() string {
return "db-snapshots/"
}
func DBSnapshotKey(dbSnapshotIdentifier string) string {
return DBSnapshotsPrefix() + dbSnapshotIdentifier
}
func DBSubnetGroupsPrefix() string {
return "db-subnet-groups/"
}
func DBSubnetGroupKey(name string) string {
return DBSubnetGroupsPrefix() + name
}
// A group's own record is at .../meta and its values hang off .../params/, so
// listing groups walks the meta keys.
func DBParameterGroupsPrefix() string {
return "db-parameter-groups/"
}
func DBParameterGroupMetaKey(name string) string {
return fmt.Sprintf("%s%s/meta", DBParameterGroupsPrefix(), name)
}
// One key per value rather than one blob, so a ModifyDBParameterGroup touching
// a single parameter cannot clobber a concurrent change to another.
func DBParameterGroupParamsPrefix(name string) string {
return fmt.Sprintf("%s%s/params/", DBParameterGroupsPrefix(), name)
}
func DBParameterGroupParamKey(name, param string) string {
return DBParameterGroupParamsPrefix(name) + param
}
// Kept separate from db-snapshots/ so the retention sweep enumerates only
// automated backups, ordered lexically by their timestamp suffix.
func AutomatedBackupsPrefix(dbInstanceIdentifier string) string {
return fmt.Sprintf("backups/%s/automated/", dbInstanceIdentifier)
}
func AutomatedBackupKey(dbInstanceIdentifier, ts string) string {
return AutomatedBackupsPrefix(dbInstanceIdentifier) + ts
}
// Data volumes held alive by surviving snapshots: a COW snapshot references its
// source volume's chunks, so deleting a DB instance cannot delete the volume.
func RetainedVolumesPrefix() string {
return "retained-volumes/"
}
func RetainedVolumeKey(volumeID string) string {
return RetainedVolumesPrefix() + volumeID
}
// Entries are rewritten on every VM replace (each mints a new instance ID) and
// removed at teardown.
func InstanceIndexPrefix() string {
return "instance-index/"
}
func InstanceIndexKey(instanceID string) string {
return InstanceIndexPrefix() + instanceID
}
type Store struct {
nc *nats.Conn
}
// Does not touch JetStream — buckets are created lazily by the factories below.
func NewStore(nc *nats.Conn) (*Store, error) {
if nc == nil {
return nil, errors.New("rds store: nats connection is nil")
}
return &Store{nc: nc}, nil
}
func AccountBucketName(accountID string) string {
return KVBucketRDSAccountPrefix + accountID
}
// Creates the bucket on first use; subsequent calls return the existing handle.
func GetOrCreateAccountBucket(ctx context.Context, js jetstream.JetStream, accountID string) (jetstream.KeyValue, error) {
bucket := AccountBucketName(accountID)
kv, err := kvutil.GetOrCreateBucket(ctx, js, bucket, KVBucketRDSAccountHistory)
if err != nil {
return nil, fmt.Errorf("failed to create RDS per-account KV bucket %s: %w", bucket, err)
}
if err := migrate.DefaultRegistry.RunKV(ctx, bucket, kv, KVBucketRDSAccountVersion); err != nil {
return nil, fmt.Errorf("migrate %s: %w", bucket, err)
}
return kv, nil
}
// Created lazily alongside the first DB instance rather than at daemon boot, so
// a cluster with no RDS usage carries no bucket.
func GetOrCreateSystemBucket(ctx context.Context, js jetstream.JetStream) (jetstream.KeyValue, error) {
kv, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketRDSSystem, KVBucketRDSSystemHistory)
if err != nil {
return nil, fmt.Errorf("failed to create RDS system KV bucket %s: %w", KVBucketRDSSystem, err)
}
if err := migrate.DefaultRegistry.RunKV(ctx, KVBucketRDSSystem, kv, KVBucketRDSSystemVersion); err != nil {
return nil, fmt.Errorf("migrate %s: %w", KVBucketRDSSystem, err)
}
return kv, nil
}
+85
View File
@@ -0,0 +1,85 @@
package handlers_rds
import (
"strings"
"testing"
"github.com/mulgadc/spinifex/spinifex/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAccountBucketName(t *testing.T) {
assert.Equal(t, "rds-account-123456789012", AccountBucketName(testAccountID))
}
func TestKeyPaths(t *testing.T) {
assert.Equal(t, "db-instances/orders-db", DBInstanceKey("orders-db"))
assert.Equal(t, "db-snapshots/orders-db-final", DBSnapshotKey("orders-db-final"))
assert.Equal(t, "db-subnet-groups/prod-db-subnets", DBSubnetGroupKey("prod-db-subnets"))
assert.Equal(t, "db-parameter-groups/pg16/meta", DBParameterGroupMetaKey("pg16"))
assert.Equal(t, "db-parameter-groups/pg16/params/shared_buffers",
DBParameterGroupParamKey("pg16", "shared_buffers"))
assert.Equal(t, "backups/orders-db/automated/20260724T170000Z",
AutomatedBackupKey("orders-db", "20260724T170000Z"))
assert.Equal(t, "retained-volumes/vol-abc123", RetainedVolumeKey("vol-abc123"))
assert.Equal(t, "instance-index/i-abc123", InstanceIndexKey("i-abc123"))
}
// Each key must sit under its own prefix, so an enumeration of one resource type
// can never pick up another's records.
func TestKeyPathsSitUnderTheirPrefix(t *testing.T) {
assert.True(t, strings.HasPrefix(DBInstanceKey("x"), DBInstancesPrefix()))
assert.True(t, strings.HasPrefix(DBSnapshotKey("x"), DBSnapshotsPrefix()))
assert.True(t, strings.HasPrefix(DBSubnetGroupKey("x"), DBSubnetGroupsPrefix()))
assert.True(t, strings.HasPrefix(DBParameterGroupMetaKey("x"), DBParameterGroupsPrefix()))
assert.True(t, strings.HasPrefix(DBParameterGroupParamKey("x", "p"), DBParameterGroupParamsPrefix("x")))
assert.True(t, strings.HasPrefix(AutomatedBackupKey("x", "t"), AutomatedBackupsPrefix("x")))
assert.True(t, strings.HasPrefix(RetainedVolumeKey("v"), RetainedVolumesPrefix()))
assert.True(t, strings.HasPrefix(InstanceIndexKey("i"), InstanceIndexPrefix()))
// A parameter group's values must not be reachable by listing groups' meta
// records, or DescribeDBParameterGroups would report one group per parameter.
assert.False(t, strings.HasPrefix(DBParameterGroupParamKey("x", "p"), DBParameterGroupMetaKey("x")))
}
func TestNewStore_NilConn(t *testing.T) {
_, err := NewStore(nil)
require.Error(t, err)
}
func TestNewStore_Valid(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
s, err := NewStore(nc)
require.NoError(t, err)
require.NotNil(t, s)
}
func TestGetOrCreateAccountBucket_Idempotent(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
kv1, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID)
require.NoError(t, err)
require.NotNil(t, kv1)
kv2, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID)
require.NoError(t, err)
require.NotNil(t, kv2)
assert.Equal(t, AccountBucketName(testAccountID), kv1.Bucket())
assert.Equal(t, kv1.Bucket(), kv2.Bucket())
}
func TestGetOrCreateSystemBucket_Idempotent(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
kv1, err := GetOrCreateSystemBucket(t.Context(), js)
require.NoError(t, err)
kv2, err := GetOrCreateSystemBucket(t.Context(), js)
require.NoError(t, err)
assert.Equal(t, KVBucketRDSSystem, kv1.Bucket())
assert.Equal(t, kv1.Bucket(), kv2.Bucket())
}
+44
View File
@@ -0,0 +1,44 @@
package handlers_rds
import "fmt"
// Layer-2 bus subjects. Instance identity is {accountID}.{dbInstanceIdentifier},
// matching the KV layout and deliberately not the internal EC2 instance ID,
// which changes on every VM replace while the subject must not.
const (
busPrefix = "rds.bus."
// What the daemon subscribes on; the account and DB segments are
// addressing, and the payload is authoritative.
SubjectRegisterWildcard = busPrefix + "*.*.register"
SubjectHealthWildcard = busPrefix + "*.*.health"
// A Layer-1 subject, not a bus one: serving bootstrap config is a
// control-plane read rather than reconciler↔agent traffic.
SubjectGetDBBootstrapConfig = "rds.GetDBBootstrapConfig"
// Queue groups are scoped per subject, so one name shares command delivery
// across gateway nodes for the whole fleet.
CommandQueueGroup = "spinifex-rds-agents"
)
func BusRegisterSubject(accountID, dbInstanceIdentifier string) string {
return fmt.Sprintf("%s%s.%s.register", busPrefix, accountID, dbInstanceIdentifier)
}
// The reserved .heartbeat subject stays unused: the beat is folded into the
// state change so a healthy instance costs one round trip per tick.
func BusHealthSubject(accountID, dbInstanceIdentifier string) string {
return fmt.Sprintf("%s%s.%s.health", busPrefix, accountID, dbInstanceIdentifier)
}
// A live long poll rather than a durable queue, so a set-password that cannot
// reach the agent fails loudly instead of deferring cleartext.
func BusCommandSubject(accountID, dbInstanceIdentifier string) string {
return fmt.Sprintf("%s%s.%s.command", busPrefix, accountID, dbInstanceIdentifier)
}
// Correlated by the issuer on CommandID.
func BusCommandReplySubject(accountID, dbInstanceIdentifier string) string {
return fmt.Sprintf("%s%s.%s.command-reply", busPrefix, accountID, dbInstanceIdentifier)
}
+72
View File
@@ -0,0 +1,72 @@
package handlers_rds
import (
"context"
"errors"
"fmt"
"github.com/mulgadc/spinifex/spinifex/config"
handlers_systemvpc "github.com/mulgadc/spinifex/spinifex/handlers/systemvpc"
"github.com/mulgadc/spinifex/spinifex/tags"
)
// One shared RDS system VPC per region holds every DB VM's primary NIC. DB
// instances are isolated by their own customer ENI and security group, so a VPC
// per instance would add a NAT gateway and EIP without adding isolation.
//
// Own tag keys and address space, so no EKS teardown or orphan reaper can reach
// it even though it shares the EKS control-plane VPC's builder and topology.
const (
rdsSystemVPCTagKey = "spinifex:rds-system-vpc"
rdsSystemVPCRoleTagKey = "spinifex:rds-role"
rdsSystemVPCRolePrefix = "rds"
)
// Seeds both the owner tag and the deterministic address hash, so it must stay
// stable across releases.
func SystemVPCName(region string) string {
return "rds-system-" + region
}
// cfg supplies the operator-overridable address space and subnet count; a nil
// or unset cfg falls back to the defaults.
func SystemVPCSpec(cfg *config.RDSConfig, region string) handlers_systemvpc.Spec {
supernet := config.RDSDefaultSystemVPCSupernet
privateSubnets := 1
if cfg != nil {
if cfg.SystemVPCSupernet != "" {
supernet = cfg.SystemVPCSupernet
}
if cfg.SystemVPCPrivateSubnets > 0 {
privateSubnets = cfg.SystemVPCPrivateSubnets
}
}
return handlers_systemvpc.Spec{
Owner: handlers_systemvpc.Owner{
Name: SystemVPCName(region),
ManagedBy: tags.ManagedByRDS,
OwnerTagKey: rdsSystemVPCTagKey,
RoleTagKey: rdsSystemVPCRoleTagKey,
},
Region: region,
RolePrefix: rdsSystemVPCRolePrefix,
Supernet: supernet,
PrivateSubnets: privateSubnets,
}
}
// Idempotent. The private subnet the DB VMs sit in routes 0.0.0.0/0 to the
// VPC's NAT gateway, which is the agent's only egress to the gateway.
func EnsureSystemVPC(ctx context.Context, deps handlers_systemvpc.Deps, cfg *config.RDSConfig, accountID, region string) (*handlers_systemvpc.Refs, error) {
if region == "" {
return nil, errors.New("rds: EnsureSystemVPC empty region")
}
refs, err := handlers_systemvpc.Ensure(ctx, deps, SystemVPCSpec(cfg, region), accountID)
if err != nil {
return nil, fmt.Errorf("rds: ensure system VPC for %s: %w", region, err)
}
if len(refs.PrivateSubnetIDs) == 0 {
return nil, fmt.Errorf("rds: system VPC %s has no private subnet to place DB VMs in", refs.VpcID)
}
return refs, nil
}
+75
View File
@@ -0,0 +1,75 @@
package handlers_rds
import (
"net/netip"
"testing"
"github.com/mulgadc/spinifex/spinifex/config"
handlers_systemvpc "github.com/mulgadc/spinifex/spinifex/handlers/systemvpc"
"github.com/mulgadc/spinifex/spinifex/tags"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSystemVPCNameIsPerRegion(t *testing.T) {
assert.Equal(t, "rds-system-ap-southeast-2", SystemVPCName("ap-southeast-2"))
assert.NotEqual(t, SystemVPCName("us-east-1"), SystemVPCName("ap-southeast-2"),
"one system VPC per region, so two regions must not resolve to the same VPC")
}
func TestSystemVPCSpecDefaults(t *testing.T) {
spec := SystemVPCSpec(nil, "ap-southeast-2")
assert.Equal(t, handlers_systemvpc.Spec{
Owner: handlers_systemvpc.Owner{
Name: "rds-system-ap-southeast-2",
ManagedBy: tags.ManagedByRDS,
OwnerTagKey: rdsSystemVPCTagKey,
RoleTagKey: rdsSystemVPCRoleTagKey,
},
Region: "ap-southeast-2",
RolePrefix: rdsSystemVPCRolePrefix,
Supernet: config.RDSDefaultSystemVPCSupernet,
PrivateSubnets: 1,
}, spec)
// An empty RDSConfig is what an operator who never wrote an rds block has,
// so it must produce the same VPC as no config at all.
assert.Equal(t, spec, SystemVPCSpec(&config.RDSConfig{}, "ap-southeast-2"))
}
func TestSystemVPCSpecHonoursOperatorOverrides(t *testing.T) {
spec := SystemVPCSpec(&config.RDSConfig{
SystemVPCSupernet: "172.16.0.0/14",
SystemVPCPrivateSubnets: 3,
}, "us-east-1")
assert.Equal(t, "172.16.0.0/14", spec.Supernet)
assert.Equal(t, 3, spec.PrivateSubnets)
assert.Equal(t, tags.ManagedByRDS, spec.ManagedBy, "an override must not change who owns the VPC")
}
func TestSystemVPCTagsAreRDSOwn(t *testing.T) {
spec := SystemVPCSpec(nil, "ap-southeast-2")
// The tag keys are the whole isolation mechanism: EKS's teardown and its
// orphan reaper sweep on the EKS keys, so RDS resources carrying them would
// be reclaimed out from under a running database.
assert.NotEqual(t, "spinifex:eks-cluster", spec.OwnerTagKey)
assert.NotEqual(t, "spinifex:eks-role", spec.RoleTagKey)
assert.Equal(t, "rds-vpc", spec.Roles().VPC)
assert.Equal(t, "rds-private", spec.Roles().PrivateSubnet)
}
func TestSystemVPCSupernetIsUsableAndDisjointFromEKS(t *testing.T) {
supernet, err := netip.ParsePrefix(config.RDSDefaultSystemVPCSupernet)
require.NoError(t, err)
assert.Equal(t, supernet.Masked(), supernet, "a supernet with host bits set is rejected by the builder")
assert.Equal(t, 14, supernet.Bits(), "the builder carves a /22 out of a /14 and requires that shape")
// EKS's control-plane supernet. A collision is survivable but leaves an
// operator unable to tell from an address which component owns it.
eks := netip.MustParsePrefix("10.252.0.0/14")
assert.False(t, supernet.Overlaps(eks),
"the RDS system VPC space %s must not overlap the EKS control-plane space %s", supernet, eks)
}
+17
View File
@@ -492,6 +492,23 @@ var AvailableImages = map[string]Images{
BootMode: "uefi",
Tags: map[string]string{"spinifex:managed-by": "ecs", "gpu-vendor": "nvidia"},
},
// Resolved by spinifex:managed-by=rds plus the engine tags an
// Engine/EngineVersion request maps onto, not by parsing the image name.
"spinifex-rds-postgres": {
Name: "spinifex-rds-postgres",
Description: "Mulga RDS PostgreSQL image — Alpine 3.24.1 + PostgreSQL 18 + rds-init (initdb, master password, parameters and TLS applied at first boot)",
Distro: "alpine",
Version: "3.24.1",
Arch: "x86_64",
Platform: "Linux/UNIX",
CreatedAt: time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC),
URL: "https://iso.mulgadc.com/system-ami/spinifex-rds-postgres-x86_64.qcow2",
Checksum: "https://iso.mulgadc.com/system-ami/spinifex-rds-postgres-x86_64.qcow2.sha256",
ChecksumType: "sha256",
BootMode: "bios",
Tags: map[string]string{"spinifex:managed-by": "rds", "engine": "postgres", "engine-version": "18"},
},
}
func ExtractDiskImageFromFile(imagepath string, tmpdir string) (diskimage string, err error) {
+10
View File
@@ -1677,3 +1677,13 @@ func TestAvailableImages_ECSNodeEntry(t *testing.T) {
assert.Equal(t, "ecs", img.Tags["spinifex:managed-by"],
"ECS node image must carry the managed-by=ecs tag the UI guard resolves on")
}
func TestAvailableImages_RDSPostgresEntry(t *testing.T) {
img, ok := AvailableImages["spinifex-rds-postgres"]
require.True(t, ok, "spinifex-rds-postgres must be in the system image catalog")
assert.Equal(t, "rds", img.Tags["spinifex:managed-by"],
"RDS image must carry the managed-by=rds tag instance launches resolve on")
assert.Equal(t, "postgres", img.Tags["engine"])
assert.Equal(t, "18", img.Tags["engine-version"],
"the pinned PostgreSQL major version is what EngineVersion resolves against")
}