diff --git a/.github/assets/diagrams/exoscale-single-node.svg b/.github/assets/diagrams/exoscale-single-node.svg new file mode 100644 index 000000000..07320d155 --- /dev/null +++ b/.github/assets/diagrams/exoscale-single-node.svg @@ -0,0 +1,79 @@ + + +SPINIFEX ON EXOSCALE — SINGLE NODE +one instance · routed NAT · Elastic IPs +One Exoscale instance, single Spinifex node + +INTERNET +AWS SDKs, SSH and guest egress all arrive here + +aws-cli / Terraform / boto3 → :9999 + +ssh → guest Elastic IP + +guest egress ← same address + + +EXOSCALE ZONE DE-FRA-1 · PUBLIC NETWORK 91.92.116.0/23 +gateway 91.92.116.1 · security group open to all + +standard.huge +spinifex-node-01 · 8 vCPU / 32 GiB · Ubuntu 26.04 +one host = one Spinifex region + + +eth0 — PUBLIC INTERFACE +the only NIC +91.92.116.250/23 · holds the default route +advertise IP · API :9999 · SSH · host masquerade + +ELASTIC IPs — MANUAL +attached to the instance +89.145.163.1/32 → delivered to eth0 +the host routes each /32 into OVN, never owns it + + +spx-nat-host ⇄ spx-nat-ovs +transit 100.127.0.0/24 · EIP/32 routed in · masquerade out eth0 for the rest + + +SPINIFEX CONTROL PLANE +every service is one binary: spx service <name> + +awsgw :9999 + +daemon + +vpcd + +NATS + +predastore + +viperblock + +northstar + +ui :3000 +SigV4 in, NATS in the middle, QEMU/KVM (nested) · OVN/OVS · Predastore · Viperblock underneath + + + +TENANT VPCs — OVN br-int +geneve-local + +vpc-a 172.31.0.0/16 +i-… 172.31.0.4 → 89.145.163.1 + +vpc-b 10.0.0.0/16 +i-… 10.0.1.9 · private, egress only + +BLOCK STORAGE VOLUME +256 GiB+ +virtio-blk → /dev/vdb +/var/lib/spinifex +found by-id, defaults,nofail +viperblock · predastore · JetStream +A guest's public address is a manual Exoscale Elastic IP, delivered to eth0 addressed to itself — no private/public pair to translate. +The host routes it into OVN, so inbound and outbound are the same address and the guest sees its private IP only. + diff --git a/.github/assets/diagrams/exoscale-three-node.svg b/.github/assets/diagrams/exoscale-three-node.svg new file mode 100644 index 000000000..024c20da2 --- /dev/null +++ b/.github/assets/diagrams/exoscale-three-node.svg @@ -0,0 +1,123 @@ + + +SPINIFEX ON EXOSCALE — THREE NODES +anti-affinity group · one zone · not yet tested +Three Exoscale instances, redundant multi-node install + +INTERNET +one endpoint, whichever node answers + +aws-cli / Terraform / boto3 → :9999 + +ssh → guest Elastic IP + +guest egress ← same address + + +EXOSCALE ZONE DE-FRA-1 · PUBLIC NETWORK + PRIVATE NETWORK +one security group · one anti-affinity group + +spinifex-node-01 +standard.huge + + +eth0 91.92.116.250 +public · default route · API + +eth1 10.10.0.11 +private network · mesh · advertise · encap + +EIP 89.145.163.1 +→ eth0 · /32 via spx-nat-host + +awsgw + +daemon + +vpcd + +nats + +predastore + +viperblock + +i-23d2… 172.31.0.4 → 89.145.163.1 + +volume /dev/vdb → /var/lib/spinifex + +spinifex-node-02 +standard.huge + + +eth0 91.92.117.14 +public · default route · API + +eth1 10.10.0.12 +private network · mesh · advertise · encap + +EIP 89.145.160.107 +→ eth0 · /32 via spx-nat-host + +awsgw + +daemon + +vpcd + +nats + +predastore + +viperblock + +i-6b91… 172.31.0.9 → 89.145.160.107 + +volume /dev/vdb → /var/lib/spinifex + +spinifex-node-03 +standard.huge + + +eth0 91.92.116.88 +public · default route · API + +eth1 10.10.0.13 +private network · mesh · advertise · encap + +EIP 89.145.162.131 +→ eth0 · /32 via spx-nat-host + +awsgw + +daemon + +vpcd + +nats + +predastore + +viperblock + +i-0ae4… 172.31.1.6 → 89.145.162.131 + +volume /dev/vdb → /var/lib/spinifex + + +EAST–WEST, OVER THE EXOSCALE PRIVATE NETWORK +no Elastic IP is involved · MTU 1500 + +Geneve UDP 6081 + +NATS JetStream R3 + +predastore RS(2,1) + +OVN Northbound raft + +OVSDB +Guest-to-guest traffic rides Geneve between the nodes' private-network interfaces — it never touches eth0 or an Elastic IP. +Each node carries the Elastic IPs of its own guests. Moving one between nodes is an Exoscale API call, not yet automated. +Three nodes is the smallest cluster that tolerates losing one; the anti-affinity group keeps them on different hypervisors. + diff --git a/.github/e2e-cells.json b/.github/e2e-cells.json index b895186d2..5c2f8fa10 100644 --- a/.github/e2e-cells.json +++ b/.github/e2e-cells.json @@ -178,5 +178,17 @@ "extra": "", "suite": "lbrecovery", "timeout_minutes": 90 + }, + { + "cell": 33, + "install": "binary", + "topo": "real-multi", + "net": "static", + "host": "debian-13", + "arch": "x86_64", + "runner": "ci-multi", + "extra": "", + "suite": "kvquorum", + "timeout_minutes": 55 } ] diff --git a/.github/scripts/suite-sets.sh b/.github/scripts/suite-sets.sh index 6818406ed..0b9746e81 100644 --- a/.github/scripts/suite-sets.sh +++ b/.github/scripts/suite-sets.sh @@ -51,7 +51,13 @@ E2E_SUITES_SINGLE="single iam cert eks ecs storagegrowth partialblock rds quota # the load balancer's, so taking that node away does not take the backends with # it. It runs beside instancerecovery at the end because it also takes a node # away and owns its environment while it does. -E2E_SUITES_MULTI="multinode lb cert quota lbrecovery instancerecovery storagefault" +# +# kvquorum is multi-only and needs three, for the same reason as those two: a +# surviving majority. It is what proves the control plane's own state is +# replicated rather than sitting on one node, so it runs before the pair that +# take a node away — a cluster whose buckets are already wrong should report +# that, not report a recovery failing. +E2E_SUITES_MULTI="multinode lb cert quota kvquorum lbrecovery instancerecovery storagefault" # grep -xE alternation form, for narrowing a requested set to the eligible one. E2E_SUITES_SINGLE_RE="$(printf '%s' "${E2E_SUITES_SINGLE}" | tr ' ' '|')" @@ -75,6 +81,10 @@ E2E_SUITES_MULTI_RE="$(printf '%s' "${E2E_SUITES_MULTI}" | tr ' ' '|')" # after a failed seal do not exist on one node, and they are the assertions # worth a nightly. E2E_SUITES_NIGHTLY_SINGLE="single cert iam" +# +# The multi-node nightly subset carries kvquorum's read-only half through +# `multinode`, which sweeps every KV bucket rather than the three the daemon +# owns. The node-loss half is too slow for a 35-minute cell and has its own. E2E_SUITES_NIGHTLY_MULTI="multinode cert lb" # How long a suite gets, for the same reason the lists above live here: the @@ -112,6 +122,10 @@ e2e_suite_timeout() { # the backends before it forwards anything. Then the node comes back and has # to be shown not to answer for an address it no longer holds. lbrecovery) echo "75m" ;; + # kvquorum audits every bucket in seconds and then spends the rest of its + # budget on a node stopping and starting: seven units down, a raft election, + # and the whole service stack back up with every stream caught up. + kvquorum) echo "40m" ;; *) echo "30m" ;; esac } diff --git a/.github/workflows/e2e-nightly.yml b/.github/workflows/e2e-nightly.yml index bd706cc95..ff8b1606a 100644 --- a/.github/workflows/e2e-nightly.yml +++ b/.github/workflows/e2e-nightly.yml @@ -10,7 +10,7 @@ on: workflow_dispatch: inputs: cells: - description: "Comma-separated cell numbers to run, no spaces (blank = all). 1-3, 8-10, 17-19 tofu permutation matrix, 20 baremetal, 21-22 ISO, 23 predastore throughput, 24 predastore fault injection, 25-27 service suites (eks, ecs, rds), 28 guest disk performance, 29 storage fault injection, 30 routed NAT on three nodes, 31 instance recovery on three nodes, 32 load balancer recovery on three nodes" + description: "Comma-separated cell numbers to run, no spaces (blank = all). 1-3, 8-10, 17-19 tofu permutation matrix, 20 baremetal, 21-22 ISO, 23 predastore throughput, 24 predastore fault injection, 25-27 service suites (eks, ecs, rds), 28 guest disk performance, 29 storage fault injection, 30 routed NAT on three nodes, 31 instance recovery on three nodes, 32 load balancer recovery on three nodes, 33 KV bucket quorum under node loss on three nodes" required: false permissions: diff --git a/Makefile b/Makefile index ca3e0965e..fc948874d 100644 --- a/Makefile +++ b/Makefile @@ -153,8 +153,9 @@ install-microvm: $(MICROVM_ARTIFACTS) ## Install microVM artifacts to /usr/share # -j2 overlaps the only two heavy gates, lint and test-cover: alone each leaves # most of the machine idle, so pairing them cuts a cold run by about a quarter. # -Otarget keeps each gate's output in one block instead of interleaving them. +# manifest-check manifest-lint test-package-check removed, can add back later preflight: - @$(MAKE) --no-print-directory -j2 -Otarget QUIET=1 manifest-check manifest-lint lint govulncheck test-cover diff-coverage test-package-check test-harness test-build-scripts + @$(MAKE) --no-print-directory -j2 -Otarget QUIET=1 lint govulncheck test-cover diff-coverage test-harness test-build-scripts @echo -e "\n ✅ Preflight passed — safe to commit." # Shell suites + shellcheck for build/scripts/, the systemd-unit helpers that @@ -317,6 +318,17 @@ bench: @echo -e "\n....Running benchmarks for $(GO_PROJECT_NAME)...." LOG_IGNORE=1 go test -benchmem -run=. -bench=. ./... +# Fuzz every Fuzz* target for FUZZTIME each; -fuzz takes one target per run. +# Plain `go test` already replays the seeds and testdata/fuzz on preflight. +FUZZTIME ?= 30s +fuzz: + @set -e; grep -rlE '^func Fuzz\w+\(' --include='*_test.go' spinifex cmd internal | while read -r file; do \ + for name in $$(grep -oE '^func Fuzz\w+' "$$file" | cut -d' ' -f2); do \ + echo -e "\n....Fuzzing $$name ($$(dirname $$file)) for $(FUZZTIME)...."; \ + LOG_IGNORE=1 go test -run='^$$' -fuzz="^$$name$$" -fuzztime=$(FUZZTIME) ./$$(dirname $$file); \ + done; \ + done + # Fast iteration: build + install binary + restart all services. # Microvm artifacts are reinstalled when they already exist on disk — the rule's # input timestamps drive a rebuild only if anything actually changed. On a fresh @@ -452,7 +464,7 @@ distro-arm64: distro-clean: rm -rf dist/ -.PHONY: test-package-check 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-rds-mariadb-image import-rds-mariadb-image build-microvm-image install-microvm go_build preflight test test-cover test-race diff-coverage bench test-actions test-images test-build-scripts test-harness test-integration generate-aws-model-coverage aws-model-coverage test-segscan-oracle manifest-check manifest-lint manifest-lint-update \ +.PHONY: test-package-check 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-rds-mariadb-image import-rds-mariadb-image build-microvm-image install-microvm go_build preflight test test-cover test-race diff-coverage bench fuzz test-actions test-images test-build-scripts test-harness test-integration generate-aws-model-coverage aws-model-coverage test-segscan-oracle manifest-check manifest-lint manifest-lint-update \ deploy reinstall clean \ install-system install-go install-aws quickinstall \ lint fix govulncheck nilaway \ diff --git a/cmd/installer/install/install.go b/cmd/installer/install/install.go index 3dde95b50..b9b300491 100644 --- a/cmd/installer/install/install.go +++ b/cmd/installer/install/install.go @@ -870,7 +870,7 @@ func fireInstallCallback() { return } client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Get(url) //nolint:noctx // installer has no context; best-effort fire-and-forget + resp, err := client.Get(url) if err != nil { slog.Warn("fireInstallCallback: request failed", "url", url, "err", err) return diff --git a/cmd/spinifex/cmd/admin.go b/cmd/spinifex/cmd/admin.go index 535980369..84de4dc34 100644 --- a/cmd/spinifex/cmd/admin.go +++ b/cmd/spinifex/cmd/admin.go @@ -30,6 +30,7 @@ import ( "github.com/mulgadc/bluebottle/pkg/masterkey" "github.com/mulgadc/bluebottle/pkg/safecast" "github.com/mulgadc/spinifex/spinifex/admin" + "github.com/mulgadc/spinifex/spinifex/clustersize" "github.com/mulgadc/spinifex/spinifex/config" "github.com/mulgadc/spinifex/spinifex/domains/ec2/ebs/metadata" "github.com/mulgadc/spinifex/spinifex/runtime/formation" @@ -372,7 +373,7 @@ func init() { adminInitCmd.Flags().String("region", "ap-southeast-2", "Mulga region to create") adminInitCmd.Flags().String("az", "ap-southeast-2a", "Mulga AZ to create") adminInitCmd.Flags().String("node", "node1", "Node name, increment for additional nodes (default, node1)") - adminInitCmd.Flags().Int("nodes", 3, "Number of nodes to expect for cluster") + adminInitCmd.Flags().Int("nodes", 3, "Number of nodes to expect for cluster (1, or 3 and up)") adminInitCmd.Flags().String("host", "", "Leader node to join (if not specified, tries multicast discovery)") adminInitCmd.Flags().Int("port", 4432, "Port to bind cluster services on") adminInitCmd.Flags().String("bind", "0.0.0.0", "IP address to bind services to (e.g., 10.11.12.1 for multi-node). Default 0.0.0.0 listens on all interfaces.") @@ -1212,6 +1213,26 @@ func runimagesListCmd(cmd *cobra.Command, args []string) { pterm.Println("spx admin images import --name ") } +// validateNodeCount accepts the cluster sizes Spinifex supports: one server, or +// three and up. +// +// Two is rejected rather than allowed and warned about. On two servers OVN runs +// standalone, the storage metadata quorum has no majority to lose, and the +// control plane's own KV state has no replica count that works — one replica +// disappears with its node, and two stop accepting writes when either goes. A +// cluster that cannot survive the event it was built for is worth refusing at the +// point someone asks for it. +func validateNodeCount(nodes int) error { + if nodes < 1 { + return fmt.Errorf("a cluster needs at least one node, got %d", nodes) + } + if nodes > 1 && nodes < clustersize.MinHANodes { + return fmt.Errorf("%d nodes is not a supported cluster size: use 1 for a single server, or %d and up for a cluster that can survive losing one", + nodes, clustersize.MinHANodes) + } + return nil +} + // TODO: Move all logic to a module, use minimal application logic in viper commands. func runAdminInit(cmd *cobra.Command, args []string) { if os.Getuid() != 0 { @@ -1243,6 +1264,15 @@ func runAdminInit(cmd *cobra.Command, args []string) { clusterName, _ := cmd.Flags().GetString("cluster-name") services, _ := cmd.Flags().GetStringSlice("services") + // Rejected before anything is written, because a two-node cluster has no + // correct answer rather than a worse one: no layer of the stack has a + // majority to lose, and no replica count for its state both survives a node + // going away and still accepts writes. + if err := validateNodeCount(nodes); err != nil { + fmt.Fprintf(os.Stderr, "--nodes: %v\n", err) + os.Exit(1) + } + // Optional operator email — validated up-front so a bad address fails // before we touch any config state. Empty is allowed here; reset / repeat // inits on a box that already has an email in /etc/spinifex/spinifex.toml @@ -2824,7 +2854,7 @@ func initIAMServiceFromConfig() (*handlers_iam.IAMServiceImpl, *config.ClusterCo // Background: this runs at CLI top level, where there is no request to // inherit a deadline from and the process exits after the one command. - svc, err := handlers_iam.NewIAMServiceImpl(context.Background(), nc, masterKey, len(cfg.Nodes)) + svc, err := handlers_iam.NewIAMServiceImpl(context.Background(), nc, masterKey) if err != nil { nc.Close() return nil, nil, nil, nil, fmt.Errorf("init IAM service: %w", err) @@ -3469,11 +3499,11 @@ func resolvePublicPoolFlags(source, poolRange, bindBridge, gateway, defaultBindB if gateway == "" { return "", "", "", "", fmt.Errorf("--external-gateway is required with --external-source=static") } - parts := strings.SplitN(poolRange, "-", 2) - if len(parts) != 2 || net.ParseIP(parts[0]) == nil || net.ParseIP(parts[1]) == nil { + var ok bool + start, end, ok = strings.Cut(poolRange, "-") + if !ok || net.ParseIP(start) == nil || net.ParseIP(end) == nil { return "", "", "", "", fmt.Errorf("--external-pool must be start-end IPs (e.g., 192.168.1.150-192.168.1.250), got: %s", poolRange) } - start, end = parts[0], parts[1] default: return "", "", "", "", fmt.Errorf("--external-source must be 'static' or 'dhcp', got: %s", source) } diff --git a/cmd/spinifex/cmd/admin_account_quota.go b/cmd/spinifex/cmd/admin_account_quota.go index 21b816caf..0d1f8f923 100644 --- a/cmd/spinifex/cmd/admin_account_quota.go +++ b/cmd/spinifex/cmd/admin_account_quota.go @@ -3,7 +3,8 @@ package cmd import ( "context" "fmt" - "sort" + "maps" + "slices" "strconv" "github.com/mulgadc/spinifex/spinifex/gateway" @@ -166,11 +167,7 @@ func printAccountQuota(resp *gateway.AccountQuotaResponse) { fmt.Printf("%-16s %10s %s\n", "DIMENSION", "LIMIT", "SOURCE") fmt.Printf("%-16s %10s %s\n", "---------", "-----", "------") - names := make([]string, 0, len(resp.Limits)) - for name := range resp.Limits { - names = append(names, name) - } - sort.Strings(names) + names := slices.Sorted(maps.Keys(resp.Limits)) for _, name := range names { limit := strconv.Itoa(resp.Limits[name]) if resp.Limits[name] == handlers_quota.Unlimited { diff --git a/cmd/spinifex/cmd/admin_kv.go b/cmd/spinifex/cmd/admin_kv.go index 3dfb5c4a7..bc5baf73c 100644 --- a/cmd/spinifex/cmd/admin_kv.go +++ b/cmd/spinifex/cmd/admin_kv.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "io/fs" + "maps" "os" "path/filepath" "slices" @@ -420,11 +421,7 @@ func hashStoredMsg(m *jetstream.RawStreamMsg) string { field([]byte(m.Subject)) binary.BigEndian.PutUint64(n[:], uint64(m.Time.UnixNano())) h.Write(n[:]) - keys := make([]string, 0, len(m.Header)) - for k := range m.Header { - keys = append(keys, k) - } - sort.Strings(keys) + keys := slices.Sorted(maps.Keys(m.Header)) for _, k := range keys { field([]byte(k)) for _, v := range m.Header[k] { diff --git a/cmd/spinifex/cmd/admin_kv_compare.go b/cmd/spinifex/cmd/admin_kv_compare.go index 876776215..db47f8e3f 100644 --- a/cmd/spinifex/cmd/admin_kv_compare.go +++ b/cmd/spinifex/cmd/admin_kv_compare.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "maps" "os" "slices" "sort" @@ -153,11 +154,7 @@ func compareNodeDigests(nodes []nodeDigest) (kvCompareResult, error) { byName[d.Name] = append(byName[d.Name], v) } } - names := make([]string, 0, len(byName)) - for name := range byName { - names = append(names, name) - } - sort.Strings(names) + names := slices.Sorted(maps.Keys(byName)) var res kvCompareResult for _, name := range names { diff --git a/cmd/spinifex/cmd/admin_kv_replicas.go b/cmd/spinifex/cmd/admin_kv_replicas.go new file mode 100644 index 000000000..ece5ec5dc --- /dev/null +++ b/cmd/spinifex/cmd/admin_kv_replicas.go @@ -0,0 +1,201 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + + "github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil" + "github.com/nats-io/nats.go/jetstream" + "github.com/spf13/cobra" +) + +var kvReplicasCmd = &cobra.Command{ + Use: "replicas", + Short: "Report how many nodes each KV bucket is replicated across", + Long: `Print one line per JetStream KV bucket: how many replicas it has, how many the +cluster's size entitles it to, and which nodes currently hold it. + +A bucket with fewer replicas than the cluster has nodes lives on fewer nodes +than exist, and every node depends on those. Losing one of them makes the +bucket unreadable and unwritable from every node at once, which is a +cluster-wide outage caused by a single-node event. + +A bucket also has to be able to serve, which is a different question from how +many replicas it was configured for. ONLINE counts the replicas that are caught +up and reachable; a bucket whose ONLINE is not a majority of REPLICAS cannot +accept a write no matter what its replica count says. + +--repair raises any under-replicated bucket to the cluster's replica count, +capped at JetStream's maximum of five. It changes nothing else about a bucket +and never lowers a replica count, so it is safe to run on a healthy cluster and +safe to run twice. + +Exit status separates the two problems, so this can gate a deployment without +parsing the output: + + 0 every bucket is at the cluster's replica count and can serve + 1 at least one bucket is under-replicated — --repair is the answer + 2 at least one bucket has no quorum, or the report could not be produced — + a node needs looking at, and --repair will not help`, + Run: runKVReplicas, +} + +func init() { + kvCmd.AddCommand(kvReplicasCmd) + kvReplicasCmd.Flags().Bool("repair", false, "raise every under-replicated bucket to the cluster's node count") + kvReplicasCmd.Flags().Bool("json", false, "emit the report as JSON") +} + +func runKVReplicas(cmd *cobra.Command, _ []string) { + repair, _ := cmd.Flags().GetBool("repair") + asJSON, _ := cmd.Flags().GetBool("json") + + // Anything that stops the report being produced exits unreachable rather + // than under-replicated: an empty answer is not a cluster with no buckets, + // and --repair is not the response to it. + _, nc, err := loadConfigAndConnect() + if err != nil { + fmt.Fprintf(os.Stderr, "connect to cluster: %v\n", err) + os.Exit(exitUnreachable) + } + defer nc.Close() + + js, err := jetstream.New(nc) + if err != nil { + fmt.Fprintf(os.Stderr, "get JetStream context: %v\n", err) + os.Exit(exitUnreachable) + } + + // Background: this runs at CLI top level, where there is no request to + // inherit a deadline from and the process exits after the one command. + ctx := context.Background() + reports, err := kvutil.AuditBucketReplicas(ctx, js) + if err != nil { + fmt.Fprintf(os.Stderr, "audit KV bucket replicas: %v\n", err) + os.Exit(exitUnreachable) + } + + if repair { + repairReplicaReports(ctx, js, reports, os.Stderr) + } + + if err := writeReplicaReport(os.Stdout, reports, asJSON); err != nil { + fmt.Fprintf(os.Stderr, "write report: %v\n", err) + os.Exit(exitUnreachable) + } + + os.Exit(replicaReportExit(reports)) +} + +// Exit statuses, documented in the command's own help because a deployment gate +// reads them rather than the output. +const ( + exitHealthy = 0 + // exitUnderReplicated is the repairable problem: the buckets serve, but on + // fewer nodes than the cluster has. + exitUnderReplicated = 1 + // exitUnreachable is the problem repair cannot touch — a bucket that cannot + // accept a write, or a report that could not be produced at all. + exitUnreachable = 2 +) + +// replicaReportExit is the exit status for a set of reports. +// +// No quorum outranks under-replicated because they need different responses: one +// is fixed by raising a count, the other by finding out why a node is not +// answering. A bucket that is both should send the operator to the node. +func replicaReportExit(reports []kvutil.BucketReport) int { + status := exitHealthy + for _, r := range reports { + if !r.HasQuorum() { + return exitUnreachable + } + if r.UnderReplicated() { + status = exitUnderReplicated + } + } + return status +} + +// repairReplicaReports raises every under-replicated bucket and updates reports +// in place for the ones that were raised. +// +// One bucket that cannot be placed must not stop the others being repaired, so a +// failure is named on errOut and the sweep continues. The bucket it failed on +// stays UNDER-REPLICATED in the report, which is already what makes the exit +// status non-zero, so nothing is lost by not returning an error here. +func repairReplicaReports(ctx context.Context, js jetstream.JetStream, reports []kvutil.BucketReport, errOut io.Writer) { + for i, r := range reports { + if !r.UnderReplicated() { + continue + } + if err := kvutil.RaiseBucketReplicas(ctx, js, r.Bucket, r.Want); err != nil { + fmt.Fprintf(errOut, "repair %s: %v\n", r.Bucket, err) + continue + } + reports[i].Replicas = r.Want + } +} + +// underReplicatedCount is how many buckets are below the cluster's replica count. +func underReplicatedCount(reports []kvutil.BucketReport) int { + under := 0 + for _, r := range reports { + if r.UnderReplicated() { + under++ + } + } + return under +} + +// noQuorumCount is how many buckets cannot currently accept a write. +func noQuorumCount(reports []kvutil.BucketReport) int { + stuck := 0 + for _, r := range reports { + if !r.HasQuorum() { + stuck++ + } + } + return stuck +} + +// replicaStatus is the one word for a bucket's condition. +// +// No quorum wins over under-replicated when both are true, matching the exit +// status: a bucket that cannot serve is the more urgent of the two and is not +// what --repair is for. +func replicaStatus(r kvutil.BucketReport) string { + switch { + case !r.HasQuorum(): + return "NO-QUORUM" + case r.UnderReplicated(): + return "UNDER-REPLICATED" + default: + return "ok" + } +} + +// writeReplicaReport renders reports as a table, or as JSON when asJSON. +func writeReplicaReport(out io.Writer, reports []kvutil.BucketReport, asJSON bool) error { + if asJSON { + return json.NewEncoder(out).Encode(reports) + } + + w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "BUCKET\tREPLICAS\tWANT\tONLINE\tSTATUS\tLEADER\tHELD BY") + for _, r := range reports { + fmt.Fprintf(w, "%s\t%d\t%d\t%d\t%s\t%s\t%s\n", + r.Bucket, r.Replicas, r.Want, r.Online, replicaStatus(r), r.Leader, strings.Join(r.Peers, ",")) + } + if err := w.Flush(); err != nil { + return err + } + _, err := fmt.Fprintf(out, "\n%d buckets, %d under-replicated, %d without quorum\n", + len(reports), underReplicatedCount(reports), noQuorumCount(reports)) + return err +} diff --git a/cmd/spinifex/cmd/admin_kv_replicas_test.go b/cmd/spinifex/cmd/admin_kv_replicas_test.go new file mode 100644 index 000000000..23114e492 --- /dev/null +++ b/cmd/spinifex/cmd/admin_kv_replicas_test.go @@ -0,0 +1,133 @@ +//test:in-package — it tests this command's unexported rendering and repair +// helpers, which exist only to be exercised without a live cluster. + +package cmd + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/mulgadc/spinifex/internal/testkit" + "github.com/mulgadc/spinifex/spinifex/clustersize" + "github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil" + "github.com/nats-io/nats.go/jetstream" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWriteReplicaReport_NamesEveryUnderReplicatedBucket(t *testing.T) { + reports := []kvutil.BucketReport{ + {Bucket: "healthy", Replicas: 3, Want: 3, Online: 3, Leader: "a", Peers: []string{"a", "b", "c"}}, + {Bucket: "short", Replicas: 1, Want: 3, Online: 1, Leader: "a", Peers: []string{"a"}}, + {Bucket: "stuck", Replicas: 3, Want: 3, Online: 1, Peers: []string{"a", "b", "c"}}, + } + + var out bytes.Buffer + require.NoError(t, writeReplicaReport(&out, reports, false)) + got := out.String() + + assert.Contains(t, got, "BUCKET") + assert.Contains(t, got, "HELD BY") + assert.Regexp(t, `healthy\s+3\s+3\s+3\s+ok\s+a\s+a,b,c`, got) + assert.Regexp(t, `short\s+1\s+3\s+1\s+UNDER-REPLICATED\s+a\s+a`, got) + // A bucket that cannot serve reads as NO-QUORUM even though its configured + // count is right, because raising a count is not the response to it. + assert.Regexp(t, `stuck\s+3\s+3\s+1\s+NO-QUORUM`, got) + assert.Contains(t, got, "3 buckets, 1 under-replicated, 1 without quorum") +} + +// TestReplicaReportExit pins the three exit classes a deployment gate reads. They +// are separate because the responses are: a raise fixes an under-replicated +// bucket, and nothing the command can do fixes one that will not answer. +func TestReplicaReportExit(t *testing.T) { + healthy := kvutil.BucketReport{Bucket: "a", Replicas: 3, Want: 3, Online: 3} + short := kvutil.BucketReport{Bucket: "b", Replicas: 1, Want: 3, Online: 1} + stuck := kvutil.BucketReport{Bucket: "c", Replicas: 3, Want: 3, Online: 1} + + assert.Equal(t, exitHealthy, replicaReportExit(nil)) + assert.Equal(t, exitHealthy, replicaReportExit([]kvutil.BucketReport{healthy})) + assert.Equal(t, exitUnderReplicated, replicaReportExit([]kvutil.BucketReport{healthy, short})) + assert.Equal(t, exitUnreachable, replicaReportExit([]kvutil.BucketReport{healthy, stuck})) + // Both problems at once sends the operator to the node rather than to --repair. + assert.Equal(t, exitUnreachable, replicaReportExit([]kvutil.BucketReport{short, stuck})) +} + +// TestWriteReplicaReport_JSONIsWhatTheE2ESuiteDecodes keeps the machine-readable +// form honest: the e2e suite and anything gating a deployment parse these field +// names, so a rename here breaks them silently. +func TestWriteReplicaReport_JSONIsWhatTheE2ESuiteDecodes(t *testing.T) { + var out bytes.Buffer + require.NoError(t, writeReplicaReport(&out, []kvutil.BucketReport{ + {Bucket: "short", Replicas: 1, Want: 3, Cluster: "spinifex", Online: 1, Leader: "a", Peers: []string{"a"}}, + }, true)) + + assert.Contains(t, out.String(), `"bucket":"short"`) + + var decoded []kvutil.BucketReport + require.NoError(t, json.Unmarshal(out.Bytes(), &decoded)) + require.Len(t, decoded, 1) + assert.Equal(t, "short", decoded[0].Bucket) + assert.Equal(t, 1, decoded[0].Replicas) + assert.Equal(t, 3, decoded[0].Want) + assert.Equal(t, 1, decoded[0].Online) + assert.Equal(t, "a", decoded[0].Leader) + assert.True(t, decoded[0].UnderReplicated()) + assert.True(t, decoded[0].HasQuorum()) +} + +func TestUnderReplicatedCount(t *testing.T) { + assert.Equal(t, 0, underReplicatedCount(nil)) + assert.Equal(t, 2, underReplicatedCount([]kvutil.BucketReport{ + {Bucket: "a", Replicas: 1, Want: 3}, + {Bucket: "b", Replicas: 3, Want: 3}, + {Bucket: "c", Replicas: 2, Want: 3}, + })) +} + +// TestRepairReplicaReports_KeepsGoingPastABucketItCannotPlace is the behaviour +// that makes --repair usable mid-growth: the buckets it can raise are raised, +// and the one it cannot is named rather than aborting the run. +func TestRepairReplicaReports_KeepsGoingPastABucketItCannotPlace(t *testing.T) { + _, nc, js := testutil.StartTestJetStream(t) + defer nc.Close() + + for _, b := range []string{"repair-alpha", "repair-beta"} { + _, err := js.CreateKeyValue(t.Context(), jetstream.KeyValueConfig{Bucket: b, History: 1}) + require.NoError(t, err) + } + + // One embedded server cannot hold two replicas, so every raise fails — and + // every bucket must still have been attempted and named. + reports := []kvutil.BucketReport{ + {Bucket: "repair-alpha", Replicas: 1, Want: 2}, + {Bucket: "repair-beta", Replicas: 1, Want: 2}, + } + var errOut bytes.Buffer + repairReplicaReports(t.Context(), js, reports, &errOut) + + assert.Contains(t, errOut.String(), "repair repair-alpha") + assert.Contains(t, errOut.String(), "repair repair-beta") + assert.Equal(t, 2, underReplicatedCount(reports), "a failed raise must not be reported as repaired") +} + +// TestRepairReplicaReports_RaisesWhatItCan covers the success side against a real +// server, so the in-place report update is proved rather than assumed. +func TestRepairReplicaReports_RaisesWhatItCan(t *testing.T) { + _, nc, js := testutil.StartTestJetStream(t) + defer nc.Close() + clustersize.DeclareForTest(t, 1) + + _, err := js.CreateKeyValue(t.Context(), jetstream.KeyValueConfig{Bucket: "repair-ok", History: 1}) + require.NoError(t, err) + + // Want 1 against a bucket already at 1: the raise is a no-op that must still + // leave the report reading as healthy rather than as repaired-and-wrong. + reports := []kvutil.BucketReport{{Bucket: "repair-ok", Replicas: 1, Want: 1}} + var errOut bytes.Buffer + repairReplicaReports(t.Context(), js, reports, &errOut) + + assert.Empty(t, strings.TrimSpace(errOut.String())) + assert.Equal(t, 0, underReplicatedCount(reports)) +} diff --git a/cmd/spinifex/cmd/admin_kv_test.go b/cmd/spinifex/cmd/admin_kv_test.go index e7996cdfa..5197ea4c6 100644 --- a/cmd/spinifex/cmd/admin_kv_test.go +++ b/cmd/spinifex/cmd/admin_kv_test.go @@ -279,7 +279,7 @@ func TestKVDigestIgnoresExpiry(t *testing.T) { require.NoError(t, err) js, err := jetstream.New(nc) require.NoError(t, err) - kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "ttl", TTL: time.Second}) + kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "ttl", TTL: 200 * time.Millisecond}) require.NoError(t, err) _, err = kv.PutString(ctx, "k", "v") require.NoError(t, err) @@ -291,7 +291,7 @@ func TestKVDigestIgnoresExpiry(t *testing.T) { ns.Shutdown() ns.WaitForShutdown() - time.Sleep(1500 * time.Millisecond) + time.Sleep(400 * time.Millisecond) d, err := digestStore(ctx, work, nil, false, metas) require.NoError(t, err) require.Len(t, d, 1) diff --git a/cmd/spinifex/cmd/admin_node_count_test.go b/cmd/spinifex/cmd/admin_node_count_test.go new file mode 100644 index 000000000..2b1ca3f71 --- /dev/null +++ b/cmd/spinifex/cmd/admin_node_count_test.go @@ -0,0 +1,39 @@ +//test:in-package — validateNodeCount is an unexported guard on this command's +// flags, and exporting it would widen the package's API for a test's convenience. + +package cmd + +import ( + "strings" + "testing" +) + +// TestValidateNodeCount pins the supported cluster sizes. Two is rejected rather +// than warned about: on two servers no layer of the stack has a majority to lose, +// and the control plane's own state has no replica count that both survives a node +// going away and still accepts writes. +func TestValidateNodeCount(t *testing.T) { + for _, nodes := range []int{1, 3, 4, 5, 9} { + if err := validateNodeCount(nodes); err != nil { + t.Errorf("validateNodeCount(%d) = %v, want nil", nodes, err) + } + } + + for _, nodes := range []int{0, -1} { + if err := validateNodeCount(nodes); err == nil { + t.Errorf("validateNodeCount(%d) returned no error", nodes) + } + } + + err := validateNodeCount(2) + if err == nil { + t.Fatal("validateNodeCount(2) returned no error") + } + // The message has to say what to do instead, because the operator typed a + // number and needs to know which one to type next. + for _, want := range []string{"2", "1", "3"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not mention %s", err, want) + } + } +} diff --git a/cmd/spinifex/cmd/admin_ochre.go b/cmd/spinifex/cmd/admin_ochre.go index b9ad9e57b..1b2df561e 100644 --- a/cmd/spinifex/cmd/admin_ochre.go +++ b/cmd/spinifex/cmd/admin_ochre.go @@ -438,14 +438,10 @@ func parseWeightsS3URI(uri string) (bucket, prefix string, err error) { if trimmed == uri { return "", "", fmt.Errorf("invalid --s3-uri %q: expected s3://bucket/prefix", uri) } - parts := strings.SplitN(trimmed, "/", 2) - bucket = parts[0] + bucket, prefix, _ = strings.Cut(trimmed, "/") if bucket == "" { return "", "", fmt.Errorf("invalid --s3-uri %q: missing bucket", uri) } - if len(parts) == 2 { - prefix = parts[1] - } if prefix != "" && !strings.HasSuffix(prefix, "/") { prefix += "/" } @@ -854,7 +850,7 @@ func runOchreWeightsStage(cmd *cobra.Command, _ []string) { ochreExit(1) return } - weightsStore := gateway_bedrock.NewWeightsStore(js, len(appConfig.Nodes)) + weightsStore := gateway_bedrock.NewWeightsStore(js) store := objectstore.NewS3ObjectStoreFromConfig(node.Predastore.Host, node.Predastore.Region, node.Predastore.AccessKey, node.Predastore.SecretKey) provider := ebsprovider.NewNATSProvider(nc, imageImportTimeout) @@ -930,7 +926,7 @@ func runOchreWeightsList(_ *cobra.Command, _ []string) { ochreExit(1) return } - weightsStore := gateway_bedrock.NewWeightsStore(js, len(appConfig.Nodes)) + weightsStore := gateway_bedrock.NewWeightsStore(js) store := objectstore.NewS3ObjectStoreFromConfig(node.Predastore.Host, node.Predastore.Region, node.Predastore.AccessKey, node.Predastore.SecretKey) checkSnapshotLive := newWeightsSnapshotChecker(store, node.Predastore.Bucket) @@ -967,7 +963,7 @@ func removeWeights(ctx context.Context, weightsStore *gateway_bedrock.WeightsSto func runOchreWeightsRemove(cmd *cobra.Command, _ []string) { modelID, _ := cmd.Flags().GetString("model-id") - appConfig, nc, err := loadConfigAndConnectFn() + _, nc, err := loadConfigAndConnectFn() if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) ochreExit(1) @@ -981,7 +977,7 @@ func runOchreWeightsRemove(cmd *cobra.Command, _ []string) { ochreExit(1) return } - weightsStore := gateway_bedrock.NewWeightsStore(js, len(appConfig.Nodes)) + weightsStore := gateway_bedrock.NewWeightsStore(js) entry, err := removeWeights(context.Background(), weightsStore, modelID) if err != nil { @@ -1014,7 +1010,7 @@ func resolveHFToken(ctx context.Context, cmd *cobra.Command, cfg *config.Cluster if err != nil { return "" } - credStore := gateway_bedrock.NewCredentialStore(js, masterKey, len(cfg.Nodes), nil) + credStore := gateway_bedrock.NewCredentialStore(js, masterKey, nil) token, ok, err := credStore.Resolve(ctx, utils.GlobalAccountID, vendorHuggingFace) if err != nil || !ok { return "" @@ -1088,7 +1084,7 @@ func ochreCredentialsStore() (*gateway_bedrock.CredentialStore, func(), error) { nc.Close() return nil, nil, fmt.Errorf("jetstream context: %w", err) } - return gateway_bedrock.NewCredentialStore(js, masterKey, len(cfg.Nodes), nil), func() { nc.Close() }, nil + return gateway_bedrock.NewCredentialStore(js, masterKey, nil), func() { nc.Close() }, nil } func runOchreCredentialsSet(cmd *cobra.Command, _ []string) { @@ -1186,7 +1182,7 @@ func init() { // ochreAccessStore connects to the cluster and returns the grant store along // with a cleanup that closes the connection. func ochreAccessStore() (*gateway_bedrock.ModelAccessStore, func(), error) { - cfg, nc, err := loadConfigAndConnectFn() + _, nc, err := loadConfigAndConnectFn() if err != nil { return nil, nil, fmt.Errorf("connect to cluster: %w", err) } @@ -1195,7 +1191,7 @@ func ochreAccessStore() (*gateway_bedrock.ModelAccessStore, func(), error) { nc.Close() return nil, nil, fmt.Errorf("jetstream context: %w", err) } - return gateway_bedrock.NewModelAccessStore(js, len(cfg.Nodes)), func() { nc.Close() }, nil + return gateway_bedrock.NewModelAccessStore(js), func() { nc.Close() }, nil } // ochreTargetModels resolves the --model-id / --all-models pair into the model diff --git a/cmd/spinifex/cmd/admin_ochre_pull_test.go b/cmd/spinifex/cmd/admin_ochre_pull_test.go index 014aebeba..0c0fd8588 100644 --- a/cmd/spinifex/cmd/admin_ochre_pull_test.go +++ b/cmd/spinifex/cmd/admin_ochre_pull_test.go @@ -470,7 +470,7 @@ func TestResolveHFToken_FallsBackToStoredPlatformCredential(t *testing.T) { require.NoError(t, os.MkdirAll(filepath.Join(baseDir, "config"), 0750)) require.NoError(t, handlers_iam.SaveMasterKey(filepath.Join(baseDir, "config", "master.key"), masterKey)) - credStore := gateway_bedrock.NewCredentialStore(js, masterKey, 1, nil) + credStore := gateway_bedrock.NewCredentialStore(js, masterKey, nil) require.NoError(t, credStore.PutCredential(context.Background(), utils.GlobalAccountID, vendorHuggingFace, "hf_stored_token")) cmd := newOchreWeightsPullTestCmd(t, selfHostModelID, testHFRepo, "main", "", "") @@ -621,7 +621,7 @@ func TestRunOchreCredentialsSet_RoundTripsAndDefaultsToPlatformAccount(t *testin defer verifyConn.Close() verifyJS := testutil.NewJetStream(t, verifyConn) - credStore := gateway_bedrock.NewCredentialStore(verifyJS, masterKey, 1, nil) + credStore := gateway_bedrock.NewCredentialStore(verifyJS, masterKey, nil) token, ok, err := credStore.Resolve(context.Background(), utils.GlobalAccountID, vendorHuggingFace) require.NoError(t, err) require.True(t, ok) diff --git a/cmd/spinifex/cmd/admin_ochre_test.go b/cmd/spinifex/cmd/admin_ochre_test.go index 9656bdefb..14533b16a 100644 --- a/cmd/spinifex/cmd/admin_ochre_test.go +++ b/cmd/spinifex/cmd/admin_ochre_test.go @@ -42,7 +42,7 @@ const ( func newWeightsStoreForTest(t *testing.T) *gateway_bedrock.WeightsStore { t.Helper() _, nc, _ := testutil.StartTestJetStream(t) - return gateway_bedrock.NewWeightsStore(testutil.NewJetStream(t, nc), 1) + return gateway_bedrock.NewWeightsStore(testutil.NewJetStream(t, nc)) } // explodingObjectStore fails the test immediately if any of its methods is @@ -775,7 +775,7 @@ func TestRunOchreWeightsList_PrintsStagedModels(t *testing.T) { } stubConnect(t, cfg, nc, nil) - seedStore := gateway_bedrock.NewWeightsStore(js, 1) + seedStore := gateway_bedrock.NewWeightsStore(js) require.NoError(t, seedStore.PutWeights(context.Background(), selfHostModelID, "s3://models/llama-3.2-1b/", "snap-0001")) out := captureStdout(t, func() { runOchreWeightsList(nil, nil) }) @@ -822,7 +822,7 @@ func TestRunOchreWeightsRemove_RoundTripPrintsSnapshotAndDropsEntry(t *testing.T ns, nc, js := testutil.StartTestJetStream(t) stubConnect(t, fakeClusterConfig(), nc, nil) - seedStore := gateway_bedrock.NewWeightsStore(js, 1) + seedStore := gateway_bedrock.NewWeightsStore(js) require.NoError(t, seedStore.PutWeights(context.Background(), selfHostModelID, "s3://models/llama-3.2-1b/", "snap-0001")) cmd := newOchreWeightsRemoveTestCmd(t, selfHostModelID) @@ -835,7 +835,7 @@ func TestRunOchreWeightsRemove_RoundTripPrintsSnapshotAndDropsEntry(t *testing.T verifyNC, err := nats.Connect(ns.ClientURL()) require.NoError(t, err) t.Cleanup(func() { verifyNC.Close() }) - verifyStore := gateway_bedrock.NewWeightsStore(testutil.NewJetStream(t, verifyNC), 1) + verifyStore := gateway_bedrock.NewWeightsStore(testutil.NewJetStream(t, verifyNC)) _, ok, err := verifyStore.GetWeights(context.Background(), selfHostModelID) require.NoError(t, err) diff --git a/cmd/spinifex/cmd/admin_ochre_vector.go b/cmd/spinifex/cmd/admin_ochre_vector.go index c3ff43bf0..321eed7cd 100644 --- a/cmd/spinifex/cmd/admin_ochre_vector.go +++ b/cmd/spinifex/cmd/admin_ochre_vector.go @@ -283,11 +283,9 @@ func truncateForTable(s string, maxRunes int) string { } // formatQueryResults renders 'ochre vector query' output: a truncated table -// by default, or the full response as indented JSON when asJSON is set -// (D10's "full text via source ref" -- here, via --json). +// by default, or the full response as indented JSON when asJSON is set. func formatQueryResults(results []handlers_ochrevector.QueryResult, asJSON bool) (string, error) { if asJSON { - //nolint:musttag // QueryResult is an internal, non-wire Go type (predates this CLI); it round-trips fine on its default field names. data, err := json.MarshalIndent(results, "", " ") if err != nil { return "", fmt.Errorf("encode results as JSON: %w", err) @@ -317,7 +315,6 @@ func parseFilterFlag(raw string) (*handlers_ochrevector.Filter, error) { return nil, nil } var f handlers_ochrevector.Filter - //nolint:musttag // Filter is the existing filter-AST type (D9); it round-trips fine on its default field names. if err := json.Unmarshal([]byte(raw), &f); err != nil { return nil, fmt.Errorf("decode --filter: %w", err) } diff --git a/cmd/spinifex/cmd/admin_principal.go b/cmd/spinifex/cmd/admin_principal.go index 1cad21c82..0da530ee6 100644 --- a/cmd/spinifex/cmd/admin_principal.go +++ b/cmd/spinifex/cmd/admin_principal.go @@ -188,13 +188,14 @@ func describePrincipal(svc handlers_iam.IAMService, accountID, userName string) } // principalPolicyActions returns the actions an inline policy allows, shortened -// to the method name. An undecodable document reports nothing rather than -// guessing, and the listing shows the user with no grants. +// to the method name. A NotAction grant is one entry naming what it excludes. +// An undecodable document reports nothing rather than guessing. func principalPolicyActions(document string) []string { var doc struct { Statement []struct { - Effect string `json:"Effect"` - Action handlers_iam.StringOrArr `json:"Action"` + Effect string `json:"Effect"` + Action handlers_iam.StringOrArr `json:"Action"` + NotAction handlers_iam.StringOrArr `json:"NotAction"` } `json:"Statement"` } if err := json.Unmarshal([]byte(document), &doc); err != nil { @@ -209,6 +210,13 @@ func principalPolicyActions(document string) []string { for _, action := range stmt.Action { actions = append(actions, strings.TrimPrefix(action, adminPrincipalService+":")) } + if len(stmt.NotAction) > 0 { + excluded := make([]string, 0, len(stmt.NotAction)) + for _, action := range stmt.NotAction { + excluded = append(excluded, strings.TrimPrefix(action, adminPrincipalService+":")) + } + actions = append(actions, "NotAction("+strings.Join(excluded, ", ")+")") + } } sort.Strings(actions) return actions diff --git a/cmd/spinifex/cmd/admin_principal_test.go b/cmd/spinifex/cmd/admin_principal_test.go index 83261d5a1..46ed88321 100644 --- a/cmd/spinifex/cmd/admin_principal_test.go +++ b/cmd/spinifex/cmd/admin_principal_test.go @@ -102,6 +102,18 @@ func TestPrincipalPolicyActionsRoundTrip(t *testing.T) { cmd.PrincipalPolicyActions(cmd.PrincipalPolicyDocument(grants))) } +// A NotAction grant covers everything but its list, so rendering only Action +// would show the principal as holding nothing. +func TestPrincipalPolicyActionsRenderNotAction(t *testing.T) { + document := `{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","NotAction":["spinifex:DeleteAccount","spinifex:CreateAccount"],"Resource":"*"}, + {"Effect":"Allow","Action":"spinifex:ListAccounts","Resource":"*"}, + {"Effect":"Deny","NotAction":"spinifex:ListAccounts","Resource":"*"}]}` + + assert.Equal(t, []string{"ListAccounts", "NotAction(DeleteAccount, CreateAccount)"}, + cmd.PrincipalPolicyActions(document)) +} + // A document nobody can parse is reported as no grants rather than as a guess. func TestPrincipalPolicyActionsIgnoreAnUndecodableDocument(t *testing.T) { assert.Empty(t, cmd.PrincipalPolicyActions("{not json")) diff --git a/cmd/spinifex/cmd/admin_upgrade.go b/cmd/spinifex/cmd/admin_upgrade.go index 559e322c7..3182550b9 100644 --- a/cmd/spinifex/cmd/admin_upgrade.go +++ b/cmd/spinifex/cmd/admin_upgrade.go @@ -7,7 +7,7 @@ import ( "os" "strings" - "github.com/mulgadc/spinifex/spinifex/migrate" + "github.com/mulgadc/spinifex/spinifex/foundation/state/migrate" "github.com/mulgadc/spinifex/spinifex/operator/host/systemd" "github.com/spf13/cobra" diff --git a/cmd/spinifex/cmd/admin_upgrade_test.go b/cmd/spinifex/cmd/admin_upgrade_test.go index 1e6689121..0a68befff 100644 --- a/cmd/spinifex/cmd/admin_upgrade_test.go +++ b/cmd/spinifex/cmd/admin_upgrade_test.go @@ -7,7 +7,7 @@ import ( "path/filepath" "testing" - "github.com/mulgadc/spinifex/spinifex/migrate" + "github.com/mulgadc/spinifex/spinifex/foundation/state/migrate" "github.com/mulgadc/spinifex/spinifex/operator/host/systemd" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" diff --git a/cmd/spinifex/cmd/cluster.go b/cmd/spinifex/cmd/cluster.go index f841690e4..5ade211c5 100644 --- a/cmd/spinifex/cmd/cluster.go +++ b/cmd/spinifex/cmd/cluster.go @@ -167,7 +167,7 @@ func runClusterShutdown(cmd *cobra.Command, args []string) { fmt.Printf("Timeout per phase: %s\n\n", timeout) // Write cluster shutdown marker to KV - jsm, err := daemon.NewJetStreamManager(nc, 1) + jsm, err := daemon.NewJetStreamManager(nc) if err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to create JetStream manager: %v\n", err) } else { diff --git a/cmd/spinifex/cmd/cluster_test.go b/cmd/spinifex/cmd/cluster_test.go index fc04aaa7d..60c4d583f 100644 --- a/cmd/spinifex/cmd/cluster_test.go +++ b/cmd/spinifex/cmd/cluster_test.go @@ -38,7 +38,7 @@ func TestDrainDHCPLeasesSumsReleased(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) fakeDrainResponder(t, nc, 7, "") - released, responders := drainDHCPLeases(nc, 2*time.Second) + released, responders := drainDHCPLeases(nc, 250*time.Millisecond) assert.Equal(t, 7, released) assert.Equal(t, 1, responders) } @@ -49,7 +49,7 @@ func TestDrainDHCPLeasesReportsResponderError(t *testing.T) { // An erroring responder still counts as a responder but contributes // zero released leases. - released, responders := drainDHCPLeases(nc, 2*time.Second) + released, responders := drainDHCPLeases(nc, 250*time.Millisecond) assert.Equal(t, 0, released) assert.Equal(t, 1, responders) } diff --git a/cmd/spinifex/cmd/nats_server_name_test.go b/cmd/spinifex/cmd/nats_server_name_test.go new file mode 100644 index 000000000..435888659 --- /dev/null +++ b/cmd/spinifex/cmd/nats_server_name_test.go @@ -0,0 +1,44 @@ +//test:in-package — the embedded nats.conf template it checks is unexported, +// and exporting it would widen the package's API for a test's convenience. + +package cmd + +import ( + "strings" + "testing" + + natssvc "github.com/mulgadc/spinifex/spinifex/services/nats" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestNATSConfServerNameMatchesTheHelper ties the rendered server_name to +// nats.ServerName. JetStream reports stream peers by server name, so anything +// mapping a replica back to the node holding it reads the prefix from that +// helper — and a template that drifted from it would break the mapping silently, +// reporting a healthy cluster as one whose buckets name no node at all. +func TestNATSConfServerNameMatchesTheHelper(t *testing.T) { + var line string + for l := range strings.SplitSeq(natsConfTemplate, "\n") { + if strings.HasPrefix(strings.TrimSpace(l), "server_name:") { + line = strings.TrimSpace(l) + break + } + } + require.NotEmpty(t, line, "nats.conf no longer sets server_name") + + got := strings.TrimSpace(strings.TrimPrefix(line, "server_name:")) + assert.Equal(t, natssvc.ServerName("{{.Node}}"), got) + + node, ok := natssvc.NodeFromServerName(natssvc.ServerName("node2")) + assert.True(t, ok) + assert.Equal(t, "node2", node) + + _, ok = natssvc.NodeFromServerName("node2") + assert.False(t, ok, "a bare node name is not a server name") + + // The bare prefix is a name we could not have generated, and mapping it to the + // empty node would put a phantom peer into anything counting nodes. + _, ok = natssvc.NodeFromServerName(natssvc.ServerNamePrefix) + assert.False(t, ok, "the prefix with no node after it is not a server name") +} diff --git a/cmd/spinifex/cmd/service.go b/cmd/spinifex/cmd/service.go index 2849ee1fa..e0a353377 100644 --- a/cmd/spinifex/cmd/service.go +++ b/cmd/spinifex/cmd/service.go @@ -225,42 +225,6 @@ var predastoreStartCmd = &cobra.Command{ }, } -var predastoreStopCmd = &cobra.Command{ - Use: "stop", - Short: "Stop the predastore service", - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println("Stopping predastore service...") - - // Only the pid directory matters here: Stop finds the running process - // through it, and start wrote the file there. - service, err := service.New("predastore", &predastore.Config{ - BasePath: viper.GetString("predastore-base-path"), - }) - - if err != nil { - return fmt.Errorf("create predastore service: %w", err) - } - - if err = service.Stop(); err != nil { - return fmt.Errorf("stop predastore service: %w", err) - } - - fmt.Println("Predastore service stopped") - return nil - }, -} - -var predastoreStatusCmd = &cobra.Command{ - Use: "status", - Short: "Get status of the predastore service", - Run: func(cmd *cobra.Command, args []string) { - // Add your status logic here - fmt.Println("Predastore service status: ...") - }, -} - // Repeat for viperblock. var viperblockStartCmd = &cobra.Command{ Use: "start", @@ -384,7 +348,6 @@ var viperblockStartCmd = &cobra.Command{ SecretKey: nodeConfig.Predastore.SecretKey, BaseDir: nodeConfig.Predastore.BaseDir, NodeName: clusterConfig.Node, - KVReplicas: len(clusterConfig.Nodes), ShardWAL: shardWAL, GCEnabled: gcEnabled, WALBaseDir: nodeConfig.Viperblock.WALBaseDir, @@ -414,37 +377,6 @@ var viperblockStartCmd = &cobra.Command{ }, } -var viperblockStopCmd = &cobra.Command{ - Use: "stop", - Short: "Stop the viperblock service", - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println("Stopping viperblock service...") - - service, err := service.New("viperblock", &viperblockd.Config{}) - - if err != nil { - return fmt.Errorf("create viperblock service: %w", err) - } - - if err = service.Stop(); err != nil { - return fmt.Errorf("stop viperblock service: %w", err) - } - - fmt.Println("Viperblock service stopped") - return nil - }, -} - -var viperblockStatusCmd = &cobra.Command{ - Use: "status", - Short: "Get status of the viperblock service", - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("Viperblock service status: ...") - }, -} - var qemunbdCmd = &cobra.Command{ Use: "qemunbd", Short: "Manage the qemunbd service", @@ -519,37 +451,6 @@ var qemunbdStartCmd = &cobra.Command{ }, } -var qemunbdStopCmd = &cobra.Command{ - Use: "stop", - Short: "Stop the qemunbd service", - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println("Stopping qemunbd service...") - - service, err := service.New("qemunbd", &qemunbdd.Config{}) - - if err != nil { - return fmt.Errorf("create qemunbd service: %w", err) - } - - if err = service.Stop(); err != nil { - return fmt.Errorf("stop qemunbd service: %w", err) - } - - fmt.Println("qemunbd service stopped") - return nil - }, -} - -var qemunbdStatusCmd = &cobra.Command{ - Use: "status", - Short: "Get status of the qemunbd service", - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("qemunbd service status: ...") - }, -} - // Repeat for nats. var natsStartCmd = &cobra.Command{ Use: "start", @@ -590,37 +491,6 @@ var natsStartCmd = &cobra.Command{ }, } -var natsStopCmd = &cobra.Command{ - Use: "stop", - Short: "Stop the nats service", - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println("Stopping nats service...") - - service, err := service.New("nats", &nats.Config{}) - - if err != nil { - return fmt.Errorf("create nats service: %w", err) - } - - if err = service.Stop(); err != nil { - return fmt.Errorf("stop nats service: %w", err) - } - - fmt.Println("Nats service stopped") - return nil - }, -} - -var natsStatusCmd = &cobra.Command{ - Use: "status", - Short: "Get status of the nats service", - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("Nats service status: ...") - }, -} - // Repeat for spinifex. var spinifexStartCmd = &cobra.Command{ Use: "start", @@ -683,37 +553,6 @@ var spinifexStartCmd = &cobra.Command{ }, } -var spinifexStopCmd = &cobra.Command{ - Use: "stop", - Short: "Stop the spinifex service", - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println("Stopping spinifex service...") - - service, err := service.New("spinifex", &config.ClusterConfig{}) - - if err != nil { - return fmt.Errorf("create spinifex service: %w", err) - } - - if err = service.Stop(); err != nil { - return fmt.Errorf("stop spinifex service: %w", err) - } - - fmt.Println("Spinifex service stopped") - return nil - }, -} - -var spinifexStatusCmd = &cobra.Command{ - Use: "status", - Short: "Get status of the spinifex service", - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("Spinifex service status: ...") - }, -} - // AWS GW var awsgwStartCmd = &cobra.Command{ @@ -786,37 +625,6 @@ var awsgwStartCmd = &cobra.Command{ }, } -var awsgwStopCmd = &cobra.Command{ - Use: "stop", - Short: "Stop the awsgw service", - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println("Stopping awsgw service...") - - service, err := service.New("awsgw", &config.ClusterConfig{}) - - if err != nil { - return fmt.Errorf("create awsgw service: %w", err) - } - - if err = service.Stop(); err != nil { - return fmt.Errorf("stop awsgw service: %w", err) - } - - fmt.Println("AWSGW service stopped") - return nil - }, -} - -var awsgwStatusCmd = &cobra.Command{ - Use: "status", - Short: "Get status of the awsgw service", - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("AWSGW service status: ...") - }, -} - // consoleRegion is the region the console signs with. awsgw pins signature // verification to this same node field, so reading it from anywhere else — // including the cluster-wide [aws] region — would let the two drift and fail @@ -877,50 +685,6 @@ var spinifexUIStartCmd = &cobra.Command{ }, } -var spinifexUIStopCmd = &cobra.Command{ - Use: "stop", - Short: "Stop the spinifex-ui service", - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println("Stopping spinifex-ui service...") - - svc, err := service.New("spinifex-ui", &spinifexui.Config{}) - - if err != nil { - return fmt.Errorf("create spinifex-ui service: %w", err) - } - - if err = svc.Stop(); err != nil { - return fmt.Errorf("stop spinifex-ui service: %w", err) - } - fmt.Println("spinifex-ui service stopped") - return nil - }, -} - -var spinifexUIStatusCmd = &cobra.Command{ - Use: "status", - Short: "Get status of the spinifex-ui service", - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - svc, err := service.New("spinifex-ui", &spinifexui.Config{}) - - if err != nil { - return fmt.Errorf("create spinifex-ui service: %w", err) - } - - status, err := svc.Status() - if err != nil { - return fmt.Errorf("get spinifex-ui service status: %w", err) - } - - fmt.Println("spinifex-ui service status:", status) - return nil - }, -} - // checkLegacyWanBridgeKey fails vpcd startup if the deprecated `wan_bridge` // TOML key or `SPINIFEX_VPCD_WAN_BRIDGE` env-var is present. There is no silent // alias or auto-rewrite; the operator must remove the key before vpcd will start. @@ -1048,35 +812,6 @@ var vpcdStartCmd = &cobra.Command{ }, } -var vpcdStopCmd = &cobra.Command{ - Use: "stop", - Short: "Stop the vpcd service", - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println("Stopping vpcd service...") - - svc, err := service.New("vpcd", &vpcd.Config{}) - if err != nil { - return fmt.Errorf("create vpcd service: %w", err) - } - - if err = svc.Stop(); err != nil { - return fmt.Errorf("stop vpcd service: %w", err) - } - fmt.Println("vpcd service stopped") - return nil - }, -} - -var vpcdStatusCmd = &cobra.Command{ - Use: "status", - Short: "Get status of the vpcd service", - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("vpcd service status: ...") - }, -} - var northstarCmd = &cobra.Command{ Use: "northstar", Short: "Manage the northstar (DNS) service", @@ -1199,35 +934,6 @@ func resolveNorthstarConfigPath(nodePath, override string) string { return nodePath } -var northstarStopCmd = &cobra.Command{ - Use: "stop", - Short: "Stop the northstar service", - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println("Stopping northstar service...") - - svc, err := service.New("northstar", &northstar.Config{}) - if err != nil { - return fmt.Errorf("create northstar service: %w", err) - } - - if err = svc.Stop(); err != nil { - return fmt.Errorf("stop northstar service: %w", err) - } - fmt.Println("northstar service stopped") - return nil - }, -} - -var northstarStatusCmd = &cobra.Command{ - Use: "status", - Short: "Get status of the northstar service", - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("northstar service status: ...") - }, -} - var qmpCollectorCmd = &cobra.Command{ Use: "qmp-collector", Short: "Manage the qmp-collector (guest metrics) service", @@ -1271,34 +977,6 @@ var qmpCollectorStartCmd = &cobra.Command{ }, } -var qmpCollectorStopCmd = &cobra.Command{ - Use: "stop", - Short: "Stop the qmp-collector service", - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println("Stopping qmp-collector service...") - - svc, err := service.New("qmp-collector", &qmpcollector.Config{}) - if err != nil { - return fmt.Errorf("create qmp-collector service: %w", err) - } - if err = svc.Stop(); err != nil { - return fmt.Errorf("stop qmp-collector service: %w", err) - } - fmt.Println("qmp-collector service stopped") - return nil - }, -} - -var qmpCollectorStatusCmd = &cobra.Command{ - Use: "status", - Short: "Get status of the qmp-collector service", - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("qmp-collector service status: ...") - }, -} - // registerPredastoreNamespacedFlags defines predastore's host, base-path and // config-path flags and binds them to viper. It must only run once (from // init()); pflag panics on a redefined flag. @@ -1428,8 +1106,6 @@ func init() { bindPredastoreCollisionEnv() predastoreCmd.AddCommand(predastoreStartCmd) - predastoreCmd.AddCommand(predastoreStopCmd) - predastoreCmd.AddCommand(predastoreStatusCmd) serviceCmd.AddCommand(viperblockCmd) @@ -1455,8 +1131,6 @@ func init() { viper.BindPFlag("viperblock-debug", viperblockCmd.PersistentFlags().Lookup("debug")) viperblockCmd.AddCommand(viperblockStartCmd) - viperblockCmd.AddCommand(viperblockStopCmd) - viperblockCmd.AddCommand(viperblockStatusCmd) serviceCmd.AddCommand(qemunbdCmd) @@ -1465,15 +1139,11 @@ func init() { viper.BindPFlag("qemunbd-debug", qemunbdCmd.PersistentFlags().Lookup("debug")) qemunbdCmd.AddCommand(qemunbdStartCmd) - qemunbdCmd.AddCommand(qemunbdStopCmd) - qemunbdCmd.AddCommand(qemunbdStatusCmd) // Nats serviceCmd.AddCommand(natsCmd) natsCmd.AddCommand(natsStartCmd) - natsCmd.AddCommand(natsStopCmd) - natsCmd.AddCommand(natsStatusCmd) // Add NATS flags natsCmd.PersistentFlags().Int("port", 4222, "NATS server port") @@ -1495,8 +1165,6 @@ func init() { serviceCmd.AddCommand(spinifexCmd) spinifexCmd.AddCommand(spinifexStartCmd) - spinifexCmd.AddCommand(spinifexStopCmd) - spinifexCmd.AddCommand(spinifexStatusCmd) spinifexCmd.PersistentFlags().String("wal-dir", "", "Write-ahead log (WAL) directory. Place on high-speed NVMe disk, or tmpfs for development.") viper.BindEnv("wal-dir", "SPINIFEX_WAL_DIR") @@ -1519,8 +1187,6 @@ func init() { bindAwsgwCollisionEnv() awsgwCmd.AddCommand(awsgwStartCmd) - awsgwCmd.AddCommand(awsgwStopCmd) - awsgwCmd.AddCommand(awsgwStatusCmd) // spinifex-ui serviceCmd.AddCommand(spinifexUICmd) @@ -1550,15 +1216,11 @@ func init() { viper.BindPFlag("spinifex-ui-ochre-enabled", spinifexUICmd.PersistentFlags().Lookup("ochre-enabled")) spinifexUICmd.AddCommand(spinifexUIStartCmd) - spinifexUICmd.AddCommand(spinifexUIStopCmd) - spinifexUICmd.AddCommand(spinifexUIStatusCmd) // vpcd serviceCmd.AddCommand(vpcdCmd) vpcdCmd.AddCommand(vpcdStartCmd) - vpcdCmd.AddCommand(vpcdStopCmd) - vpcdCmd.AddCommand(vpcdStatusCmd) // northstar serviceCmd.AddCommand(northstarCmd) @@ -1568,13 +1230,9 @@ func init() { viper.BindPFlag("northstar-config", northstarCmd.PersistentFlags().Lookup("northstar-config")) northstarCmd.AddCommand(northstarStartCmd) - northstarCmd.AddCommand(northstarStopCmd) - northstarCmd.AddCommand(northstarStatusCmd) // qmp-collector serviceCmd.AddCommand(qmpCollectorCmd) qmpCollectorCmd.AddCommand(qmpCollectorStartCmd) - qmpCollectorCmd.AddCommand(qmpCollectorStopCmd) - qmpCollectorCmd.AddCommand(qmpCollectorStatusCmd) } diff --git a/cmd/spinifex/cmd/service_exit_test.go b/cmd/spinifex/cmd/service_exit_test.go index 0303e4ae9..6d75de778 100644 --- a/cmd/spinifex/cmd/service_exit_test.go +++ b/cmd/spinifex/cmd/service_exit_test.go @@ -62,16 +62,8 @@ func writeMalformedToml(t *testing.T) string { return writeSpinifexToml(t, "[section\nkey = value without quotes\n") } -// isolateRuntimeDir redirects the pid-file fallback (utils.pidPath, via -// XDG_RUNTIME_DIR) to an empty temp dir, so a stop command with no explicit -// directory override can't resolve against a real, shared, ambient pid dir. -func isolateRuntimeDir(t *testing.T) { - t.Helper() - t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) -} - // TestServiceStartCmdsReturnErrorOnMissingConfig exercises each service -// start/stop command's RunE directly and asserts a fatal precondition +// start command's RunE directly and asserts a fatal precondition // failure surfaces as a returned error, not a silent zero-exit return. // Cases with a setup func drive further into RunE toward a specific branch. func TestServiceStartCmdsReturnErrorOnMissingConfig(t *testing.T) { @@ -339,99 +331,6 @@ func TestServiceStartCmdsReturnErrorOnMissingConfig(t *testing.T) { }, wantErr: "start qmp-collector service", }, - // The stop commands below resolve their pid directory to an isolated, - // always-empty temp dir, so Stop() safely fails to find a pid file - // rather than risking a real running process via an ambient path. - { - name: "predastore stop (no running process)", - cmd: predastoreStopCmd, - setup: func(t *testing.T) { - resetGlobalViper(t) - viper.Set("predastore-base-path", t.TempDir()) - }, - wantErr: "stop predastore service", - }, - { - name: "viperblock stop (no running process)", - cmd: viperblockStopCmd, - setup: func(t *testing.T) { - resetGlobalViper(t) - isolateRuntimeDir(t) - }, - wantErr: "stop viperblock service", - }, - { - name: "qemunbd stop (no running process)", - cmd: qemunbdStopCmd, - setup: func(t *testing.T) { - resetGlobalViper(t) - isolateRuntimeDir(t) - }, - wantErr: "stop qemunbd service", - }, - { - name: "nats stop (no running process)", - cmd: natsStopCmd, - setup: func(t *testing.T) { - resetGlobalViper(t) - isolateRuntimeDir(t) - }, - wantErr: "stop nats service", - }, - { - name: "spinifex stop (no running process)", - cmd: spinifexStopCmd, - setup: func(t *testing.T) { - resetGlobalViper(t) - isolateRuntimeDir(t) - }, - wantErr: "stop spinifex service", - }, - { - name: "awsgw stop (no running process)", - cmd: awsgwStopCmd, - setup: func(t *testing.T) { - resetGlobalViper(t) - isolateRuntimeDir(t) - }, - wantErr: "stop awsgw service", - }, - { - name: "spinifex-ui stop (no running process)", - cmd: spinifexUIStopCmd, - setup: func(t *testing.T) { - resetGlobalViper(t) - isolateRuntimeDir(t) - }, - wantErr: "stop spinifex-ui service", - }, - { - name: "vpcd stop (no running process)", - cmd: vpcdStopCmd, - setup: func(t *testing.T) { - resetGlobalViper(t) - isolateRuntimeDir(t) - }, - wantErr: "stop vpcd service", - }, - { - name: "northstar stop (no running process)", - cmd: northstarStopCmd, - setup: func(t *testing.T) { - resetGlobalViper(t) - isolateRuntimeDir(t) - }, - wantErr: "stop northstar service", - }, - { - name: "qmp-collector stop (no running process)", - cmd: qmpCollectorStopCmd, - setup: func(t *testing.T) { - resetGlobalViper(t) - isolateRuntimeDir(t) - }, - wantErr: "stop qmp-collector service", - }, } for _, tt := range tests { @@ -451,36 +350,6 @@ func TestServiceStartCmdsReturnErrorOnMissingConfig(t *testing.T) { } } -// TestSpinifexUIStatusCmdReportsStoppedWithNoRunningProcess drives the -// status command's success path: with no pid file at an isolated -// XDG_RUNTIME_DIR, Status reports "stopped" and RunE must return nil. -func TestSpinifexUIStatusCmdReportsStoppedWithNoRunningProcess(t *testing.T) { - isolateRuntimeDir(t) - - require.NotNil(t, spinifexUIStatusCmd.RunE) - err := spinifexUIStatusCmd.RunE(spinifexUIStatusCmd, nil) - require.NoError(t, err) -} - -// TestSpinifexUIStatusCmdReturnsErrorOnUnreadablePidDir drives the status -// command's other branch: a genuine read failure (permission denied, not -// ENOENT) must surface as an error rather than the benign "stopped" case. -func TestSpinifexUIStatusCmdReturnsErrorOnUnreadablePidDir(t *testing.T) { - if os.Geteuid() == 0 { - t.Skip("root bypasses directory permission checks") - } - - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "spinifex-ui.pid"), []byte("123"), 0o600)) - require.NoError(t, os.Chmod(dir, 0o000)) - t.Cleanup(func() { _ = os.Chmod(dir, 0o755) }) - t.Setenv("XDG_RUNTIME_DIR", dir) - - require.NotNil(t, spinifexUIStatusCmd.RunE) - err := spinifexUIStatusCmd.RunE(spinifexUIStatusCmd, nil) - require.ErrorContains(t, err, "get spinifex-ui service status") -} - // TestServiceStartExitsNonZeroOnFailure proves a fatal service-start error // terminates the process non-zero (systemd's Restart=on-failure depends on // it). os.Exit can't be observed in-process, so this re-execs the test diff --git a/docs/coverage/ec2/README.md b/docs/coverage/ec2/README.md index 040ef4b44..574b1697e 100644 --- a/docs/coverage/ec2/README.md +++ b/docs/coverage/ec2/README.md @@ -24,6 +24,10 @@ Spinifex implements **127 operations** in the EC2 `2016-11-15` API model. Spot Instance Requests are a mock over the on-demand `RunInstances` path. A request synchronously launches real VMs on the operator's own compute and is then reported `active` and `fulfilled`. There is no spot market: no bidding, no price rejection, no interruption and no reclamation, and instances are never taken back. +### AMI Launch Permissions Are Refused + +An AMI cannot be shared with another account or made public. `ModifyImageAttribute` refuses `LaunchPermission`, `UserIds`, `UserGroups`, `OrganizationArns`, `OrganizationalUnitArns` and `OperationType`, and `ResetImageAttribute` resets only `description`, not `launchPermission`. Each refusal is `InvalidParameterValue`. Terraform's `aws_ami_launch_permission` therefore cannot be created. `description` is the only image attribute that can be modified or reset. + ### Operations | Operation | diff --git a/docs/coverage/ec2/intro.md b/docs/coverage/ec2/intro.md index c59acdbcb..06a50d328 100644 --- a/docs/coverage/ec2/intro.md +++ b/docs/coverage/ec2/intro.md @@ -1,3 +1,7 @@ ### Spot Instances Are a Mock Spot Instance Requests are a mock over the on-demand `RunInstances` path. A request synchronously launches real VMs on the operator's own compute and is then reported `active` and `fulfilled`. There is no spot market: no bidding, no price rejection, no interruption and no reclamation, and instances are never taken back. + +### AMI Launch Permissions Are Refused + +An AMI cannot be shared with another account or made public. `ModifyImageAttribute` refuses `LaunchPermission`, `UserIds`, `UserGroups`, `OrganizationArns`, `OrganizationalUnitArns` and `OperationType`, and `ResetImageAttribute` resets only `description`, not `launchPermission`. Each refusal is `InvalidParameterValue`. Terraform's `aws_ami_launch_permission` therefore cannot be created. `description` is the only image attribute that can be modified or reset. diff --git a/docs/coverage/rds/README.md b/docs/coverage/rds/README.md index 5f6de84b1..656fc5b17 100644 --- a/docs/coverage/rds/README.md +++ b/docs/coverage/rds/README.md @@ -54,7 +54,7 @@ A parameter whose omission would create a false safety, security or availability | `DBSecurityGroups` | EC2-Classic security groups — use `VpcSecurityGroupIds` | | `DBClusterIdentifier`, `DBClusterSnapshotIdentifier` | Clustered engines are not offered | | `EnableCloudwatchLogsExports` | Log export is not implemented | -| `EngineVersion` other than the engine's pin, `Engine` on modify | No in-place engine or version change | +| `EngineVersion` other than the engine's pin; on modify, any `EngineVersion` and `Engine` | No in-place engine or version change | | `Engine=mysql` (and Aurora engines) | Oracle MySQL is not offered; `mariadb` is a distinct engine, not an alias for it | | `NewDBInstanceIdentifier` | The identifier is the DNS label and the KV key | | `DBPortNumber`, `DBSubnetGroupName` on modify | Both would move the endpoint | @@ -67,6 +67,8 @@ A parameter whose omission would create a false safety, security or availability | `EnableCustomerOwnedIp` | An Outposts feature | | `ForceFailover` (reboot) | No standby to fail over to | | `DBSnapshotIdentifier` (stop) | Snapshot-on-stop is not implemented | +| `DBInstanceAutomatedBackupsArn` (DescribeDBInstanceAutomatedBackups) | Cross-region automated backup replication is not offered, so no replicated backup has an ARN | +| `dbi-resource-id` filter (DescribeDBSnapshots, DescribeDBInstanceAutomatedBackups) | Snapshots and automated backups do not record their source instance's resource ID | ### Operations diff --git a/docs/coverage/rds/intro.md b/docs/coverage/rds/intro.md index f56508dc0..10b1cfb49 100644 --- a/docs/coverage/rds/intro.md +++ b/docs/coverage/rds/intro.md @@ -31,7 +31,7 @@ A parameter whose omission would create a false safety, security or availability | `DBSecurityGroups` | EC2-Classic security groups — use `VpcSecurityGroupIds` | | `DBClusterIdentifier`, `DBClusterSnapshotIdentifier` | Clustered engines are not offered | | `EnableCloudwatchLogsExports` | Log export is not implemented | -| `EngineVersion` other than the engine's pin, `Engine` on modify | No in-place engine or version change | +| `EngineVersion` other than the engine's pin; on modify, any `EngineVersion` and `Engine` | No in-place engine or version change | | `Engine=mysql` (and Aurora engines) | Oracle MySQL is not offered; `mariadb` is a distinct engine, not an alias for it | | `NewDBInstanceIdentifier` | The identifier is the DNS label and the KV key | | `DBPortNumber`, `DBSubnetGroupName` on modify | Both would move the endpoint | @@ -44,3 +44,5 @@ A parameter whose omission would create a false safety, security or availability | `EnableCustomerOwnedIp` | An Outposts feature | | `ForceFailover` (reboot) | No standby to fail over to | | `DBSnapshotIdentifier` (stop) | Snapshot-on-stop is not implemented | +| `DBInstanceAutomatedBackupsArn` (DescribeDBInstanceAutomatedBackups) | Cross-region automated backup replication is not offered, so no replicated backup has an ARN | +| `dbi-resource-id` filter (DescribeDBSnapshots, DescribeDBInstanceAutomatedBackups) | Snapshots and automated backups do not record their source instance's resource ID | diff --git a/docs/exoscale-integration/README.md b/docs/exoscale-integration/README.md new file mode 100644 index 000000000..50e9d7fb1 --- /dev/null +++ b/docs/exoscale-integration/README.md @@ -0,0 +1,258 @@ +--- +title: "Spinifex on Exoscale" +seoTitle: "Run Spinifex on Exoscale with Elastic IPs — Spinifex Docs" +description: "Install Spinifex on one Exoscale instance and run EC2, load balancers and EKS on real Exoscale Elastic IPs." +category: "Install" +tags: + - install + - exoscale + - cloud + - elastic ip +resources: + - title: "Single-Node Install" + url: "/docs/install" + - title: "Spinifex on Oracle Cloud" + url: "/docs/oci-integration" + - title: "VPC Networking" + url: "/docs/vpc-networking" + - title: "Spinifex Repository" + url: "https://github.com/mulgadc/spinifex" +--- + +# Running Spinifex on Exoscale + +> [!WARNING] +> **Work in progress.** This page is not yet published on docs.mulgadc.com. Do not add it to `scripts/docs-config.json` in `mulga-docs` until the [Limits](#limits) section is settled. + +> One Exoscale instance, the AWS APIs on its address, and guests reachable on real Exoscale Elastic IPs that Spinifex creates and deletes for you. + +## Table of Contents + +- [What you get](#what-you-get) +- [Before you start](#before-you-start) +- [Install](#install) +- [Use it from your laptop](#use-it-from-your-laptop) +- [Troubleshooting](#troubleshooting) +- [Limits](#limits) + +--- + +## What you get + +A single Exoscale instance runs the whole Spinifex stack: EC2, EBS, S3, VPC, load balancers and EKS, behind an endpoint the ordinary AWS CLI, SDKs and Terraform provider talk to unmodified. + +Whenever something needs a public address, such as an instance in a public subnet, an Elastic IP, an internet-facing load balancer or a NAT gateway, Spinifex creates an Exoscale Elastic IP, attaches it to your instance, and deletes it again when the resource goes away. You never manage the addresses by hand. + +

+ Single node on Exoscale — the instance on the zone's public network with one interface, Elastic IPs routed into OVN through the routed-NAT transit veth, the control plane, tenant VPCs and the block storage volume +

+ +--- + +## Before you start + +You need: + +- An Exoscale organisation and an **API key** that can create, attach, detach and delete Elastic IPs. +- An SSH key pair. +- The AWS CLI on your laptop. + +Then create these in one zone, from the Exoscale console or the `exo` CLI. This guide uses `de-fra-1`. + +| Resource | Setting | +| --- | --- | +| Compute instance | `standard.huge` (8 vCPU / 32 GiB) or larger, template `Linux Ubuntu 26.04 LTS 64-bit`, 100 GiB disk, your SSH key | +| Block storage volume | 256 GiB or more, attached to the instance | +| Security group | Allow all inbound TCP, UDP and ICMP from `0.0.0.0/0`, attached to the instance | + +Note the instance's **ID** (a UUID, shown on its page in the console) and the volume's **ID**. You do not need to create any Elastic IPs. + +> [!NOTE] +> The security group is open on purpose. Guests are filtered by their own Spinifex security groups, and the node by the Spinifex host firewall you turn on in step 5. + +--- + +## Install + +Everything below runs on the instance, over SSH as `ubuntu`. + +### 1. Check that the instance can run VMs + +```bash +ls -l /dev/kvm && cat /sys/module/kvm_intel/parameters/nested +``` + +`/dev/kvm` must exist and the second line must print `Y`. If either fails, pick a different instance type before going further. + +### 2. Put the data volume at `/var/lib/spinifex` + +Every guest disk and S3 object lives here, so it belongs on the volume and not on the 100 GiB root disk. + +```bash +VOL_ID= +DEV=/dev/disk/by-id/virtio-${VOL_ID:0:20} + +sudo mkfs.ext4 -L spinifex-data "$DEV" # only on a new, empty volume +sudo mkdir -p /var/lib/spinifex +echo "UUID=$(sudo blkid -o value -s UUID "$DEV") /var/lib/spinifex ext4 defaults,nofail 0 2" | sudo tee -a /etc/fstab +sudo mount /var/lib/spinifex +findmnt /var/lib/spinifex # must print a line +``` + +### 3. Install the `exo` CLI and give it your key + +Spinifex manages Elastic IPs by running `exo`, so it needs the CLI and a key file. + +```bash +curl -fsSLO https://github.com/exoscale/cli/releases/download/v1.101.0/exoscale-cli_1.101.0_linux_amd64.deb +sudo dpkg -i exoscale-cli_1.101.0_linux_amd64.deb + +sudo mkdir -p /etc/spinifex/exoscale +sudo tee /etc/spinifex/exoscale/exoscale.toml >/dev/null <<'EOF' +defaultaccount = "spinifex" + +[[accounts]] + name = "spinifex" + key = "EXO..." + secret = "..." + defaultZone = "de-fra-1" +EOF +sudo chmod 700 /etc/spinifex/exoscale && sudo chmod 600 /etc/spinifex/exoscale/exoscale.toml +``` + +Check the key works: `sudo exo --config /etc/spinifex/exoscale/exoscale.toml compute elastic-ip list -z de-fra-1`. + +### 4. Install Spinifex + +```bash +curl -sfL https://install.mulgadc.com | sudo bash +sudo /usr/local/share/spinifex/setup-ovn.sh --management --nat-uplink + +sudo spx admin init --node node1 --nodes 1 --region eu-central-1 --az eu-central-1a \ + --ipsec=false --external-mode=nat +sudo chown -R spinifex-daemon: /etc/spinifex/exoscale +``` + +Now tell Spinifex where to get public addresses. Add this to the end of `/etc/spinifex/spinifex.toml`, just above the `[bootstrap]` section, using your instance ID: + +```toml +[[network.external_pools]] +name = "exo" +source = "exoscale" +exoscale_zone = "de-fra-1" +exoscale_instance_id = "" +``` + +Leave the `nat-transit` pool that `init` wrote where it is. + +> [!NOTE] +> `eu-central-1` is the region name your AWS tools will see. It is your choice and does not have to match the Exoscale zone. + +### 5. Turn on the firewall, then start + +```bash +sudo env SETUP_STAGES=firewall /usr/local/share/spinifex/setup.sh --firewall on +sudo systemctl start spinifex.target +systemctl list-units 'spinifex-*' --no-pager +``` + +Every `spinifex-*` unit should be `active`. The daemon log confirms the Elastic IP integration is working: + +```bash +sudo journalctl -u spinifex-daemon | grep -E 'exo CLI|Exoscale allocator ready' +``` + +### 6. Import an image + +```bash +sudo spx admin images import --name ubuntu-26.04-x86_64 --config /etc/spinifex/spinifex.toml +``` + +For EKS, also import `spinifex-eks-node`. `sudo spx admin images list --config /etc/spinifex/spinifex.toml` shows the full catalogue. + +--- + +## Use it from your laptop + +Copy the cluster's CA and the admin credentials from the instance: + +```bash +NODE= +mkdir -p ~/.spinifex +ssh ubuntu@$NODE 'sudo cat /etc/spinifex/ca.pem' > ~/.spinifex/exo-ca.pem +``` + +Copy the `[spinifex]` section of `~/.aws/credentials` on the instance into your own `~/.aws/credentials` as `[spinifex-exo]`, then: + +```bash +aws configure set --profile spinifex-exo region eu-central-1 +export AWS_PROFILE=spinifex-exo AWS_CA_BUNDLE=~/.spinifex/exo-ca.pem +export AWS_ENDPOINT_URL=https://$NODE:9999 + +aws ec2 describe-regions # proves the connection +``` + +### Launch an instance + +```bash +aws ec2 import-key-pair --key-name demo --public-key-material fileb://~/.ssh/id_ed25519.pub +SG=$(aws ec2 describe-security-groups --filters Name=group-name,Values=default --query 'SecurityGroups[0].GroupId' --output text) +aws ec2 authorize-security-group-ingress --group-id "$SG" --protocol tcp --port 22 --cidr 0.0.0.0/0 + +AMI=$(aws ec2 describe-images --filters Name=name,Values='*ubuntu-26.04*' --query 'Images[0].ImageId' --output text) +aws ec2 run-instances --image-id "$AMI" --instance-type t3.small --key-name demo \ + --query 'Instances[0].InstanceId' --output text +``` + +After about a minute, `describe-instances` shows a public IP. That is a new Exoscale Elastic IP, and it appears in the Exoscale console with a description beginning `spinifex:`. + +```bash +ssh ubuntu@ 'curl -s ifconfig.me' # prints the same address +``` + +Terminating the instance deletes the Elastic IP at Exoscale. + +### Load balancers and EKS + +The [Terraform workbooks](https://github.com/mulgadc/spinifex/tree/main/docs/terraform-workbooks) run unchanged. Pass the region and endpoint: + +```bash +cd nginx-alb +terraform init +terraform apply -var region=eu-central-1 -var spinifex_endpoint=https://$NODE:9999 +``` + +`nginx-alb` builds two web servers behind an internet-facing load balancer. `eks-quickstart` builds a Kubernetes cluster with one worker. Run `terraform destroy` before starting the next one, because of the quota below. + +> [!IMPORTANT] +> **Exoscale allows five Elastic IPs per organisation by default**, and every public address Spinifex hands out uses one. An instance with a public IP uses one, an internet-facing load balancer one, a NAT gateway one, and `eks-quickstart` three. Past the quota, `allocate-address` returns `AddressLimitExceeded` and a launch returns `InsufficientAddressCapacity`. Ask Exoscale support to raise it. +> +> **Exoscale keeps counting a deleted Elastic IP for a while.** After a `terraform destroy`, `exo limits` still shows the old usage even though `exo compute elastic-ip list` no longer shows the addresses, so an `apply` straight afterwards can hit the quota. Wait until `exo limits` agrees with the list before starting the next workbook. + +--- + +## Troubleshooting + +**A guest's public IP does not answer from the instance itself.** That is expected: traffic from the host to a guest's Elastic IP is dropped. Test from your laptop. + +**SSH or HTTP is refused straight after launch.** Guests take 30 to 60 seconds to boot. Wait and retry. + +**An EKS cluster goes to `FAILED` within seconds, or a load balancer never becomes active.** Run `exo limits`. If Elastic IP usage is at the maximum, the cluster could not get its public endpoint; destroy it, wait for usage to drop, and apply again. + +**The daemon logs an `exo` error at start.** Run the check at the end of step 3. A wrong key, secret or zone is the usual cause. + +**An Elastic IP you created yourself.** Spinifex only ever touches Elastic IPs whose description starts with `spinifex::`. Anything else in the organisation is left alone, but it still counts against the quota. + +--- + +## Limits + +Proven on one `standard.huge` instance in `de-fra-1` on 2026-09-30: EC2 with a public IP, Elastic IP allocate and release, the `nginx-alb` workbook and the `eks-quickstart` workbook with one worker. Each was reached from the internet and left no Elastic IP behind at Exoscale. + +Not yet tested: + +- **More than one node.** Each node would carry its own guests' Elastic IPs, with cluster traffic over an Exoscale private network. +- **Moving an Elastic IP between nodes** with its guest. +- **A narrowly scoped API key.** The proof used an unrestricted key. +- **Other zones, bare metal and GPU instances.** +- **Guest disk performance** on Exoscale block storage. diff --git a/docs/install/install-multi-node/README.md b/docs/install/install-multi-node/README.md index b45fb67fb..c42d5bdb6 100644 --- a/docs/install/install-multi-node/README.md +++ b/docs/install/install-multi-node/README.md @@ -30,6 +30,7 @@ resources: - [Overview](#overview) - [Cluster sizing](#cluster-sizing) +- [How the cluster's own state is replicated](#how-the-clusters-own-state-is-replicated) - [Hardware](#hardware) - [Network requirements](#network-requirements) - [Prerequisites](#prerequisites) @@ -65,11 +66,82 @@ A Spinifex cluster distributes services across multiple servers for high availab | **VPC networking** — OVN | Control-plane databases run clustered, surviving the loss of any one node. | | **Object storage** — Predastore (S3) | Objects are erasure coded `RS(2,1)`, surviving the loss of any one node's shards. | | **Block storage** — Viperblock (EBS) | Volumes are stored in Predastore, so they inherit the same durability. | +| **Cluster state** — NATS JetStream | Every key-value bucket is replicated onto all three nodes, so the control plane keeps its own state through the loss of any one. | On one or two servers none of that holds. OVN runs standalone on the first node, and the storage metadata quorum has no majority to lose. If that node goes down, running instances keep full networking — but nothing can *change*: no new VPCs, no launches, no security group edits. See [OVN control plane on multi-node clusters](/docs/vpc-networking#ovn-control-plane-on-multi-node-clusters). Servers beyond the third run the full set of services — storage, gateway and networking agents — and add their capacity to the pool. What they do not do is join the OVN database cluster, which stays at three members, so write latency there stays flat as the cluster grows. +### How the cluster's own state is replicated + +Guest data has an obvious home: volumes live in Viperblock, objects in Predastore, both erasure coded across the cluster. The cluster's own bookkeeping has a less obvious one, and it matters just as much. Every instance record, every VPC and subnet, the server roster, IAM users and access keys, quotas and DNS records are kept in **NATS JetStream key-value buckets** — Spinifex's control-plane database, replicated across the servers you installed rather than sitting on any one of them. A cluster that has lost its state has lost the ability to describe, launch, terminate or route anything, even while every guest is still running. + +**A bucket is replicated across the largest odd number of nodes the cluster has, up to five.** That number is decided once, by the `--nodes` count you give `spx admin init` in Step 4, and every service on every node reads it back out of `/etc/spinifex/spinifex.toml` before it creates anything. + +| Servers | Replicas per bucket | Quorum | Survives | +|---|---|---|---| +| 1 | 1 | 1 | nothing — a single node is a single point of failure by definition | +| 2 | — | — | rejected by `spx admin init`; see below | +| 3 | 3 | 2 | any 1 node lost | +| 4 | 3 | 2 | any 1 node lost | +| 5 | 5 | 3 | any 2 nodes lost | +| 10 | 5 | 3 | any 2 of the 5 nodes holding each bucket | + +**The count is always odd, and that is why four servers replicate three ways rather than four.** Agreement is by majority, and an even number of copies does not buy you one: four copies cost a fourth write every time and still survive exactly one loss, the same as three. On four servers NATS spreads which three nodes hold each bucket, so all four carry their share and none of them is idle. + +Five is JetStream's own ceiling on a stream's replica count, not a choice of ours. Past five servers the buckets stay at five and NATS spreads which five nodes hold each one, so the cluster keeps growing while the cost of a write does not. + +**Two servers is refused rather than allowed with a warning.** There is no replica count that works on two: one disappears with the node holding it, and two stop accepting writes when either member goes, because neither survivor is a majority on its own. OVN and the storage metadata quorum have the same problem on two servers, so `spx admin init --nodes 2` fails and tells you to use one server or three. + +#### Worked example — three servers + +Say you ran Step 4 with `--nodes 3` on `node1`, `node2` and `node3`. Every bucket the cluster creates is held by all three servers, with one of them responsible for coordinating changes to it: + +``` +KV bucket "spinifex-instance-state" KV bucket "spinifex-vpcd-reconcile" +┌──────────┬──────────┬──────────┐ ┌──────────┬──────────┬──────────┐ +│ node1 │ node2 │ node3 │ │ node1 │ node2 │ node3 │ +│ leader │ follower │ follower │ │ follower │ leader │ follower │ +└──────────┴──────────┴──────────┘ └──────────┴──────────┴──────────┘ + quorum = 2 of 3 quorum = 2 of 3 +``` + +Three things follow from that picture, and they are the whole reason three servers is the floor: + +- **A change is not confirmed until most of the cluster has it.** When you launch an instance, the API does not report success until at least two of the three servers have written the record to disk. Losing a server afterwards cannot lose that instance. +- **Losing a server costs a pause, not the data.** If `node1` goes down, `node2` and `node3` are still a majority. They take over the buckets `node1` was responsible for — seconds on a healthy cluster — and the API keeps answering throughout. Nothing becomes unreadable, because nothing lived only on `node1`. +- **The cluster keeps repairing itself.** Spinifex runs background workers that fix up VPC state, enforce quotas and publish DNS. Only one server runs each at a time, and they claim that right by writing to a bucket. If those buckets were not replicated, a server failure would stop the repair work at precisely the moment there was repair work to do. + +Contrast that with a bucket on **one** server. It lives wherever it happened to be created, which is not recorded in any config file you can inspect. Stopping, rebooting or losing that one server makes the bucket unreachable from *every* node at once — a single-server event becomes a cluster-wide outage, on a server nobody chose. + +#### What you see when a server fails + +Majority is not an implementation detail. It is what stops two halves of a damaged cluster from both believing they are in charge, and it is the reason the minimum is three servers rather than two. + +**With one server down, the cluster carries on.** `node2` and `node3` are two of three. Instances launch, VPCs change, the API answers normally, and there is nothing to do but replace the failed server. What you have lost is the *next* failure: a second server down leaves no majority and the cluster stops accepting changes until one comes back. + +**A server that is cut off stops accepting changes, on purpose.** If `node1` is not down but isolated — a failed switch, a misconfigured port — it is one server out of three, which is not a majority, so it will not record anything. An operator working on `node1` who tries to launch an instance gets a failure rather than a success. That is the behaviour you want: if `node1` accepted changes while `node2` and `node3` were also accepting them, you would end up with two different versions of the same cluster and no way to merge them. Refusing the isolated side is what makes the surviving side worth trusting. + +Everything else on `node1` keeps running. Guests continue to execute and their volumes keep serving. What stops is anything that has to be recorded: launching, terminating, attaching a volume, changing a security group. Storage writes fail alongside it, so a guest on the isolated server stalls rather than quietly diverging from the rest of the cluster. + +**Be careful reading state from an isolated server.** A server that is cut off can still answer `describe-instances` from its own copy of the data — frozen at the moment it lost contact, with nothing in the answer to say so. **Run operator commands from a server you know is with the majority**, and if one server's answers disagree with its peers', believe the peers. + +**A returning server catches up on its own.** Bring `node1` back and it synchronises from the others automatically: no command to run, no restart, no repair step. (`spx admin kv replicas --repair` exists for a different situation — a bucket configured with too few copies — not a server that is simply behind.) + +#### Growing an existing cluster + +Adding a fourth server does not, on its own, re-replicate the buckets that already exist — the nodes that hold them are perfectly healthy, so nothing prompts a change. Spinifex raises them for you at the next service start: each service checks every bucket it opens against the cluster's current node count and raises any that are short. It never lowers one. + +**A config that names a node before that node is serving is safe.** It is the ordinary middle of growing a cluster, and services start normally through it: a bucket that cannot be raised yet keeps working at the count it has, and the node logs `Could not raise KV bucket to the cluster's replica count`. The raise is retried every time a service opens the bucket, so it completes on its own once the new server is in the cluster. + +To do it immediately rather than waiting for a restart, run the audit with `--repair` from any node: + +```bash +sudo spx admin kv replicas --repair +``` + +It changes nothing about a bucket except the replica count, never lowers one, and is safe to run twice or on a healthy cluster. + ### Hardware Per server, for the three-node minimum: @@ -252,6 +324,8 @@ sudo spx admin init --force \ `--nodes 3` is the number of servers init waits for. Set it to your total node count if you are building a larger cluster. +It is also what decides how many nodes the cluster's JetStream state is replicated across, so it is worth getting right rather than raising later — see [How the cluster's own state is replicated](#how-the-clusters-own-state-is-replicated). Every joining server takes the count from server 1, so it is chosen once, here. `--nodes 2` is refused: use 1 for a single server, or 3 and up for a cluster that can survive losing one. + IPsec encrypts the Geneve overlay between servers and is on by default. Joining servers take the setting from server 1, so it is chosen once, on init. On servers that share a trusted private link, `--ipsec=false` leaves the overlay unencrypted in exchange for considerably higher throughput between instances. The init output displays the join command including the token: @@ -360,7 +434,40 @@ The top table is per-node CPU, memory and GPU usage. The bottom table is what th If capacity looks like a single server rather than the sum of your nodes, the others have not joined. -**3. The AWS API answers.** +**3. The cluster's own state is replicated across every node.** + +```bash +sudo spx admin kv replicas +``` + +``` +spinifex@node1:~$ sudo spx admin kv replicas +BUCKET REPLICAS WANT ONLINE STATUS LEADER HELD BY +spinifex-cluster-state 3 3 3 ok spinifex-nats-node1 spinifex-nats-node1,spinifex-nats-node2,spinifex-nats-node3 +spinifex-dns-reconcile 3 3 3 ok spinifex-nats-node2 spinifex-nats-node2,spinifex-nats-node1,spinifex-nats-node3 +spinifex-iam 3 3 3 ok spinifex-nats-node3 spinifex-nats-node3,spinifex-nats-node1,spinifex-nats-node2 +spinifex-instance-state 3 3 3 ok spinifex-nats-node1 spinifex-nats-node1,spinifex-nats-node2,spinifex-nats-node3 +spinifex-quota-reconcile 3 3 3 ok spinifex-nats-node2 spinifex-nats-node2,spinifex-nats-node3,spinifex-nats-node1 +spinifex-terminated-instances 3 3 3 ok spinifex-nats-node3 spinifex-nats-node3,spinifex-nats-node2,spinifex-nats-node1 +spinifex-vpcd-reconcile 3 3 3 ok spinifex-nats-node2 spinifex-nats-node2,spinifex-nats-node1,spinifex-nats-node3 + +47 buckets, 0 under-replicated, 0 without quorum +``` + +Server names are the node name behind a `spinifex-nats-` prefix, which is what NATS itself calls each server. + +What to check: + +- **`0 under-replicated, 0 without quorum`.** These are different problems. Under-replicated means a bucket lives on fewer nodes than depend on it — see [Buckets report as under-replicated](#buckets-report-as-under-replicated), and `--repair` is the fix. Without quorum means a bucket is not able to accept a write right now, whatever its replica count says, and no repair helps until you find out why a node is not answering. +- **`WANT` is the largest odd number of nodes you have**, capped at five — 3 on three or four servers, 5 on five or more. If it says 1 on a three-server cluster, the node the command ran on is not configured for three: check `--nodes` in Step 4 and the `[nodes.*]` sections of that host's `/etc/spinifex/spinifex.toml`. +- **`ONLINE` equals `REPLICAS`.** It counts the replicas that are caught up and reachable, so a bucket at `3` replicas with `ONLINE 2` is one node away from being unavailable and needs looking at before anything else is done to the cluster. +- **`LEADER` is spread across the cluster** rather than parked on one node, and `HELD BY` names as many distinct servers as `REPLICAS`. + +Exit status is what a deployment gate should read rather than the output: `0` healthy, `1` something is under-replicated, `2` something has no quorum or the report could not be produced at all. + +Run it on every node, not just one. It reads cluster-global Raft metadata, so all of them should give the same answer; one that disagrees is not fully part of the cluster. + +**4. The AWS API answers.** ```bash export AWS_PROFILE=spinifex @@ -403,6 +510,28 @@ The node has its own cluster configuration — normal for anything installed fro Safe on a freshly installed node. On one that has been in service it orphans every volume and fragment sealed under the old key, so check before forcing. +### Buckets Report as Under-Replicated + +```bash +sudo spx admin kv replicas +``` + +A bucket showing `UNDER-REPLICATED` lives on fewer nodes than the cluster has, so losing the node holding it takes it down for the whole cluster. Two things put a bucket in that state, and the fix is the same for both: + +```bash +sudo spx admin kv replicas --repair +``` + +The first cause is **a cluster that grew**: buckets created when there were three servers stay on three replicas until something raises them. Services do this themselves as they restart, and `--repair` does it now. + +The second is **a node that was down when the bucket was created**, so the Raft group formed short. Bring the node back first — `--repair` on an incomplete cluster raises the configured count, but the group cannot place the extra replica until the node is there to hold it. Confirm with `spx get nodes` before repairing. + +If `WANT` itself is wrong — 1 on a three-server cluster — the problem is not the buckets but that host's view of the cluster. Check it has all three `[nodes.*]` sections: + +```bash +sudo grep -oP '^\[nodes\.\K[^.\]]+' /etc/spinifex/spinifex.toml | sort -u +``` + ### OVN Database Cluster Not Forming ```bash diff --git a/docs/terraform-workbooks/eks-gitops-argocd/README.md b/docs/terraform-workbooks/eks-gitops-argocd/README.md index 4b570e163..046bc8ca2 100644 --- a/docs/terraform-workbooks/eks-gitops-argocd/README.md +++ b/docs/terraform-workbooks/eks-gitops-argocd/README.md @@ -256,7 +256,7 @@ kubectl -n kube-system logs deploy/aws-load-balancer-controller --tail=50 Confirm the cluster carries `spinifex.io/managed-ingress = "false"` so the LBC owns ingress. -If the controller logs show `FailedBuildModel ... DescribeAvailabilityZones ... 403 ... AccessDenied`, the node role is missing the LBC permissions. The controller runs with the node instance-profile credentials, so the `${var.cluster_name}-node-lbc` policy (`aws_iam_policy.node_lbc`) must be attached to the node role — re-run `tofu apply` on the parent module if it was provisioned before that policy existed. +If the controller logs show `FailedBuildModel ... DescribeAvailabilityZones ... 403 ... UnauthorizedOperation`, the node role is missing the LBC permissions. The controller runs with the node instance-profile credentials, so the `${var.cluster_name}-node-lbc` policy (`aws_iam_policy.node_lbc`) must be attached to the node role — re-run `tofu apply` on the parent module if it was provisioned before that policy existed. ### Provider Connection Refused diff --git a/docs/testing/e2e-coverage.md b/docs/testing/e2e-coverage.md index b0b0847d1..a23819ff3 100644 --- a/docs/testing/e2e-coverage.md +++ b/docs/testing/e2e-coverage.md @@ -7,11 +7,13 @@ Suites live in `tests/e2e//` and build to `tests/e2e/_bin/.test`. Ev | Set | Suites | | --- | --- | | `E2E_SUITES_SINGLE` | `single iam cert eks ecs storagegrowth partialblock rds quota storagefault` | -| `E2E_SUITES_MULTI` | `multinode lb cert quota lbrecovery instancerecovery storagefault` | +| `E2E_SUITES_MULTI` | `multinode lb cert quota kvquorum lbrecovery instancerecovery storagefault` | | `E2E_SUITES_NIGHTLY_SINGLE` | `single cert iam` | | `E2E_SUITES_NIGHTLY_MULTI` | `multinode cert lb` | -The nightly permutation sets are deliberately narrower than the full ones: those cells have a ~35 minute budget and exist to prove every install / network / host-OS combination boots and serves. `eks`, `ecs`, `rds`, `storagefault`, `instancerecovery` and `lbrecovery` each get a dedicated cell instead. +The nightly permutation sets are deliberately narrower than the full ones: those cells have a ~35 minute budget and exist to prove every install / network / host-OS combination boots and serves. `eks`, `ecs`, `rds`, `storagefault`, `instancerecovery`, `lbrecovery` and `kvquorum` each get a dedicated cell instead. + +**`multinode` carries the KV replication sweep as well**, which is why the thing `kvquorum` proves is still checked in every multi-node permutation cell even though the suite itself is not. The read-only half costs a NATS connection and a stream listing; only the node-loss half needs a cell of its own. ## The suites @@ -27,6 +29,7 @@ The largest suite, 25 top-level tests. Notable entry points: - `TestInstanceMetadata` — IMDS from inside a guest. - `TestStopStart`, `TestEarlyRebootLiveness`, `TestGuestChurnDurability`, `TestSnapshotLifecycle`, `TestSnapshotBackedLaunch`, `TestCreateImage`, `TestVolumeLifecycle`, `TestSpotInstanceLifecycle`, `TestLaunchTemplateBoot`. - `TestNegativeErrorPaths` — the `8a`–`8h` AWS error-shape cases (malformed AMI ID, invalid instance type, volume in use, detach-root forbidden, and so on). +- **`TestKVReplication`** — the single-node half of the KV replication policy, and it is not the multi-node assertion with a smaller number in it. On one node one replica per bucket is the correct answer rather than the defect, so what it proves is the opposite: that nothing declared a replica count this deployment cannot place, that `spx admin kv replicas` exits 0 and reports `WANT 1`, and that `--repair` run twice against a cluster with nothing to repair changes no bucket's replica count and leaves every bucket serving. The multi-node suites cannot cover any of that, because on three nodes the unplaceable count is the right one. It lives here rather than in its own suite so it runs in every single-node nightly cell, across all three host and network permutations. ### `natuplink` — routed NAT (`external_mode = "nat"`), one node or three @@ -42,6 +45,22 @@ Two nightly cells run it: `nat-single` (cell 19) and `nat-multi` (cell 30). Cell `VPCSetup`, `SpansMultipleNodes`, `SpreadPlacement`, `EveryRunningInstanceReported`, `BastionSSH`, and its own NAT Gateway lane: `PreNATIsolation`, `NATGatewayInternet`, `NATCleanupOrdering`. +**`TestMultinodeJetStreamReplicas` is a whole-cluster sweep, not a named list, and it lives here on purpose.** It audits every KV bucket the cluster has and fails any that is replicated across fewer nodes than the cluster has, capped at JetStream's ceiling of five. Naming buckets would only ever cover the ones somebody remembered — a replica count is set where a bucket is created, and there is a bucket-creating call in most services — so the sweep is the assertion and the three daemon-owned buckets are named only so that a run enumerating nothing still fails. Because `multinode` is in `E2E_SUITES_NIGHTLY_MULTI`, this runs in every multi-node permutation cell, and no cell can be added that quietly opts out of it. + +### `kvquorum` — the cluster's own state survives losing a node (multi-node, needs three) + +**The control plane keeps its state in JetStream KV buckets, and this is the suite that proves those buckets are actually replicated.** A bucket on one replica lives on one JetStream-chosen server recorded in no config, so losing that node makes it return `nats: no responders available for request` from *every* node at once — a cluster-wide outage caused by a single-node event. Leader leases are the worst case: acquiring one is a write, so a lease bucket without a quorum cannot be acquired by anyone, and every reconciler sharing it stops cluster-wide at exactly the moment a node has failed and there is repair work to do. + +Three entry points, in the order they run: + +- **`TestKVBucketsAreReplicatedAcrossTheCluster`** is read-only and runs first, so a cluster that is already wrong is reported as wrong rather than as a failure to survive a node loss. It sweeps every bucket, then separately requires the leader-lease buckets to be among those checked — they were the worst case of the defect, and a regression reaching only them would otherwise be one line in a long list rather than the headline. +- **`TestKVReplicasCommandReportsHealthy`** runs `spx admin kv replicas --json` on **every** node, not one. The command reads cluster-global Raft metadata, so all nodes must give the same answer; a node that disagrees is reporting on a cluster it is not fully part of, which is worth knowing before a deployment is gated on its exit status. +- **`TestKVBucketsSurviveANodeLoss`** produces the fault rather than inspecting for it. It takes away **the node leading the most buckets** — the worst one to lose, and the one a test picking arbitrarily would usually miss — then requires every survivor to still answer for every bucket, brings the node back, and requires it to carry its share again. + +**Configured, placed and working replicas are three different facts, and all three are checked.** A stream can carry the right replica count while its Raft group is short of peers, or hold the right peers while one of them is offline or still catching up, and either reads as healthy right up until the node it is really on goes away. The audit reports the leader and a count of peers NATS itself calls current and reachable, and the assertion is against that count rather than against the configured one. The last assertion is that no bucket came back with *fewer* replicas than it had: a cluster that "repaired" itself by lowering a count would pass every other assertion here and have quietly traded the durability this suite is about for a green run. + +**Its own suite and its own cell, for lbrecovery's reasons.** It takes a node away, so anything sharing the environment reports that outage as its own failure, and the question it answers — "is our internal state actually replicated" — has to read as itself rather than as one more red subtest somewhere else. Three nodes is the floor: on two, a majority is two, so losing either leaves no quorum for any stream and there is nothing to assert beyond "Raft needs a majority", which is true of NATS and not a property of ours. + ### `instancerecovery` — a guest whose node goes away (multi-node) **Asserted from both sides, and the pair is the point.** `InstanceAutoRecovery` takes a node down and requires the guest to come back on exactly one survivor, identified by the qemu process rather than by what the API says, then brings the node back and requires it to drop the local copy instead of relaunching it. `InstanceRecoveryRefusesStorageFault` freezes predastore cluster-wide first, so the guest is paused by `werror=stop` when its node goes away, and requires that **nothing moves** — a store that refuses every node cannot be fixed by moving a guest, and the relaunch would cost it the request QEMU is holding. A reconciler that cannot tell a host failure from a storage failure passes the first and fails the second. Neither turns recovery on, because there is no setting for it: both wait for every node to report the reconciler active on a cluster nothing configured, so a build that reintroduced a switch fails here. diff --git a/go.mod b/go.mod index 6b361639b..51c2dbea5 100644 --- a/go.mod +++ b/go.mod @@ -23,10 +23,10 @@ require ( github.com/klauspost/cpuid/v2 v2.4.0 github.com/miekg/dns v1.1.73 github.com/minio/highwayhash v1.0.4 - github.com/mulgadc/bluebottle v1.20.1-0.20260928004821-163f43196afd - github.com/mulgadc/northstar v1.20.1-0.20260917032839-357bbc94201d - github.com/mulgadc/predastore v1.20.1-0.20260918222809-019dd349cf1f - github.com/mulgadc/viperblock v1.20.0 + github.com/mulgadc/bluebottle v1.21.1-0.20261001020941-036da4967e1c + github.com/mulgadc/northstar v1.21.0 + github.com/mulgadc/predastore v1.21.0 + github.com/mulgadc/viperblock v1.21.0 github.com/nats-io/nats-server/v2 v2.15.0 github.com/nats-io/nats.go v1.54.0 github.com/opencontainers/runtime-spec v1.3.0 @@ -60,16 +60,15 @@ require ( github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 // indirect github.com/Microsoft/hcsshim v0.15.0-rc.4 // indirect github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect - github.com/armon/go-metrics v0.4.1 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.3 // indirect - github.com/aws/aws-sdk-go-v2/service/s3 v1.113.0 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.113.3 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.10.0 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.38.0 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0 // indirect @@ -125,12 +124,12 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect - github.com/hashicorp/go-metrics v0.5.4 // indirect + github.com/hashicorp/go-metrics v0.7.0 // indirect github.com/hashicorp/go-msgpack/v2 v2.1.5 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect - github.com/hashicorp/raft v1.7.3 // indirect - github.com/hashicorp/raft-boltdb/v2 v2.3.1 // indirect + github.com/hashicorp/raft v1.8.0 // indirect + github.com/hashicorp/raft-boltdb/v2 v2.4.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -141,8 +140,8 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.21 // indirect github.com/mdlayher/packet v1.1.2 // indirect @@ -166,10 +165,10 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.24.1 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.70.1 // indirect + github.com/prometheus/client_model v0.6.3 // indirect + github.com/prometheus/common v0.71.0 // indirect github.com/prometheus/procfs v0.21.1 // indirect - github.com/quic-go/quic-go v0.62.0 // indirect + github.com/quic-go/quic-go v0.63.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/sirupsen/logrus v1.10.2 // indirect diff --git a/go.sum b/go.sum index e8a185f1a..b5a688c67 100644 --- a/go.sum +++ b/go.sum @@ -6,10 +6,8 @@ atomicgo.dev/keyboard v0.2.9 h1:tOsIid3nlPLZ3lwgG8KZMp/SFmr7P0ssEN5JUsm78K8= atomicgo.dev/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ= atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs= atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= -github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs= github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8= github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII= @@ -23,11 +21,6 @@ github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 h1:0kQAzHq8vL github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29/go.mod h1:ZWa7ssZJT30CCDGJ7fk/2SBTq9BIQrrVjrcss0UW2s0= github.com/Microsoft/hcsshim v0.15.0-rc.4 h1:aZFX4LH0S20Lgjq0wG61StIClj7im4yzrxIClkaR8Z8= github.com/Microsoft/hcsshim v0.15.0-rc.4/go.mod h1:BA9CBztgu4h/6Jsvo1O1M4qjWw09PoYpaEYgexPE578= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= @@ -55,14 +48,14 @@ github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 h1:fuSCw4Z2qfRCztMPO3GXJNSiEp6W github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3/go.mod h1:6SxcHheD1pPR5+kWm1wGvjlL/YqUsh267sAfEmN4K7A= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.3 h1:BHKCSX4QXERe8So8rbWqaM7owqOmDJxATXgJwGng22A= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.3/go.mod h1:GqWeeKfYfezihA2KfFL9l7ohEdZWe1tuFWh3GfyNSnE= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.4 h1:7TXEkbDzy4BkYYTfTVjKcADTfkDnvHxDwcF1pJG1yF0= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.4/go.mod h1:GqWeeKfYfezihA2KfFL9l7ohEdZWe1tuFWh3GfyNSnE= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 h1:bON1rJf67TSTDCKg816AAIE4xSTtoo9tl0XRkO72R+I= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3/go.mod h1:c5BBpjJcQXpfeq9iASyVKA3T6vX6B6LEXY4mL/gklDY= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.3 h1:L8vIOxylma91TcR96NFTEC07G3JDwSl+CvK2b+IODms= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.3/go.mod h1:fmPIZQzTExYuBNWFyi1P7IoDjvskgphXqK1yObMzusM= -github.com/aws/aws-sdk-go-v2/service/s3 v1.113.0 h1:0slwIjBv1sEigSUW4EfTtNw9mbHl0PlOUPX9Y1C/eLE= -github.com/aws/aws-sdk-go-v2/service/s3 v1.113.0/go.mod h1:/uA+2Qj4jd5qBWagVC1AyzzDFXVK997E7U04w7Kw0wI= +github.com/aws/aws-sdk-go-v2/service/s3 v1.113.3 h1:IDIchqG5C/o/JdprtYn1NirCi7iUx7XQ8+WwZ6odFX8= +github.com/aws/aws-sdk-go-v2/service/s3 v1.113.3/go.mod h1:iJ69H4lkK6a9zQ+L5i9pDERMok5Jvts0iZaMjWEi/78= github.com/aws/aws-sdk-go-v2/service/signin v1.10.0 h1:ZD5qFpWcaOKdTuhBi431pIDkCgrMkMlMT6jlpSPoIRI= github.com/aws/aws-sdk-go-v2/service/signin v1.10.0/go.mod h1:8Nuuf+tR346PjJ3MvZPh9pekbLiLQFWJhzMXfwy7alA= github.com/aws/aws-sdk-go-v2/service/sso v1.38.0 h1:JGeeBcMlhg1xtOXYpeCaTQBZObtXMPQCUqBcmr65NRA= @@ -71,14 +64,10 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0 h1:obhahQXDEdVEv8y5bTKXR30L github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0/go.mod h1:6twZZ/aXHNy1vXUO8koUbp++MYzMASkOgEBdkbJYmO0= github.com/aws/aws-sdk-go-v2/service/sts v1.51.0 h1:Zpnqa6XtrNzXZnwbdCqHOXpXhMsa01ql/pcRQ1sb4hk= github.com/aws/aws-sdk-go-v2/service/sts v1.51.0/go.mod h1:/8JRcdTt//hG0Q4BTmGbuOplT7ABe+5rdtqUHqXvYIM= -github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ= -github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aws/smithy-go v1.28.2 h1:myhcykQcatTul2B/zITjDk203G7t0awUAs1hVry5Bvg= github.com/aws/smithy-go v1.28.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4= @@ -93,7 +82,6 @@ github.com/cenkalti/hub v1.0.2 h1:Nqv9TNaA9boeO2wQFW8o87BY3zKthtnzXmWGmJqhAV8= github.com/cenkalti/hub v1.0.2/go.mod h1:8LAFAZcCasb83vfxatMUnZHRoQcffho2ELpHb+kaTJU= github.com/cenkalti/rpc2 v1.0.5 h1:T6l4SS3ja3eaJfRyZrn7Oco/PSx/pr3YK5cjCgLVLTk= github.com/cenkalti/rpc2 v1.0.5/go.mod h1:2yfU5b86vOr16+iY1jN3MvT6Kxc9Nf8j5iZWwUf7iaw= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= @@ -110,8 +98,6 @@ github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMx github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= -github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= @@ -179,12 +165,6 @@ github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9 github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -198,40 +178,22 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gofrs/flock v0.10.0 h1:SHMXenfaB03KbroETaCMtbBg3Yn29v4w1r+tgy4ff4k= github.com/gofrs/flock v0.10.0/go.mod h1:FirDy1Ing0mI2+kB6wk+vyyAH+e6xiE+EYA0jnzV9jc= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs= github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786 h1:rcv+Ippz6RAtvaGgKxc+8FQIpxHgsF+HBzPyYL2cyVU= github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -244,19 +206,16 @@ github.com/gookit/color v1.6.0 h1:JjJXBTk1ETNyqyilJhkTXJYYigHG24TM9Xa2M1xAhRA= github.com/gookit/color v1.6.0/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= -github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= -github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-metrics v0.5.4 h1:8mmPiIJkTPPEbAiV97IxdAGNdRdaWwVap1BU6elejKY= -github.com/hashicorp/go-metrics v0.5.4/go.mod h1:CG5yz4NZ/AI/aQt9Ucm/vdBnbh7fvmv4lxZ350i+QQI= +github.com/hashicorp/go-metrics v0.7.0 h1:lLWieZTcbzZT+rY0zrqKbyryXG8RIajdUjmM0+R79eg= +github.com/hashicorp/go-metrics v0.7.0/go.mod h1:8T/Es8FPTfQvY7azBPGyrwXwwg7mbA9/TmQ1/lWfxb4= github.com/hashicorp/go-msgpack v0.5.5 h1:i9R9JSrqIz0QVLz3sz+i3YJdT7TTSLcfLLzJi9aZTuI= github.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= github.com/hashicorp/go-msgpack/v2 v2.1.5 h1:Ue879bPnutj/hXfmUk6s/jtIK90XxgiUIcXRl656T44= github.com/hashicorp/go-msgpack/v2 v2.1.5/go.mod h1:bjCsRXpZ7NsJdk45PoCQnzRGDaK8TKm5ZnDI/9y3J4M= -github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1 h1:fv1ep09latC32wFoVwnqcnKJGnMSdBanPczbHAYm1BE= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= @@ -265,12 +224,12 @@ github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iP github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/hashicorp/raft v1.7.3 h1:DxpEqZJysHN0wK+fviai5mFcSYsCkNpFUl1xpAW8Rbo= -github.com/hashicorp/raft v1.7.3/go.mod h1:DfvCGFxpAUPE0L4Uc8JLlTPtc3GzSbdH0MTJCLgnmJQ= +github.com/hashicorp/raft v1.8.0 h1:YbfecBcuTar/LNFEDfVTpqu9Aw+MczTk7MYczvy+62k= +github.com/hashicorp/raft v1.8.0/go.mod h1:agL5fncrpEsbxr5P5KOd2srskDwPY18opjXN5x0661s= github.com/hashicorp/raft-boltdb v0.0.0-20230125174641-2a8082862702 h1:RLKEcCuKcZ+qp2VlaaZsYZfLOmIiuJNpEi48Rl8u9cQ= github.com/hashicorp/raft-boltdb v0.0.0-20230125174641-2a8082862702/go.mod h1:nTakvJ4XYq45UXtn0DbwR4aU9ZdjlnIenpbs6Cd+FM0= -github.com/hashicorp/raft-boltdb/v2 v2.3.1 h1:ackhdCNPKblmOhjEU9+4lHSJYFkJd6Jqyvj6eW9pwkc= -github.com/hashicorp/raft-boltdb/v2 v2.3.1/go.mod h1:n4S+g43dXF1tqDT+yzcXHhXM6y7MrlUd3TTwGRcUvQE= +github.com/hashicorp/raft-boltdb/v2 v2.4.2 h1:r2RRgZ6ajT+VH8/9yC5mfoKAlt3KrYiUDhfbUSmaoTs= +github.com/hashicorp/raft-boltdb/v2 v2.4.2/go.mod h1:+wvKK1thWEqM7amY1MXjtQpkm+b9KNUXks0fcus5ZGU= github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714 h1:/jC7qQFrv8CrSJVmaolDVOxTfS9kc36uB6H40kdbQq8= github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714/go.mod h1:2Goc3h8EklBH5mspfHFxBnEoURQCGzQQH1ga9Myjvis= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -292,13 +251,6 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfC github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= -github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= -github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -308,9 +260,6 @@ github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV91 github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= github.com/klauspost/reedsolomon v1.14.2 h1:SafJYwpBBQBI6amHUygcjxZjXeN2HpiENHQDwuPWCCQ= github.com/klauspost/reedsolomon v1.14.2/go.mod h1:yjqqjgMTQkBUHSG97/rm4zipffCNbCiZcB3kTqr++sQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -326,18 +275,17 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mdlayher/packet v1.1.2 h1:3Up1NG6LZrsgDVn6X4L9Ge/iyRyxFEFD9o6Pr3Q1nQY= github.com/mdlayher/packet v1.1.2/go.mod h1:GEu1+n9sG5VtiRE4SydOmX5GTwyyYlteZiFU+x0kew4= github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU= @@ -358,30 +306,22 @@ github.com/moby/sys/user v0.4.1 h1:RgjRlaDKi/Xmyrz4t8lyzXT6v2ooFeO/7xtchmhVWE0= github.com/moby/sys/user v0.4.1/go.mod h1:E9QsW5WRe1kUAf7kW8hXKwu1uhsZEAdPLYHYSDudF4Y= github.com/moby/sys/userns v0.2.1 h1:4OvdM7BcPkASbuouHsbW3aeMJSFlYDldBRnXVZhaRk8= github.com/moby/sys/userns v0.2.1/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/mulgadc/bluebottle v1.20.1-0.20260916040751-5a5ca6f0fb33 h1:Rw3mnRF2YrhVZgHpCsOi6La8cOu/V3xnhB000iWcdr8= -github.com/mulgadc/bluebottle v1.20.1-0.20260916040751-5a5ca6f0fb33/go.mod h1:4rjLL3B0xZBPmxacaXPkw6sPOZZgyuCDBFmPSOC81yE= -github.com/mulgadc/bluebottle v1.20.1-0.20260928004821-163f43196afd h1:Ffa6OIUqHLUNn+NM4Ij4kGOLm7DEeicM16+Sn/Kulm8= -github.com/mulgadc/bluebottle v1.20.1-0.20260928004821-163f43196afd/go.mod h1:LH0MuQcFxet0dDEISlZsuULIYj592eCN2Fv39A6EH5o= -github.com/mulgadc/northstar v1.20.1-0.20260917032839-357bbc94201d h1:xYdO+KXrn+/aBLva3yuRly9MnKrSNSouGYleITgkoXE= -github.com/mulgadc/northstar v1.20.1-0.20260917032839-357bbc94201d/go.mod h1:3GB49VUE9rA2idp88se5jAMv3ql1Oq9kIcT29WAZwDU= -github.com/mulgadc/predastore v1.20.1-0.20260918222809-019dd349cf1f h1:P7echVc7fCYiU2CS/L0dLAf0i/6eWcjDqqf7AnFKpDg= -github.com/mulgadc/predastore v1.20.1-0.20260918222809-019dd349cf1f/go.mod h1:r1idTXiSBAq1GtIIVpRFjlplXzWm0iS8hviF33K85us= -github.com/mulgadc/viperblock v1.20.0 h1:g17NkHQcXGSXBGp20UcAPrklzTGXdyERXdaldo1aKM4= -github.com/mulgadc/viperblock v1.20.0/go.mod h1:q+Zs0C1pKfJVj8oALXZrZl33Sd+vPX0yc8ZroFtJBxM= +github.com/mulgadc/bluebottle v1.21.1-0.20261001020941-036da4967e1c h1:oRb9PlvooMenQjKU9dD76/bxUOvJaYi6taxm3famoj4= +github.com/mulgadc/bluebottle v1.21.1-0.20261001020941-036da4967e1c/go.mod h1:LH0MuQcFxet0dDEISlZsuULIYj592eCN2Fv39A6EH5o= +github.com/mulgadc/northstar v1.21.0 h1:LE/5kiT3kBelCg02cXa3b7awhaYkGHsOXeJ4nHRLNaQ= +github.com/mulgadc/northstar v1.21.0/go.mod h1:O446cApNMB6qebqieBhDZfCIGVWX++v3QdOz+wIGhUk= +github.com/mulgadc/predastore v1.21.0 h1:pAm4fFWf0vnlwRgnoL2oJ+KSCw8mtjT2rQoBHFmnjzk= +github.com/mulgadc/predastore v1.21.0/go.mod h1:icCfKGnyqDaJ/Jlbp2aYeo2qwrKHZfxmPHDYK1MM7wc= +github.com/mulgadc/viperblock v1.21.0 h1:RbQ/GpHCg/uoPffYioSKqzqyoz22Uc7wpQ7XY7etLg0= +github.com/mulgadc/viperblock v1.21.0/go.mod h1:n0wCOGV58Lt2IVPXEvpbHDkTITlXtFw+2CMrQXgbJ7s= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc= github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc= github.com/nats-io/nats-server/v2 v2.15.0 h1:M99yf0y05rTr46/qc/Is6ZAowI58Ryp2SjufLCUeVJc= @@ -402,43 +342,22 @@ github.com/oracle/oci-go-sdk/v65 v65.126.0 h1:RuV0MEcLOOgNOBadYbbkUQriCK4Gm5348F github.com/oracle/oci-go-sdk/v65 v65.126.0/go.mod h1:Pzy+BpgkDesvGZXEHgslwhIYobHCPHg6wRta1mWnlqQ= github.com/ovn-kubernetes/libovsdb v0.8.1 h1:M2J8bcJt5mXCom0HqzfEtuHkT80CTSQRcYG7acT8gf4= github.com/ovn-kubernetes/libovsdb v0.8.1/go.mod h1:ZlnHLzagmLOSvyd9qfxBIZp6wOSOw0IsRsc+6lNUGbU= -github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= -github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= -github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= -github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= -github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= -github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= -github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= -github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= -github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= -github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= +github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM= +github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8= +github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI= @@ -452,8 +371,8 @@ github.com/pterm/pterm v0.12.83 h1:ie+YmGmA727VuhxBlyGr74Ks+7McV6kT99IB8EU80aA= github.com/pterm/pterm v0.12.83/go.mod h1:xlgc6bFWyJIMtmLJvGim+L7jhSReilOlOnodeIYe4Tk= github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= -github.com/quic-go/quic-go v0.62.0 h1:ZHDjCk5OacATwGvs8PWE97CTvX7AqZiVoW7++ZOXTf8= -github.com/quic-go/quic-go v0.62.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= +github.com/quic-go/quic-go v0.63.0 h1:LIFGHI4PFUhhw2dDD1ARHdCff143ffMHwZtbnbuJ78A= +github.com/quic-go/quic-go v0.63.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -464,9 +383,6 @@ github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88ee github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= github.com/sony/gobreaker/v2 v2.4.0 h1:g2KJRW1Ubty3+ZOcSEUN7K+REQJdN6yo6XvaML+jptg= @@ -483,13 +399,11 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -505,7 +419,6 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA= github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A= -github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 h1:tHNk7XK9GkmKUR6Gh8gVBKXc2MVSZ4G/NnWLtzw4gNA= github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264= github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= @@ -563,9 +476,7 @@ go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= @@ -577,44 +488,23 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -627,7 +517,6 @@ golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= @@ -641,7 +530,6 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= @@ -663,36 +551,23 @@ golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGN golang.org/x/vuln v1.6.0 h1:FeMO9Rm/HwyduOztbvKcOw+zvDEPr4I4aQNSfevFcKY= golang.org/x/vuln v1.6.0/go.mod h1:bWlG2493/sjR7ksvicBgMrznH3eYQEyK8ifUYBrqUbg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 h1:izFU9hz7aeLI/Mi1J0991ae+xcwRLr7hTqWnB/9aIIU= google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5/go.mod h1:3LhxRw4YYkf+ylAfgaY9JlVLFKhokkCV8duhLLe7+t0= google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 h1:1VUiZAXyC+zmiFYi+WLtBzr68Cj8wOofHjjrA/kkizc= google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw= gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/awsmodel/ec2error_test.go b/internal/awsmodel/ec2error_test.go index 01bbfbbda..f229487ef 100644 --- a/internal/awsmodel/ec2error_test.go +++ b/internal/awsmodel/ec2error_test.go @@ -77,8 +77,8 @@ func TestEC2ErrorCatalogMetadataAndClasses(t *testing.T) { if catalog.metadata.VerifiedOn == "" { t.Fatal("verifiedOn is empty") } - if len(catalog.codes) != 112 { - t.Fatalf("catalog code count = %d, want 112", len(catalog.codes)) + if len(catalog.codes) != 114 { + t.Fatalf("catalog code count = %d, want 114", len(catalog.codes)) } if catalog.codes["InvalidParameterValue"] != ec2ErrorClassClient { t.Fatal("InvalidParameterValue is not classified as client") diff --git a/internal/awsmodel/error.go b/internal/awsmodel/error.go index b6ec619d0..16f974467 100644 --- a/internal/awsmodel/error.go +++ b/internal/awsmodel/error.go @@ -48,7 +48,14 @@ func ValidateErrorResponse(service Service, operationName string, body []byte) ( // they are not treated as proof that an operation-specific code conforms. func isUnmodelledCommonError(service Service, code string) bool { switch service { - case IAM, ElasticLoadBalancingV2: + case IAM: + return isUnmodelledQueryError(code) + case ElasticLoadBalancingV2: + // ELBv2's common errors list the parameter validation codes too. + switch code { + case "InvalidParameterCombination", "InvalidParameterValue", "MissingParameter", "ValidationError": + return true + } return isUnmodelledQueryError(code) case STS: // STS also uses these two runtime input errors even though the model @@ -107,9 +114,7 @@ func (m *Model) decodeErrorCode(body []byte) (string, error) { if separator := strings.LastIndex(code, "#"); separator >= 0 { code = code[separator+1:] } - if separator := strings.Index(code, ":"); separator >= 0 { - code = code[:separator] - } + code, _, _ = strings.Cut(code, ":") if code == "" { return "", fmt.Errorf("JSON error envelope has no code") } diff --git a/internal/awsmodel/error_test.go b/internal/awsmodel/error_test.go index abf47ca12..3fdf93a0f 100644 --- a/internal/awsmodel/error_test.go +++ b/internal/awsmodel/error_test.go @@ -97,6 +97,12 @@ func TestValidateErrorResponseClassifiesCommonErrorsAsUnmodelled(t *testing.T) { operation: "AssumeRole", body: `ValidationError`, }, + { + name: "ELBv2 parameter validation", + service: ElasticLoadBalancingV2, + operation: "AddTags", + body: `InvalidParameterValue`, + }, { name: "JSON authorization", service: ECS, diff --git a/internal/awsmodel/model.go b/internal/awsmodel/model.go index 348d10f84..81adc8242 100644 --- a/internal/awsmodel/model.go +++ b/internal/awsmodel/model.go @@ -131,12 +131,7 @@ var modelCache sync.Map // Services returns the supported service identifiers in stable order. func Services() []Service { - services := make([]Service, 0, len(modelFiles)) - for service := range modelFiles { - services = append(services, service) - } - slices.Sort(services) - return services + return slices.Sorted(maps.Keys(modelFiles)) } // Load parses and indexes the embedded model for service. Each service is diff --git a/internal/awsmodel/models/ec2/error-codes.json b/internal/awsmodel/models/ec2/error-codes.json index d3e4d4778..63718a159 100644 --- a/internal/awsmodel/models/ec2/error-codes.json +++ b/internal/awsmodel/models/ec2/error-codes.json @@ -68,6 +68,7 @@ "InvalidLaunchTemplateName.AlreadyExistsException", "InvalidLaunchTemplateName.MalformedException", "InvalidLaunchTemplateName.NotFoundException", + "InvalidMaxResults", "InvalidNatGatewayID.NotFound", "InvalidNetworkInterface.InUse", "InvalidNetworkInterface.NotFound", @@ -77,6 +78,7 @@ "InvalidPlacementGroup.Duplicate", "InvalidPlacementGroup.InUse", "InvalidPlacementGroup.Unknown", + "InvalidRequest", "InvalidRoute.NotFound", "InvalidRouteTableID.NotFound", "InvalidSecurityGroupRuleId.Malformed", diff --git a/internal/awsmodel/page.go b/internal/awsmodel/page.go index 299beade9..7644443af 100644 --- a/internal/awsmodel/page.go +++ b/internal/awsmodel/page.go @@ -4,8 +4,9 @@ import ( "bytes" "encoding/json" "fmt" + "maps" "regexp" - "sort" + "slices" "strconv" "strings" "unicode/utf8" @@ -253,12 +254,7 @@ func validateFrontmatter(owner string, page PageMetadata) error { // key rather than by first use, so adding an operation cannot renumber a note // that is already published. func noteOrder(notes map[string]string) []string { - keys := make([]string, 0, len(notes)) - for key := range notes { - keys = append(keys, key) - } - sort.Strings(keys) - return keys + return slices.Sorted(maps.Keys(notes)) } // checkNotes rejects a note nothing references and a reference to a note that diff --git a/internal/awsmodel/pattern.go b/internal/awsmodel/pattern.go index 5eafaa018..f0592c52a 100644 --- a/internal/awsmodel/pattern.go +++ b/internal/awsmodel/pattern.go @@ -51,8 +51,24 @@ func newPatternSampler(pattern string) (*patternSampler, error) { return sampler, err } +// re2Equivalents rewrite model patterns that use lookahead, which Go regexp +// lacks, as patterns matching the same strings. +var re2Equivalents = map[string]string{ + // ACM DomainNameString: labels start and end alphanumeric, the last has two + // or more characters, with an optional leading wildcard label. + `^(\*\.)?(((?!-)[A-Za-z0-9-]{0,62}[A-Za-z0-9])\.)+((?!-)[A-Za-z0-9-]{1,62}[A-Za-z0-9])$`: `^(\*\.)?([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z0-9][A-Za-z0-9-]{0,61}[A-Za-z0-9]$`, +} + +// re2Pattern spells a model pattern in Go regexp syntax. +func re2Pattern(pattern string) string { + if equivalent, ok := re2Equivalents[pattern]; ok { + return equivalent + } + return javaEscape.ReplaceAllString(pattern, `\x{$1}`) +} + func compilePatternSampler(pattern string) (*patternSampler, error) { - pattern = javaEscape.ReplaceAllString(pattern, `\x{$1}`) + pattern = re2Pattern(pattern) re, err := regexp.Compile(pattern) if err != nil { return nil, err diff --git a/internal/awsmodel/request.go b/internal/awsmodel/request.go index f9a8b7ac1..33e8629f3 100644 --- a/internal/awsmodel/request.go +++ b/internal/awsmodel/request.go @@ -65,22 +65,33 @@ func GenerateRequests(service Service, operationName string, options RequestOpti if !ok { return RequestPlan{}, fmt.Errorf("awsmodel: %s operation %q is not modelled", service, operationName) } + if service == ACM { + if _, err := importMaterial(); err != nil { + return RequestPlan{}, err + } + } declared := model.operationErrorCodes(operation) if operation.Input == nil { return RequestPlan{Cases: []RequestCase{{Operation: operationName, Input: map[string]any{}}}, DeclaredErrors: declared}, nil } - first := model.newRequestGenerator(0, options) + // A create or delete names a resource of its own, so it neither collides + // with a fixture nor removes one. + if strings.HasPrefix(operationName, "Create") || strings.HasPrefix(operationName, "Delete") { + options.Fixtures = nil + } + first := model.newRequestGenerator(operationName, 0, options) full, ok := first.structure(operation.Input.Shape, nil, 0) plan := RequestPlan{Skipped: first.skipped, Unbroken: first.unbroken, DeclaredErrors: declared} if !ok { plan.Skipped = append(plan.Skipped, Skip{Path: "$", Reason: "a required member cannot be generated"}) return plan, nil } - input := model.shapes[operation.Input.Shape] + // Conditionally required members join every request but are never omitted. + required := append(slices.Clone(model.shapes[operation.Input.Shape].Required), conditionallyRequired[service][operationName]...) optional := make([]string, 0, len(full)) for _, member := range slices.Sorted(maps.Keys(full)) { - if !slices.Contains(input.Required, member) { + if !slices.Contains(required, member) { optional = append(optional, member) } } @@ -90,7 +101,7 @@ func GenerateRequests(service Service, operationName string, options RequestOpti // what an earlier case created. salt := 0 generate := func() (map[string]any, []constraintSite, error) { - generator := model.newRequestGenerator(salt, options) + generator := model.newRequestGenerator(operationName, salt, options) salt++ value, _ := generator.structure(operation.Input.Shape, nil, 0) if len(generator.sites) != len(first.sites) { @@ -111,7 +122,7 @@ func GenerateRequests(service Service, operationName string, options RequestOpti Operation: operationName, Member: member, Path: memberPathOf(member), - Input: keepMembers(value, input.Required, member), + Input: keepMembers(value, required, member), }) } for index := range first.sites { @@ -121,10 +132,10 @@ func GenerateRequests(service Service, operationName string, options RequestOpti } site := sites[index] member, _ := site.path[0].(string) - if slices.Contains(input.Required, member) { + if slices.Contains(required, member) { member = "" } - broken, err := site.apply(keepMembers(value, input.Required, member)) + broken, err := site.apply(keepMembers(value, required, member)) if err != nil { return RequestPlan{}, err } @@ -269,6 +280,7 @@ var generatedTimestamp = time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) type requestGenerator struct { model *Model + operation string options RequestOptions salt string saltNumber int @@ -278,8 +290,8 @@ type requestGenerator struct { visiting map[string]bool } -func (m *Model) newRequestGenerator(salt int, options RequestOptions) *requestGenerator { - return &requestGenerator{model: m, options: options, salt: saltLetters(salt), saltNumber: salt, visiting: map[string]bool{}} +func (m *Model) newRequestGenerator(operation string, salt int, options RequestOptions) *requestGenerator { + return &requestGenerator{model: m, operation: operation, options: options, salt: saltLetters(salt), saltNumber: salt, visiting: map[string]bool{}} } // saltLetters spells n in base 26 with lower-case letters, which fit more @@ -462,6 +474,9 @@ func (g *requestGenerator) mapValue(shape *Shape, path []pathStep, depth int) (a func (g *requestGenerator) stringValue(shape *Shape, ref ShapeRef, path []pathStep) (any, bool) { if len(shape.Enum) > 0 { g.site(path, constraintSite{constraint: ConstraintEnum, detail: "value not in enum", replacement: invalidEnumValue(shape.Enum)}) + if value, ok := g.enumHint(path); ok && slices.Contains(shape.Enum, value) { + return value, true + } return shape.Enum[0], true } @@ -583,7 +598,10 @@ func invalidEnumValue(values []string) string { } func (g *requestGenerator) integer(shape *Shape, path []pathStep) int64 { - value := int64(1) + value, ok := g.integerHint(path) + if !ok { + value = 1 + } if shape.Min != nil { value = max(value, int64(math.Ceil(*shape.Min))) } @@ -630,5 +648,8 @@ func (g *requestGenerator) blob(shape *Shape, path []pathStep) []byte { g.leaveUnbroken(path, fmt.Sprintf("max length %g bytes is above the generated limit", *shape.Max)) } } + if value, ok := g.blobHint(path); ok && len(value) >= minLength && len(value) <= maxLength { + return value + } return []byte(fitLength("spx"+g.salt, minLength, maxLength)) } diff --git a/internal/awsmodel/request_hints.go b/internal/awsmodel/request_hints.go index 76fa6faa0..e1b27bef2 100644 --- a/internal/awsmodel/request_hints.go +++ b/internal/awsmodel/request_hints.go @@ -1,17 +1,29 @@ package awsmodel import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" "fmt" + "math/big" "regexp" "slices" "strings" + "sync" + "time" ) // RequestOptions places generated ARNs in the account and region the -// requests are sent as. +// requests are sent as. AccessKeyID is the key the requests are signed with. +// Fixtures maps a member to an existing resource it can name. type RequestOptions struct { - AccountID string - Region string + AccountID string + Region string + AccessKeyID string + Fixtures map[string]string } // Models constrain most identifiers only by length, so "spxa" is a model-valid @@ -56,11 +68,71 @@ var ec2IDPrefixes = map[string]string{ var ec2IDMember = regexp.MustCompile(`^(.*?)Ids?$`) +// conditionallyRequired names members the model marks optional that the +// operation still needs in its base request. The services refuse their absence +// with a code the judge cannot tell from a real finding. +var conditionallyRequired = map[Service]map[string][]string{ + EC2: {"CreateCapacityReservation": {"AvailabilityZone"}}, + ECS: { + "CreateCapacityProvider": {"autoScalingGroupProvider"}, + "CreateService": {"taskDefinition"}, + }, + RDS: { + "CreateDBInstance": {"AllocatedStorage", "MasterUsername", "MasterUserPassword"}, + "RestoreDBInstanceFromDBSnapshot": {"DBSnapshotIdentifier"}, + }, +} + const ( + // imageManifest is well formed but names blobs no repository holds. + imageManifest = `{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","size":2,"digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000"},"layers":[]}` + lifecyclePolicy = `{"rules":[{"rulePriority":1,"description":"expire untagged","selection":{"tagStatus":"untagged","countType":"sinceImagePushed","countUnit":"days","countNumber":14},"action":{"type":"expire"}}]}` + repositoryPolicy = `{"Version":"2012-10-17","Statement":[{"Sid":"pull","Effect":"Allow","Principal":{"AWS":"arn:aws:iam::%s:root"},"Action":"ecr:BatchGetImage"}]}` + policyDocument = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` trustPolicyDocument = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}` ) +// ec2TaggedTypes is the resource type each EC2 create tags through its +// TagSpecifications. AWS refuses any other type, so the enum's first value +// would make an otherwise valid request invalid. +var ec2TaggedTypes = map[string]string{ + "AllocateAddress": "elastic-ip", + "AuthorizeSecurityGroupEgress": "security-group-rule", + "AuthorizeSecurityGroupIngress": "security-group-rule", + "CopyImage": "image", + "CopySnapshot": "snapshot", + "CreateCapacityReservation": "capacity-reservation", + "CreateEgressOnlyInternetGateway": "egress-only-internet-gateway", + "CreateImage": "image", + "CreateInternetGateway": "internet-gateway", + "CreateKeyPair": "key-pair", + "CreateLaunchTemplate": "launch-template", + "CreateNatGateway": "natgateway", + "CreateNetworkInterface": "network-interface", + "CreatePlacementGroup": "placement-group", + "CreateRouteTable": "route-table", + "CreateSecurityGroup": "security-group", + "CreateSnapshot": "snapshot", + "CreateSubnet": "subnet", + "CreateVolume": "volume", + "CreateVpc": "vpc", + "ImportKeyPair": "key-pair", + "RegisterImage": "image", + "RequestSpotInstances": "spot-instances-request", + "RunInstances": "instance", +} + +// enumHint returns the enum value a valid request needs at path, where the +// first value would not do. +func (g *requestGenerator) enumHint(path []pathStep) (string, bool) { + if g.model.service != EC2 || len(path) == 0 || path[0] != "TagSpecifications" || memberName(path) != "ResourceType" { + return "", false + } + value, ok := ec2TaggedTypes[g.operation] + return value, ok +} + // hint returns a value of the right form for the member at path, if one is // known. salt keeps names distinct between request cases. func (g *requestGenerator) hint(path []pathStep) (string, bool) { @@ -69,11 +141,20 @@ func (g *requestGenerator) hint(path []pathStep) (string, bool) { return "", false } options := g.options + if fixture, ok := options.Fixtures[member]; ok { + return fixture, true + } suffix := fmt.Sprintf("%017x", g.saltNumber) name := "spx" + g.salt switch g.model.service { case EC2: + switch member { + case "AvailabilityZone": + return options.Region + "a", true + case "SourceRegion": + return options.Region, true + } if match := ec2IDMember.FindStringSubmatch(capitalize(member)); match != nil { if prefix, ok := ec2IDPrefixes[match[1]]; ok { return prefix + "-" + suffix, true @@ -90,6 +171,10 @@ func (g *requestGenerator) hint(path []pathStep) (string, bool) { return base + "listener/app/" + name + "/" + suffix[1:] + "/" + suffix[1:], true case "RuleArn", "RuleArns": return base + "listener-rule/app/" + name + "/" + suffix[1:] + "/" + suffix[1:] + "/" + suffix[1:], true + case "ProtocolVersion": + return "HTTP1", true + case "HttpCode": + return "200", true } case RDS: switch member { @@ -101,6 +186,8 @@ func (g *requestGenerator) hint(path []pathStep) (string, bool) { return "db.t3.medium", true case "DBParameterGroupFamily": return "postgres18", true + case "MasterUserPassword": + return "spx-conformance-" + g.salt, true case "SnapshotType": return "manual", true case "Source": @@ -113,13 +200,40 @@ func (g *requestGenerator) hint(path []pathStep) (string, bool) { case "taskDefinition": return name + ":1", true } + case ACM: + // The integration tenant CA permits example.com. + switch member { + case "DomainName", "SubjectAlternativeNames", "ValidationDomain": + return name + ".example.com", true + } case ECR: - if member == "resourceArn" { + switch member { + case "resourceArn": return fmt.Sprintf("arn:aws:ecr:%s:%s:repository/%s", options.Region, options.AccountID, name), true + case "imageManifest": + return imageManifest, true + case "lifecyclePolicyText": + return lifecyclePolicy, true + case "policyText": + return fmt.Sprintf(repositoryPolicy, options.AccountID), true } case EKS: - if member == "resourceArn" { + switch member { + case "resourceArn": return fmt.Sprintf("arn:aws:eks:%s:%s:cluster/%s", options.Region, options.AccountID, name), true + case "policyArn": + return "arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy", true + case "addonName": + return "aws-ebs-csi-driver", true + } + // An access entry's type; a nested type names an identity provider's. + if member == "type" && len(path) == 1 { + return "STANDARD", true + } + case STS: + // GetAccessKeyInfo answers only for a key that exists. + if member == "AccessKeyId" && options.AccessKeyID != "" { + return options.AccessKeyID, true } } @@ -142,6 +256,103 @@ func (g *requestGenerator) hint(path []pathStep) (string, bool) { return "", false } +// integerHint returns a value AWS accepts for an integer member whose range +// the model leaves out, if one is known. +func (g *requestGenerator) integerHint(path []pathStep) (int64, bool) { + if g.model.service == RDS && memberName(path) == "AllocatedStorage" { + // The smallest gp2/gp3 allocation PostgreSQL, the Engine hint, accepts. + return 20, true + } + return 0, false +} + +// blobHint returns a value AWS accepts for a blob member, if one is known. +func (g *requestGenerator) blobHint(path []pathStep) ([]byte, bool) { + if g.model.service != ACM { + return nil, false + } + // GenerateRequests has already surfaced a generation error. + material, err := importMaterial() + if err != nil { + return nil, false + } + switch memberName(path) { + case "Certificate": + return material.certificate, true + case "PrivateKey": + return material.privateKey, true + case "CertificateChain": + return material.chain, true + } + return nil, false +} + +// certificateMaterial is a PEM leaf certificate, its key and the CA that +// signed it, as ImportCertificate takes them. +type certificateMaterial struct { + certificate, privateKey, chain []byte +} + +// importMaterial is generated once per run; minting a key per request would +// dominate the ACM cases. +var importMaterial = sync.OnceValues(func() (certificateMaterial, error) { + now := time.Now().UTC() + caKey, caDER, err := newCertificate(&x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Spinifex Conformance CA"}, + NotBefore: now.Add(-time.Hour), + NotAfter: now.AddDate(1, 0, 0), + KeyUsage: x509.KeyUsageCertSign, + BasicConstraintsValid: true, + IsCA: true, + }, nil, nil) + if err != nil { + return certificateMaterial{}, err + } + ca, err := x509.ParseCertificate(caDER) + if err != nil { + return certificateMaterial{}, fmt.Errorf("awsmodel: parse conformance CA: %w", err) + } + leafKey, leafDER, err := newCertificate(&x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "spx.example.com"}, + DNSNames: []string{"spx.example.com"}, + NotBefore: now.Add(-time.Hour), + NotAfter: now.AddDate(1, 0, 0), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + }, ca, caKey) + if err != nil { + return certificateMaterial{}, err + } + keyDER, err := x509.MarshalPKCS8PrivateKey(leafKey) + if err != nil { + return certificateMaterial{}, fmt.Errorf("awsmodel: marshal conformance key: %w", err) + } + return certificateMaterial{ + certificate: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER}), + privateKey: pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), + chain: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}), + }, nil +}) + +// newCertificate creates a P-256 key and a certificate for it from template, +// signed by parent's key, or self-signed when parent is nil. +func newCertificate(template, parent *x509.Certificate, parentKey *ecdsa.PrivateKey) (*ecdsa.PrivateKey, []byte, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, nil, fmt.Errorf("awsmodel: generate conformance key: %w", err) + } + if parent == nil { + parent, parentKey = template, key + } + der, err := x509.CreateCertificate(rand.Reader, template, parent, &key.PublicKey, parentKey) + if err != nil { + return nil, nil, fmt.Errorf("awsmodel: create conformance certificate: %w", err) + } + return key, der, nil +} + // memberName is the structure member a value at path belongs to, looking // through list indices. func memberName(path []pathStep) string { diff --git a/internal/awsmodel/request_test.go b/internal/awsmodel/request_test.go index 13b496754..270f37095 100644 --- a/internal/awsmodel/request_test.go +++ b/internal/awsmodel/request_test.go @@ -4,6 +4,8 @@ package awsmodel //shapes, and the unexported pattern sampler and salt helpers. import ( + "crypto/tls" + "crypto/x509" "fmt" "maps" "regexp" @@ -17,7 +19,7 @@ import ( "github.com/stretchr/testify/require" ) -var testRequestOptions = RequestOptions{AccountID: "123456789012", Region: "ap-southeast-2"} +var testRequestOptions = RequestOptions{AccountID: "123456789012", Region: "ap-southeast-2", AccessKeyID: "AKIAIOSFODNN7EXAMPLE"} // constraintViolation is one model constraint an input breaks, found by // walking the input against the model independently of the generator. @@ -99,7 +101,7 @@ func testPattern(pattern string) *regexp.Regexp { if re, ok := testPatterns.Load(pattern); ok { return re.(*regexp.Regexp) } - re := regexp.MustCompile(javaEscape.ReplaceAllString(pattern, `\x{$1}`)) + re := regexp.MustCompile(re2Pattern(pattern)) testPatterns.Store(pattern, re) return re } @@ -136,7 +138,7 @@ func TestGeneratedRequestsBreakOnlyTheirConstraint(t *testing.T) { var violations []constraintViolation model.inputViolations(operation.Input.Shape, "$", request.Input, &violations) for member := range request.Input { - if !slices.Contains(input.Required, member) { + if !slices.Contains(input.Required, member) && !slices.Contains(conditionallyRequired[service][operationName], member) { require.Equal(t, request.Member, member, "%s %s %s carries an unrelated optional member", operationName, request.Constraint, request.Path) } } @@ -180,6 +182,65 @@ func TestGenerateRequestsIsolatesEachOptionalMember(t *testing.T) { } } +func TestGenerateRequestsSeedsConditionallyRequiredMembers(t *testing.T) { + for service, operations := range conditionallyRequired { + model, err := Load(service) + require.NoError(t, err) + for operationName, members := range operations { + operation, ok := model.Operation(operationName) + require.True(t, ok, "%s %s is not modelled", service, operationName) + input := model.shapes[operation.Input.Shape] + for _, member := range members { + require.Contains(t, input.Members, member, "%s %s", service, operationName) + require.NotContains(t, input.Required, member, "%s %s now models %s as required", service, operationName, member) + } + + plan, err := GenerateRequests(service, operationName, testRequestOptions) + require.NoError(t, err) + for _, request := range plan.Cases { + for _, member := range members { + require.NotEqual(t, member, request.Member, "%s %s", service, operationName) + require.Contains(t, request.Input, member, "%s %s %s %s", service, operationName, request.Constraint, request.Path) + } + } + } + } + + plan, err := GenerateRequests(RDS, "CreateDBInstance", testRequestOptions) + require.NoError(t, err) + require.Equal(t, int64(20), plan.Cases[0].Input["AllocatedStorage"]) + plan, err = GenerateRequests(EC2, "CreateCapacityReservation", testRequestOptions) + require.NoError(t, err) + require.Equal(t, "ap-southeast-2a", plan.Cases[0].Input["AvailabilityZone"]) + + // A constraint inside a seeded member is judged against the base request. + plan, err = GenerateRequests(ECS, "CreateCapacityProvider", testRequestOptions) + require.NoError(t, err) + nested := 0 + for _, request := range plan.Cases { + if strings.HasPrefix(request.Path, "$.autoScalingGroupProvider.") { + nested++ + require.Empty(t, request.Member, request.Path) + } + } + require.NotZero(t, nested) +} + +func TestGenerateRequestsTargetsFixturesOutsideCreateAndDelete(t *testing.T) { + options := testRequestOptions + options.Fixtures = map[string]string{"RoleName": "fixture"} + for operation, wantFixture := range map[string]bool{"TagRole": true, "CreateRole": false, "DeleteRole": false} { + plan, err := GenerateRequests(IAM, operation, options) + require.NoError(t, err) + for _, request := range plan.Cases { + if request.Path == "$.RoleName" { + continue + } + require.Equal(t, wantFixture, request.Input["RoleName"] == "fixture", "%s %s %s", operation, request.Constraint, request.Path) + } + } +} + func TestGenerateRequestsAttributesRejections(t *testing.T) { plan, err := GenerateRequests(IAM, "CreateRole", testRequestOptions) require.NoError(t, err) @@ -278,6 +339,13 @@ func TestGenerateRequestsUsesResourceHints(t *testing.T) { {RDS, "CreateDBInstance", "Engine", regexp.MustCompile(`^postgres$`)}, {ECR, "TagResource", "resourceArn", regexp.MustCompile(`^arn:aws:ecr:ap-southeast-2:123456789012:repository/`)}, {EKS, "TagResource", "resourceArn", regexp.MustCompile(`^arn:aws:eks:ap-southeast-2:123456789012:cluster/`)}, + {EKS, "AssociateAccessPolicy", "policyArn", regexp.MustCompile(`^arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy$`)}, + {EKS, "CreateAddon", "addonName", regexp.MustCompile(`^aws-ebs-csi-driver$`)}, + {EKS, "CreateAccessEntry", "type", regexp.MustCompile(`^STANDARD$`)}, + {ElasticLoadBalancingV2, "CreateTargetGroup", "ProtocolVersion", regexp.MustCompile(`^HTTP1$`)}, + {EC2, "CopyImage", "SourceRegion", regexp.MustCompile(`^ap-southeast-2$`)}, + {STS, "GetAccessKeyInfo", "AccessKeyId", regexp.MustCompile(`^AKIAIOSFODNN7EXAMPLE$`)}, + {IAM, "DeleteAccessKey", "AccessKeyId", regexp.MustCompile(`^AKIA[0-9A-F]{16}$`)}, } for _, test := range tests { t.Run(test.operation+"."+test.member, func(t *testing.T) { @@ -302,6 +370,80 @@ func TestGenerateRequestsUsesResourceHints(t *testing.T) { } } +func TestGenerateRequestsHintsNestedMembers(t *testing.T) { + plan, err := GenerateRequests(ElasticLoadBalancingV2, "CreateTargetGroup", testRequestOptions) + require.NoError(t, err) + for _, request := range plan.Cases { + if request.Acceptance() && request.Member == "Matcher" { + require.Equal(t, "200", request.Input["Matcher"].(map[string]any)["HttpCode"]) + return + } + } + t.Fatal("no acceptance request sets Matcher") +} + +// AWS refuses a tag resource type the operation does not create, so a valid +// request tags the operation's own type rather than the enum's first value. +func TestGenerateRequestsTagsTheOperationsOwnResourceType(t *testing.T) { + for operation, want := range map[string]string{"CreateVpc": "vpc", "CreateLaunchTemplate": "launch-template"} { + t.Run(operation, func(t *testing.T) { + plan, err := GenerateRequests(EC2, operation, testRequestOptions) + require.NoError(t, err) + for _, request := range plan.Cases { + if request.Acceptance() && request.Member == "TagSpecifications" { + spec := request.Input["TagSpecifications"].([]any)[0].(map[string]any) + require.Equal(t, want, spec["ResourceType"]) + return + } + } + t.Fatal("no acceptance request sets TagSpecifications") + }) + } +} + +func TestGenerateRequestsImportsAVerifiableCertificate(t *testing.T) { + plan, err := GenerateRequests(ACM, "ImportCertificate", testRequestOptions) + require.NoError(t, err) + var input map[string]any + for _, request := range plan.Cases { + if request.Acceptance() && request.Member == "CertificateChain" { + input = request.Input + } + } + require.NotNil(t, input, "no acceptance request sets CertificateChain") + + pair, err := tls.X509KeyPair(input["Certificate"].([]byte), input["PrivateKey"].([]byte)) + require.NoError(t, err) + leaf, err := x509.ParseCertificate(pair.Certificate[0]) + require.NoError(t, err) + roots := x509.NewCertPool() + require.True(t, roots.AppendCertsFromPEM(input["CertificateChain"].([]byte))) + _, err = leaf.Verify(x509.VerifyOptions{Roots: roots, DNSName: "spx.example.com"}) + require.NoError(t, err) +} + +func TestRE2EquivalentsMatchTheModelledSemantics(t *testing.T) { + modelled := map[string]bool{} + for _, service := range Services() { + model, err := Load(service) + require.NoError(t, err) + for _, shape := range model.shapes { + modelled[shape.Pattern] = true + } + } + for pattern := range re2Equivalents { + require.True(t, modelled[pattern], "no model uses %s", pattern) + } + + domain := testPattern(`^(\*\.)?(((?!-)[A-Za-z0-9-]{0,62}[A-Za-z0-9])\.)+((?!-)[A-Za-z0-9-]{1,62}[A-Za-z0-9])$`) + for _, name := range []string{"example.com", "*.example.com", "a.b.co", "x-y.example.com", "a." + strings.Repeat("b", 63)} { + require.True(t, domain.MatchString(name), name) + } + for _, name := range []string{"example", "-a.com", "a-.com", "a.c", "a.-com", "a.com-", "a..com", "*.com.*", strings.Repeat("a", 64) + ".com"} { + require.False(t, domain.MatchString(name), name) + } +} + func TestPatternSamplerMatchesWholeValue(t *testing.T) { tests := []struct { pattern string diff --git a/internal/awsmodel/verdict.go b/internal/awsmodel/verdict.go index 7a7b593cb..35ac214a2 100644 --- a/internal/awsmodel/verdict.go +++ b/internal/awsmodel/verdict.go @@ -135,9 +135,7 @@ func DecodeRequestResult(service Service, status int, header http.Header, body [ } func trimErrorCode(code string) string { - if separator := strings.Index(code, ":"); separator >= 0 { - code = code[:separator] - } + code, _, _ = strings.Cut(code, ":") if separator := strings.LastIndex(code, "#"); separator >= 0 { code = code[separator+1:] } diff --git a/internal/ecrauth/ecrauth.go b/internal/ecrauth/ecrauth.go index bfd58fc79..e0828890d 100644 --- a/internal/ecrauth/ecrauth.go +++ b/internal/ecrauth/ecrauth.go @@ -109,11 +109,9 @@ func GatewayHTTPClient(caPath string) (*http.Client, error) { // HostFromImage returns the host[:port] portion of an image ref or URL. It strips // any scheme and trims at the first path separator. func HostFromImage(image string) string { - if i := strings.Index(image, "://"); i >= 0 { - image = image[i+3:] + if _, rest, ok := strings.Cut(image, "://"); ok { + image = rest } - if i := strings.IndexByte(image, '/'); i >= 0 { - image = image[:i] - } - return image + host, _, _ := strings.Cut(image, "/") + return host } diff --git a/internal/testkit/loadgen/loadgen.go b/internal/testkit/loadgen/loadgen.go index 0a878bd16..61e1921e8 100644 --- a/internal/testkit/loadgen/loadgen.go +++ b/internal/testkit/loadgen/loadgen.go @@ -12,7 +12,9 @@ import ( "context" "errors" "fmt" + "maps" "math" + "slices" "sort" "sync" "time" @@ -503,10 +505,5 @@ func Summary(report *Report) string { } func sortedOps(ops map[string]OpStats) []string { - names := make([]string, 0, len(ops)) - for name := range ops { - names = append(names, name) - } - sort.Strings(names) - return names + return slices.Sorted(maps.Keys(ops)) } diff --git a/internal/testkit/loadgen/ops.go b/internal/testkit/loadgen/ops.go index 1e34b5e18..fb560e5ac 100644 --- a/internal/testkit/loadgen/ops.go +++ b/internal/testkit/loadgen/ops.go @@ -3,7 +3,8 @@ package loadgen import ( "context" "fmt" - "sort" + "maps" + "slices" "strings" "github.com/aws/aws-sdk-go/aws" @@ -102,12 +103,7 @@ var DefaultOps = []string{"DescribeInstances", "DescribeVpcs", "DescribeVolumes" // OpNames lists every operation the registry knows, for the usage text. func OpNames() []string { - names := make([]string, 0, len(registry)) - for name := range registry { - names = append(names, name) - } - sort.Strings(names) - return names + return slices.Sorted(maps.Keys(registry)) } // ResolveOps turns names into operations, rejecting an unknown one rather than @@ -135,22 +131,16 @@ func ResolveOps(names []string) ([]Op, error) { // NeedsVPC reports whether any selected operation acts on a tenant resource, // so the caller knows whether to pay for discovery before the run starts. func NeedsVPC(ops []Op) bool { - for _, op := range ops { - if op.Name == "CreateTags" { - return true - } - } - return false + return slices.ContainsFunc(ops, func(op Op) bool { + return op.Name == "CreateTags" + }) } // NeedsVolume reports whether any selected operation needs a volume id. func NeedsVolume(ops []Op) bool { - for _, op := range ops { - if op.Name == "DescribeVolumesByID" { - return true - } - } - return false + return slices.ContainsFunc(ops, func(op Op) bool { + return op.Name == "DescribeVolumesByID" + }) } // ResolveVolume finds one volume for the target to ask about by id. A tenant diff --git a/internal/testkit/nats.go b/internal/testkit/nats.go index 42ad5aa86..5d900aeee 100644 --- a/internal/testkit/nats.go +++ b/internal/testkit/nats.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/mulgadc/spinifex/spinifex/clustersize" "github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" @@ -73,6 +74,11 @@ func StartTestJetStream(t *testing.T) (*server.Server, *nats.Conn, jetstream.Jet js, err := jetstream.New(nc) require.NoError(t, err) + + // One embedded server is a one-node cluster, and bucket creation refuses to + // guess. A test that wants a different count calls RedeclareForTest, because + // declaring is write-once and this has already done it. + clustersize.DeclareForTest(t, 1) return ns, nc, js } @@ -95,6 +101,7 @@ var vpcdStubTopics = []string{ "vpc.update-port-sgs", "vpc.create-port", "vpc.delete-port", + "vpc.delete-nat", } // vpcdStubRegistry holds the per-conn response map. Per-conn so concurrent tests with separate conns don't interfere. diff --git a/internal/testkit/recordingstore/recordingstore.go b/internal/testkit/recordingstore/recordingstore.go index 5cae6ae28..0ea0b370f 100644 --- a/internal/testkit/recordingstore/recordingstore.go +++ b/internal/testkit/recordingstore/recordingstore.go @@ -7,6 +7,7 @@ package recordingstore import ( "context" + "slices" "strings" "sync" @@ -77,10 +78,7 @@ func (s *Store) TouchedPrefix(prefix string) bool { return true } } - for _, listed := range s.ListPrefixes() { - if strings.HasPrefix(listed, prefix) || strings.HasPrefix(prefix, listed) { - return true - } - } - return false + return slices.ContainsFunc(s.ListPrefixes(), func(listed string) bool { + return strings.HasPrefix(listed, prefix) || strings.HasPrefix(prefix, listed) + }) } diff --git a/spinifex/accountteardown/cluster_test.go b/spinifex/accountteardown/cluster_test.go index 879206f56..b0dacbb9f 100644 --- a/spinifex/accountteardown/cluster_test.go +++ b/spinifex/accountteardown/cluster_test.go @@ -20,7 +20,7 @@ func TestNewClusterEngineCoversEveryStage(t *testing.T) { masterKey, err := handlers_iam.GenerateMasterKey() require.NoError(t, err) - svc, err := handlers_iam.NewIAMServiceImpl(t.Context(), nc, masterKey, 1) + svc, err := handlers_iam.NewIAMServiceImpl(t.Context(), nc, masterKey) require.NoError(t, err) engine, err := NewClusterEngine(t.Context(), nc, 1, svc, newFakeBuckets()) @@ -48,7 +48,7 @@ func TestClusterEnginePrechecks(t *testing.T) { masterKey, err := handlers_iam.GenerateMasterKey() require.NoError(t, err) - svc, err := handlers_iam.NewIAMServiceImpl(t.Context(), nc, masterKey, 1) + svc, err := handlers_iam.NewIAMServiceImpl(t.Context(), nc, masterKey) require.NoError(t, err) engine, err := NewClusterEngine(t.Context(), nc, 1, svc, newFakeBuckets()) diff --git a/spinifex/accountteardown/engine.go b/spinifex/accountteardown/engine.go index ac5e8533a..bbd673f03 100644 --- a/spinifex/accountteardown/engine.go +++ b/spinifex/accountteardown/engine.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log/slog" + "slices" "sort" "time" ) @@ -270,12 +271,9 @@ func (e *Engine) drainPoll() time.Duration { } func containsResource(resources []Resource, want Resource) bool { - for _, resource := range resources { - if resource.Kind == want.Kind && resource.ID == want.ID { - return true - } - } - return false + return slices.ContainsFunc(resources, func(resource Resource) bool { + return resource.Kind == want.Kind && resource.ID == want.ID + }) } // SortReapers orders reapers by stage while preserving the caller's order diff --git a/spinifex/accountteardown/reapers_ec2.go b/spinifex/accountteardown/reapers_ec2.go index d745dc8a7..627ae2a3c 100644 --- a/spinifex/accountteardown/reapers_ec2.go +++ b/spinifex/accountteardown/reapers_ec2.go @@ -3,6 +3,7 @@ package accountteardown import ( "context" "fmt" + "slices" "strings" "time" @@ -664,12 +665,9 @@ func isAlreadyGone(err error) bool { return true } message := err.Error() - for _, marker := range notFoundMarkers { - if strings.Contains(message, marker) { - return true - } - } - return false + return slices.ContainsFunc(notFoundMarkers, func(marker string) bool { + return strings.Contains(message, marker) + }) } // isNotAssociated reports the benign case of disassociating something that was diff --git a/spinifex/accountteardown/reapers_iam_test.go b/spinifex/accountteardown/reapers_iam_test.go index 7863fbd94..c49f315a3 100644 --- a/spinifex/accountteardown/reapers_iam_test.go +++ b/spinifex/accountteardown/reapers_iam_test.go @@ -398,7 +398,7 @@ func TestPolicyReaperDeletesAPolicyWithSeveralVersions(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) masterKey, err := handlers_iam.GenerateMasterKey() require.NoError(t, err) - svc, err := handlers_iam.NewIAMServiceImpl(t.Context(), nc, masterKey, 1) + svc, err := handlers_iam.NewIAMServiceImpl(t.Context(), nc, masterKey) require.NoError(t, err) const accountID = "000000000042" diff --git a/spinifex/admin/network_detect.go b/spinifex/admin/network_detect.go index b48764345..067dcb471 100644 --- a/spinifex/admin/network_detect.go +++ b/spinifex/admin/network_detect.go @@ -4,6 +4,7 @@ import ( "fmt" "net" "os/exec" + "slices" "strings" "github.com/mulgadc/bluebottle/pkg/safecast" @@ -190,10 +191,7 @@ func isVirtualInterface(name string, defaultRouteDev string) bool { "docker", "br-", "veth", "ovs", "virbr", "tun", "tap", "tailscale", "wg", "lo", } - for _, p := range prefixes { - if strings.HasPrefix(name, p) { - return true - } - } - return false + return slices.ContainsFunc(prefixes, func(p string) bool { + return strings.HasPrefix(name, p) + }) } diff --git a/spinifex/bootstrap/preflight/preflight.go b/spinifex/bootstrap/preflight/preflight.go index 18f51ed19..a3bd8f983 100644 --- a/spinifex/bootstrap/preflight/preflight.go +++ b/spinifex/bootstrap/preflight/preflight.go @@ -9,6 +9,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" "github.com/mulgadc/spinifex/spinifex/utils" @@ -86,12 +87,9 @@ func CheckHostAt(root string) []Result { // HasProblem reports whether any result is not OK. func HasProblem(results []Result) bool { - for _, r := range results { - if r.Status != OK { - return true - } - } - return false + return slices.ContainsFunc(results, func(r Result) bool { + return r.Status != OK + }) } func checkHelper(root, path string) Result { diff --git a/spinifex/cloud/exoscale/client.go b/spinifex/cloud/exoscale/client.go new file mode 100644 index 000000000..9ffa91365 --- /dev/null +++ b/spinifex/cloud/exoscale/client.go @@ -0,0 +1,246 @@ +// Package exoscale is the narrow slice of the Exoscale API the external +// allocator needs, driven through the exo CLI rather than the Go SDK. +// +// The CLI keeps a heavy dependency out of the build while this is a proof of +// concept, and it means every call Spinifex makes is one an operator can repeat +// by hand with the same config file. Nothing outside this package knows a +// process is being run, so moving to the SDK later changes only this file. +package exoscale + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/netip" + "os" + "os/exec" + "strings" + "time" +) + +// DefaultBinary is where the exo release package installs the CLI. +const DefaultBinary = "/usr/bin/exo" + +// DefaultConfigFile holds the node's scoped API key, in the exo CLI's own +// format, readable by the daemon's service user only. +const DefaultConfigFile = "/etc/spinifex/exoscale/exoscale.toml" + +// DefaultCallTimeout bounds one CLI invocation. Allocation sits inside the AWS +// client's 60s read timeout and makes two calls, so each gets well under half. +const DefaultCallTimeout = 25 * time.Second + +var ( + // ErrNotFound means the EIP or instance does not exist in the zone. + ErrNotFound = errors.New("exoscale: resource not found") + // ErrQuotaExceeded means the organisation's EIP quota is used up. + ErrQuotaExceeded = errors.New("exoscale: quota exceeded") +) + +// ElasticIP is one Exoscale Elastic IP as the allocator sees it. +type ElasticIP struct { + ID string + Address netip.Addr + Description string + Zone string +} + +// Client is the surface the allocator uses. Expressed in this package's types +// so the fake in fake.go is a map, not a mock of a process. +type Client interface { + // CreateElasticIP creates an unattached manual IPv4 EIP. + CreateElasticIP(ctx context.Context, description string) (ElasticIP, error) + // AttachElasticIP attaches eipID to instanceID. + AttachElasticIP(ctx context.Context, instanceID, eipID string) error + // DetachElasticIP detaches eipID from instanceID, keeping the address. + DetachElasticIP(ctx context.Context, instanceID, eipID string) error + // DeleteElasticIP releases the address back to Exoscale. + DeleteElasticIP(ctx context.Context, eipID string) error + // ListElasticIPs returns every EIP in the zone, ours or not. + ListElasticIPs(ctx context.Context) ([]ElasticIP, error) + // Version reports the CLI version, proving the binary runs at all. + Version(ctx context.Context) (string, error) +} + +// runFunc executes the CLI. Swapped in tests so no process is started. +type runFunc func(ctx context.Context, binary string, args []string) (stdout, stderr []byte, err error) + +// CLIConfig selects the binary, credentials and zone. +type CLIConfig struct { + Binary string + ConfigFile string + Account string + Zone string + CallTimeout time.Duration +} + +// cliClient implements Client by running exo with JSON output. +type cliClient struct { + cfg CLIConfig + run runFunc +} + +var _ Client = (*cliClient)(nil) + +// NewCLIClient builds a client over the exo binary. It does not run anything; +// call Version to prove the binary and config are usable. +func NewCLIClient(cfg CLIConfig) (Client, error) { + if cfg.Zone == "" { + return nil, errors.New("exoscale: zone is required") + } + if cfg.Binary == "" { + cfg.Binary = DefaultBinary + } + if cfg.ConfigFile == "" { + cfg.ConfigFile = DefaultConfigFile + } + if cfg.CallTimeout <= 0 { + cfg.CallTimeout = DefaultCallTimeout + } + return &cliClient{cfg: cfg, run: execRun}, nil +} + +// eipJSON is the shape of `exo compute elastic-ip create|show|list -O json`. +type eipJSON struct { + ID string `json:"id"` + IPAddress string `json:"ip_address"` + Description string `json:"description"` + Zone string `json:"zone"` +} + +func (e eipJSON) toElasticIP() (ElasticIP, error) { + if e.ID == "" { + return ElasticIP{}, errors.New("exoscale: elastic IP with no id in exo output") + } + addr, err := netip.ParseAddr(e.IPAddress) + if err != nil { + return ElasticIP{}, fmt.Errorf("exoscale: elastic IP %s has unparseable address %q: %w", e.ID, e.IPAddress, err) + } + return ElasticIP{ID: e.ID, Address: addr, Description: e.Description, Zone: e.Zone}, nil +} + +func (c *cliClient) CreateElasticIP(ctx context.Context, description string) (ElasticIP, error) { + out, err := c.exo(ctx, "compute", "elastic-ip", "create", "--description", description, "-z", c.cfg.Zone) + if err != nil { + return ElasticIP{}, err + } + var e eipJSON + if err := json.Unmarshal(out, &e); err != nil { + return ElasticIP{}, fmt.Errorf("exoscale: decode created elastic IP: %w", err) + } + return e.toElasticIP() +} + +func (c *cliClient) AttachElasticIP(ctx context.Context, instanceID, eipID string) error { + _, err := c.exo(ctx, "compute", "instance", "elastic-ip", "attach", instanceID, eipID, "-z", c.cfg.Zone) + return err +} + +func (c *cliClient) DetachElasticIP(ctx context.Context, instanceID, eipID string) error { + _, err := c.exo(ctx, "compute", "instance", "elastic-ip", "detach", instanceID, eipID, "-z", c.cfg.Zone) + return err +} + +func (c *cliClient) DeleteElasticIP(ctx context.Context, eipID string) error { + _, err := c.exo(ctx, "compute", "elastic-ip", "delete", "--force", eipID, "-z", c.cfg.Zone) + return err +} + +func (c *cliClient) ListElasticIPs(ctx context.Context) ([]ElasticIP, error) { + out, err := c.exo(ctx, "compute", "elastic-ip", "list", "-z", c.cfg.Zone) + if err != nil { + return nil, err + } + var raw []eipJSON + if err := json.Unmarshal(out, &raw); err != nil { + return nil, fmt.Errorf("exoscale: decode elastic IP list: %w", err) + } + eips := make([]ElasticIP, 0, len(raw)) + for _, e := range raw { + // The list spans zones on some CLI versions; only ours is ours to judge. + if e.Zone != "" && e.Zone != c.cfg.Zone { + continue + } + eip, err := e.toElasticIP() + if err != nil { + return nil, err + } + eips = append(eips, eip) + } + return eips, nil +} + +func (c *cliClient) Version(ctx context.Context) (string, error) { + ctx, cancel := context.WithTimeout(ctx, c.cfg.CallTimeout) + defer cancel() + stdout, stderr, err := c.run(ctx, c.cfg.Binary, []string{"version"}) + if err != nil { + return "", classify(err, stderr) + } + return strings.TrimSpace(string(stdout)), nil +} + +// exo runs one command with JSON output against the configured account. +func (c *cliClient) exo(ctx context.Context, args ...string) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, c.cfg.CallTimeout) + defer cancel() + full := []string{"--config", c.cfg.ConfigFile, "--output-format", "json", "--quiet"} + if c.cfg.Account != "" { + full = append(full, "--use-account", c.cfg.Account) + } + full = append(full, args...) + stdout, stderr, err := c.run(ctx, c.cfg.Binary, full) + if err != nil { + return nil, fmt.Errorf("exo %s: %w", strings.Join(args, " "), classify(err, stderr)) + } + return stdout, nil +} + +// classify maps the CLI's stderr onto the sentinels the allocator acts on. The +// texts are the ones exo 1.101 prints; anything else keeps its own message. +func classify(runErr error, stderr []byte) error { + msg := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(string(stderr)), "error:")) + switch { + case strings.Contains(msg, "resource not found"): + return fmt.Errorf("%w: %s", ErrNotFound, msg) + case strings.Contains(msg, "has been exceeded"): + return fmt.Errorf("%w: %s", ErrQuotaExceeded, msg) + case msg != "": + return fmt.Errorf("%s: %w", msg, runErr) + default: + return runErr + } +} + +// credentialEnv are the variables exo would read in preference to --config. A +// daemon started with them in its environment would act as a different key +// from the one the operator provisioned, so they are dropped. +var credentialEnv = []string{"EXOSCALE_API_KEY=", "EXOSCALE_API_SECRET=", "EXOSCALE_ACCOUNT=", "EXOSCALE_CONFIG="} + +func execRun(ctx context.Context, binary string, args []string) ([]byte, []byte, error) { + cmd := exec.CommandContext(ctx, binary, args...) + cmd.Env = scrubEnv(os.Environ()) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + return stdout.Bytes(), stderr.Bytes(), err +} + +func scrubEnv(env []string) []string { + out := env[:0:0] + for _, kv := range env { + drop := false + for _, prefix := range credentialEnv { + if strings.HasPrefix(kv, prefix) { + drop = true + break + } + } + if !drop { + out = append(out, kv) + } + } + return out +} diff --git a/spinifex/cloud/exoscale/client_test.go b/spinifex/cloud/exoscale/client_test.go new file mode 100644 index 000000000..b9e0b2580 --- /dev/null +++ b/spinifex/cloud/exoscale/client_test.go @@ -0,0 +1,140 @@ +package exoscale + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// scripted replays captured exo output and records the argv it was given. +type scripted struct { + stdout, stderr []byte + err error + args []string +} + +func (s *scripted) run(_ context.Context, _ string, args []string) ([]byte, []byte, error) { + s.args = args + return s.stdout, s.stderr, s.err +} + +func testdata(t *testing.T, name string) []byte { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", name)) + require.NoError(t, err) + return b +} + +func newScripted(t *testing.T, s *scripted) *cliClient { + t.Helper() + c, err := NewCLIClient(CLIConfig{Zone: "de-fra-1", Account: "spx"}) + require.NoError(t, err) + cc := c.(*cliClient) + cc.run = s.run + return cc +} + +func TestCreateElasticIP_DecodesCapturedOutput(t *testing.T) { + s := &scripted{stdout: testdata(t, "create.json")} + e, err := newScripted(t, s).CreateElasticIP(context.Background(), "spinifex:x:eipalloc-1") + require.NoError(t, err) + assert.Equal(t, "9d86594b-1bb0-4908-ae28-5870f961aaef", e.ID) + assert.Equal(t, "194.182.171.24", e.Address.String()) + assert.Equal(t, "spinifex:capture:1", e.Description) + + // Credentials come only from the named config file and account, and the + // description travels as one argv element however it is spelled. + assert.Equal(t, []string{ + "--config", DefaultConfigFile, "--output-format", "json", "--quiet", "--use-account", "spx", + "compute", "elastic-ip", "create", "--description", "spinifex:x:eipalloc-1", "-z", "de-fra-1", + }, s.args) +} + +func TestListElasticIPs_DecodesCapturedOutput(t *testing.T) { + s := &scripted{stdout: testdata(t, "list.json")} + eips, err := newScripted(t, s).ListElasticIPs(context.Background()) + require.NoError(t, err) + require.Len(t, eips, 5) + assert.Equal(t, "mulga1", eips[0].Description) + assert.Equal(t, "89.145.162.53", eips[0].Address.String()) +} + +func TestListElasticIPs_SkipsOtherZones(t *testing.T) { + s := &scripted{stdout: []byte(`[{"id":"a","ip_address":"1.2.3.4","zone":"ch-gva-2"},{"id":"b","ip_address":"1.2.3.5","zone":"de-fra-1"}]`)} + eips, err := newScripted(t, s).ListElasticIPs(context.Background()) + require.NoError(t, err) + require.Len(t, eips, 1) + assert.Equal(t, "b", eips[0].ID) +} + +func TestCreateElasticIP_RejectsOutputWithoutAddress(t *testing.T) { + s := &scripted{stdout: []byte(`{"id":"a","ip_address":""}`)} + _, err := newScripted(t, s).CreateElasticIP(context.Background(), "d") + require.Error(t, err) + assert.Contains(t, err.Error(), "unparseable address") +} + +func TestClassify_CapturedErrors(t *testing.T) { + exitErr := errors.New("exit status 1") + cases := []struct { + file string + want error + }{ + {"quota.stderr", ErrQuotaExceeded}, + {"notfound.stderr", ErrNotFound}, + } + for _, tc := range cases { + t.Run(tc.file, func(t *testing.T) { + s := &scripted{stderr: testdata(t, tc.file), err: exitErr} + _, err := newScripted(t, s).CreateElasticIP(context.Background(), "d") + require.ErrorIs(t, err, tc.want) + }) + } +} + +func TestClassify_UnknownErrorKeepsMessageAndCause(t *testing.T) { + exitErr := errors.New("exit status 1") + s := &scripted{stderr: []byte("error: invalid API key\n"), err: exitErr} + err := newScripted(t, s).DeleteElasticIP(context.Background(), "a") + require.ErrorIs(t, err, exitErr) + assert.NotErrorIs(t, err, ErrNotFound) + assert.Contains(t, err.Error(), "invalid API key") +} + +func TestScrubEnv_DropsCredentialVariables(t *testing.T) { + got := scrubEnv([]string{"PATH=/usr/bin", "EXOSCALE_API_KEY=k", "EXOSCALE_API_SECRET=s", "EXOSCALE_CONFIG=/x", "HOME=/h"}) + assert.Equal(t, []string{"PATH=/usr/bin", "HOME=/h"}, got) +} + +// A binary that exits non-zero is reported, not mistaken for empty output. +func TestExecRun_RealProcessFailure(t *testing.T) { + sh, err := exec.LookPath("sh") + require.NoError(t, err) + c, err := NewCLIClient(CLIConfig{Zone: "de-fra-1", Binary: sh}) + require.NoError(t, err) + _, err = c.Version(context.Background()) + require.Error(t, err) +} + +// The instance comes before the EIP in both commands. Swapped, exo reports the +// instance as not found, which Release would read as already detached. +func TestAttachDetach_PassInstanceThenEIP(t *testing.T) { + s := &scripted{} + c := newScripted(t, s) + require.NoError(t, c.AttachElasticIP(context.Background(), "inst-1", "eip-1")) + assert.Equal(t, []string{"compute", "instance", "elastic-ip", "attach", "inst-1", "eip-1", "-z", "de-fra-1"}, s.args[7:]) + require.NoError(t, c.DetachElasticIP(context.Background(), "inst-1", "eip-1")) + assert.Equal(t, []string{"compute", "instance", "elastic-ip", "detach", "inst-1", "eip-1", "-z", "de-fra-1"}, s.args[7:]) +} + +func TestDetach_NotFoundIsTheSentinel(t *testing.T) { + s := &scripted{stderr: testdata(t, "notfound.stderr"), err: errors.New("exit status 1")} + err := newScripted(t, s).DetachElasticIP(context.Background(), "inst-1", "eip-1") + assert.ErrorIs(t, err, ErrNotFound) +} diff --git a/spinifex/cloud/exoscale/fake.go b/spinifex/cloud/exoscale/fake.go new file mode 100644 index 000000000..8d54b4f30 --- /dev/null +++ b/spinifex/cloud/exoscale/fake.go @@ -0,0 +1,155 @@ +package exoscale + +import ( + "context" + "fmt" + "net/netip" + "slices" + "sync" +) + +// Fake is an in-memory Client for tests. It enforces the organisation quota +// and the attach relationship, which are the two behaviours the allocator's +// error and rollback paths depend on. +type Fake struct { + mu sync.Mutex + zone string + quota int + next netip.Addr + seq int + eips map[string]ElasticIP + attached map[string]string // eip ID -> instance ID + + // Fail, when set for an operation name ("create", "attach", "detach", + // "delete", "list"), makes that operation return the error once. + Fail map[string]error + // Calls records operation names in order. + Calls []string +} + +var _ Client = (*Fake)(nil) + +// NewFake returns a fake with the given quota; zero means unlimited. +func NewFake(zone string, quota int) *Fake { + return &Fake{ + zone: zone, + quota: quota, + next: netip.MustParseAddr("194.182.160.1"), + eips: map[string]ElasticIP{}, + attached: map[string]string{}, + Fail: map[string]error{}, + } +} + +// Seed adds an EIP the allocator did not create, such as an operator's own. +func (f *Fake) Seed(e ElasticIP) { + f.mu.Lock() + defer f.mu.Unlock() + f.eips[e.ID] = e +} + +// AttachedTo reports which instance an EIP is attached to, if any. +func (f *Fake) AttachedTo(eipID string) (string, bool) { + f.mu.Lock() + defer f.mu.Unlock() + inst, ok := f.attached[eipID] + return inst, ok +} + +// IDs returns every EIP ID, sorted. +func (f *Fake) IDs() []string { + f.mu.Lock() + defer f.mu.Unlock() + ids := make([]string, 0, len(f.eips)) + for id := range f.eips { + ids = append(ids, id) + } + slices.Sort(ids) + return ids +} + +func (f *Fake) fail(op string) error { + f.Calls = append(f.Calls, op) + if err, ok := f.Fail[op]; ok { + delete(f.Fail, op) + return err + } + return nil +} + +func (f *Fake) CreateElasticIP(_ context.Context, description string) (ElasticIP, error) { + f.mu.Lock() + defer f.mu.Unlock() + if err := f.fail("create"); err != nil { + return ElasticIP{}, err + } + if f.quota > 0 && len(f.eips) >= f.quota { + return ElasticIP{}, fmt.Errorf("%w: Conflict: Usage of resource 'eip' has been exceeded", ErrQuotaExceeded) + } + f.seq++ + e := ElasticIP{ + ID: fmt.Sprintf("00000000-0000-4000-8000-%012d", f.seq), + Address: f.next, + Description: description, + Zone: f.zone, + } + f.next = f.next.Next() + f.eips[e.ID] = e + return e, nil +} + +func (f *Fake) AttachElasticIP(_ context.Context, instanceID, eipID string) error { + f.mu.Lock() + defer f.mu.Unlock() + if err := f.fail("attach"); err != nil { + return err + } + if _, ok := f.eips[eipID]; !ok { + return fmt.Errorf("%w: elastic IP %s", ErrNotFound, eipID) + } + f.attached[eipID] = instanceID + return nil +} + +func (f *Fake) DetachElasticIP(_ context.Context, instanceID, eipID string) error { + f.mu.Lock() + defer f.mu.Unlock() + if err := f.fail("detach"); err != nil { + return err + } + if f.attached[eipID] != instanceID { + return fmt.Errorf("%w: elastic IP %s is not attached to %s", ErrNotFound, eipID, instanceID) + } + delete(f.attached, eipID) + return nil +} + +func (f *Fake) DeleteElasticIP(_ context.Context, eipID string) error { + f.mu.Lock() + defer f.mu.Unlock() + if err := f.fail("delete"); err != nil { + return err + } + if _, ok := f.eips[eipID]; !ok { + return fmt.Errorf("%w: elastic IP %s", ErrNotFound, eipID) + } + delete(f.eips, eipID) + delete(f.attached, eipID) + return nil +} + +func (f *Fake) ListElasticIPs(context.Context) ([]ElasticIP, error) { + f.mu.Lock() + defer f.mu.Unlock() + if err := f.fail("list"); err != nil { + return nil, err + } + out := make([]ElasticIP, 0, len(f.eips)) + for _, e := range f.eips { + out = append(out, e) + } + slices.SortFunc(out, func(a, b ElasticIP) int { return a.Address.Compare(b.Address) }) + return out, nil +} + +func (f *Fake) Version(context.Context) (string, error) { return "exo fake", nil } diff --git a/spinifex/cloud/exoscale/fake_test.go b/spinifex/cloud/exoscale/fake_test.go new file mode 100644 index 000000000..fc3b1e857 --- /dev/null +++ b/spinifex/cloud/exoscale/fake_test.go @@ -0,0 +1,68 @@ +package exoscale_test + +import ( + "context" + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mulgadc/spinifex/spinifex/cloud/exoscale" +) + +// The Fake is what every exonet test is measured against, so these pin the +// behaviours of the real CLI that the allocator's rollback and release paths +// depend on. + +// The quota is per organisation, so an EIP Spinifex did not make still counts. +func TestFakeQuotaCountsEveryEIPAndUsesTheSentinel(t *testing.T) { + ctx := context.Background() + f := exoscale.NewFake("de-fra-1", 2) + f.Seed(exoscale.ElasticIP{ID: "operator", Address: netip.MustParseAddr("89.145.162.53")}) + + _, err := f.CreateElasticIP(ctx, "spinifex:a") + require.NoError(t, err) + _, err = f.CreateElasticIP(ctx, "spinifex:b") + require.ErrorIs(t, err, exoscale.ErrQuotaExceeded) +} + +func TestFakeGoneEIPIsNotFoundOnEveryCall(t *testing.T) { + ctx := context.Background() + f := exoscale.NewFake("de-fra-1", 0) + e, err := f.CreateElasticIP(ctx, "spinifex:a") + require.NoError(t, err) + require.NoError(t, f.AttachElasticIP(ctx, "inst-1", e.ID)) + require.NoError(t, f.DeleteElasticIP(ctx, e.ID)) + + _, attached := f.AttachedTo(e.ID) + assert.False(t, attached, "deleting an EIP detaches it") + require.ErrorIs(t, f.AttachElasticIP(ctx, "inst-1", e.ID), exoscale.ErrNotFound) + require.ErrorIs(t, f.DetachElasticIP(ctx, "inst-1", e.ID), exoscale.ErrNotFound) + require.ErrorIs(t, f.DeleteElasticIP(ctx, e.ID), exoscale.ErrNotFound) +} + +func TestFakeDetachFromTheWrongInstanceIsNotFound(t *testing.T) { + ctx := context.Background() + f := exoscale.NewFake("de-fra-1", 0) + e, err := f.CreateElasticIP(ctx, "spinifex:a") + require.NoError(t, err) + require.NoError(t, f.AttachElasticIP(ctx, "inst-1", e.ID)) + + require.ErrorIs(t, f.DetachElasticIP(ctx, "inst-2", e.ID), exoscale.ErrNotFound) + inst, attached := f.AttachedTo(e.ID) + assert.True(t, attached) + assert.Equal(t, "inst-1", inst) +} + +func TestFakeFailIsOneShot(t *testing.T) { + ctx := context.Background() + f := exoscale.NewFake("de-fra-1", 0) + f.Fail["list"] = assert.AnError + + _, err := f.ListElasticIPs(ctx) + require.ErrorIs(t, err, assert.AnError) + _, err = f.ListElasticIPs(ctx) + require.NoError(t, err) + assert.Equal(t, []string{"list", "list"}, f.Calls) +} diff --git a/spinifex/cloud/exoscale/testdata/create.json b/spinifex/cloud/exoscale/testdata/create.json new file mode 100644 index 000000000..4ed3b1a40 --- /dev/null +++ b/spinifex/cloud/exoscale/testdata/create.json @@ -0,0 +1 @@ +{"id":"9d86594b-1bb0-4908-ae28-5870f961aaef","ip_address":"194.182.171.24","address_family":"inet4","cidr":"194.182.171.24/32","description":"spinifex:capture:1","zone":"de-fra-1","type":"manual","reverse_dns":"","instances":null} diff --git a/spinifex/cloud/exoscale/testdata/list.json b/spinifex/cloud/exoscale/testdata/list.json new file mode 100644 index 000000000..7dc2ab101 --- /dev/null +++ b/spinifex/cloud/exoscale/testdata/list.json @@ -0,0 +1 @@ +[{"id":"94ca392b-621e-4989-a7f8-6f690d5abcb9","ip_address":"89.145.162.53","description":"mulga1","type":"Manual","zone":"de-fra-1"},{"id":"49381b16-0521-41d0-9ce6-6bd55d1cb254","ip_address":"194.182.169.155","description":"spinifex:capture:3","type":"Manual","zone":"de-fra-1"},{"id":"4b6e412e-93a1-489c-8a87-ef3c4081def9","ip_address":"194.182.170.227","description":"spinifex:capture:2","type":"Manual","zone":"de-fra-1"},{"id":"ab4cb90f-e2dc-48ea-ab49-8e3a82d2f2b6","ip_address":"194.182.170.246","description":"spinifex:capture:4","type":"Manual","zone":"de-fra-1"},{"id":"9d86594b-1bb0-4908-ae28-5870f961aaef","ip_address":"194.182.171.24","description":"spinifex:capture:1","type":"Manual","zone":"de-fra-1"}] diff --git a/spinifex/cloud/exoscale/testdata/notfound.stderr b/spinifex/cloud/exoscale/testdata/notfound.stderr new file mode 100644 index 000000000..a89ead3e2 --- /dev/null +++ b/spinifex/cloud/exoscale/testdata/notfound.stderr @@ -0,0 +1 @@ +error: resource not found in zone "de-fra-1" diff --git a/spinifex/cloud/exoscale/testdata/quota.stderr b/spinifex/cloud/exoscale/testdata/quota.stderr new file mode 100644 index 000000000..be7eab6d9 --- /dev/null +++ b/spinifex/cloud/exoscale/testdata/quota.stderr @@ -0,0 +1 @@ +error: Conflict: Usage of resource 'eip' has been exceeded. diff --git a/spinifex/clustersize/clustersize.go b/spinifex/clustersize/clustersize.go new file mode 100644 index 000000000..985b0506e --- /dev/null +++ b/spinifex/clustersize/clustersize.go @@ -0,0 +1,157 @@ +// Package clustersize holds one fact and the one number derived from it: how +// many nodes this process believes the cluster has, and therefore how many +// nodes each of its JetStream streams is replicated across. +// +// It is its own package because the fact is declared in one place, +// config.LoadConfig, and read in another, kvutil, and those sit either side of +// packages that would otherwise import each other. +// +// The number matters because a stream on one replica lives on one +// JetStream-chosen server recorded in no config. Losing that node makes the +// stream unreadable and unwritable from every node at once, so a single-node +// event becomes a cluster-wide outage. +package clustersize + +import ( + "errors" + "fmt" + "sync/atomic" + "testing" +) + +// MaxReplicas is JetStream's own ceiling on stream replicas, so a cluster +// larger than this replicates to five nodes and no further. Asking for more is +// refused by the server, which is why the count is clamped rather than passed +// through. +const MaxReplicas = 5 + +// MinHANodes is the smallest cluster in which losing a node is survivable. Below +// it no layer of the stack has a majority to keep: OVN runs standalone, the +// storage metadata quorum has nothing to lose, and a KV bucket has no replica +// count that both survives a loss and accepts writes. +const MinHANodes = 3 + +// ErrUndeclared is returned by Replicas in a process that never loaded a +// cluster config. It is an error rather than a fallback to one replica because +// one replica is the failure this package exists to prevent, and a fallback +// would reinstate it in exactly the processes nobody checked. +var ErrUndeclared = errors.New("cluster size was never declared, so the stream replica count is unknown") + +// nodes is the declared node count. Zero means undeclared. +var nodes atomic.Int64 + +// Declare records the cluster's node count for this process. It is called from +// config.LoadConfig, so every process that can reach a cluster's NATS has +// already declared it before any handler creates a bucket. +// +// The first declaration wins. Repeating the same count is a no-op, because two +// call sites legitimately read it from the same config; a different count is an +// error rather than an overwrite, since a process that disagrees with itself +// about how many nodes exist would silently pick whichever ran last. +func Declare(n int) error { + n = max(n, 0) + if nodes.CompareAndSwap(0, int64(n)) { + return nil + } + if have := Nodes(); have != n { + return fmt.Errorf("cluster size was already declared as %d nodes and cannot be redeclared as %d", have, n) + } + return nil +} + +// ResetForTest clears the declaration so a test can declare a different size. +// It panics outside a test binary: production has exactly one cluster size for +// the life of the process, and a reset available there would undo that. +func ResetForTest() { + if !testing.Testing() { + //nolint:forbidigo // Returning an error would let a caller ignore it and carry on with the count cleared. + panic("clustersize.ResetForTest called outside a test binary") + } + nodes.Store(0) +} + +// DeclareForTest declares n for the rest of the test binary, tolerating a fixture +// that already declared the same count. +// +// It is for fixtures rather than for tests. Standing up a one-node embedded server +// says something about the binary, not about one test, and several tests running +// in parallel share that one server — so restoring the previous count when any one +// of them finishes would leave its siblings undeclared mid-run. +func DeclareForTest(tb testing.TB, n int) { + tb.Helper() + if err := Declare(n); err != nil { + tb.Fatalf("declare cluster size %d: %v", n, err) + } +} + +// RedeclareForTest declares n for the duration of one test, whatever was declared +// before it, and puts the previous count back afterwards. +// +// Declaring is write-once, so a test needing a different size than its fixture set +// up cannot simply call Declare again. It must not run in parallel with anything +// else that reads the count, because there is one count per process and this +// changes it. +func RedeclareForTest(tb testing.TB, n int) { + tb.Helper() + was := Nodes() + ResetForTest() + if err := Declare(n); err != nil { + tb.Fatalf("declare cluster size %d: %v", n, err) + } + tb.Cleanup(func() { + ResetForTest() + if was > 0 { + _ = Declare(was) + } + }) +} + +// Nodes returns the declared node count, or 0 when it was never declared. +func Nodes() int { + return int(nodes.Load()) +} + +// Replicas is the replica count every stream is created with: the largest odd +// number of nodes this cluster can place a replica on, capped at JetStream's +// ceiling of five. +// +// Odd, because a majority is what a raft group needs and an even count buys +// none. Four replicas store a fourth full copy of every write and still survive +// only one loss, exactly like three; two survive nothing, because losing either +// member leaves no majority to accept a write. So four nodes replicate three +// ways and five replicate five ways, which is also the most any cluster does +// however large it grows. +// +// There is no argument and no override. A replica count is a property of the +// cluster, not of the stream or of the caller, and every stream here is read or +// written by a node other than the one that created it. +func Replicas() (int, error) { + n := Nodes() + if n < 1 { + return 0, ErrUndeclared + } + return ReplicasFor(n), nil +} + +// ReplicasFor is Replicas for a node count the caller already holds, so code that +// knows the cluster's size without having declared it — a test harness reading a +// cluster it did not build — gets its answer from the same rule rather than a +// second copy of it. +func ReplicasFor(nodes int) int { + r := min(max(nodes, 1), MaxReplicas) + if r%2 == 0 { + r-- + } + return r +} + +// Permanent reports whether err is this package's own refusal rather than a +// transient cluster condition. +// +// The retry loops that wait for JetStream quorum on a cold start must stop on +// one: no amount of waiting declares a cluster size, so a loop that cannot tell +// the two apart spends its whole budget on a misconfiguration and then reports +// a quorum problem that was never the cause. +func Permanent(err error) bool { + return errors.Is(err, ErrUndeclared) +} diff --git a/spinifex/clustersize/clustersize_test.go b/spinifex/clustersize/clustersize_test.go new file mode 100644 index 000000000..5fbe3192c --- /dev/null +++ b/spinifex/clustersize/clustersize_test.go @@ -0,0 +1,143 @@ +package clustersize_test + +import ( + "errors" + "strings" + "testing" + + "github.com/mulgadc/spinifex/spinifex/clustersize" +) + +func TestDeclareAndNodes(t *testing.T) { + t.Cleanup(clustersize.ResetForTest) + clustersize.ResetForTest() + + if got := clustersize.Nodes(); got != 0 { + t.Fatalf("undeclared cluster size = %d, want 0", got) + } + if err := clustersize.Declare(3); err != nil { + t.Fatalf("Declare(3): %v", err) + } + if got := clustersize.Nodes(); got != 3 { + t.Fatalf("Declare(3) = %d, want 3", got) + } +} + +// TestDeclareIsWriteOnce covers the three ways a second declaration can arrive. +// Repeating the same count is how two call sites reading the same config behave +// and must be accepted; a different count is a process disagreeing with itself +// about how many nodes exist, which must not be resolved by whichever ran last. +func TestDeclareIsWriteOnce(t *testing.T) { + t.Cleanup(clustersize.ResetForTest) + clustersize.ResetForTest() + + if err := clustersize.Declare(3); err != nil { + t.Fatalf("first Declare(3): %v", err) + } + if err := clustersize.Declare(3); err != nil { + t.Fatalf("repeating Declare(3) must be accepted, got %v", err) + } + err := clustersize.Declare(5) + if err == nil { + t.Fatal("Declare(5) after Declare(3) returned no error") + } + if got := clustersize.Nodes(); got != 3 { + t.Fatalf("a rejected redeclaration changed the count to %d, want 3 kept", got) + } + // The message has to name both counts, because the failure it reports is a + // disagreement and neither number alone identifies it. + for _, want := range []string{"3", "5"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not name the count %s", err, want) + } + } +} + +// TestDeclareRejectsANegativeCount: a negative node count is not a smaller +// cluster, it is a broken config, and storing it as-is would read back as a +// count rather than as undeclared. +func TestDeclareRejectsANegativeCount(t *testing.T) { + t.Cleanup(clustersize.ResetForTest) + clustersize.ResetForTest() + + if err := clustersize.Declare(-2); err != nil { + t.Fatalf("Declare(-2): %v", err) + } + if got := clustersize.Nodes(); got != 0 { + t.Fatalf("Declare(-2) = %d, want 0 (undeclared)", got) + } + if _, err := clustersize.Replicas(); !errors.Is(err, clustersize.ErrUndeclared) { + t.Fatalf("Replicas() after Declare(-2) = %v, want ErrUndeclared", err) + } +} + +// TestReplicas covers the policy and the fail-closed answer together: a bucket is +// replicated across the largest odd number of nodes the cluster can place, capped +// at five, and a process that never declared a size gets an error rather than a +// guess at one. +// +// The even counts are the point of the table. Four replicas cost a fourth full +// copy of every write and survive exactly one loss, the same as three, and two +// survive nothing at all because losing either member leaves no majority. +func TestReplicas(t *testing.T) { + t.Cleanup(clustersize.ResetForTest) + + for _, tc := range []struct{ nodes, want int }{ + {1, 1}, {2, 1}, {3, 3}, {4, 3}, {5, 5}, {6, 5}, {10, 5}, {64, 5}, + } { + clustersize.ResetForTest() + if err := clustersize.Declare(tc.nodes); err != nil { + t.Fatalf("Declare(%d): %v", tc.nodes, err) + } + got, err := clustersize.Replicas() + if err != nil { + t.Fatalf("Replicas() with %d nodes: %v", tc.nodes, err) + } + if got != tc.want { + t.Errorf("Replicas() with %d nodes = %d, want %d", tc.nodes, got, tc.want) + } + if got%2 == 0 { + t.Errorf("Replicas() with %d nodes = %d, which is even", tc.nodes, got) + } + if fromCount := clustersize.ReplicasFor(tc.nodes); fromCount != got { + t.Errorf("ReplicasFor(%d) = %d but Replicas() = %d; the two must be one rule", + tc.nodes, fromCount, got) + } + } + + clustersize.ResetForTest() + if _, err := clustersize.Replicas(); !errors.Is(err, clustersize.ErrUndeclared) { + t.Fatalf("Replicas() with an undeclared cluster size = %v, want ErrUndeclared", err) + } +} + +// TestReplicasForNeverReturnsZero: callers that hold a node count use it without +// an error return, so a count below one has to resolve to the single replica a +// lone server can hold rather than to none. +func TestReplicasForNeverReturnsZero(t *testing.T) { + for _, nodes := range []int{-1, 0} { + if got := clustersize.ReplicasFor(nodes); got != 1 { + t.Errorf("ReplicasFor(%d) = %d, want 1", nodes, got) + } + } +} + +// TestPermanent separates this package's own refusal from a cluster that has yet +// to form. The retry loops waiting for JetStream quorum stop on the first and +// keep waiting through the second, so confusing them spends a whole startup +// budget on a misconfiguration. +func TestPermanent(t *testing.T) { + if !clustersize.Permanent(clustersize.ErrUndeclared) { + t.Error("Permanent(ErrUndeclared) = false") + } + wrapped := errors.Join(errors.New("create KV bucket x"), clustersize.ErrUndeclared) + if !clustersize.Permanent(wrapped) { + t.Error("Permanent() did not see through a wrapped ErrUndeclared") + } + if clustersize.Permanent(errors.New("no suitable peers for placement")) { + t.Error("Permanent() treated a placement failure as permanent") + } + if clustersize.Permanent(nil) { + t.Error("Permanent(nil) = true") + } +} diff --git a/spinifex/config/config.go b/spinifex/config/config.go index 23c3a8180..167517acf 100644 --- a/spinifex/config/config.go +++ b/spinifex/config/config.go @@ -5,9 +5,11 @@ import ( "log/slog" "net/netip" "os" + "path/filepath" "slices" "strings" + "github.com/mulgadc/spinifex/spinifex/clustersize" "github.com/spf13/viper" ) @@ -65,7 +67,7 @@ type AWSConfig struct { // ExternalPool defines a range of routable IPs that Spinifex manages for public subnets. type ExternalPool struct { Name string `mapstructure:"name"` // Pool identifier (e.g., "wan", "dc1-primary") - Source string `mapstructure:"source"` // IP source: "static" (default), "dhcp" or "oci" + Source string `mapstructure:"source"` // IP source: "static" (default), "dhcp", "oci" or "exoscale" BindBridge string `mapstructure:"bind_bridge"` // Linux bridge for DHCP DORA (source=dhcp only) DHCPMAC string `mapstructure:"dhcp_mac"` // DHCP client MAC strategy: "derived" (default) or "interface" (source=dhcp only) RangeStart string `mapstructure:"range_start"` // First IP in range (static source only) @@ -92,6 +94,20 @@ type ExternalPool struct { OCIPublicIPPool string `mapstructure:"oci_public_ip_pool"` // Public IP pool OCID for BYOIP (optional) OCIConfigFile string `mapstructure:"oci_config_file"` // API-key config file (optional; defaults to ~/.oci/config) OCIConfigProfile string `mapstructure:"oci_config_profile"` // Profile within that file (optional; defaults to DEFAULT) + + // Exoscale names the zone and the instance its Elastic IPs attach to + // (source=exoscale only). Calls go through the exo CLI, so the instance ID + // is configured rather than read from metadata the IMDS endpoint shadows. + ExoscaleZone string `mapstructure:"exoscale_zone"` // Zone the EIPs are created in, e.g. "de-fra-1" + ExoscaleInstanceID string `mapstructure:"exoscale_instance_id"` // Exoscale instance UUID of this node + ExoscaleConfigFile string `mapstructure:"exoscale_config_file"` // exo CLI config holding the API key (optional; see exoscale.DefaultConfigFile) + ExoscaleAccount string `mapstructure:"exoscale_account"` // Account name within that file (optional; the file's default) + ExoscaleBinary string `mapstructure:"exoscale_binary"` // Absolute path to exo (optional; see exoscale.DefaultBinary) +} + +// hasExoscaleKeys reports whether any exoscale_* key is set. +func (p ExternalPool) hasExoscaleKeys() bool { + return p.ExoscaleZone != "" || p.ExoscaleInstanceID != "" || p.ExoscaleConfigFile != "" || p.ExoscaleAccount != "" || p.ExoscaleBinary != "" } // DefaultUnderlayMTU is the standard Ethernet payload, and the assumption a @@ -605,6 +621,24 @@ func LoadConfig(configPath string) (*ClusterConfig, error) { return nil, err } + // Every process that creates a KV bucket loads the cluster config first, so + // this is the one place the node count can be declared without a service + // being able to forget to. Buckets refuse to be created until it has been. + // + // A conflicting second load is reported rather than refused, because this + // function also reads config files that are not this process's own cluster — + // an installer inspecting a peer's, a tool rendering one. The first + // declaration stands either way, so nothing that already created a bucket can + // have the count changed underneath it. + if err := clustersize.Declare(len(config.Nodes)); err != nil { + slog.Warn("Ignoring a cluster size that disagrees with the one already declared; the first one stands", + "loaded_nodes", len(config.Nodes), "declared_nodes", clustersize.Nodes(), "error", err) + } + if len(config.Nodes) > 1 && len(config.Nodes) < clustersize.MinHANodes { + slog.Warn("This cluster has too few nodes to survive losing one, so its state is replicated to a single node", + "nodes", len(config.Nodes), "ha_minimum", clustersize.MinHANodes) + } + return &config, nil } @@ -657,6 +691,9 @@ func validateClusterConfig(cc *ClusterConfig) error { if p.Source != "oci" && (p.OCICompartmentID != "" || p.OCIVNICID != "" || p.OCIVNICIface != "" || p.OCISubnetID != "" || p.OCIPublicIPPool != "" || p.OCIConfigFile != "" || p.OCIConfigProfile != "") { return fmt.Errorf("config: [[network.external_pools]] %q: oci_* keys are only valid with source=\"oci\"", p.Name) } + if p.Source != "exoscale" && p.hasExoscaleKeys() { + return fmt.Errorf("config: [[network.external_pools]] %q: exoscale_* keys are only valid with source=\"exoscale\"", p.Name) + } switch p.DHCPMAC { case "", "derived", "interface": default: @@ -712,8 +749,30 @@ func validateClusterConfig(cc *ClusterConfig) error { return fmt.Errorf("config: [[network.external_pools]] %q: source=\"oci\" requires [network] external_mode = \"nat\" (routed); OCI VNICs accept only their own MAC, so pool mode's per-VPC gateway MACs are dropped by the provider", p.Name) } continue + case "exoscale": + if p.ExoscaleZone == "" || p.ExoscaleInstanceID == "" { + return fmt.Errorf("config: [[network.external_pools]] %q: source=\"exoscale\" requires exoscale_zone and exoscale_instance_id", p.Name) + } + if p.ExoscaleBinary != "" && !filepath.IsAbs(p.ExoscaleBinary) { + return fmt.Errorf("config: [[network.external_pools]] %q: exoscale_binary must be an absolute path, got %q", p.Name, p.ExoscaleBinary) + } + if p.BindBridge != "" || p.DHCPMAC != "" { + return fmt.Errorf("config: [[network.external_pools]] %q: bind_bridge/dhcp_mac are only valid with source=\"dhcp\"", p.Name) + } + if p.RangeStart != "" || p.RangeEnd != "" || p.Gateway != "" || p.GatewayIP != "" { + return fmt.Errorf("config: [[network.external_pools]] %q: range_start/range_end/gateway/gateway_ip not allowed with source=\"exoscale\" (Exoscale picks the address)", p.Name) + } + if p.GwLrpRangeStart != "" || p.GwLrpRangeEnd != "" { + return fmt.Errorf("config: [[network.external_pools]] %q: gw_lrp_range_start/gw_lrp_range_end not allowed with source=\"exoscale\" (an EIP per VPC gateway would exhaust the per-organisation quota)", p.Name) + } + // An Exoscale instance answers ARP only from its own MAC and drops + // unknown MACs, so pool mode's per-VPC gateway MACs never reach the wire. + if cc.Network.ExternalMode != "nat" { + return fmt.Errorf("config: [[network.external_pools]] %q: source=\"exoscale\" requires [network] external_mode = \"nat\" (routed); Exoscale instances accept only their own MAC, so pool mode's per-VPC gateway MACs are dropped by the provider", p.Name) + } + continue default: - return fmt.Errorf("config: [[network.external_pools]] %q: source=%q unsupported; use \"static\", \"dhcp\" or \"oci\"", p.Name, p.Source) + return fmt.Errorf("config: [[network.external_pools]] %q: source=%q unsupported; use \"static\", \"dhcp\", \"oci\" or \"exoscale\"", p.Name, p.Source) } if p.RangeStart == "" || p.RangeEnd == "" { continue diff --git a/spinifex/config/config_test.go b/spinifex/config/config_test.go index 7a02291b6..80ea03ef3 100644 --- a/spinifex/config/config_test.go +++ b/spinifex/config/config_test.go @@ -1332,3 +1332,45 @@ func TestLoadConfig_UnknownSourceErrorNamesOCI(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), `"oci"`) } + +func TestLoadConfig_NetworkPoolExoscaleSourceAccepted(t *testing.T) { + cfg, err := loadOCIPool(t, "nat", ` +source = "exoscale" +exoscale_zone = "de-fra-1" +exoscale_instance_id = "0f1e2d3c-0000-4000-8000-000000000001" +exoscale_binary = "/usr/local/bin/exo" +`) + require.NoError(t, err) + p := cfg.Network.ExternalPools[0] + assert.Equal(t, "exoscale", p.Source) + assert.Equal(t, "de-fra-1", p.ExoscaleZone) + assert.Equal(t, "0f1e2d3c-0000-4000-8000-000000000001", p.ExoscaleInstanceID) +} + +func TestLoadConfig_NetworkPoolExoscaleRejections(t *testing.T) { + const base = ` +source = "exoscale" +exoscale_zone = "de-fra-1" +exoscale_instance_id = "0f1e2d3c-0000-4000-8000-000000000001" +` + cases := []struct { + name, mode, body, want string + }{ + {"pool mode", "pool", base, `requires [network] external_mode = "nat"`}, + {"no zone", "nat", "source = \"exoscale\"\nexoscale_instance_id = \"i\"\n", "requires exoscale_zone and exoscale_instance_id"}, + {"no instance", "nat", "source = \"exoscale\"\nexoscale_zone = \"de-fra-1\"\n", "requires exoscale_zone and exoscale_instance_id"}, + {"range", "nat", base + "range_start = \"1.2.3.4\"\nrange_end = \"1.2.3.4\"\n", "Exoscale picks the address"}, + {"gateway", "nat", base + "gateway = \"1.2.3.1\"\n", "Exoscale picks the address"}, + {"gw lrp range", "nat", base + "gw_lrp_range_start = \"1.2.3.4\"\ngw_lrp_range_end = \"1.2.3.5\"\n", "per-organisation quota"}, + {"relative binary", "nat", base + "exoscale_binary = \"exo\"\n", "absolute path"}, + {"oci keys", "nat", base + "oci_compartment_id = \"c\"\n", `oci_* keys are only valid with source="oci"`}, + {"exoscale keys on static", "nat", "exoscale_zone = \"de-fra-1\"\n", `exoscale_* keys are only valid with source="exoscale"`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := loadOCIPool(t, tc.mode, tc.body) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.want) + }) + } +} diff --git a/spinifex/daemon/bedrock_deps.go b/spinifex/daemon/bedrock_deps.go index 256d8d3a7..be2e57fa0 100644 --- a/spinifex/daemon/bedrock_deps.go +++ b/spinifex/daemon/bedrock_deps.go @@ -22,11 +22,7 @@ func (d *Daemon) buildBedrockLaunchDeps() handlers_bedrock.LaunchDeps { if err != nil { slog.Warn("Bedrock: jetstream init failed; serving VMs cannot resolve staged weights", "err", err) } else { - clusterSize := 1 - if d.clusterConfig != nil { - clusterSize = len(d.clusterConfig.Nodes) - } - weights = gateway_bedrock.NewWeightsStore(js, clusterSize) + weights = gateway_bedrock.NewWeightsStore(js) } return handlers_bedrock.LaunchDeps{ diff --git a/spinifex/daemon/cluster_ready_test.go b/spinifex/daemon/cluster_ready_test.go index f34f2c4db..9731d1088 100644 --- a/spinifex/daemon/cluster_ready_test.go +++ b/spinifex/daemon/cluster_ready_test.go @@ -92,7 +92,10 @@ func TestWaitForClusterReady_PredastoreBecomesReady(t *testing.T) { require.NoError(t, err) d := &Daemon{ - config: &config.Config{Predastore: config.PredastoreConfig{Host: closedPort(t)}}, + config: &config.Config{ + Predastore: config.PredastoreConfig{Host: closedPort(t)}, + NATS: config.NATSConfig{CACert: writeServerCA(t, srv)}, + }, natsConn: nc, } diff --git a/spinifex/daemon/daemon.go b/spinifex/daemon/daemon.go index 5c65d4813..d5c4bfb72 100644 --- a/spinifex/daemon/daemon.go +++ b/spinifex/daemon/daemon.go @@ -17,6 +17,7 @@ import ( "os/signal" "path/filepath" "runtime" + "slices" "strconv" "strings" "sync" @@ -36,6 +37,7 @@ import ( operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1" "github.com/mulgadc/spinifex/spinifex/admin" "github.com/mulgadc/spinifex/spinifex/bootstrap/preflight" + "github.com/mulgadc/spinifex/spinifex/clustersize" "github.com/mulgadc/spinifex/spinifex/config" "github.com/mulgadc/spinifex/spinifex/domains/ec2/instancetypes" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" @@ -69,6 +71,7 @@ import ( handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds" "github.com/mulgadc/spinifex/spinifex/network/external" "github.com/mulgadc/spinifex/spinifex/network/external/dhcp" + "github.com/mulgadc/spinifex/spinifex/network/external/exonet" "github.com/mulgadc/spinifex/spinifex/network/external/ocinet" "github.com/mulgadc/spinifex/spinifex/network/host" "github.com/mulgadc/spinifex/spinifex/objectstore" @@ -1460,6 +1463,12 @@ func (d *Daemon) externalPoolConfigs() (pools []external.ExternalPoolConfig, any OCIPublicIPPool: p.OCIPublicIPPool, OCIConfigFile: p.OCIConfigFile, OCIConfigProfile: p.OCIConfigProfile, + + ExoscaleZone: p.ExoscaleZone, + ExoscaleInstanceID: p.ExoscaleInstanceID, + ExoscaleConfigFile: p.ExoscaleConfigFile, + ExoscaleAccount: p.ExoscaleAccount, + ExoscaleBinary: p.ExoscaleBinary, }) if p.Source == "dhcp" { anyDHCP = true @@ -1511,6 +1520,31 @@ func (d *Daemon) installOCIAllocators(ipam *handlers_ec2_vpc.ExternalIPAM, js je return nil } +// installExoscaleAllocators builds one allocator per source="exoscale" pool +// and reconciles it before it serves. As with OCI, Allocate creates the EIP +// before recording it, and this pass is the only thing that finds one a crash +// left behind — on a five-EIP quota, one leak is a fifth of the capacity. +func (d *Daemon) installExoscaleAllocators(ipam *handlers_ec2_vpc.ExternalIPAM, js jetstream.JetStream) error { + for _, p := range ipam.PoolsWithSource(external.SourceExoscale) { + alloc, err := exonet.FromPoolConfig(d.ctx, js, p) + if err != nil { + return fmt.Errorf("build Exoscale allocator for pool %q: %w", p.Name, err) + } + if err := ipam.InstallAllocator(p.Name, alloc); err != nil { + return err + } + res, err := alloc.Reconcile(d.ctx) + if err != nil { + slog.Error("Exoscale allocator reconcile failed; leaked elastic IPs may be billing", + "pool", p.Name, "err", err) + continue + } + slog.Info("Exoscale allocator ready", "pool", p.Name, + "collected", len(res.Collected), "stale_bindings", len(res.Stale), "skipped", res.Skipped) + } + return nil +} + // ociPool pairs a pool's allocator with its name, so one pass can cover every // pool and reach a single verdict about each guest. Per-pool loops could not: a // guest is impaired or it is not, and two loops writing that answer from half the @@ -1670,14 +1704,18 @@ func (d *Daemon) startCluster() error { return fmt.Errorf("connect NATS: %w", err) } - if err := d.initJetStream(); err != nil { - return fmt.Errorf("initialize JetStream: %w", err) + // Declared before JetStream, not after: initJetStream creates buckets, and + // bucket creation refuses until the count is known. A daemon assembled in a + // test may never have gone through config.LoadConfig, which is where a real + // process declares it from this same source. + if d.clusterConfig != nil { + if err := clustersize.Declare(len(d.clusterConfig.Nodes)); err != nil { + return err + } } - // Set the default KV replica count before any handler creates a bucket, so - // lazily-created buckets are born at cluster-size replication instead of R1. - if d.clusterConfig != nil { - kvutil.SetDefaultKVReplicas(len(d.clusterConfig.Nodes)) + if err := d.initJetStream(); err != nil { + return fmt.Errorf("initialize JetStream: %w", err) } // Remove the obsolete spinifex-dhcp-leases bucket (idempotent). @@ -1751,6 +1789,9 @@ func (d *Daemon) startCluster() error { return fmt.Errorf("failed to get tags KV bucket: %w", err) } d.tagsService = handlers_ec2_tags.NewTagsServiceImpl(d.config, tagsKV) + // Key pairs keep their creation tags in their own metadata; project them so + // describe-tags sees them, and clear them when the key pair is deleted. + d.keyService.SetCentralTagStore(d.tagsService) d.eigwService, err = initServiceWithRetry("EIGW service", func() (*handlers_ec2_eigw.EgressOnlyIGWServiceImpl, error) { return handlers_ec2_eigw.NewEgressOnlyIGWServiceImplWithNATS(d.ctx, d.config, d.natsConn) @@ -1801,6 +1842,12 @@ func (d *Daemon) startCluster() error { // describe-tags agrees with each resource's own describe from birth and // stops answering for it once it is deleted. d.vpcService.SetCentralTagStore(d.tagsService) + // Name interfaces as their instances are named, so the two agree. + region, internalDomain := d.config.Region, handlers_dns.ResolveInternalDomain(d.config) + d.vpcService.SetPrivateDNSNamer(func(privateIP string) string { + _, private := handlers_dns.EC2DNSNames(region, "", internalDomain, "", privateIP) + return private + }) d.routeTableService, err = initServiceWithRetry("RouteTable service", func() (*handlers_ec2_routetable.RouteTableServiceImpl, error) { return handlers_ec2_routetable.NewRouteTableServiceImplWithNATS(d.ctx, d.config, d.natsConn) @@ -1841,6 +1888,9 @@ func (d *Daemon) startCluster() error { if ociErr := d.installOCIAllocators(ipam, js); ociErr != nil { return nil, ociErr } + if exoErr := d.installExoscaleAllocators(ipam, js); exoErr != nil { + return nil, exoErr + } return ipam, nil }) if err != nil { @@ -2359,11 +2409,7 @@ func (d *Daemon) nodeRunningVMs() ([]*vm.VM, error) { if err != nil { return nil, err } - vms := make([]*vm.VM, 0, len(running)) - for _, v := range running { - vms = append(vms, v) - } - return vms, nil + return slices.Collect(maps.Values(running)), nil } // connectNATS connects to NATS with infinite retry (cap 60s backoff). Tests @@ -2400,7 +2446,7 @@ func (d *Daemon) initJetStream() error { for { attempt++ var err error - d.jsManager, err = NewJetStreamManager(d.natsConn, 1) + d.jsManager, err = NewJetStreamManager(d.natsConn) if err == nil { err = d.jsManager.InitKVBucket() } @@ -2415,10 +2461,20 @@ func (d *Daemon) initJetStream() error { if err == nil { d.jsManager.SetSyncObserver(d) - slog.Info("JetStream KV stores initialized successfully", "replicas", 1, "attempts", attempt, "elapsed_ms", otelsetup.Millis(time.Since(start))) + // Replicas cannot be undeclared here: bucket creation refuses + // before it, so reaching this line means it was declared. + replicas, _ := clustersize.Replicas() + slog.Info("JetStream KV stores initialized successfully", "replicas", replicas, "attempts", attempt, "elapsed_ms", otelsetup.Millis(time.Since(start))) break } + // A misdeclared cluster size is not something quorum arrives and fixes, + // so waiting for it spends the whole budget and then reports a quorum + // problem that was never the cause. + if clustersize.Permanent(err) { + return fmt.Errorf("initialize JetStream: %w", err) + } + elapsed := time.Since(start) if elapsed >= maxWait { return fmt.Errorf("failed to initialize JetStream after %s (%d attempts): %w", elapsed.Round(time.Second), attempt, err) @@ -2434,15 +2490,20 @@ func (d *Daemon) initJetStream() error { return nil } -// upgradeJetStreamReplicas bumps KV_* stream replication to match the cluster -// size. Runs after all buckets are created and the cluster is ready. +// upgradeJetStreamReplicas raises every KV bucket to the cluster's replica +// count. Runs after all buckets are created and the cluster is ready, which is +// when a bucket created by whichever node got there first can be repaired. func (d *Daemon) upgradeJetStreamReplicas() { - clusterSize := len(d.clusterConfig.Nodes) - if clusterSize <= 1 || d.jsManager == nil { + if d.jsManager == nil || d.jsManager.js == nil { return } - if err := d.jsManager.UpdateReplicas(clusterSize); err != nil { - slog.Warn("Failed to upgrade JetStream replicas", "targetReplicas", clusterSize, "error", err) + raised, err := kvutil.RaiseAllBucketReplicas(d.ctx, d.jsManager.js) + if err != nil { + slog.Warn("Failed to raise KV bucket replicas to the cluster's node count", "error", err) + return + } + if raised > 0 { + slog.Info("Raised KV buckets to the cluster's replica count", "buckets", raised) } } @@ -2551,15 +2612,6 @@ func (d *Daemon) checkViperblockReady() bool { return err == nil } -// predastoreReadinessClient is hoisted to package scope so the 2s readiness -// poll loop doesn't build a fresh transport every call. Verification is off -// because the probe authenticates nothing and reads no data. -var predastoreReadinessClient = &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // readiness probe only, no data exchanged - }, -} - // checkPredastoreReady reports whether predastore is serving HTTPS, not just // listening. Any response counts as ready, including the 401/403 an S3 // endpoint returns for this deliberately unsigned request. @@ -2572,11 +2624,18 @@ func (d *Daemon) checkPredastoreReady() bool { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() + rootCAs, err := loadClusterTrustRoot(d) + if err != nil { + slog.Warn("predastore readiness probe: could not load cluster CA", "err", err) + return false + } + client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: rootCAs}}} + defer client.CloseIdleConnections() req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://"+host+"/", nil) if err != nil { return false } - resp, err := predastoreReadinessClient.Do(req) + resp, err := client.Do(req) if err != nil { return false } @@ -3525,13 +3584,9 @@ func probeGPU() gpuProbeResult { // MIG-enabled GPUs are capable without vfio-pci: the NVIDIA driver owns // isolation via the mdev subsystem, so vfio-pci is not required. - hasMIG := false - for _, d := range r.Devices { - if d.MIGEnabled { - hasMIG = true - break - } - } + hasMIG := slices.ContainsFunc(r.Devices, func(d gpu.GPUDevice) bool { + return d.MIGEnabled + }) r.Capable = len(r.Devices) > 0 && ((r.IOMMUActive && r.VFIOPresent) || hasMIG) return r } diff --git a/spinifex/daemon/daemon_handlers.go b/spinifex/daemon/daemon_handlers.go index d1fa8c666..e7fb45469 100644 --- a/spinifex/daemon/daemon_handlers.go +++ b/spinifex/daemon/daemon_handlers.go @@ -483,7 +483,7 @@ var roleHTTPClient = &http.Client{Timeout: 500 * time.Millisecond} // fetchNATSRole queries a NATS /varz endpoint and returns "leader", "follower", or "". func fetchNATSRole(url string, client *http.Client) string { - resp, err := client.Get(url) //nolint:noctx // internal monitoring call + resp, err := client.Get(url) if err != nil { slog.Debug("Failed to query NATS monitoring", "err", err) return "" diff --git a/spinifex/daemon/daemon_handlers_test.go b/spinifex/daemon/daemon_handlers_test.go index b6194299e..317d86f14 100644 --- a/spinifex/daemon/daemon_handlers_test.go +++ b/spinifex/daemon/daemon_handlers_test.go @@ -135,7 +135,7 @@ func createFullTestDaemonWithJetStream(t *testing.T, natsURL string) *Daemon { daemon := createFullTestDaemon(t, natsURL) var err error - daemon.jsManager, err = NewJetStreamManager(daemon.natsConn, 1) + daemon.jsManager, err = NewJetStreamManager(daemon.natsConn) require.NoError(t, err) err = daemon.jsManager.InitKVBucket() require.NoError(t, err) @@ -611,7 +611,7 @@ func runInstancesAndCheckENISGs(t *testing.T, mutator func(input *ec2.RunInstanc subnetID := *subnetOut.Subnet.SubnetId sg1Out, err := daemon.vpcService.CreateSecurityGroup(t.Context(), &ec2.CreateSecurityGroupInput{ - GroupName: aws.String("sg-prop-1"), + GroupName: aws.String("grp-prop-1"), Description: aws.String("test"), VpcId: aws.String(vpcID), }, testAccountID) @@ -619,7 +619,7 @@ func runInstancesAndCheckENISGs(t *testing.T, mutator func(input *ec2.RunInstanc sg1 = *sg1Out.GroupId sg2Out, err := daemon.vpcService.CreateSecurityGroup(t.Context(), &ec2.CreateSecurityGroupInput{ - GroupName: aws.String("sg-prop-2"), + GroupName: aws.String("grp-prop-2"), Description: aws.String("test"), VpcId: aws.String(vpcID), }, testAccountID) @@ -2648,7 +2648,7 @@ func TestDelegateHandlers_EIGW(t *testing.T) { topic: "ec2.test.DeleteEgressOnlyIGW", handler: asMsgHandler(handleNATSRequest(daemon.node, daemon.eigwService.DeleteEgressOnlyInternetGateway)), input: &ec2.DeleteEgressOnlyInternetGatewayInput{EgressOnlyInternetGatewayId: aws.String("eigw-nonexistent")}, - expectedCode: awserrors.ErrorInvalidEgressOnlyInternetGatewayIdNotFound, + expectedCode: awserrors.ErrorInvalidGatewayIDNotFound, }, { name: "DescribeEgressOnlyInternetGateways", diff --git a/spinifex/daemon/daemon_mgmt_ip_test.go b/spinifex/daemon/daemon_mgmt_ip_test.go index 9817e6190..c533c7edf 100644 --- a/spinifex/daemon/daemon_mgmt_ip_test.go +++ b/spinifex/daemon/daemon_mgmt_ip_test.go @@ -24,7 +24,7 @@ func newTestMgmtJSM(t *testing.T) *JetStreamManager { } t.Cleanup(nc.Close) - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) if err != nil { t.Fatalf("new JetStreamManager: %v", err) } @@ -552,7 +552,7 @@ func TestMgmtIPAllocator_Allocate_RefusedWhenKVUnhealthy(t *testing.T) { if err != nil { t.Fatal(err) } - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) if err != nil { t.Fatal(err) } diff --git a/spinifex/daemon/daemon_system_instance_test.go b/spinifex/daemon/daemon_system_instance_test.go index 137b836d7..222723e60 100644 --- a/spinifex/daemon/daemon_system_instance_test.go +++ b/spinifex/daemon/daemon_system_instance_test.go @@ -799,6 +799,7 @@ func TestLaunchSystemInstance_NATFailureRollsBackPublicIP(t *testing.T) { // rule vpcd may have committed after the AddNAT timeout window. deleteNATCh := make(chan map[string]string, 1) delSub, err := d.natsConn.Subscribe("vpc.delete-nat", func(msg *nats.Msg) { + _ = msg.Respond([]byte(`{"success":true}`)) var p map[string]string _ = json.Unmarshal(msg.Data, &p) select { diff --git a/spinifex/daemon/daemon_test.go b/spinifex/daemon/daemon_test.go index 07b451a48..d274a885b 100644 --- a/spinifex/daemon/daemon_test.go +++ b/spinifex/daemon/daemon_test.go @@ -742,7 +742,7 @@ func TestDaemon_BootAllocation(t *testing.T) { defer nc.Close() daemon.natsConn = nc - daemon.jsManager, err = NewJetStreamManager(nc, 1) + daemon.jsManager, err = NewJetStreamManager(nc) require.NoError(t, err) err = daemon.jsManager.InitKVBucket() require.NoError(t, err) @@ -1694,7 +1694,7 @@ func TestTerminatedTeardownReaper_SelfHealsFailedVolumeTeardown(t *testing.T) { // A real terminated-instance KV, so Sweep can list/update/(not yet)purge // the record exactly as production does. - jsManager, err := NewJetStreamManager(daemon.natsConn, 1) + jsManager, err := NewJetStreamManager(daemon.natsConn) require.NoError(t, err) require.NoError(t, jsManager.InitTerminatedInstanceBucket()) daemon.stateStore = newStateStoreAdapter(jsManager, daemon.persistState) @@ -4547,7 +4547,7 @@ func newEBSProviderTestDaemon(t *testing.T, provider string) *Daemon { require.NoError(t, err) t.Cleanup(nc.Close) - jsManager, err := NewJetStreamManager(nc, 1) + jsManager, err := NewJetStreamManager(nc) require.NoError(t, err) ctx := context.Background() diff --git a/spinifex/daemon/dns_reconcile.go b/spinifex/daemon/dns_reconcile.go index f20251189..d5ef1a2aa 100644 --- a/spinifex/daemon/dns_reconcile.go +++ b/spinifex/daemon/dns_reconcile.go @@ -45,9 +45,8 @@ func (d *Daemon) instanceStateWatchBuckets(context.Context) ([]*kvstore.Bucket, return nil, nil } return []*kvstore.Bucket{kvstore.NewBucket(d.jsManager.js, kvstore.Config{ - Name: InstanceStateBucket, - History: 1, - Replicas: d.jsManager.replicas, + Name: InstanceStateBucket, + History: 1, })}, nil } diff --git a/spinifex/daemon/dns_reconcile_ec2_test.go b/spinifex/daemon/dns_reconcile_ec2_test.go index ff2c3246a..7f329db05 100644 --- a/spinifex/daemon/dns_reconcile_ec2_test.go +++ b/spinifex/daemon/dns_reconcile_ec2_test.go @@ -26,10 +26,10 @@ func dnsTestDaemon(t *testing.T) (*Daemon, func(id, publicIP, privateIP string, t.Helper() _, _, js := testutil.StartTestJetStream(t) store := kvstore.New[vm.InstanceRecord](js, kvstore.Config{ - Name: InstanceStateBucket, History: 1, Replicas: 1, + Name: InstanceStateBucket, History: 1, }) d := &Daemon{ - jsManager: &JetStreamManager{js: js, records: store, replicas: 1}, + jsManager: &JetStreamManager{js: js, records: store}, vmMgr: vm.NewManager(), config: &config.Config{Region: "ap-southeast-2"}, dnsBaseDomain: "spx3.net", diff --git a/spinifex/daemon/dns_reconcile_test.go b/spinifex/daemon/dns_reconcile_test.go index 521beeaf0..413ef5b7d 100644 --- a/spinifex/daemon/dns_reconcile_test.go +++ b/spinifex/daemon/dns_reconcile_test.go @@ -62,7 +62,7 @@ func TestDNSWatchBuckets_ZeroValueDaemonIsNilSafe(t *testing.T) { func TestInstanceStateWatchBuckets_ReturnsSharedBucket(t *testing.T) { _, _, js := testutil.StartTestJetStream(t) - d := &Daemon{jsManager: &JetStreamManager{js: js, replicas: 1}} + d := &Daemon{jsManager: &JetStreamManager{js: js}} buckets, err := d.instanceStateWatchBuckets(t.Context()) require.NoError(t, err) @@ -111,7 +111,7 @@ func TestEksWatchBuckets_NilGuards(t *testing.T) { func TestEksWatchBuckets_DelegatesToAccountWatchBuckets(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - _, err := handlers_eks.GetOrCreateAccountBucket(t.Context(), js, "111111111111", 1) + _, err := handlers_eks.GetOrCreateAccountBucket(t.Context(), js, "111111111111") require.NoError(t, err) d := &Daemon{eksService: &handlers_eks.EKSServiceImpl{}, natsConn: nc} diff --git a/spinifex/daemon/ecs_deps.go b/spinifex/daemon/ecs_deps.go index 1542f425f..a31823285 100644 --- a/spinifex/daemon/ecs_deps.go +++ b/spinifex/daemon/ecs_deps.go @@ -43,16 +43,12 @@ func (d *Daemon) buildECSServiceDeps() handlers_ecs.Deps { return deps } - clusterSize := 1 - if d.clusterConfig != nil { - clusterSize = len(d.clusterConfig.Nodes) - } // Retried, because the constructor runs a KV migration that needs JetStream // to have responders. A daemon that started first got one attempt, failed // it, and had capacity provisioning off for the life of the process with no // symptom but a 500 from ProvisionCapacity. iamSvc, iamErr := initServiceWithRetry("ECS IAM service", func() (*handlers_iam.IAMServiceImpl, error) { - return handlers_iam.NewIAMServiceImpl(d.ctx, d.natsConn, masterKey, clusterSize) + return handlers_iam.NewIAMServiceImpl(d.ctx, d.natsConn, masterKey) }) if iamErr != nil { // Off rather than fatal: ECS CRUD, EKS and EC2 do not need this, and a diff --git a/spinifex/daemon/eks_deps.go b/spinifex/daemon/eks_deps.go index ee28153d7..ef83bd80b 100644 --- a/spinifex/daemon/eks_deps.go +++ b/spinifex/daemon/eks_deps.go @@ -36,11 +36,7 @@ func (d *Daemon) systemRoleEnsurer() handlers_iam.SystemInstanceRoleEnsurer { "err", err) return nil } - clusterSize := 1 - if d.clusterConfig != nil { - clusterSize = len(d.clusterConfig.Nodes) - } - iamSvc, iamErr := handlers_iam.NewIAMServiceImpl(d.ctx, d.natsConn, masterKey, clusterSize) + iamSvc, iamErr := handlers_iam.NewIAMServiceImpl(d.ctx, d.natsConn, masterKey) if iamErr != nil { slog.Warn("System role ensurer: IAM service init failed (retried on next launch); system VMs fall back to baked static creds", "err", iamErr) @@ -61,10 +57,8 @@ func (d *Daemon) buildEKSServiceDeps() handlers_eks.EKSServiceDeps { } internalSuffix := "" - clusterSize := 1 if d.clusterConfig != nil { internalSuffix = d.clusterConfig.AWS.ServicesDomain - clusterSize = len(d.clusterConfig.Nodes) } gatewayCA := "" @@ -84,7 +78,6 @@ func (d *Daemon) buildEKSServiceDeps() handlers_eks.EKSServiceDeps { GatewayBaseURL: d.resolveGatewayBaseURL(), Region: d.config.Region, HolderID: d.node, - ClusterSize: clusterSize, InternalSuffix: internalSuffix, SystemGatewayURL: d.resolveSystemGatewayBaseURL(), SystemAccessKey: d.config.Predastore.AccessKey, diff --git a/spinifex/daemon/health.go b/spinifex/daemon/health.go index 82540a9b5..677c6b4a9 100644 --- a/spinifex/daemon/health.go +++ b/spinifex/daemon/health.go @@ -165,7 +165,7 @@ func computePredastoreHealth(ctx context.Context, d *Daemon) string { return probeLocalGate(ctx, d, cfg, hostID) } - rootCAs, err := loadPredastoreTrustRoot(d) + rootCAs, err := loadClusterTrustRoot(d) if err != nil { slog.Warn("predastore health probe: could not load cluster CA", "err", err) return predastoreHealthUnreachable @@ -332,11 +332,11 @@ func predastoreConfigPath(daemonConfigPath string) string { return filepath.Join(filepath.Dir(daemonConfigPath), "predastore", "predastore.toml") } -// loadPredastoreTrustRoot returns the cluster CA pool predastore's meta -// replicas are issued from — the same CA (d.config.NATS.CACert, conventionally -// /etc/spinifex/ca.pem) the daemon already trusts NATS peers with. -func loadPredastoreTrustRoot(d *Daemon) (*x509.CertPool, error) { - if d.config.NATS.CACert == "" { +// loadClusterTrustRoot returns the cluster CA pool (d.config.NATS.CACert, +// conventionally /etc/spinifex/ca.pem) that issues every node's server cert: +// daemon peers, predastore replicas and NATS alike. +func loadClusterTrustRoot(d *Daemon) (*x509.CertPool, error) { + if d.config == nil || d.config.NATS.CACert == "" { return nil, fmt.Errorf("cluster CA not configured (nats.cacert)") } return utils.LoadCertPool(d.config.NATS.CACert) diff --git a/spinifex/daemon/heartbeat_test.go b/spinifex/daemon/heartbeat_test.go index 4176525f8..c0d48ae2b 100644 --- a/spinifex/daemon/heartbeat_test.go +++ b/spinifex/daemon/heartbeat_test.go @@ -103,7 +103,7 @@ func TestHeartbeatKVContract(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitClusterStateBucket()) diff --git a/spinifex/daemon/instance_record_repair_test.go b/spinifex/daemon/instance_record_repair_test.go index 95bf0abea..83463e9b2 100644 --- a/spinifex/daemon/instance_record_repair_test.go +++ b/spinifex/daemon/instance_record_repair_test.go @@ -19,7 +19,7 @@ func newRecordManagerInPackage(t *testing.T) *JetStreamManager { t.Helper() _, nc, _ := testutil.StartTestJetStream(t) - m, err := NewJetStreamManager(nc, 1) + m, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, m.InitKVBucket()) require.NoError(t, m.InitTerminatedInstanceBucket()) diff --git a/spinifex/daemon/instance_records_rekey_test.go b/spinifex/daemon/instance_records_rekey_test.go index 883f12d62..30d8aca11 100644 --- a/spinifex/daemon/instance_records_rekey_test.go +++ b/spinifex/daemon/instance_records_rekey_test.go @@ -46,7 +46,7 @@ func TestRekey_MovesInstanceStateRecordsOntoTheDot(t *testing.T) { kv := seedSlashRecord(t, nc, daemon.InstanceStateBucket, 3, &vm.VM{ID: "i-1", InstanceType: "t3.nano", LastNode: "node-1"}) - m, err := daemon.NewJetStreamManager(nc, 1) + m, err := daemon.NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, m.InitKVBucket()) @@ -64,7 +64,7 @@ func TestRekey_MovesTerminatedRecordsOntoTheDot(t *testing.T) { kv := seedSlashRecord(t, nc, daemon.TerminatedInstanceBucket, 2, &vm.VM{ID: "i-1", InstanceType: "t3.nano", LastNode: "node-1"}) - m, err := daemon.NewJetStreamManager(nc, 1) + m, err := daemon.NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, m.InitTerminatedInstanceBucket()) diff --git a/spinifex/daemon/instance_records_test.go b/spinifex/daemon/instance_records_test.go index f26d396b8..6354cf65e 100644 --- a/spinifex/daemon/instance_records_test.go +++ b/spinifex/daemon/instance_records_test.go @@ -29,7 +29,7 @@ func newRecordManagerConn(t *testing.T) (*daemon.JetStreamManager, *nats.Conn) { t.Helper() _, nc, _ := testutil.StartTestJetStream(t) - m, err := daemon.NewJetStreamManager(nc, 1) + m, err := daemon.NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, m.InitKVBucket()) require.NoError(t, m.InitTerminatedInstanceBucket()) @@ -259,7 +259,7 @@ func TestInstanceRecord_CarriesDeletionTimestamp(t *testing.T) { // store: Tier 1 boot runs before cluster KV exists. func TestInstanceRecordAccessors_ReportAnUnopenedBucket(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) - m, err := daemon.NewJetStreamManager(nc, 1) + m, err := daemon.NewJetStreamManager(nc) require.NoError(t, err) assert.Error(t, m.WriteInstanceRecord("i-1", testRecord("i-1"))) diff --git a/spinifex/daemon/instance_records_transition_test.go b/spinifex/daemon/instance_records_transition_test.go index 599221b05..11ffc7dfc 100644 --- a/spinifex/daemon/instance_records_transition_test.go +++ b/spinifex/daemon/instance_records_transition_test.go @@ -223,7 +223,7 @@ func TestInstanceStateMigration_CopiesStoppedInstancesForward(t *testing.T) { seedLegacyBucket(t, nc, daemon.InstanceStateBucket, daemon.StoppedInstancePrefix, &vm.VM{ID: "i-1", InstanceType: "t3.nano"}) - m, err := daemon.NewJetStreamManager(nc, 1) + m, err := daemon.NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, m.InitKVBucket()) @@ -247,7 +247,7 @@ func TestInstanceStateMigration_RunsEveryStepFromTheOldestVersion(t *testing.T) _, err = kv.Put(context.Background(), daemon.InstanceStatePrefix+"node-1", blob) require.NoError(t, err) - m, err := daemon.NewJetStreamManager(nc, 1) + m, err := daemon.NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, m.InitKVBucket()) @@ -277,7 +277,7 @@ func TestInstanceStateMigration_DoesNotOverwriteAFresherRecord(t *testing.T) { _, err = kv.Put(context.Background(), daemon.StoppedInstancePrefix+"i-2", untouched) require.NoError(t, err) - m, err := daemon.NewJetStreamManager(nc, 1) + m, err := daemon.NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, m.InitKVBucket()) @@ -297,12 +297,12 @@ func TestInstanceStateMigration_IsSafeToRunTwice(t *testing.T) { kv := seedLegacyBucket(t, nc, daemon.InstanceStateBucket, daemon.StoppedInstancePrefix, &vm.VM{ID: "i-1", InstanceType: "t3.nano"}) - first, err := daemon.NewJetStreamManager(nc, 1) + first, err := daemon.NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, first.InitKVBucket()) require.NoError(t, kvutil.WriteVersion(context.Background(), kv, 1)) - second, err := daemon.NewJetStreamManager(nc, 1) + second, err := daemon.NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, second.InitKVBucket()) @@ -317,7 +317,7 @@ func TestTerminatedInstanceMigration_CopiesTerminatedInstancesForward(t *testing seedLegacyBucket(t, nc, daemon.TerminatedInstanceBucket, daemon.TerminatedInstancePrefix, &vm.VM{ID: "i-1", InstanceType: "t3.nano"}) - m, err := daemon.NewJetStreamManager(nc, 1) + m, err := daemon.NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, m.InitTerminatedInstanceBucket()) diff --git a/spinifex/daemon/instance_recovery.go b/spinifex/daemon/instance_recovery.go index f494f45db..5e22e8edc 100644 --- a/spinifex/daemon/instance_recovery.go +++ b/spinifex/daemon/instance_recovery.go @@ -522,7 +522,7 @@ func (d *Daemon) newLiveness() *instancecache.Liveness { if d.jsManager == nil || d.jsManager.js == nil { return nil } - return instancecache.NewLiveness(d.jsManager.js, clusterStateConfig(d.jsManager.replicas)) + return instancecache.NewLiveness(d.jsManager.js, clusterStateConfig()) } // forgetSupersededInstances drops any local copy of an instance the records say diff --git a/spinifex/daemon/instance_running_set_test.go b/spinifex/daemon/instance_running_set_test.go index e965c3f1a..19c2baaad 100644 --- a/spinifex/daemon/instance_running_set_test.go +++ b/spinifex/daemon/instance_running_set_test.go @@ -234,7 +234,7 @@ func TestWriteRunningSet_SeedsFromWhatIsAlreadyThere(t *testing.T) { writeRunningSet(t, first, runningVM("i-1", "t3.nano")) before := recordRevision(t, nc, "i-1") - second, err := daemon.NewJetStreamManager(nc, 1) + second, err := daemon.NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, second.InitKVBucket()) writeRunningSet(t, second, runningVM("i-1", "t3.nano")) @@ -293,7 +293,7 @@ func TestInstanceStateMigration_SplitsTheNodeBlob(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) seedNodeBlobBucket(t, nc, localNode, runningSet(runningVM("i-1", "t3.nano"), runningVM("i-2", "t3.micro"))) - m, err := daemon.NewJetStreamManager(nc, 1) + m, err := daemon.NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, m.InitKVBucket()) @@ -314,7 +314,7 @@ func TestInstanceStateMigration_LeavesTheNodeBlobIntact(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) kv := seedNodeBlobBucket(t, nc, localNode, runningSet(runningVM("i-1", "t3.nano"))) - m, err := daemon.NewJetStreamManager(nc, 1) + m, err := daemon.NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, m.InitKVBucket()) writeRunningSet(t, m, runningVM("i-1", "t3.nano")) @@ -330,7 +330,7 @@ func TestInstanceStateMigration_SeedsAPresenceMarkerPerNode(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) kv := seedNodeBlobBucket(t, nc, localNode, runningSet(runningVM("i-1", "t3.nano"))) - m, err := daemon.NewJetStreamManager(nc, 1) + m, err := daemon.NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, m.InitKVBucket()) @@ -359,7 +359,7 @@ func TestInstanceStateMigration_SplitsEveryNodesBlob(t *testing.T) { _, err = kv.Put(context.Background(), daemon.InstanceStatePrefix+"node-2", data) require.NoError(t, err) - m, err := daemon.NewJetStreamManager(nc, 1) + m, err := daemon.NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, m.InitKVBucket()) @@ -383,7 +383,7 @@ func TestInstanceStateMigration_CarriesOwnershipOffTheBlobKey(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) seedNodeBlobBucket(t, nc, localNode, runningSet(unownedVM("i-1", "t3.nano"))) - m, err := daemon.NewJetStreamManager(nc, 1) + m, err := daemon.NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, m.InitKVBucket()) @@ -401,7 +401,7 @@ func TestInstanceStateMigration_ANodeRecoversAnUnownedRunningSet(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) seedNodeBlobBucket(t, nc, localNode, runningSet(unownedVM("i-1", "t3.nano"), unownedVM("i-2", "t3.micro"))) - m, err := daemon.NewJetStreamManager(nc, 1) + m, err := daemon.NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, m.InitKVBucket()) @@ -419,7 +419,7 @@ func TestInstanceStateMigration_LeavesAnOwnedRecordAlone(t *testing.T) { moved := &vm.VM{ID: "i-1", InstanceType: "t3.nano", Status: vm.StateRunning, LastNode: "node-2"} seedNodeBlobBucket(t, nc, localNode, runningSet(moved)) - m, err := daemon.NewJetStreamManager(nc, 1) + m, err := daemon.NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, m.InitKVBucket()) @@ -433,13 +433,13 @@ func TestInstanceStateMigration_OwnershipStampIsSafeToRunTwice(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) kv := seedNodeBlobBucket(t, nc, localNode, runningSet(unownedVM("i-1", "t3.nano"))) - first, err := daemon.NewJetStreamManager(nc, 1) + first, err := daemon.NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, first.InitKVBucket()) before := recordRevision(t, nc, "i-1") require.NoError(t, kvutil.WriteVersion(context.Background(), kv, 4)) - second, err := daemon.NewJetStreamManager(nc, 1) + second, err := daemon.NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, second.InitKVBucket()) @@ -450,13 +450,13 @@ func TestInstanceStateMigration_BlobSplitIsSafeToRunTwice(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) kv := seedNodeBlobBucket(t, nc, localNode, runningSet(runningVM("i-1", "t3.nano"))) - first, err := daemon.NewJetStreamManager(nc, 1) + first, err := daemon.NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, first.InitKVBucket()) before := recordRevision(t, nc, "i-1") require.NoError(t, kvutil.WriteVersion(context.Background(), kv, 2)) - second, err := daemon.NewJetStreamManager(nc, 1) + second, err := daemon.NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, second.InitKVBucket()) diff --git a/spinifex/daemon/ipsec_barrier_test.go b/spinifex/daemon/ipsec_barrier_test.go index ebd31c0f9..f90165a53 100644 --- a/spinifex/daemon/ipsec_barrier_test.go +++ b/spinifex/daemon/ipsec_barrier_test.go @@ -21,7 +21,7 @@ func newTestIPSecBarrier(t *testing.T) (*KVIPSecBarrier, *JetStreamManager) { require.NoError(t, err) t.Cleanup(nc.Close) - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitClusterStateBucket()) diff --git a/spinifex/daemon/jetstream.go b/spinifex/daemon/jetstream.go index 67c2c99ca..71ec59ff3 100644 --- a/spinifex/daemon/jetstream.go +++ b/spinifex/daemon/jetstream.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "log/slog" - "strings" "time" "github.com/aws/aws-sdk-go/aws" @@ -83,7 +82,6 @@ type JetStreamManager struct { records *kvstore.Store[vm.InstanceRecord] // i., instance-state bucket termRecords *kvstore.Store[vm.InstanceRecord] // i., terminated bucket clusterKV jetstream.KeyValue // spinifex-cluster-state - replicas int obs KVSyncObserver running runningSetState } @@ -117,28 +115,23 @@ func (m *JetStreamManager) KVHealthy() bool { } // NewJetStreamManager creates a new JetStreamManager from a NATS connection. -// replicas specifies the number of replicas for the KV bucket (typically matches cluster node count). -func NewJetStreamManager(nc *nats.Conn, replicas int) (*JetStreamManager, error) { +func NewJetStreamManager(nc *nats.Conn) (*JetStreamManager, error) { js, err := jetstream.New(nc) if err != nil { return nil, err } - return &JetStreamManager{ - js: js, - replicas: replicas, - }, nil + return &JetStreamManager{js: js}, nil } // instanceStateConfig describes the instance-state bucket. Extracted so the // bucket can be rebuilt against a different JetStream client without the // description drifting from the one InitKVBucket creates. -func instanceStateConfig(replicas int) kvstore.Config { +func instanceStateConfig() kvstore.Config { return kvstore.Config{ Name: InstanceStateBucket, Description: "Spinifex instance state storage", History: 1, - Replicas: replicas, // The owning node republishes its record on its next write, so an // emptied bucket costs a sync rather than the records themselves. RecreateIfMissing: true, @@ -150,12 +143,11 @@ func instanceStateConfig(replicas int) kvstore.Config { } // terminatedInstanceConfig describes the terminated-instances bucket. -func terminatedInstanceConfig(replicas int) kvstore.Config { +func terminatedInstanceConfig() kvstore.Config { return kvstore.Config{ Name: TerminatedInstanceBucket, Description: "Terminated instances (auto-expire after 1 hour)", History: 1, - Replicas: replicas, TTL: 1 * time.Hour, RecreateIfMissing: true, OnOpen: func(ctx context.Context, kv jetstream.KeyValue) error { @@ -176,7 +168,7 @@ func (m *JetStreamManager) setInstanceStateBucket(b *kvstore.Bucket) { // InitKVBucket initializes the KV bucket, creating it if it doesn't exist. func (m *JetStreamManager) InitKVBucket() error { - m.setInstanceStateBucket(kvstore.NewBucket(m.js, instanceStateConfig(m.replicas))) + m.setInstanceStateBucket(kvstore.NewBucket(m.js, instanceStateConfig())) // Opened eagerly: a bucket that cannot be created must fail startup here, // not on the first write, and Tier 1 boot must not reach cluster KV at all. @@ -185,12 +177,11 @@ func (m *JetStreamManager) InitKVBucket() error { } // clusterStateConfig describes the cluster-state bucket. -func clusterStateConfig(replicas int) kvstore.Config { +func clusterStateConfig() kvstore.Config { return kvstore.Config{ Name: ClusterStateBucket, Description: "Spinifex cluster state (heartbeats, shutdown markers, service maps)", History: 1, - Replicas: replicas, TTL: 1 * time.Hour, OnOpen: func(ctx context.Context, kv jetstream.KeyValue) error { return migrate.DefaultRegistry.RunKV(ctx, ClusterStateBucket, kv, ClusterStateBucketVersion) @@ -204,7 +195,7 @@ func clusterStateConfig(replicas int) kvstore.Config { // The handle is kept rather than the Bucket: this one holds several unrelated // record types under one namespace, and its callers still read it as raw keys. func (m *JetStreamManager) InitClusterStateBucket() error { - kv, err := kvstore.NewBucket(m.js, clusterStateConfig(m.replicas)).KV(context.Background()) + kv, err := kvstore.NewBucket(m.js, clusterStateConfig()).KV(context.Background()) if err != nil { return err } @@ -215,7 +206,7 @@ func (m *JetStreamManager) InitClusterStateBucket() error { // InitTerminatedInstanceBucket initializes the terminated-instances KV bucket with a 1-hour TTL. // JetStream automatically purges keys after 1 hour, matching AWS behavior for terminated instances. func (m *JetStreamManager) InitTerminatedInstanceBucket() error { - m.term = kvstore.New[vm.VM](m.js, terminatedInstanceConfig(m.replicas)) + m.term = kvstore.New[vm.VM](m.js, terminatedInstanceConfig()) // A second view over the same handle, for the same reason the instance-state // bucket carries several. m.termRecords = kvstore.On[vm.InstanceRecord](m.term.Bucket) @@ -521,63 +512,6 @@ func (m *JetStreamManager) DeleteState(nodeID string) error { return nil } -// UpdateReplicas updates the replica count for ALL JetStream KV buckets. -// It iterates over every KV_* stream and bumps replicas to match the cluster size. -// This ensures service buckets (IAM, VPC, IGW, etc.) are replicated alongside daemon buckets. -// This should be called when new nodes join the cluster. -func (m *JetStreamManager) UpdateReplicas(newReplicas int) error { - if m.js == nil { - return errors.New("JetStream context not initialized") - } - - m.replicas = newReplicas - - ctx := context.Background() - // Iterate all streams and update any KV-backed stream (prefixed "KV_") - updated := 0 - lister := m.js.StreamNames(ctx) - for name := range lister.Name() { - if !strings.HasPrefix(name, "KV_") { - continue - } - - stream, err := m.js.Stream(ctx, name) - if err != nil { - slog.Warn("Failed to open stream", "stream", name, "error", err) - continue - } - info, err := stream.Info(ctx) - if err != nil { - slog.Warn("Failed to get stream info", "stream", name, "error", err) - continue - } - - if info.Config.Replicas >= newReplicas { - continue - } - - oldReplicas := info.Config.Replicas - info.Config.Replicas = newReplicas - if _, err := m.js.UpdateStream(ctx, info.Config); err != nil { - slog.Warn("Failed to update KV bucket replicas", "stream", name, "error", err) - continue - } - - bucket := strings.TrimPrefix(name, "KV_") - slog.Info("Updated KV bucket replicas", "bucket", bucket, "oldReplicas", oldReplicas, "newReplicas", newReplicas) - updated++ - } - if err := lister.Err(); err != nil { - return fmt.Errorf("list JetStream streams: %w", err) - } - - if updated > 0 { - slog.Info("KV replication update complete", "bucketsUpdated", updated, "replicas", newReplicas) - } - - return nil -} - // WriteStoppedInstance writes a stopped instance to the shared KV store, // replacing any existing record for instanceID. Uses CAS internally (bounded // retry) so a write racing a concurrent update is detected and retried diff --git a/spinifex/daemon/jetstream_test.go b/spinifex/daemon/jetstream_test.go index 19ffa0c05..0ddf85bcb 100644 --- a/spinifex/daemon/jetstream_test.go +++ b/spinifex/daemon/jetstream_test.go @@ -40,7 +40,7 @@ func TestJetStreamManager_WriteAndLoadState(t *testing.T) { defer nc.Close() // Create JetStreamManager - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err, "Failed to create JetStreamManager") // Initialize the KV bucket @@ -90,7 +90,7 @@ func TestJetStreamManager_LoadState_KeyNotFound(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) err = jsm.InitKVBucket() @@ -112,7 +112,7 @@ func TestJetStreamManager_BucketCreation(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) // InitKVBucket should create the bucket @@ -134,7 +134,7 @@ func TestJetStreamManager_BucketReconnection(t *testing.T) { nc1, err := nats.Connect(natsURL) require.NoError(t, err) - jsm1, err := NewJetStreamManager(nc1, 1) + jsm1, err := NewJetStreamManager(nc1) require.NoError(t, err) err = jsm1.InitKVBucket() @@ -157,7 +157,7 @@ func TestJetStreamManager_BucketReconnection(t *testing.T) { require.NoError(t, err) defer nc2.Close() - jsm2, err := NewJetStreamManager(nc2, 1) + jsm2, err := NewJetStreamManager(nc2) require.NoError(t, err) err = jsm2.InitKVBucket() @@ -180,7 +180,7 @@ func TestJetStreamManager_DeleteState(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) err = jsm.InitKVBucket() @@ -222,7 +222,7 @@ func TestJetStreamManager_DeleteState_NonExistent(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) err = jsm.InitKVBucket() @@ -241,7 +241,7 @@ func TestJetStreamManager_WriteState_UpdateExisting(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) err = jsm.InitKVBucket() @@ -289,7 +289,7 @@ func TestJetStreamManager_MultipleNodes(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) err = jsm.InitKVBucket() @@ -337,7 +337,7 @@ func TestJetStreamManager_KVNotInitialized(t *testing.T) { defer nc.Close() // Create JetStreamManager but don't call InitKVBucket - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) testInstances := make(map[string]*vm.VM) @@ -351,51 +351,6 @@ func TestJetStreamManager_KVNotInitialized(t *testing.T) { assert.Error(t, err, "DeleteState should error when KV not initialized") } -// TestJetStreamManager_UpdateReplicas tests updating replica count for the KV bucket. -func TestJetStreamManager_UpdateReplicas(t *testing.T) { - natsURL := sharedJSNATSURL - - nc, err := nats.Connect(natsURL) - require.NoError(t, err) - defer nc.Close() - - // Create with 1 replica (typical for single node startup) - jsm, err := NewJetStreamManager(nc, 1) - require.NoError(t, err) - - err = jsm.InitKVBucket() - require.NoError(t, err) - - // Verify initial replica count - js, _ := jetstream.New(nc) - stream, err := js.Stream(t.Context(), "KV_"+InstanceStateBucket) - require.NoError(t, err) - streamInfo, err := stream.Info(t.Context()) - require.NoError(t, err) - assert.Equal(t, 1, streamInfo.Config.Replicas, "Should start with 1 replica") - - // Try to update to same replica count (should be a no-op) - err = jsm.UpdateReplicas(1) - assert.NoError(t, err, "Updating to same replica count should succeed") - - // Note: Increasing replicas beyond 1 requires additional NATS servers in the cluster, - // which we don't have in the test environment. In a single-node test server, - // attempting to increase replicas will fail with "insufficient resources" error. - // This test verifies the basic functionality works. -} - -// TestJetStreamManager_UpdateReplicas_NoInit tests UpdateReplicas when JS not initialized. -func TestJetStreamManager_UpdateReplicas_NoInit(t *testing.T) { - // Test with nil JetStream context - jsm := &JetStreamManager{ - js: nil, - replicas: 1, - } - - err := jsm.UpdateReplicas(3) - assert.Error(t, err, "UpdateReplicas should error when JetStream not initialized") -} - // --- Stopped instance KV tests --- // TestJetStreamManager_WriteAndLoadStoppedInstance tests round-trip write and load of a stopped instance. @@ -404,7 +359,7 @@ func TestJetStreamManager_WriteAndLoadStoppedInstance(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) err = jsm.InitKVBucket() require.NoError(t, err) @@ -439,7 +394,7 @@ func TestJetStreamManager_LoadStoppedInstance_NotFound(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) err = jsm.InitKVBucket() require.NoError(t, err) @@ -455,7 +410,7 @@ func TestJetStreamManager_DeleteStoppedInstance(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) err = jsm.InitKVBucket() require.NoError(t, err) @@ -489,7 +444,7 @@ func TestJetStreamManager_ClaimStoppedInstance_HappyPath(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitKVBucket()) @@ -517,7 +472,7 @@ func TestJetStreamManager_ClaimStoppedInstance_NotFound(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitKVBucket()) @@ -537,7 +492,7 @@ func TestJetStreamManager_ClaimStoppedInstance_ConcurrentClaim(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitKVBucket()) @@ -588,7 +543,7 @@ func TestJetStreamManager_UpdateStoppedInstance_ConflictRetry(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitKVBucket()) @@ -631,7 +586,7 @@ func TestJetStreamManager_UpdateStoppedInstance_NotFound(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitKVBucket()) @@ -652,7 +607,7 @@ func TestJetStreamManager_UpdateStoppedInstance_NoResurrectAfterClaim(t *testing require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitKVBucket()) @@ -696,7 +651,7 @@ func TestJetStreamManager_ListStoppedInstances(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) err = jsm.InitKVBucket() require.NoError(t, err) @@ -738,7 +693,7 @@ func TestJetStreamManager_StoppedInstances_NoInterference(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) err = jsm.InitKVBucket() require.NoError(t, err) @@ -778,7 +733,7 @@ func TestJetStreamManager_StoppedInstance_KVNotInitialized(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) // Don't call InitKVBucket @@ -803,7 +758,7 @@ func TestJetStreamManager_WriteServiceManifest(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) err = jsm.InitClusterStateBucket() require.NoError(t, err) @@ -840,7 +795,7 @@ func TestJetStreamManager_WriteServiceManifest_EmptyServices(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) err = jsm.InitClusterStateBucket() require.NoError(t, err) @@ -867,7 +822,7 @@ func TestJetStreamManager_WriteServiceManifest_ClusterKVNotInitialized(t *testin require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) // Don't call InitClusterStateBucket @@ -895,7 +850,7 @@ func TestJetStreamManager_WriteState_RecoverAfterStreamLost(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) err = jsm.InitKVBucket() require.NoError(t, err) @@ -922,7 +877,7 @@ func TestJetStreamManager_LoadState_RecoverAfterStreamLost(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) err = jsm.InitKVBucket() require.NoError(t, err) @@ -941,7 +896,7 @@ func TestJetStreamManager_DeleteState_RecoverAfterStreamLost(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) err = jsm.InitKVBucket() require.NoError(t, err) @@ -958,7 +913,7 @@ func TestJetStreamManager_WriteStoppedInstance_RecoverAfterStreamLost(t *testing require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) err = jsm.InitKVBucket() require.NoError(t, err) @@ -984,7 +939,7 @@ func TestJetStreamManager_LoadStoppedInstance_RecoverAfterStreamLost(t *testing. require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) err = jsm.InitKVBucket() require.NoError(t, err) @@ -1002,7 +957,7 @@ func TestJetStreamManager_DeleteStoppedInstance_RecoverAfterStreamLost(t *testin require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) err = jsm.InitKVBucket() require.NoError(t, err) @@ -1018,7 +973,7 @@ func TestJetStreamManager_ListStoppedInstances_RecoverAfterStreamLost(t *testing require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) err = jsm.InitKVBucket() require.NoError(t, err) @@ -1055,12 +1010,12 @@ func swapToNonJSContext(t *testing.T, jsm *JetStreamManager) { if jsm.stateB != nil { kv, err := jsm.stateB.KV(t.Context()) require.NoError(t, err) - jsm.setInstanceStateBucket(kvstore.NewOpenBucket(js, kv, instanceStateConfig(jsm.replicas))) + jsm.setInstanceStateBucket(kvstore.NewOpenBucket(js, kv, instanceStateConfig())) } if jsm.term != nil { kv, err := jsm.term.KV(t.Context()) require.NoError(t, err) - jsm.term = kvstore.Over[vm.VM](js, kv, terminatedInstanceConfig(jsm.replicas)) + jsm.term = kvstore.Over[vm.VM](js, kv, terminatedInstanceConfig()) } } @@ -1069,7 +1024,7 @@ func TestJetStreamManager_WriteState_RecoveryFailure(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) err = jsm.InitKVBucket() require.NoError(t, err) @@ -1087,7 +1042,7 @@ func TestJetStreamManager_LoadState_RecoveryFailure(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) err = jsm.InitKVBucket() require.NoError(t, err) @@ -1105,7 +1060,7 @@ func TestJetStreamManager_DeleteState_RecoveryFailure(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) err = jsm.InitKVBucket() require.NoError(t, err) @@ -1122,7 +1077,7 @@ func TestJetStreamManager_WriteStoppedInstance_RecoveryFailure(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) err = jsm.InitKVBucket() require.NoError(t, err) @@ -1140,7 +1095,7 @@ func TestJetStreamManager_LoadStoppedInstance_RecoveryFailure(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) err = jsm.InitKVBucket() require.NoError(t, err) @@ -1158,7 +1113,7 @@ func TestJetStreamManager_DeleteStoppedInstance_RecoveryFailure(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) err = jsm.InitKVBucket() require.NoError(t, err) @@ -1175,7 +1130,7 @@ func TestJetStreamManager_ListStoppedInstances_RecoveryFailure(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) err = jsm.InitKVBucket() require.NoError(t, err) @@ -1195,7 +1150,7 @@ func TestJetStreamManager_WriteAndLoadTerminatedInstance(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitTerminatedInstanceBucket()) @@ -1225,7 +1180,7 @@ func TestJetStreamManager_LoadTerminatedInstance_NotFound(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitTerminatedInstanceBucket()) @@ -1239,7 +1194,7 @@ func TestJetStreamManager_DeleteTerminatedInstance(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitTerminatedInstanceBucket()) @@ -1268,7 +1223,7 @@ func TestJetStreamManager_ListTerminatedInstances(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitTerminatedInstanceBucket()) @@ -1304,7 +1259,7 @@ func TestJetStreamManager_WriteLoadDeleteTerminatedInstance_RoundTrip(t *testing require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitTerminatedInstanceBucket()) @@ -1336,7 +1291,7 @@ func TestJetStreamManager_TerminatedInstance_KVNotInitialized(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) // Don't call InitTerminatedInstanceBucket @@ -1365,7 +1320,7 @@ func TestJetStreamManager_UpdateTerminatedInstance_ConflictRetry(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitTerminatedInstanceBucket()) @@ -1414,7 +1369,7 @@ func TestJetStreamManager_UpdateTerminatedInstance_NotFound(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitTerminatedInstanceBucket()) @@ -1432,7 +1387,7 @@ func TestJetStreamManager_WriteStoppedInstance_OverwritesConcurrentValue(t *test require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitKVBucket()) @@ -1470,7 +1425,7 @@ func TestJetStreamManager_WriteTerminatedInstance_RecoverAfterStreamLost(t *test require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitTerminatedInstanceBucket()) @@ -1493,7 +1448,7 @@ func TestJetStreamManager_LoadTerminatedInstance_RecoverAfterStreamLost(t *testi require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitTerminatedInstanceBucket()) @@ -1509,7 +1464,7 @@ func TestJetStreamManager_DeleteTerminatedInstance_RecoverAfterStreamLost(t *tes require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitTerminatedInstanceBucket()) @@ -1524,7 +1479,7 @@ func TestJetStreamManager_ListTerminatedInstances_RecoverAfterStreamLost(t *test require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitTerminatedInstanceBucket()) @@ -1540,7 +1495,7 @@ func TestJetStreamManager_InitBuckets_WritesVersion(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitKVBucket()) @@ -1570,7 +1525,7 @@ func TestJetStreamManager_ListStoppedInstances_SkipsVersionKey(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitKVBucket()) @@ -1585,7 +1540,7 @@ func TestJetStreamManager_ListTerminatedInstances_SkipsVersionKey(t *testing.T) require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitTerminatedInstanceBucket()) @@ -1632,7 +1587,7 @@ func TestJetStreamManager_BestEffort_Success_NotifiesObserver(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitKVBucket()) @@ -1651,7 +1606,7 @@ func TestJetStreamManager_BestEffort_PutError_NotifiesObserver(t *testing.T) { nc, err := nats.Connect(sharedJSNATSURL) require.NoError(t, err) - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitKVBucket()) @@ -1675,7 +1630,7 @@ func TestJetStreamManager_BestEffort_NilObserver_NoPanic(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitKVBucket()) @@ -1696,7 +1651,7 @@ func TestJetStreamManager_UpdateMgmtIPAM_ConflictRetry(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitClusterStateBucket()) @@ -1746,7 +1701,7 @@ func TestJetStreamManager_UpdateMgmtIPAM_NotFound(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitClusterStateBucket()) @@ -1761,7 +1716,7 @@ func TestJetStreamManager_UpdateMgmtIPAM_ClusterKVNotInitialized(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) // Don't call InitClusterStateBucket @@ -1781,7 +1736,7 @@ func TestJetStreamManager_UpdateMgmtIPAM_ClusterKVNotInitialized(t *testing.T) { func TestJetStreamManager_ListStoppedInstances_FailsOnAnUndecodableRecord(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitKVBucket()) @@ -1806,7 +1761,7 @@ func TestJetStreamManager_UpdateStoppedInstance_AbsentKeyIsNotFound(t *testing.T require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitKVBucket()) require.NoError(t, jsm.InitTerminatedInstanceBucket()) @@ -1842,7 +1797,7 @@ func TestJetStreamManager_LoadState_UnversionedRecordStillReads(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitKVBucket()) @@ -1867,7 +1822,7 @@ func TestJetStreamManager_LoadState_FutureRecordIsRefused(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitKVBucket()) @@ -1887,7 +1842,7 @@ func TestJetStreamManager_WriteState_StampsSchemaVersion(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, jsm.InitKVBucket()) diff --git a/spinifex/daemon/peer_health.go b/spinifex/daemon/peer_health.go index 04c3d0006..27697b0c1 100644 --- a/spinifex/daemon/peer_health.go +++ b/spinifex/daemon/peer_health.go @@ -55,10 +55,17 @@ func (d *Daemon) monitorPeerReachability() { return } + // Without the CA every probe would fail, flipping the node to partitioned; + // leave peersReachable at its initial true instead. + rootCAs, err := loadClusterTrustRoot(d) + if err != nil { + slog.Error("peer reachability probe disabled: could not load cluster CA", "err", err) + return + } client := &http.Client{ Timeout: peerProbeTimeout, Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // self-signed per-node certs (matches harness DaemonClient) + TLSClientConfig: &tls.Config{RootCAs: rootCAs}, }, } defer client.CloseIdleConnections() diff --git a/spinifex/daemon/peer_health_test.go b/spinifex/daemon/peer_health_test.go index 5d598d1cb..bc7aba433 100644 --- a/spinifex/daemon/peer_health_test.go +++ b/spinifex/daemon/peer_health_test.go @@ -2,11 +2,20 @@ package daemon import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" "net" "net/http" "net/http/httptest" "net/url" + "os" + "path/filepath" "sync/atomic" "testing" "time" @@ -20,8 +29,9 @@ import ( // /health responses, returning the cluster config the daemon-under-test should // see plus a handle to flip each peer up or down at runtime. type peerHealthFixture struct { - peers []*peerStub - cfg *config.ClusterConfig + peers []*peerStub + cfg *config.ClusterConfig + caPath string } type peerStub struct { @@ -61,6 +71,7 @@ func newPeerHealthFixture(t *testing.T, peerCount int) *peerHealthFixture { Daemon: config.DaemonConfig{Host: "0.0.0.0:" + port}, } f.peers = append(f.peers, stub) + f.caPath = writeServerCA(t, stub.srv) } f.cfg = &config.ClusterConfig{Node: "node-self", Nodes: nodes} return f @@ -76,6 +87,40 @@ func daemonForPeerHealth(t *testing.T, cfg *config.ClusterConfig) (*Daemon, cont return d, cancel } +// writeServerCA writes srv's self-signed cert as a PEM CA file, standing in for +// the cluster CA. Every httptest TLS server shares that one cert. +func writeServerCA(t *testing.T, srv *httptest.Server) string { + t.Helper() + return writePEMCert(t, srv.Certificate().Raw) +} + +// writeUntrustedCA writes a freshly generated self-signed CA that signed none +// of the httptest servers' certs. +func writeUntrustedCA(t *testing.T) string { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "untrusted test CA"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + require.NoError(t, err) + return writePEMCert(t, der) +} + +func writePEMCert(t *testing.T, der []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), "ca.pem") + require.NoError(t, os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600)) + return path +} + func TestPeerCount_SingleNode(t *testing.T) { cfg := &config.ClusterConfig{ Node: "only", @@ -194,6 +239,7 @@ func TestMonitorPeerReachability_EndToEnd(t *testing.T) { } f := newPeerHealthFixture(t, 2) d, cancel := daemonForPeerHealth(t, f.cfg) + d.config = &config.Config{NATS: config.NATSConfig{CACert: f.caPath}} for _, p := range f.peers { p.setHealthy(false) @@ -211,3 +257,47 @@ func TestMonitorPeerReachability_EndToEnd(t *testing.T) { peerProbeInterval+peerProbeTimeout+time.Second, 50*time.Millisecond, "peersReachable should flip true when any peer comes back") } + +// TestMonitorPeerReachability_UntrustedPeerCert asserts a peer whose cert the +// cluster CA did not sign counts as unreachable, even while serving 200. +func TestMonitorPeerReachability_UntrustedPeerCert(t *testing.T) { + if testing.Short() { + t.Skip("skipping ticker-driven peer probe test in short mode") + } + f := newPeerHealthFixture(t, 1) + d, cancel := daemonForPeerHealth(t, f.cfg) + d.config = &config.Config{NATS: config.NATSConfig{CACert: writeUntrustedCA(t)}} + d.peersReachable.Store(true) + + go d.monitorPeerReachability() + defer cancel() + + require.Eventually(t, func() bool { return !d.peersReachable.Load() }, + peerProbeTimeout+time.Second, 50*time.Millisecond, + "a peer presenting an untrusted cert must not count as reachable") +} + +// TestMonitorPeerReachability_NoCAKeepsPeersReachable asserts a node that +// cannot load the cluster CA stops probing rather than declaring every peer +// unreachable and dropping out of cluster mode. +func TestMonitorPeerReachability_NoCAKeepsPeersReachable(t *testing.T) { + f := newPeerHealthFixture(t, 2) + for _, p := range f.peers { + p.setHealthy(false) + } + d, _ := daemonForPeerHealth(t, f.cfg) + d.config = &config.Config{} + d.peersReachable.Store(true) + + done := make(chan struct{}) + go func() { + d.monitorPeerReachability() + close(done) + }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("monitorPeerReachability kept running without a cluster CA") + } + assert.True(t, d.peersReachable.Load()) +} diff --git a/spinifex/daemon/predastore_ready_test.go b/spinifex/daemon/predastore_ready_test.go index 2a6c9cb38..80f08092d 100644 --- a/spinifex/daemon/predastore_ready_test.go +++ b/spinifex/daemon/predastore_ready_test.go @@ -26,10 +26,46 @@ func TestCheckPredastoreReady_HTTPSServing(t *testing.T) { u, err := url.Parse(srv.URL) require.NoError(t, err) - d := &Daemon{config: &config.Config{Predastore: config.PredastoreConfig{Host: u.Host}}} + d := &Daemon{config: &config.Config{ + Predastore: config.PredastoreConfig{Host: u.Host}, + NATS: config.NATSConfig{CACert: writeServerCA(t, srv)}, + }} assert.True(t, d.checkPredastoreReady()) } +// TestCheckPredastoreReady_UntrustedCert asserts an endpoint whose cert the +// cluster CA did not sign is not ready, even though it answers HTTPS. +func TestCheckPredastoreReady_UntrustedCert(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + t.Cleanup(srv.Close) + + u, err := url.Parse(srv.URL) + require.NoError(t, err) + + d := &Daemon{config: &config.Config{ + Predastore: config.PredastoreConfig{Host: u.Host}, + NATS: config.NATSConfig{CACert: writeUntrustedCA(t)}, + }} + assert.False(t, d.checkPredastoreReady()) +} + +// TestCheckPredastoreReady_NoClusterCA asserts a node without a configured +// cluster CA fails closed instead of probing unverified. +func TestCheckPredastoreReady_NoClusterCA(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + t.Cleanup(srv.Close) + + u, err := url.Parse(srv.URL) + require.NoError(t, err) + + d := &Daemon{config: &config.Config{Predastore: config.PredastoreConfig{Host: u.Host}}} + assert.False(t, d.checkPredastoreReady()) +} + // TestCheckPredastoreReady_ClosedPort asserts a port nothing is listening on // is reported not-ready, rather than panicking or failing open. func TestCheckPredastoreReady_ClosedPort(t *testing.T) { diff --git a/spinifex/daemon/shutdown_test.go b/spinifex/daemon/shutdown_test.go index 7e974483a..d9161cc6c 100644 --- a/spinifex/daemon/shutdown_test.go +++ b/spinifex/daemon/shutdown_test.go @@ -31,7 +31,7 @@ func TestClusterShutdownStateKVRoundTrip(t *testing.T) { require.NoError(t, err) defer nc.Close() - jsm, err := NewJetStreamManager(nc, 1) + jsm, err := NewJetStreamManager(nc) require.NoError(t, err) err = jsm.InitClusterStateBucket() require.NoError(t, err) diff --git a/spinifex/daemon/state_test.go b/spinifex/daemon/state_test.go index 5ed3e7c26..60a66cd2c 100644 --- a/spinifex/daemon/state_test.go +++ b/spinifex/daemon/state_test.go @@ -41,7 +41,7 @@ func createDaemonWithJetStream(t *testing.T) *Daemon { daemon.config = &config.Config{BaseDir: tmpDir, DataDir: tmpDir} daemon.natsConn = nc - daemon.jsManager, err = NewJetStreamManager(nc, 1) + daemon.jsManager, err = NewJetStreamManager(nc) require.NoError(t, err) require.NoError(t, daemon.jsManager.InitKVBucket()) require.NoError(t, daemon.jsManager.InitClusterStateBucket()) @@ -254,7 +254,7 @@ func TestEveryState_CanReachTerminated(t *testing.T) { continue } - visited := map[vm.InstanceState]bool{start: true} //nolint:exhaustive // BFS seed — populated dynamically + visited := map[vm.InstanceState]bool{start: true} queue := []vm.InstanceState{start} found := false @@ -415,7 +415,7 @@ func TestTransitionState_ConcurrentTransitions(t *testing.T) { // reachableStates returns all states reachable from start via BFS. func reachableStates(start vm.InstanceState) map[vm.InstanceState]bool { - visited := map[vm.InstanceState]bool{start: true} //nolint:exhaustive // BFS seed — populated dynamically + visited := map[vm.InstanceState]bool{start: true} queue := []vm.InstanceState{start} for len(queue) > 0 { current := queue[0] diff --git a/spinifex/foundation/aws/arn/acm.go b/spinifex/foundation/aws/arn/acm.go index 60cfa0444..738f32b78 100644 --- a/spinifex/foundation/aws/arn/acm.go +++ b/spinifex/foundation/aws/arn/acm.go @@ -23,11 +23,11 @@ func FormatACMCertificate(region, accountID, id string) string { // The id keeps everything after the first "/". Truncating at the last one would // let a grant on certificate/ authorize a request naming certificate//. func ParseACMCertificateID(certARN string) (string, bool) { - parts := strings.SplitN(certARN, ":", 6) - if len(parts) != 6 || parts[0] != "arn" || parts[2] != "acm" { + service, _, _, resource, ok := Split(certARN) + if !ok || service != "acm" { return "", false } - kind, id, found := strings.Cut(parts[5], "/") + kind, id, found := strings.Cut(resource, "/") if !found || ACMResourceType(kind) != ACMCertificate || id == "" { return "", false } diff --git a/spinifex/foundation/aws/arn/arn.go b/spinifex/foundation/aws/arn/arn.go new file mode 100644 index 000000000..9f0c3c998 --- /dev/null +++ b/spinifex/foundation/aws/arn/arn.go @@ -0,0 +1,14 @@ +package arn + +import "strings" + +// Split breaks an arn:aws ARN into its service, region, account and resource +// components. The resource keeps any further colons or slashes verbatim. ok is +// false for anything outside the aws partition, the only one this stack builds. +func Split(s string) (service, region, account, resource string, ok bool) { + parts := strings.SplitN(s, ":", 6) + if len(parts) != 6 || parts[0] != "arn" || parts[1] != "aws" { + return "", "", "", "", false + } + return parts[2], parts[3], parts[4], parts[5], true +} diff --git a/spinifex/foundation/aws/arn/arn_test.go b/spinifex/foundation/aws/arn/arn_test.go new file mode 100644 index 000000000..5fbb7661d --- /dev/null +++ b/spinifex/foundation/aws/arn/arn_test.go @@ -0,0 +1,81 @@ +package arn_test + +import ( + "testing" + + "github.com/mulgadc/spinifex/spinifex/foundation/aws/arn" + "github.com/stretchr/testify/assert" +) + +func TestSplit(t *testing.T) { + tests := []struct { + name string + value string + service string + region string + account string + resource string + wantOK bool + }{ + { + name: "regional ARN", + value: "arn:aws:ecs:ap-southeast-2:123456789012:cluster/web", + service: "ecs", + region: "ap-southeast-2", + account: "123456789012", + resource: "cluster/web", + wantOK: true, + }, + { + name: "global ARN has an empty region", + value: "arn:aws:iam::123456789012:root", + service: "iam", + account: "123456789012", + resource: "root", + wantOK: true, + }, + { + name: "resource keeps its own colons", + value: "arn:aws:rds:ap-southeast-2:123456789012:snapshot:rds:orders-2026-09-30", + service: "rds", + region: "ap-southeast-2", + account: "123456789012", + resource: "snapshot:rds:orders-2026-09-30", + wantOK: true, + }, + { + name: "empty resource", + value: "arn:aws:ecs:ap-southeast-2:123456789012:", + service: "ecs", + region: "ap-southeast-2", + account: "123456789012", + wantOK: true, + }, + { + name: "another partition", + value: "arn:aws-cn:ecs:cn-north-1:123456789012:cluster/web", + }, + { + name: "not an ARN", + value: "urn:aws:ecs:ap-southeast-2:123456789012:cluster/web", + }, + { + name: "too few components", + value: "arn:aws:ecs:ap-southeast-2:cluster/web", + }, + { + name: "empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service, region, account, resource, ok := arn.Split(tt.value) + assert.Equal(t, tt.wantOK, ok) + assert.Equal(t, tt.service, service) + assert.Equal(t, tt.region, region) + assert.Equal(t, tt.account, account) + assert.Equal(t, tt.resource, resource) + }) + } +} diff --git a/spinifex/foundation/aws/arn/elbv2.go b/spinifex/foundation/aws/arn/elbv2.go index eaa19fc12..589d168ff 100644 --- a/spinifex/foundation/aws/arn/elbv2.go +++ b/spinifex/foundation/aws/arn/elbv2.go @@ -70,18 +70,18 @@ type ParsedELBv2 struct { // arn:aws:elasticloadbalancing ARN naming one of the four resource types: an // ARN this stack never builds has no resource it can correctly stand for. func ParseELBv2(value string) (ParsedELBv2, bool) { - parts := strings.SplitN(value, ":", 6) - if len(parts) != 6 || parts[0] != "arn" || parts[1] != "aws" || parts[2] != "elasticloadbalancing" { + service, region, account, rest, ok := Split(value) + if !ok || service != "elasticloadbalancing" { return ParsedELBv2{}, false } - rawKind, resource, found := strings.Cut(parts[5], "/") + rawKind, resource, found := strings.Cut(rest, "/") if !found || rawKind == "" { return ParsedELBv2{}, false } kind := ELBv2ResourceType(rawKind) switch kind { case ELBv2LoadBalancer, ELBv2TargetGroup, ELBv2Listener, ELBv2ListenerRule: - return ParsedELBv2{Region: parts[3], AccountID: parts[4], Kind: kind, Resource: resource}, true + return ParsedELBv2{Region: region, AccountID: account, Kind: kind, Resource: resource}, true default: return ParsedELBv2{}, false } diff --git a/spinifex/foundation/aws/errors/awserrors.go b/spinifex/foundation/aws/errors/awserrors.go index 627eeeab0..7483351cc 100644 --- a/spinifex/foundation/aws/errors/awserrors.go +++ b/spinifex/foundation/aws/errors/awserrors.go @@ -314,7 +314,7 @@ var ( ErrorInvalidVpcPeeringConnectionIDNotFound = "InvalidVpcPeeringConnectionID.NotFound" ErrorInvalidVpcPeeringConnectionIdMalformed = "InvalidVpcPeeringConnectionId.Malformed" ErrorInvalidVpcPeeringConnectionStateDnsHostnamesDisabled = "InvalidVpcPeeringConnectionState.DnsHostnamesDisabled" - ErrorInvalidVpcRange = "InvalidVpcRange" + ErrorInvalidVpcRange = "InvalidVpc.Range" ErrorInvalidVpcState = "InvalidVpcState" ErrorInvalidVpnConnectionInvalidState = "InvalidVpnConnection.InvalidState" ErrorInvalidVpnConnectionInvalidType = "InvalidVpnConnection.InvalidType" @@ -577,7 +577,7 @@ func ValidErrorCode(code string) string { // ResolveErrorCode returns the first registered AWS error code in err's unwrap tree. func ResolveErrorCode(err error) (string, bool) { - code, _, ok := resolveErrorDetail(err) + code, _, ok := ResolveErrorDetail(err) return code, ok } @@ -585,12 +585,6 @@ func ResolveErrorCode(err error) (string, bool) { // plus the message the producing call site attached via Errorf, if any. A // generic %w wrapper added purely for internal context carries no message. func ResolveErrorDetail(err error) (code, message string, ok bool) { - return resolveErrorDetail(err) -} - -// resolveErrorDetail is the shared unwrap-tree walk behind ResolveErrorCode -// and ResolveErrorDetail. -func resolveErrorDetail(err error) (code, message string, ok bool) { if err == nil { return "", "", false } @@ -604,14 +598,14 @@ func resolveErrorDetail(err error) (code, message string, ok bool) { if joined, isJoined := err.(interface{ Unwrap() []error }); isJoined { for _, inner := range joined.Unwrap() { - if c, m, found := resolveErrorDetail(inner); found { + if c, m, found := ResolveErrorDetail(inner); found { return c, m, true } } return "", "", false } if wrapped, isWrapped := err.(interface{ Unwrap() error }); isWrapped { - return resolveErrorDetail(wrapped.Unwrap()) + return ResolveErrorDetail(wrapped.Unwrap()) } return "", "", false } @@ -640,6 +634,18 @@ func Errorf(code, format string, args ...any) error { return outer } +// IDNotFound returns code with EC2's not-found message for one resource ID, +// "The ID '' does not exist". +func IDNotFound(code, kind, id string) error { + return Errorf(code, "The %s ID '%s' does not exist", kind, id) +} + +// HasDependencies returns DependencyViolation with EC2's message for a +// resource that cannot be deleted while others depend on it. +func HasDependencies(kind, id string) error { + return Errorf(ErrorDependencyViolation, "The %s '%s' has dependencies and cannot be deleted.", kind, id) +} + // retryableError wraps a registered code with a suggested Retry-After // duration, for a 503 that a client can expect to clear on its own within a // bounded window (e.g. a warm-up race) rather than an open-ended outage. @@ -658,7 +664,7 @@ func RetryAfter(code string, d time.Duration) error { } // ResolveRetryAfter returns the Retry-After duration attached to err via -// RetryAfter, if any, walking the same unwrap tree resolveErrorDetail does. +// RetryAfter, if any, walking the same unwrap tree ResolveErrorDetail does. func ResolveRetryAfter(err error) (time.Duration, bool) { if err == nil { return 0, false @@ -1012,7 +1018,7 @@ var ErrorLookup = map[string]ErrorMessage{ ErrorInvalidNetworkLoadBalancerArnNotFound: {HTTPCode: 404, Message: "The specified Network Load Balancer ARN does not exist."}, ErrorInvalidNextToken: {HTTPCode: 400, Message: "The specified NextToken is not valid."}, ErrorInvalidOptionConflict: {HTTPCode: 409, Message: "A VPN connection between the virtual private gateway and the customer gateway already exists."}, - ErrorInvalidPaginationToken: {HTTPCode: 403, Message: "The specified pagination token is not valid or is expired."}, + ErrorInvalidPaginationToken: {HTTPCode: 400, Message: "The specified pagination token is not valid or is expired."}, ErrorInvalidParameter: {HTTPCode: 400, Message: "A parameter specified in a request is not valid, is unsupported, or cannot be used. The returned message provides an explanation of the error value. For example, if you are launching an instance, you can't specify a security group and subnet that are in different VPCs."}, ErrorInvalidParameterCombination: {HTTPCode: 400, Message: "Indicates an incorrect combination of parameters, or a missing parameter. For example, trying to terminate an instance without specifying the instance ID."}, ErrorInvalidParameterDependency: {HTTPCode: 400, Message: "Indicates an incorrect combination of parameters, or a missing parameter. For example, trying to terminate an instance without specifying the instance ID."}, @@ -1069,7 +1075,7 @@ var ErrorLookup = map[string]ErrorMessage{ ErrorInvalidSubnetConflict: {HTTPCode: 409, Message: "The specified CIDR block conflicts with that of another subnet in your VPC."}, ErrorInvalidSubnetRange: {HTTPCode: 400, Message: "The CIDR block you've specified for the subnet is not valid. The allowed block size is between a /28 netmask and /16 netmask."}, ErrorInvalidSubnetIDMalformed: {HTTPCode: 400, Message: "The specified subnet ID is malformed. Ensure that you specify the ID in the form subnet-xxxxxxxxxxxxxxxxx"}, - ErrorInvalidSubnetIDNotFound: {HTTPCode: 404, Message: "or InvalidSubnetId.NotFound \tThe specified subnet does not exist."}, + ErrorInvalidSubnetIDNotFound: {HTTPCode: 404, Message: "The specified subnet does not exist."}, ErrorInvalidTagKeyMalformed: {HTTPCode: 400, Message: "The specified tag key is not valid. Tag keys cannot be empty or null, and cannot start with aws:."}, ErrorInvalidTargetArnUnknown: {HTTPCode: 404, Message: "The specified ARN for the specified user or role is not valid or does not exist."}, ErrorInvalidTargetException: {HTTPCode: 404, Message: "The specified TargetId is not valid, does not exist, or is in another organization. You can only generate an account status report for declarative policies in your own organization. Ensure that you specify the TargetId in one of the following forms: r-xxxx, ou-xxxx-xxxxxxxx, or a 12-digit account ID in the form xxxxxxxxxxxx."}, @@ -1097,7 +1103,7 @@ var ErrorLookup = map[string]ErrorMessage{ ErrorInvalidVpcPeeringConnectionIDNotFound: {HTTPCode: 404, Message: "The specified VPC peering connection ID does not exist."}, ErrorInvalidVpcPeeringConnectionIdMalformed: {HTTPCode: 400, Message: "The specified VPC peering connection ID is malformed. Ensure that you provide the ID in the form pcx-xxxxxxxxxxxxxxxxx."}, ErrorInvalidVpcPeeringConnectionStateDnsHostnamesDisabled: {HTTPCode: 400, Message: "To enable DNS hostname resolution for the VPC peering connection, DNS hostname support must be enabled for the VPCs."}, - ErrorInvalidVpcRange: {HTTPCode: 400, Message: "The specified CIDR block range is not valid. The block range must be between a /28 netmask and /16 netmask. For more information, see VPC CIDR blocks."}, + ErrorInvalidVpcRange: {HTTPCode: 400, Message: "The CIDR block is invalid."}, ErrorInvalidVpcState: {HTTPCode: 400, Message: "The specified VPC already has a virtual private gateway attached to it."}, ErrorInvalidVpnConnectionInvalidState: {HTTPCode: 400, Message: "The VPN connection must be in the available state to complete the request."}, ErrorInvalidVpnConnectionInvalidType: {HTTPCode: 400, Message: "The specified VPN connection does not support static routes."}, diff --git a/spinifex/foundation/aws/filters/filterutil.go b/spinifex/foundation/aws/filters/filterutil.go index 6c39e625e..a346e4d6e 100644 --- a/spinifex/foundation/aws/filters/filterutil.go +++ b/spinifex/foundation/aws/filters/filterutil.go @@ -4,6 +4,7 @@ package filters import ( "log/slog" + "slices" "strings" "github.com/aws/aws-sdk-go/service/ec2" @@ -48,18 +49,34 @@ func MatchesAny(filterValues []string, value string) bool { if len(filterValues) == 0 { return true } - for _, pattern := range filterValues { - if MatchWildcard(pattern, value) { - return true - } - } - return false + return slices.ContainsFunc(filterValues, func(pattern string) bool { + return MatchWildcard(pattern, value) + }) } -// MatchesTags checks whether a resource's tags satisfy all tag:Key filters in the map. -// Each tag:Key filter uses OR logic across its values, with wildcard support. +// IsTagFilter reports whether MatchesTags evaluates the named filter, so a +// resource matcher's switch can skip it. +func IsTagFilter(name string) bool { + return strings.HasPrefix(name, "tag:") || name == "tag-key" || name == "tag-value" +} + +// MatchesTags checks whether a resource's tags satisfy every tag:Key, tag-key +// and tag-value filter in the map. Each filter uses OR logic across its values, +// with wildcard support; tag-key and tag-value match any one of the tags. func MatchesTags(filters map[string][]string, tags map[string]string) bool { for name, values := range filters { + switch name { + case "tag-key": + if !matchesAnyTag(tags, values, func(k, _ string) string { return k }) { + return false + } + continue + case "tag-value": + if !matchesAnyTag(tags, values, func(_, v string) string { return v }) { + return false + } + continue + } tagKey, ok := strings.CutPrefix(name, "tag:") if !ok { continue @@ -75,6 +92,17 @@ func MatchesTags(filters map[string][]string, tags map[string]string) bool { return true } +// matchesAnyTag reports whether any tag's selected field (key or value) +// matches any of the filter values. +func matchesAnyTag(tags map[string]string, values []string, field func(k, v string) string) bool { + for k, v := range tags { + if MatchesAny(values, field(k, v)) { + return true + } + } + return false +} + // EC2TagsToMap converts []*ec2.Tag to map[string]string for MatchesTags. func EC2TagsToMap(tags []*ec2.Tag) map[string]string { if len(tags) == 0 { diff --git a/spinifex/foundation/aws/filters/filterutil_test.go b/spinifex/foundation/aws/filters/filterutil_test.go index 05ae103eb..3fb09047e 100644 --- a/spinifex/foundation/aws/filters/filterutil_test.go +++ b/spinifex/foundation/aws/filters/filterutil_test.go @@ -262,6 +262,36 @@ func TestMatchesTags_EmptyFilters(t *testing.T) { } } +func TestMatchesTags_TagKeyAndTagValue(t *testing.T) { + tags := map[string]string{"Name": "web", "awsdiff": "yes"} + cases := []struct { + name string + filters map[string][]string + want bool + }{ + {"tag-key present", map[string][]string{"tag-key": {"awsdiff"}}, true}, + {"tag-key absent", map[string][]string{"tag-key": {"zz-awsdiff-none"}}, false}, + {"tag-key any of values", map[string][]string{"tag-key": {"missing", "Name"}}, true}, + {"tag-key wildcard", map[string][]string{"tag-key": {"aws*"}}, true}, + {"tag-key does not match a value", map[string][]string{"tag-key": {"web"}}, false}, + {"tag-value on any tag", map[string][]string{"tag-value": {"yes"}}, true}, + {"tag-value absent", map[string][]string{"tag-value": {"zz-awsdiff-none"}}, false}, + {"tag-value does not match a key", map[string][]string{"tag-value": {"Name"}}, false}, + {"tag-key and tag-value from different tags", map[string][]string{"tag-key": {"Name"}, "tag-value": {"yes"}}, true}, + {"tag-key ANDed with tag:", map[string][]string{"tag-key": {"awsdiff"}, "tag:Name": {"db"}}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := MatchesTags(tc.filters, tags); got != tc.want { + t.Fatalf("MatchesTags(%v) = %v, want %v", tc.filters, got, tc.want) + } + }) + } + if MatchesTags(map[string][]string{"tag-key": {"*"}}, nil) { + t.Fatal("expected an untagged resource to fail tag-key") + } +} + func TestEC2TagsToMap(t *testing.T) { tags := []*ec2.Tag{ {Key: aws.String("Env"), Value: aws.String("prod")}, diff --git a/spinifex/foundation/lifecycle/idempotency/store.go b/spinifex/foundation/lifecycle/idempotency/store.go index 2a86d8a65..23eb5b7f8 100644 --- a/spinifex/foundation/lifecycle/idempotency/store.go +++ b/spinifex/foundation/lifecycle/idempotency/store.go @@ -14,6 +14,8 @@ import ( "time" "github.com/nats-io/nats.go/jetstream" + + "github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil" ) const ( @@ -59,14 +61,7 @@ type Store[T any] struct { // from NewStore so several typed stores can share one bucket without rebinding // it per request. func OpenBucket(ctx context.Context, js jetstream.JetStream, bucket string, ttl time.Duration) (jetstream.KeyValue, error) { - kv, err := js.KeyValue(ctx, bucket) - if errors.Is(err, jetstream.ErrBucketNotFound) { - kv, err = js.CreateKeyValue(ctx, jetstream.KeyValueConfig{ - Bucket: bucket, - History: 1, - TTL: ttl, - }) - } + kv, err := kvutil.GetOrCreateBucketWithTTL(ctx, js, bucket, 1, ttl) if err != nil { return nil, fmt.Errorf("open idempotency bucket %s: %w", bucket, err) } diff --git a/spinifex/foundation/lifecycle/resource/resource_test.go b/spinifex/foundation/lifecycle/resource/resource_test.go index ab5e7ef43..fafa6f099 100644 --- a/spinifex/foundation/lifecycle/resource/resource_test.go +++ b/spinifex/foundation/lifecycle/resource/resource_test.go @@ -2,6 +2,7 @@ package resource_test import ( "encoding/json" + "maps" "slices" "testing" "time" @@ -202,10 +203,5 @@ func TestObjectJSON_RoundTrips(t *testing.T) { } func sortedKeys(m map[string]json.RawMessage) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - slices.Sort(keys) - return keys + return slices.Sorted(maps.Keys(m)) } diff --git a/spinifex/foundation/state/kvlease/bucket.go b/spinifex/foundation/state/kvlease/bucket.go index 14d95e322..cae044f9d 100644 --- a/spinifex/foundation/state/kvlease/bucket.go +++ b/spinifex/foundation/state/kvlease/bucket.go @@ -7,8 +7,10 @@ import ( "log/slog" "time" + "github.com/mulgadc/spinifex/spinifex/clustersize" + "github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil" "github.com/mulgadc/spinifex/spinifex/foundation/state/migrate" - "github.com/mulgadc/spinifex/spinifex/foundation/telemetry" + telemetry "github.com/mulgadc/spinifex/spinifex/foundation/telemetry" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" ) @@ -28,10 +30,6 @@ type BucketConfig struct { // does not park the key forever. It must match the lease's own Config.TTL. TTL time.Duration - // Replicas is the replica count applied on a genuine first creation only. - // Zero leaves it to the server, which means one. - Replicas int - // Version runs the registered schema migrations for the bucket after it is // opened. Zero skips them, for a bucket holding nothing but lease keys. Version int @@ -42,18 +40,32 @@ type BucketConfig struct { // // Attach before create, because callers re-open this on every reconcile tick: // CreateKeyValue against a bucket that already exists is a STREAM.CREATE the -// meta leader answers with an error, so creating first bills one per tick. It is -// also what makes Replicas mean "on first creation" rather than a config change -// against a live bucket. +// meta leader answers with an error, so creating first bills one per tick. +// +// A leader lease is the worst thing here to lose to a single node. Nobody can +// acquire a lease whose bucket has no quorum, so every reconciler that shares +// it stops at once, cluster-wide, at exactly the moment a node has failed. func OpenBucket(ctx context.Context, js jetstream.JetStream, cfg BucketConfig) (jetstream.KeyValue, error) { + replicas, err := clustersize.Replicas() + if err != nil { + return nil, fmt.Errorf("kvlease: open or create lease bucket %s: %w", cfg.Name, err) + } + kv, err := js.KeyValue(ctx, cfg.Name) - if errors.Is(err, jetstream.ErrBucketNotFound) { + switch { + case errors.Is(err, jetstream.ErrBucketNotFound): kv, err = js.CreateKeyValue(ctx, jetstream.KeyValueConfig{ Bucket: cfg.Name, History: 1, TTL: cfg.TTL, - Replicas: cfg.Replicas, + Replicas: replicas, }) + case err == nil: + // A failed raise must not fail the open. This runs on every reconcile + // tick, so refusing here would stop every reconciler sharing the bucket + // — the outage this replica count exists to prevent — over a bucket that + // is present and quorate at the count it already has. + kvutil.TryRaiseBucketReplicas(ctx, js, cfg.Name, replicas) } if err != nil { return nil, fmt.Errorf("kvlease: open or create lease bucket %s: %w", cfg.Name, err) @@ -63,7 +75,7 @@ func OpenBucket(ctx context.Context, js jetstream.JetStream, cfg BucketConfig) ( return nil, fmt.Errorf("migrate %s: %w", cfg.Name, err) } } - slog.DebugContext(ctx, "kvlease: lease bucket ready", "bucket", cfg.Name, "ttl_ms", otelsetup.Millis(cfg.TTL)) + slog.DebugContext(ctx, "kvlease: lease bucket ready", "bucket", cfg.Name, "ttl_ms", telemetry.Millis(cfg.TTL)) return kv, nil } @@ -82,6 +94,11 @@ func NATSBucket(nc *nats.Conn, bucket string, ttl time.Duration) BucketFunc { if err == nil { return kv, nil } + // An undeclared cluster size does not become declared by waiting, so + // it surfaces now rather than after the whole retry window. + if clustersize.Permanent(err) { + return nil, err + } if time.Now().After(deadline) { return nil, fmt.Errorf("kvlease: KV bucket %q unreachable after %s: %w", bucket, bucketRetryFor, err) } diff --git a/spinifex/foundation/state/kvlease/bucket_test.go b/spinifex/foundation/state/kvlease/bucket_test.go new file mode 100644 index 000000000..946702ea2 --- /dev/null +++ b/spinifex/foundation/state/kvlease/bucket_test.go @@ -0,0 +1,76 @@ +package kvlease_test + +import ( + "testing" + "time" + + "github.com/mulgadc/spinifex/internal/testkit" + "github.com/mulgadc/spinifex/spinifex/clustersize" + "github.com/mulgadc/spinifex/spinifex/foundation/state/kvlease" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestOpenBucket_OpensALeaseBucketTheClusterCannotYetRaise is the assertion that +// keeps this fix from becoming the outage it prevents. +// +// OpenBucket runs on every reconcile tick. If a raise it cannot place failed the +// open, no reconciler could acquire its lease, and every one of them would stop +// cluster-wide — over a lease bucket that is present and quorate at the replica +// count it already has. The config naming a node that is not serving yet is the +// documented way a cluster grows, so this is a state to tolerate, not to refuse. +func TestOpenBucket_OpensALeaseBucketTheClusterCannotYetRaise(t *testing.T) { + _, nc, js := testutil.StartTestJetStream(t) + defer nc.Close() + + cfg := kvlease.BucketConfig{Name: "lease-growing-cluster", TTL: time.Minute} + kv, err := kvlease.OpenBucket(t.Context(), js, cfg) + require.NoError(t, err) + _, err = kv.Put(t.Context(), "leader", []byte("node1")) + require.NoError(t, err) + + // One embedded server cannot hold three replicas, so the raise must fail. + clustersize.RedeclareForTest(t, 3) + + reopened, err := kvlease.OpenBucket(t.Context(), js, cfg) + require.NoError(t, err, "a reconciler must still be able to reach its lease bucket") + entry, err := reopened.Get(t.Context(), "leader") + require.NoError(t, err) + assert.Equal(t, "node1", string(entry.Value())) +} + +// TestOpenBucket_RefusesWhenClusterSizeIsUndeclared pins the fail-closed half: +// creating a lease bucket without knowing the cluster size must not guess at one +// replica, which is the state that makes a lease unacquirable when a node fails. +func TestOpenBucket_RefusesWhenClusterSizeIsUndeclared(t *testing.T) { + _, nc, js := testutil.StartTestJetStream(t) + defer nc.Close() + + clustersize.RedeclareForTest(t, 0) + + _, err := kvlease.OpenBucket(t.Context(), js, kvlease.BucketConfig{Name: "lease-undeclared", TTL: time.Minute}) + require.ErrorIs(t, err, clustersize.ErrUndeclared) +} + +// TestNATSBucket_StopsOnAConfigFault covers the retry loop rather than the open. +// +// The loop exists because a cold multi-node start has no JetStream quorum yet, and +// waiting for one is right. An undeclared cluster size never becomes declared by +// waiting, so a loop that cannot tell them apart holds a reconciler for its whole +// retry window and then reports the bucket as unreachable — which was never the +// problem. +func TestNATSBucket_StopsOnAConfigFault(t *testing.T) { + _, nc, _ := testutil.StartTestJetStream(t) + defer nc.Close() + + clustersize.RedeclareForTest(t, 0) + + start := time.Now() + _, err := kvlease.NATSBucket(nc, "lease-config-fault", time.Minute)(t.Context()) + elapsed := time.Since(start) + + require.ErrorIs(t, err, clustersize.ErrUndeclared, + "the error has to name the config fault, not the bucket it never reached") + require.Lessf(t, elapsed, 10*time.Second, + "returned after %s; a permanent fault must not consume the retry window", elapsed) +} diff --git a/spinifex/foundation/state/kvstore/bucket.go b/spinifex/foundation/state/kvstore/bucket.go index 19a72e75d..ccfe2b336 100644 --- a/spinifex/foundation/state/kvstore/bucket.go +++ b/spinifex/foundation/state/kvstore/bucket.go @@ -23,7 +23,6 @@ type Config struct { // Description is set on the bucket at creation, including a recovery // recreate, so a recreated bucket is not left anonymous next to its peers. Description string - Replicas int TTL time.Duration Missing string Attempts int @@ -393,15 +392,13 @@ func (b *Bucket) open(ctx context.Context) (jetstream.KeyValue, error) { Name: b.cfg.Name, Description: b.cfg.Description, History: b.cfg.History, - Replicas: b.cfg.Replicas, TTL: b.cfg.TTL, }) - case b.cfg.Replicas > 0 || b.cfg.Description != "": + case b.cfg.Description != "": return kvutil.GetOrCreateBucketWithOptions(ctx, b.js, kvutil.BucketOptions{ Name: b.cfg.Name, Description: b.cfg.Description, History: b.cfg.History, - Replicas: b.cfg.Replicas, }) default: return kvutil.GetOrCreateBucket(ctx, b.js, b.cfg.Name, b.cfg.History) diff --git a/spinifex/foundation/state/kvstore/wait.go b/spinifex/foundation/state/kvstore/wait.go index aae4731fc..2c6cb750f 100644 --- a/spinifex/foundation/state/kvstore/wait.go +++ b/spinifex/foundation/state/kvstore/wait.go @@ -6,7 +6,8 @@ import ( "log/slog" "time" - "github.com/mulgadc/spinifex/spinifex/foundation/telemetry" + "github.com/mulgadc/spinifex/spinifex/clustersize" + telemetry "github.com/mulgadc/spinifex/spinifex/foundation/telemetry" ) // DefaultOpenWindow is how long OpenWithRetry keeps trying. Long enough to @@ -38,11 +39,16 @@ func OpenWithRetry[T any](ctx context.Context, what string, window time.Duration if err == nil { if attempt > 1 { slog.InfoContext(ctx, "kvstore: opened after retrying", "what", what, - "attempts", attempt, "waited_ms", otelsetup.Millis(time.Since(started))) + "attempts", attempt, "waited_ms", telemetry.Millis(time.Since(started))) } return v, nil } + // An undeclared cluster size does not become declared by waiting, so it + // surfaces now rather than as the last error of an exhausted window. + if clustersize.Permanent(err) { + return zero, fmt.Errorf("open %s: %w", what, err) + } if ctx.Err() != nil { return zero, fmt.Errorf("open %s: %w", what, ctx.Err()) } @@ -52,7 +58,7 @@ func OpenWithRetry[T any](ctx context.Context, what string, window time.Duration } slog.WarnContext(ctx, "kvstore: not ready, retrying", "what", what, - "attempt", attempt, "window_ms", otelsetup.Millis(window), "err", err) + "attempt", attempt, "window_ms", telemetry.Millis(window), "err", err) select { case <-ctx.Done(): diff --git a/spinifex/foundation/state/kvstore/wait_test.go b/spinifex/foundation/state/kvstore/wait_test.go index 95941ec6d..e5ee5a026 100644 --- a/spinifex/foundation/state/kvstore/wait_test.go +++ b/spinifex/foundation/state/kvstore/wait_test.go @@ -3,9 +3,11 @@ package kvstore_test import ( "context" "errors" + "fmt" "testing" "time" + "github.com/mulgadc/spinifex/spinifex/clustersize" "github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -54,6 +56,23 @@ func TestOpenWithRetry_GivesUpAndKeepsTheCause(t *testing.T) { assert.Contains(t, err.Error(), "spinifex-instance-state") } +// TestOpenWithRetry_StopsOnAnUndeclaredClusterSize keeps a configuration fault +// fast and legible. Waiting cannot declare a cluster size, so retrying one turns +// a clear error into a minute and a half of silence followed by the same error. +func TestOpenWithRetry_StopsOnAnUndeclaredClusterSize(t *testing.T) { + defer kvstore.SetOpenRetryInterval(time.Millisecond)() + + calls := 0 + _, err := kvstore.OpenWithRetry(context.Background(), "bucket", time.Minute, + func(context.Context) (string, error) { + calls++ + return "", fmt.Errorf("create KV bucket bucket: %w", clustersize.ErrUndeclared) + }) + + require.ErrorIs(t, err, clustersize.ErrUndeclared) + assert.Equal(t, 1, calls, "a permanent fault must not be retried") +} + func TestOpenWithRetry_StopsOnCancelledContext(t *testing.T) { defer kvstore.SetOpenRetryInterval(time.Millisecond)() ctx, cancel := context.WithCancel(context.Background()) diff --git a/spinifex/foundation/state/kvutil/bucket_creation_invariants_test.go b/spinifex/foundation/state/kvutil/bucket_creation_invariants_test.go new file mode 100644 index 000000000..2c0dc3858 --- /dev/null +++ b/spinifex/foundation/state/kvutil/bucket_creation_invariants_test.go @@ -0,0 +1,116 @@ +package kvutil_test + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" +) + +// createFuncs are the JetStream calls that bring a KV bucket into existence. +// Each one takes a KeyValueConfig whose zero Replicas means one, so each one is +// a way to create a bucket on a single node without saying so. +var createFuncs = []string{"CreateKeyValue(", "CreateOrUpdateKeyValue("} + +// exempt are the only packages allowed to call them: the chokepoint itself, the +// lease-bucket opener it shares its rule with, and the test helpers that build +// buckets for a single embedded server. +var exempt = []string{ + filepath.Join("spinifex", "foundation", "state", "kvutil"), + filepath.Join("spinifex", "foundation", "state", "kvlease"), + filepath.Join("internal", "testkit"), +} + +// TestKVBuckets_AreOnlyCreatedThroughTheChokepoint keeps the replica-count rule +// enforceable by keeping the number of places that can break it at one. +// +// A KV bucket created with the default single replica puts a service every node +// depends on onto one node, so losing that node is a cluster-wide outage from a +// single-node event. The rule is that the cluster's node count decides the +// replica count, and a rule that lives in one function stays true only while +// that function is the only way through. This is what has failed before: there +// were five independent ways to create a bucket and the replica count was +// opt-in in all of them. +func TestKVBuckets_AreOnlyCreatedThroughTheChokepoint(t *testing.T) { + root := repoRoot(t) + + type hit struct { + file string + line int + call string + } + var hits []hit + + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + switch d.Name() { + case ".git", "vendor", "node_modules", "tests": + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + rel, relErr := filepath.Rel(root, path) + if relErr != nil { + return relErr + } + for _, dir := range exempt { + if strings.HasPrefix(rel, dir+string(filepath.Separator)) { + return nil + } + } + + data, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + for i, line := range strings.Split(string(data), "\n") { + for _, fn := range createFuncs { + if strings.Contains(line, fn) { + hits = append(hits, hit{file: rel, line: i + 1, call: strings.TrimSpace(line)}) + } + } + } + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", root, err) + } + + if len(hits) > 0 { + var b strings.Builder + for _, h := range hits { + fmt.Fprintf(&b, "\n %s:%d: %s", h.file, h.line, h.call) + } + t.Fatalf("a KV bucket is created outside kvutil, so its replica count is "+ + "whatever the literal happened to say — and an omitted Replicas is one, "+ + "on a cluster where every node depends on the bucket.\n"+ + "Use kvutil.GetOrCreateBucket, GetOrCreateBucketWithTTL or "+ + "GetOrCreateBucketWithOptions, which take the count from the cluster:%s", b.String()) + } +} + +func repoRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatalf("no go.mod above %s", dir) + } + dir = parent + } +} diff --git a/spinifex/foundation/state/kvutil/export_test.go b/spinifex/foundation/state/kvutil/export_test.go new file mode 100644 index 000000000..6957757ff --- /dev/null +++ b/spinifex/foundation/state/kvutil/export_test.go @@ -0,0 +1,28 @@ +package kvutil + +import ( + "time" + + "github.com/nats-io/nats.go/jetstream" +) + +// NewRaiseThrottleForTest exposes the throttle the open path uses, so its +// behaviour can be driven by an injected clock rather than by sleeping. +func NewRaiseThrottleForTest(every time.Duration) *RaiseThrottleForTest { + return &RaiseThrottleForTest{t: newRaiseThrottle(every)} +} + +// RaiseThrottleForTest wraps the unexported throttle for the external test +// package. +type RaiseThrottleForTest struct{ t *raiseThrottle } + +// Allow reports whether bucket may be attempted at now. +func (r *RaiseThrottleForTest) Allow(bucket string, now time.Time) bool { + return r.t.allow(bucket, now) +} + +// NewBucketReportForTest exposes how one stream's info becomes a report, which is +// where configured replicas and working replicas are told apart. +func NewBucketReportForTest(bucket string, want int, info *jetstream.StreamInfo) BucketReport { + return newBucketReport(bucket, want, info) +} diff --git a/spinifex/foundation/state/kvutil/kvutil.go b/spinifex/foundation/state/kvutil/kvutil.go index 305979cd2..c49df8ae9 100644 --- a/spinifex/foundation/state/kvutil/kvutil.go +++ b/spinifex/foundation/state/kvutil/kvutil.go @@ -17,83 +17,83 @@ import ( "time" "github.com/mulgadc/bluebottle/pkg/safecast" + "github.com/mulgadc/spinifex/spinifex/clustersize" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" ) -// GetOrCreateBucket creates or opens a KV bucket at the cluster's default -// replica count (see SetDefaultKVReplicas), so buckets created lazily -// after boot are quorate on multi-node rather than stuck at R1. +// GetOrCreateBucket creates or opens a KV bucket replicated across the cluster. func GetOrCreateBucket(ctx context.Context, js jetstream.KeyValueManager, bucket string, history int) (jetstream.KeyValue, error) { - return GetOrCreateBucketWithReplicas(ctx, js, bucket, history, DefaultKVReplicas()) + return getOrCreateBucket(ctx, js, jetstream.KeyValueConfig{ + Bucket: bucket, + History: safecast.IntToUint8(history), + }) } // GetOrCreateBucketWithTTL is GetOrCreateBucket for buckets whose entries // should age out on their own — request-dedupe records and other short-lived // state that would otherwise accumulate without a sweeper. TTL applies at -// creation only, like Replicas. +// creation only; the replica count does not. func GetOrCreateBucketWithTTL(ctx context.Context, js jetstream.KeyValueManager, bucket string, history int, ttl time.Duration) (jetstream.KeyValue, error) { return getOrCreateBucket(ctx, js, jetstream.KeyValueConfig{ - Bucket: bucket, - History: safecast.IntToUint8(history), - Replicas: max(DefaultKVReplicas(), 1), - TTL: ttl, - }) -} - -// GetOrCreateBucketWithReplicas creates or opens a KV bucket at the given -// replica count (clamped to a minimum of 1). The replica count applies to -// creation only: an existing bucket is opened with the config it already has, -// so one created before the cluster grew is upgraded on rebalance, not here. -func GetOrCreateBucketWithReplicas(ctx context.Context, js jetstream.KeyValueManager, bucket string, history, replicas int) (jetstream.KeyValue, error) { - return getOrCreateBucket(ctx, js, jetstream.KeyValueConfig{ - Bucket: bucket, - History: safecast.IntToUint8(history), - Replicas: max(replicas, 1), + Bucket: bucket, + History: safecast.IntToUint8(history), + TTL: ttl, }) } // BucketOptions is GetOrCreateBucket's full argument set, for callers needing a -// combination the three named helpers do not cover. A zero Replicas means the -// cluster default, matching those helpers. +// combination the named helpers do not cover. There is deliberately no Replicas +// field: see Replicas. type BucketOptions struct { Name string Description string History int - Replicas int TTL time.Duration } -// GetOrCreateBucketWithOptions creates or opens a KV bucket from opts. Like the -// named helpers, every field but Name applies at creation only: an existing -// bucket is opened with the config it already has. +// GetOrCreateBucketWithOptions creates or opens a KV bucket from opts. Every +// field but Name applies at creation only: an existing bucket is opened with +// the config it already has, apart from its replica count. func GetOrCreateBucketWithOptions(ctx context.Context, js jetstream.KeyValueManager, opts BucketOptions) (jetstream.KeyValue, error) { - replicas := opts.Replicas - if replicas == 0 { - replicas = DefaultReplicas() - } return getOrCreateBucket(ctx, js, jetstream.KeyValueConfig{ Bucket: opts.Name, Description: opts.Description, History: safecast.IntToUint8(opts.History), - Replicas: max(replicas, 1), TTL: opts.TTL, }) } -// DefaultReplicas is the cluster's default KV replica count, exposed so callers -// building a BucketOptions can tell "unset" from a deliberate 1. -func DefaultReplicas() int { return DefaultKVReplicas() } - func getOrCreateBucket(ctx context.Context, js jetstream.KeyValueManager, cfg jetstream.KeyValueConfig) (jetstream.KeyValue, error) { bucket := cfg.Bucket - kv, err := js.CreateKeyValue(ctx, cfg) + replicas, err := clustersize.Replicas() + if err != nil { + return nil, fmt.Errorf("create KV bucket %s: %w", bucket, err) + } + cfg.Replicas = replicas + + // Attach before create, so whether the bucket exists is this function's own + // answer rather than an inference from which error the server reported + // first. A create asking for more replicas than the cluster can place is + // refused before the name is looked at, which would otherwise read as + // "create failed" for a bucket that is present and serving. + kv, err := js.KeyValue(ctx, bucket) + switch { + case err == nil: + return openAndRaise(ctx, js, kv, bucket, replicas), nil + case !errors.Is(err, jetstream.ErrBucketNotFound): + return nil, fmt.Errorf("open KV bucket %s: %w", bucket, err) + } + + kv, err = js.CreateKeyValue(ctx, cfg) if err == nil { return kv, nil } - // A previous boot or a concurrent daemon already created it, so opening it - // is the expected outcome. Every other create failure is real and surfaces. + // A concurrent daemon created it in the gap, so opening it is the expected + // outcome. Every other create failure is real and surfaces — a create at + // fewer replicas than the cluster wants is the defect, so it must not be + // reached by falling back. if !errors.Is(err, jetstream.ErrBucketExists) { return nil, fmt.Errorf("create KV bucket %s: %w", bucket, err) } @@ -101,7 +101,19 @@ func getOrCreateBucket(ctx context.Context, js jetstream.KeyValueManager, cfg je if err != nil { return nil, fmt.Errorf("open KV bucket %s: %w", bucket, err) } - return kv, nil + return openAndRaise(ctx, js, kv, bucket, replicas), nil +} + +// openAndRaise returns an already-open bucket, raising it to the cluster's +// replica count on a best-effort basis. +// +// An existing bucket may predate this rule, or predate the cluster growing, and +// raising it on open is what makes a formed cluster self-heal rather than wait +// for someone to run a repair. The daemon's sweep and +// `spx admin kv replicas --repair` finish the job. +func openAndRaise(ctx context.Context, js jetstream.KeyValueManager, kv jetstream.KeyValue, bucket string, replicas int) jetstream.KeyValue { + TryRaiseBucketReplicas(ctx, js, bucket, replicas) + return kv } // IsStreamUnavailable reports whether err means the KV bucket's underlying diff --git a/spinifex/foundation/state/kvutil/kvutil_test.go b/spinifex/foundation/state/kvutil/kvutil_test.go index d0ff4e9bc..d79252174 100644 --- a/spinifex/foundation/state/kvutil/kvutil_test.go +++ b/spinifex/foundation/state/kvutil/kvutil_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/mulgadc/spinifex/internal/testkit" + "github.com/mulgadc/spinifex/spinifex/clustersize" "github.com/nats-io/nats.go/jetstream" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -30,13 +31,13 @@ func streamReplicas(t *testing.T, js jetstream.JetStream, bucket string) int { return info.Config.Replicas } -func TestGetOrCreateBucket_CreatesAtDefaultReplicas(t *testing.T) { +func TestGetOrCreateBucket_CreatesAtTheClusterReplicaCount(t *testing.T) { js := startJetStream(t) kv, err := GetOrCreateBucket(t.Context(), js, "regression-bucket", 5) require.NoError(t, err) require.NotNil(t, kv) - assert.Equal(t, 1, streamReplicas(t, js, "regression-bucket")) + assert.Equal(t, 1, streamReplicas(t, js, "regression-bucket"), "one embedded server is a one-node cluster") } // TestGetOrCreateBucket_OpensExisting covers the second-boot path: a bucket that @@ -57,33 +58,63 @@ func TestGetOrCreateBucket_OpensExisting(t *testing.T) { assert.Equal(t, "value", string(entry.Value())) } -func TestGetOrCreateBucketWithReplicas_ClampsBelowOne(t *testing.T) { +// TestGetOrCreateBucket_RefusesWhenClusterSizeIsUndeclared pins the fail-closed +// choice: an undeclared size must not fall back to one replica, because one +// replica on a multi-node cluster is the outage this package exists to prevent. +func TestGetOrCreateBucket_RefusesWhenClusterSizeIsUndeclared(t *testing.T) { js := startJetStream(t) + clustersize.RedeclareForTest(t, 0) - kv, err := GetOrCreateBucketWithReplicas(t.Context(), js, "clamped-zero", 1, 0) - require.NoError(t, err) - require.NotNil(t, kv) - assert.Equal(t, 1, streamReplicas(t, js, "clamped-zero")) + _, err := GetOrCreateBucket(t.Context(), js, "undeclared", 1) + require.ErrorIs(t, err, clustersize.ErrUndeclared) - kv, err = GetOrCreateBucketWithReplicas(t.Context(), js, "clamped-negative", 1, -3) - require.NoError(t, err) - require.NotNil(t, kv) - assert.Equal(t, 1, streamReplicas(t, js, "clamped-negative")) + _, err = js.KeyValue(t.Context(), "undeclared") + require.ErrorIs(t, err, jetstream.ErrBucketNotFound, "a refused create must not leave a bucket behind") } -// TestGetOrCreateBucketWithReplicas_SurfacesCreateFailure pins the reason the -// open is scoped to "bucket exists": a create that fails for any other reason -// must report that reason, not the "bucket not found" a blind reopen produces. -func TestGetOrCreateBucketWithReplicas_SurfacesCreateFailure(t *testing.T) { +// TestGetOrCreateBucket_SurfacesCreateFailure pins the reason the open is scoped +// to "bucket exists": a create that fails for any other reason must report that +// reason, not the "bucket not found" a blind reopen produces. +func TestGetOrCreateBucket_SurfacesCreateFailure(t *testing.T) { js := startJetStream(t) - // The embedded single-node server rejects Replicas > 1. - _, err := GetOrCreateBucketWithReplicas(t.Context(), js, "over-replicated", 1, 3) + // The embedded single-node server rejects a three-node replica count. + clustersize.RedeclareForTest(t, 3) + + _, err := GetOrCreateBucket(t.Context(), js, "over-replicated", 1) require.Error(t, err) assert.NotErrorIs(t, err, jetstream.ErrBucketNotFound) assert.Contains(t, err.Error(), "create KV bucket over-replicated") } +// TestGetOrCreateBucket_OpensAnExistingBucketTheClusterCannotYetRaise pins the +// other half of the line, and it is the half that matters in production: a +// bucket that exists and works must be handed back even when it cannot be +// raised to the count the config now asks for. +// +// That happens on the documented growth path — the config names a new node +// before that node is serving — and refusing would stop every service on the +// host over a bucket that is perfectly usable. The sweep and +// `kv replicas --repair` are what finish the raise. +func TestGetOrCreateBucket_OpensAnExistingBucketTheClusterCannotYetRaise(t *testing.T) { + js := startJetStream(t) + + kv, err := GetOrCreateBucket(t.Context(), js, "growing-cluster", 1) + require.NoError(t, err) + _, err = kv.Put(t.Context(), "key", []byte("value")) + require.NoError(t, err) + + // One embedded server cannot hold three replicas, so the raise must fail. + clustersize.RedeclareForTest(t, 3) + + reopened, err := GetOrCreateBucket(t.Context(), js, "growing-cluster", 1) + require.NoError(t, err, "an existing, working bucket must open even when it cannot be raised") + entry, err := reopened.Get(t.Context(), "key") + require.NoError(t, err) + assert.Equal(t, "value", string(entry.Value())) + assert.Equal(t, 1, streamReplicas(t, js, "growing-cluster"), "the raise must not have been faked") +} + func TestDeleteBucketIfExists(t *testing.T) { js := startJetStream(t) diff --git a/spinifex/foundation/state/kvutil/replicas.go b/spinifex/foundation/state/kvutil/replicas.go new file mode 100644 index 000000000..d16b96da8 --- /dev/null +++ b/spinifex/foundation/state/kvutil/replicas.go @@ -0,0 +1,249 @@ +package kvutil + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sort" + "sync" + "time" + + "github.com/mulgadc/spinifex/spinifex/clustersize" + "github.com/nats-io/nats.go/jetstream" +) + +// streamPrefix is how JetStream names the stream behind a KV bucket. Raising a +// bucket's replica count is a stream update, so the name has to be built here. +const streamPrefix = "KV_" + +// BucketReport is one bucket's replication, as the cluster currently holds it. +// +// Configured replicas and working replicas are separate fields because they are +// separate facts. A stream can report the count it was asked for while a peer is +// offline or still catching up, and that reads as healthy right up until the +// node actually serving it goes away. +type BucketReport struct { + Bucket string `json:"bucket"` + Replicas int `json:"replicas"` + Want int `json:"want"` + Cluster string `json:"cluster,omitempty"` + + // Leader is the server currently accepting writes, empty when there is none. + Leader string `json:"leader,omitempty"` + + // Online counts the peers that are caught up and reachable, the leader + // included. This is the number a majority is measured against. + Online int `json:"online"` + + // Peers names every server assigned to the bucket, leader first. + Peers []string `json:"peers,omitempty"` +} + +// UnderReplicated reports whether this bucket has fewer replicas than the +// cluster can give it. A bucket at the cluster's replica count is correct; one +// below it is a cluster-wide service living on fewer nodes than exist. +func (r BucketReport) UnderReplicated() bool { return r.Replicas < r.Want } + +// HasQuorum reports whether enough of the bucket's replicas are working for it +// to accept a write. +// +// Measured against the configured count rather than the wanted one: a bucket +// that has not been raised yet is still perfectly able to serve at the size it +// has, and calling that a quorum failure would confuse a durability shortfall +// with an outage. +func (r BucketReport) HasQuorum() bool { return r.Online > r.Replicas/2 } + +// Healthy reports whether the bucket is both at the cluster's replica count and +// able to serve. +func (r BucketReport) Healthy() bool { return !r.UnderReplicated() && r.HasQuorum() } + +// AuditBucketReplicas reports every KV bucket's replica count against what the +// cluster's size entitles it to. It reads and changes nothing, so it is safe to +// run against a live cluster and is what both the admin command and the e2e +// assertion are built on. +func AuditBucketReplicas(ctx context.Context, js jetstream.JetStream) ([]BucketReport, error) { + want, err := clustersize.Replicas() + if err != nil { + return nil, err + } + + names, err := BucketNames(ctx, js) + if err != nil { + return nil, err + } + sort.Strings(names) + + reports := make([]BucketReport, 0, len(names)) + for _, name := range names { + info, err := js.Stream(ctx, streamPrefix+name) + if err != nil { + // A bucket deleted between the listing and the read is not a finding. + if errors.Is(err, jetstream.ErrStreamNotFound) { + continue + } + return nil, fmt.Errorf("read KV bucket %s: %w", name, err) + } + cached, err := info.Info(ctx) + if err != nil { + return nil, fmt.Errorf("read KV bucket %s: %w", name, err) + } + reports = append(reports, newBucketReport(name, want, cached)) + } + return reports, nil +} + +// newBucketReport reads one stream's replication and placement out of its info. +// +// A non-clustered server reports no cluster block at all. Its single replica is +// nonetheless online, because the read that produced this info was answered, and +// reporting zero there would make every single-node bucket look like it had lost +// quorum. +func newBucketReport(bucket string, want int, info *jetstream.StreamInfo) BucketReport { + report := BucketReport{Bucket: bucket, Replicas: info.Config.Replicas, Want: want} + if info.Cluster == nil { + report.Online = 1 + return report + } + + report.Cluster = info.Cluster.Name + report.Leader = info.Cluster.Leader + if report.Leader != "" { + report.Peers = append(report.Peers, report.Leader) + report.Online++ + } + for _, peer := range info.Cluster.Replicas { + report.Peers = append(report.Peers, peer.Name) + // Current and not offline is JetStream's own statement that this peer is + // caught up and reachable, which is what a majority has to be counted + // from. A peer that is merely assigned cannot acknowledge a write. + if peer.Current && !peer.Offline { + report.Online++ + } + } + return report +} + +// RaiseAllBucketReplicas raises every KV bucket to the cluster's replica count +// and reports how many it had to change. +// +// It is the cluster-wide counterpart to the raise a bucket gets when a service +// opens it: a node joining changes the answer for every bucket at once, and +// most of them will not be opened again until something restarts. +// +// Every bucket is attempted even after one fails, and the failures come back +// joined. Stopping at the first would leave the rest of a cluster's buckets +// short because one of them could not be placed, which is the opposite of what +// a sweep is for. +func RaiseAllBucketReplicas(ctx context.Context, js jetstream.JetStream) (int, error) { + reports, err := AuditBucketReplicas(ctx, js) + if err != nil { + return 0, err + } + raised := 0 + var failures []error + for _, r := range reports { + if !r.UnderReplicated() { + continue + } + if err := RaiseBucketReplicas(ctx, js, r.Bucket, r.Want); err != nil { + failures = append(failures, err) + continue + } + raised++ + } + return raised, errors.Join(failures...) +} + +// raiseRetryEvery is how often the open path will re-attempt a raise for the +// same bucket. It is a floor on retries, not a deadline: the next open after the +// window tries again, so a cluster that gains a node still converges without an +// operator. +const raiseRetryEvery = 1 * time.Minute + +// openRaises throttles the raise attempts made on the open path. +var openRaises = newRaiseThrottle(raiseRetryEvery) + +// TryRaiseBucketReplicas raises a bucket toward want on the path a service takes +// when it opens one, warning rather than failing when it cannot. +// +// A failed raise is not a failed open. The config can legitimately name nodes +// that are not serving yet — that is what growing a cluster looks like — and +// refusing would stop every service on the host over a bucket that is present +// and quorate at the count it has. +// +// Throttled per bucket, because callers reopen buckets on every reconcile tick. +// Without it a cluster that cannot place the replica bills a stream update to the +// meta leader and writes a warning line every few seconds, for every bucket, on +// every node — load and noise arriving exactly when a cluster is already unwell. +func TryRaiseBucketReplicas(ctx context.Context, js jetstream.KeyValueManager, bucket string, want int) { + if !openRaises.allow(bucket, time.Now()) { + return + } + if err := RaiseBucketReplicas(ctx, js, bucket, want); err != nil { + slog.WarnContext(ctx, "Could not raise KV bucket to the cluster's replica count; it stays where it is until the cluster can hold more", + "bucket", bucket, "want", want, "error", err) + } +} + +// raiseThrottle records when each bucket was last attempted, so a caller on a +// reconcile loop retries on a schedule of its own rather than the loop's. +type raiseThrottle struct { + mu sync.Mutex + every time.Duration + last map[string]time.Time +} + +func newRaiseThrottle(every time.Duration) *raiseThrottle { + return &raiseThrottle{every: every, last: make(map[string]time.Time)} +} + +// allow reports whether bucket may be attempted at now, recording the attempt +// when it may. The first attempt for a bucket is always allowed, so a raise is +// never delayed by the throttle existing. +func (t *raiseThrottle) allow(bucket string, now time.Time) bool { + t.mu.Lock() + defer t.mu.Unlock() + if last, seen := t.last[bucket]; seen && now.Sub(last) < t.every { + return false + } + t.last[bucket] = now + return true +} + +// RaiseBucketReplicas raises a bucket to want replicas, leaving every other +// setting as it is. +// +// It updates the stream rather than the bucket deliberately. A KV update takes +// a KeyValueConfig, which cannot express a stream's full configuration, so +// applying one would quietly reset whatever the caller's literal omitted — +// history and TTL among them. Reading the live config and changing one field +// cannot do that. +// +// Lowering is never done: a bucket with more replicas than the cluster is +// entitled to is not a fault, and a cluster that has lost nodes must not have +// its durability reduced to match. +func RaiseBucketReplicas(ctx context.Context, js jetstream.KeyValueManager, bucket string, want int) error { + streams, ok := js.(jetstream.StreamManager) + if !ok { + return fmt.Errorf("raise KV bucket %s to %d replicas: no stream manager available", bucket, want) + } + + stream, err := streams.Stream(ctx, streamPrefix+bucket) + if err != nil { + return fmt.Errorf("read KV bucket %s: %w", bucket, err) + } + cfg := stream.CachedInfo().Config + if cfg.Replicas >= want { + return nil + } + + was := cfg.Replicas + cfg.Replicas = want + if _, err := streams.UpdateStream(ctx, cfg); err != nil { + return fmt.Errorf("raise KV bucket %s from %d to %d replicas: %w", bucket, was, want, err) + } + slog.InfoContext(ctx, "raised a KV bucket to the cluster's replica count", + "bucket", bucket, "was", was, "now", want) + return nil +} diff --git a/spinifex/foundation/state/kvutil/replicas_health_test.go b/spinifex/foundation/state/kvutil/replicas_health_test.go new file mode 100644 index 000000000..9d44bf2b6 --- /dev/null +++ b/spinifex/foundation/state/kvutil/replicas_health_test.go @@ -0,0 +1,130 @@ +package kvutil_test + +import ( + "testing" + "time" + + "github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil" + "github.com/nats-io/nats.go/jetstream" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestNewBucketReport_TellsConfiguredFromWorking is the distinction the whole +// health report exists for. A stream can carry the replica count it was asked for +// while a peer is offline or still catching up, and that reads as healthy right up +// until the node actually serving it goes away. +func TestNewBucketReport_TellsConfiguredFromWorking(t *testing.T) { + info := &jetstream.StreamInfo{ + Config: jetstream.StreamConfig{Replicas: 3}, + Cluster: &jetstream.ClusterInfo{ + Name: "spinifex", + Leader: "spinifex-nats-node1", + Replicas: []*jetstream.PeerInfo{ + {Name: "spinifex-nats-node2", Current: true}, + {Name: "spinifex-nats-node3", Current: false}, + }, + }, + } + + got := kvutil.NewBucketReportForTest("spinifex-instance-state", 3, info) + + assert.Equal(t, 3, got.Replicas, "the configured count is what the stream asked for") + assert.Equal(t, 2, got.Online, "a peer that is not current cannot acknowledge a write") + assert.Equal(t, "spinifex-nats-node1", got.Leader) + assert.Equal(t, []string{"spinifex-nats-node1", "spinifex-nats-node2", "spinifex-nats-node3"}, got.Peers, + "every assigned peer is named, leader first") + assert.False(t, got.UnderReplicated()) + assert.True(t, got.HasQuorum(), "two of three is a majority") + assert.True(t, got.Healthy()) +} + +// TestNewBucketReport_OfflinePeerIsNotOnline: JetStream reports current and offline +// separately, and a peer can be marked current from a stale view while being +// unreachable. Both have to disqualify it. +func TestNewBucketReport_OfflinePeerIsNotOnline(t *testing.T) { + info := &jetstream.StreamInfo{ + Config: jetstream.StreamConfig{Replicas: 3}, + Cluster: &jetstream.ClusterInfo{ + Leader: "spinifex-nats-node1", + Replicas: []*jetstream.PeerInfo{ + {Name: "spinifex-nats-node2", Current: true, Offline: true}, + {Name: "spinifex-nats-node3", Current: true, Offline: true}, + }, + }, + } + + got := kvutil.NewBucketReportForTest("b", 3, info) + + assert.Equal(t, 1, got.Online) + assert.False(t, got.HasQuorum(), "one of three cannot accept a write") + assert.False(t, got.Healthy()) + assert.False(t, got.UnderReplicated(), "no quorum is not the same finding as too few replicas") +} + +// TestNewBucketReport_NoLeaderIsNoQuorum: a raft group mid-election has no writer, +// and reporting it as serving would hide exactly the window this matters in. +func TestNewBucketReport_NoLeaderIsNoQuorum(t *testing.T) { + info := &jetstream.StreamInfo{ + Config: jetstream.StreamConfig{Replicas: 3}, + Cluster: &jetstream.ClusterInfo{ + Replicas: []*jetstream.PeerInfo{{Name: "spinifex-nats-node2", Current: true}}, + }, + } + + got := kvutil.NewBucketReportForTest("b", 3, info) + + assert.Empty(t, got.Leader) + assert.Equal(t, 1, got.Online) + assert.False(t, got.HasQuorum()) +} + +// TestNewBucketReport_SingleNodeServerReportsNoCluster is the single-node case, and +// it must not read as an outage. A non-clustered server sends no cluster block at +// all, and its one replica is serving — the read that produced this info was +// answered by it. +func TestNewBucketReport_SingleNodeServerReportsNoCluster(t *testing.T) { + got := kvutil.NewBucketReportForTest("b", 1, &jetstream.StreamInfo{ + Config: jetstream.StreamConfig{Replicas: 1}, + }) + + assert.Equal(t, 1, got.Replicas) + assert.Equal(t, 1, got.Online) + assert.True(t, got.HasQuorum()) + assert.True(t, got.Healthy()) +} + +// TestHasQuorumIsMeasuredAgainstConfiguredReplicas: a bucket not yet raised is +// still able to serve at the size it has, and calling that a quorum failure would +// confuse a durability shortfall with an outage. +func TestHasQuorumIsMeasuredAgainstConfiguredReplicas(t *testing.T) { + r := kvutil.BucketReport{Bucket: "b", Replicas: 1, Want: 3, Online: 1} + assert.True(t, r.HasQuorum(), "one of one is a majority even when three are wanted") + assert.True(t, r.UnderReplicated()) + assert.False(t, r.Healthy()) +} + +// TestRaiseThrottle_LetsTheFirstAttemptThroughThenWaits is what keeps a reconcile +// loop from billing a stream update and a warning line every few seconds while a +// cluster cannot place a replica. The first attempt is never delayed, because the +// throttle must not slow down a raise that would have succeeded. +func TestRaiseThrottle_LetsTheFirstAttemptThroughThenWaits(t *testing.T) { + th := kvutil.NewRaiseThrottleForTest(time.Minute) + start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + require.True(t, th.Allow("alpha", start), "the first attempt must never be throttled") + assert.False(t, th.Allow("alpha", start.Add(time.Second)), "a retry one second later is the noise this prevents") + assert.False(t, th.Allow("alpha", start.Add(59*time.Second))) + assert.True(t, th.Allow("alpha", start.Add(time.Minute)), "past the window it tries again, so a cluster that grows still converges") +} + +// TestRaiseThrottle_IsPerBucket: one bucket that cannot be placed must not silence +// the raise for every other bucket on the node. +func TestRaiseThrottle_IsPerBucket(t *testing.T) { + th := kvutil.NewRaiseThrottleForTest(time.Minute) + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + require.True(t, th.Allow("alpha", now)) + assert.True(t, th.Allow("beta", now), "beta has never been attempted") + assert.False(t, th.Allow("alpha", now)) +} diff --git a/spinifex/foundation/state/kvutil/replicas_test.go b/spinifex/foundation/state/kvutil/replicas_test.go new file mode 100644 index 000000000..796c1c31a --- /dev/null +++ b/spinifex/foundation/state/kvutil/replicas_test.go @@ -0,0 +1,162 @@ +package kvutil_test + +import ( + "testing" + + "github.com/mulgadc/spinifex/internal/testkit" + "github.com/mulgadc/spinifex/spinifex/clustersize" + "github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil" + "github.com/nats-io/nats.go/jetstream" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAuditBucketReplicas_ReportsEveryBucketAgainstTheClusterSize(t *testing.T) { + js := newJetStream(t) + + for _, bucket := range []string{"audit-alpha", "audit-beta"} { + _, err := kvutil.GetOrCreateBucket(t.Context(), js, bucket, 1) + require.NoError(t, err) + } + + reports, err := kvutil.AuditBucketReplicas(t.Context(), js) + require.NoError(t, err) + require.Len(t, reports, 2) + assert.Equal(t, "audit-alpha", reports[0].Bucket, "reports are sorted by bucket name") + + for _, r := range reports { + assert.Equal(t, 1, r.Replicas) + assert.Equal(t, 1, r.Want) + assert.False(t, r.UnderReplicated()) + } + + // The cluster grew. Every existing bucket is now behind it, which is exactly + // what the audit has to say before anything can repair it. + clustersize.RedeclareForTest(t, 3) + + reports, err = kvutil.AuditBucketReplicas(t.Context(), js) + require.NoError(t, err) + require.Len(t, reports, 2) + for _, r := range reports { + assert.Equal(t, 1, r.Replicas) + assert.Equal(t, 3, r.Want) + assert.True(t, r.UnderReplicated(), "%s", r.Bucket) + } +} + +func TestAuditBucketReplicas_RefusesWhenClusterSizeIsUndeclared(t *testing.T) { + js := newJetStream(t) + clustersize.RedeclareForTest(t, 0) + + _, err := kvutil.AuditBucketReplicas(t.Context(), js) + require.ErrorIs(t, err, clustersize.ErrUndeclared) +} + +// TestRaiseBucketReplicas_NeverLowers is the property that makes the raise safe +// to run on every service start: a bucket already at or above the count is left +// alone, so a cluster that has lost a node does not have its durability cut to +// match. +func TestRaiseBucketReplicas_NeverLowers(t *testing.T) { + js := newJetStream(t) + + _, err := kvutil.GetOrCreateBucket(t.Context(), js, "no-downgrade", 1) + require.NoError(t, err) + + require.NoError(t, kvutil.RaiseBucketReplicas(t.Context(), js, "no-downgrade", 1)) + assert.Equal(t, 1, streamConfig(t, js, "no-downgrade").Replicas) + + // Zero and negative are below the current count, so they are no-ops too + // rather than an update that would ask the server for an invalid config. + require.NoError(t, kvutil.RaiseBucketReplicas(t.Context(), js, "no-downgrade", 0)) + assert.Equal(t, 1, streamConfig(t, js, "no-downgrade").Replicas) +} + +// TestRaiseBucketReplicas_PreservesTheRestOfTheConfig is why this updates the +// stream rather than the KV bucket: a KeyValueConfig cannot express a stream's +// full configuration, so applying one would reset whatever it omits. +func TestRaiseBucketReplicas_PreservesTheRestOfTheConfig(t *testing.T) { + js := newJetStream(t) + + _, err := kvutil.GetOrCreateBucketWithOptions(t.Context(), js, kvutil.BucketOptions{ + Name: "config-preserved", + Description: "a bucket with settings worth not losing", + History: 7, + }) + require.NoError(t, err) + + before := streamConfig(t, js, "config-preserved") + require.NoError(t, kvutil.RaiseBucketReplicas(t.Context(), js, "config-preserved", 1)) + after := streamConfig(t, js, "config-preserved") + + assert.Equal(t, before.Description, after.Description) + assert.Equal(t, before.MaxMsgsPerSubject, after.MaxMsgsPerSubject, "history is MaxMsgsPerSubject on the stream") + assert.Equal(t, before.MaxAge, after.MaxAge) +} + +// TestRaiseBucketReplicas_SurfacesAMissingBucket keeps a raise against a bucket +// that is not there from reading as success. +func TestRaiseBucketReplicas_SurfacesAMissingBucket(t *testing.T) { + js := newJetStream(t) + + err := kvutil.RaiseBucketReplicas(t.Context(), js, "never-created", 1) + require.Error(t, err) + assert.ErrorIs(t, err, jetstream.ErrStreamNotFound) +} + +func streamConfig(t *testing.T, js jetstream.JetStream, bucket string) jetstream.StreamConfig { + t.Helper() + stream, err := js.Stream(t.Context(), "KV_"+bucket) + require.NoError(t, err) + info, err := stream.Info(t.Context()) + require.NoError(t, err) + return info.Config +} + +// newJetStream starts an embedded one-node JetStream for a test in this package. +func newJetStream(t *testing.T) jetstream.JetStream { + t.Helper() + _, nc, _ := testutil.StartTestJetStream(t) + return testutil.NewJetStream(t, nc) +} + +// TestRaiseAllBucketReplicas_RaisesWhatItCanAndReportsWhatItCannot covers the +// sweep's two halves at once. It is the cluster-wide counterpart to the raise a +// bucket gets when a service opens it: a node joining changes the answer for +// every bucket, and most will not be reopened until something restarts. +func TestRaiseAllBucketReplicas_RaisesWhatItCanAndReportsWhatItCannot(t *testing.T) { + js := newJetStream(t) + + for _, b := range []string{"sweep-alpha", "sweep-beta"} { + _, err := kvutil.GetOrCreateBucket(t.Context(), js, b, 1) + require.NoError(t, err) + } + + // Nothing is short at one node, so the sweep must change nothing and say so + // rather than rewriting every bucket it looked at. + raised, err := kvutil.RaiseAllBucketReplicas(t.Context(), js) + require.NoError(t, err) + assert.Zero(t, raised, "a healthy cluster needs no repair") + + // The cluster grew beyond what one embedded server can place, so every + // bucket is short and every raise fails. The sweep must attempt all of them + // and join the failures rather than stopping at the first. + clustersize.RedeclareForTest(t, 3) + + raised, err = kvutil.RaiseAllBucketReplicas(t.Context(), js) + require.Error(t, err) + assert.Zero(t, raised) + assert.Contains(t, err.Error(), "sweep-alpha") + assert.Contains(t, err.Error(), "sweep-beta", "the sweep stopped at the first failure") + assert.Equal(t, 1, streamConfig(t, js, "sweep-alpha").Replicas, "a failed raise must not have changed anything") +} + +// TestRaiseAllBucketReplicas_RefusesWhenClusterSizeIsUndeclared keeps the sweep +// from reading an undeclared size as "one replica is what everything wants", +// which would report a whole cluster of single-replica buckets as healthy. +func TestRaiseAllBucketReplicas_RefusesWhenClusterSizeIsUndeclared(t *testing.T) { + js := newJetStream(t) + clustersize.RedeclareForTest(t, 0) + + _, err := kvutil.RaiseAllBucketReplicas(t.Context(), js) + require.ErrorIs(t, err, clustersize.ErrUndeclared) +} diff --git a/spinifex/foundation/state/kvutil/state.go b/spinifex/foundation/state/kvutil/state.go index 915801536..b8b1ee331 100644 --- a/spinifex/foundation/state/kvutil/state.go +++ b/spinifex/foundation/state/kvutil/state.go @@ -1,22 +1,4 @@ package kvutil -import "sync/atomic" - -// defaultKVReplicas is the replica count applied to lazily-created KV buckets. -// The daemon sets it once at bootstrap from the configured cluster size; an -// unset value is one replica for single-node operation and tests. -var defaultKVReplicas atomic.Int64 - -// SetDefaultKVReplicas sets the replica count for buckets subsequently created -// through this package. Values below one are clamped to one. -func SetDefaultKVReplicas(n int) { - defaultKVReplicas.Store(int64(max(n, 1))) -} - -// DefaultKVReplicas returns the cluster default for newly created buckets. -func DefaultKVReplicas() int { - return max(int(defaultKVReplicas.Load()), 1) -} - // VersionKey is the well-known KV key used to store a bucket's schema version. const VersionKey = "_version" diff --git a/spinifex/foundation/state/kvutil/state_test.go b/spinifex/foundation/state/kvutil/state_test.go deleted file mode 100644 index 7874ac231..000000000 --- a/spinifex/foundation/state/kvutil/state_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package kvutil - -import ( - "testing" -) - -func TestDefaultKVReplicas(t *testing.T) { - t.Cleanup(func() { defaultKVReplicas.Store(0) }) - - if got := DefaultKVReplicas(); got != 1 { - t.Fatalf("unset default = %d, want 1", got) - } - SetDefaultKVReplicas(0) - if got := DefaultKVReplicas(); got != 1 { - t.Fatalf("SetDefaultKVReplicas(0) = %d, want 1 (clamped)", got) - } - SetDefaultKVReplicas(3) - if got := DefaultKVReplicas(); got != 3 { - t.Fatalf("SetDefaultKVReplicas(3) = %d, want 3", got) - } -} diff --git a/spinifex/foundation/telemetry/metrics.go b/spinifex/foundation/telemetry/metrics.go index 262982f89..19a86a411 100644 --- a/spinifex/foundation/telemetry/metrics.go +++ b/spinifex/foundation/telemetry/metrics.go @@ -13,7 +13,7 @@ import ( // meterName is the stable instrumentation scope every spinifex request metric // shares. It intentionally survives this Go package relocation so existing // dashboards and alerts do not split their metric series. -const meterName = "github.com/mulgadc/spinifex/spinifex/otelsetup" +const meterName = "github.com/mulgadc/spinifex/spinifex/foundation/telemetry" // actionAttrKey names the logical operation on request metrics. Values must // stay low-cardinality: resolved action names only, never resource IDs. diff --git a/spinifex/gateway/admin_createaccount_test.go b/spinifex/gateway/admin_createaccount_test.go index 28a4d1cda..4340d6317 100644 --- a/spinifex/gateway/admin_createaccount_test.go +++ b/spinifex/gateway/admin_createaccount_test.go @@ -241,7 +241,7 @@ func newCreateAccountGateway(t *testing.T) *GatewayConfig { masterKey, err := handlers_iam.GenerateMasterKey() require.NoError(t, err) - svc, err := handlers_iam.NewIAMServiceImpl(t.Context(), nc, masterKey, 1) + svc, err := handlers_iam.NewIAMServiceImpl(t.Context(), nc, masterKey) require.NoError(t, err) sub, err := nc.Subscribe(utils.SubjectEnsureDefaultVpc, func(msg *nats.Msg) { diff --git a/spinifex/gateway/admin_deleteaccount_job_test.go b/spinifex/gateway/admin_deleteaccount_job_test.go index e3088e1c8..c82cde419 100644 --- a/spinifex/gateway/admin_deleteaccount_job_test.go +++ b/spinifex/gateway/admin_deleteaccount_job_test.go @@ -40,7 +40,7 @@ func newDeleteAccountGateway(t *testing.T) *GatewayConfig { masterKey, err := handlers_iam.GenerateMasterKey() require.NoError(t, err) - svc, err := handlers_iam.NewIAMServiceImpl(t.Context(), nc, masterKey, 1) + svc, err := handlers_iam.NewIAMServiceImpl(t.Context(), nc, masterKey) require.NoError(t, err) for _, subject := range teardownSubjects { diff --git a/spinifex/gateway/auth.go b/spinifex/gateway/auth.go index ee772b9c6..6ad59d3dd 100644 --- a/spinifex/gateway/auth.go +++ b/spinifex/gateway/auth.go @@ -14,9 +14,10 @@ import ( "github.com/mulgadc/bluebottle/pkg/sigv4" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" + telemetry "github.com/mulgadc/spinifex/spinifex/foundation/telemetry" + gateway_eks "github.com/mulgadc/spinifex/spinifex/gateway/eks" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" handlers_sts "github.com/mulgadc/spinifex/spinifex/handlers/sts" - "github.com/mulgadc/spinifex/spinifex/foundation/telemetry" "github.com/mulgadc/spinifex/spinifex/utils" ) @@ -71,7 +72,7 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler { "sourceIP", clientIP, "requestTime", signingTime(r), "serverTime", time.Now().UTC().Format("20060102T150405Z"), - "max_skew_ms", otelsetup.Millis(sigv4.MaxClockSkew)) + "max_skew_ms", telemetry.Millis(sigv4.MaxClockSkew)) // Anonymous: the request was rejected before its key id was parsed, // so there is no client identity for the lockout to protect. gw.RateLimiter.RecordFailure(clientIP, anonymousAttempt) @@ -521,7 +522,7 @@ func (gw *GatewayConfig) writeSigV4Error(w http.ResponseWriter, r *http.Request, w.Header().Set("Content-Type", eksJSONContentType) w.Header().Set("X-Amzn-Errortype", jsonErrorType(errorCode)) w.WriteHeader(errorMsg.HTTPCode) - _, _ = w.Write(GenerateEKSErrorResponse(errorCode, errorMsg.Message, requestID)) + _, _ = w.Write(gateway_eks.GenerateEKSErrorResponse(errorCode, errorMsg.Message)) return } diff --git a/spinifex/gateway/bedrock.go b/spinifex/gateway/bedrock.go index f8d6d09e7..459fbf7ca 100644 --- a/spinifex/gateway/bedrock.go +++ b/spinifex/gateway/bedrock.go @@ -235,7 +235,7 @@ func (gw *GatewayConfig) bedrockLoggingConfigStore() *gateway_bedrock.LoggingCon if gw.BedrockLoggingConfig != nil { return gw.BedrockLoggingConfig } - return gateway_bedrock.NewLoggingConfigStore(nil, 1) + return gateway_bedrock.NewLoggingConfigStore(nil) } // bedrockRecorder returns gw.BedrockRecorder, or the no-op fallback when no @@ -269,7 +269,7 @@ func (gw *GatewayConfig) bedrockProvisionedStore() *gateway_bedrock.ProvisionedS if gw.BedrockProvisioned != nil { return gw.BedrockProvisioned } - return gateway_bedrock.NewProvisionedStore(nil, 1, gw.Region, nil) + return gateway_bedrock.NewProvisionedStore(nil, gw.Region, nil) } // bedrockGuardrailStore returns gw.BedrockGuardrails, or a store backed by no @@ -281,7 +281,7 @@ func (gw *GatewayConfig) bedrockGuardrailStore() *gateway_bedrock.GuardrailStore if gw.BedrockGuardrails != nil { return gw.BedrockGuardrails } - return gateway_bedrock.NewGuardrailStore(nil, 1, gw.Region) + return gateway_bedrock.NewGuardrailStore(nil, gw.Region) } // bedrockEndpointResolver returns the registry-backed resolver when one is diff --git a/spinifex/gateway/bedrock/access.go b/spinifex/gateway/bedrock/access.go index 58d981d57..2a8607e7e 100644 --- a/spinifex/gateway/bedrock/access.go +++ b/spinifex/gateway/bedrock/access.go @@ -65,13 +65,12 @@ type ModelAccessStore struct { var _ AccessResolver = (*ModelAccessStore)(nil) // NewModelAccessStore constructs a ModelAccessStore over the cluster's -// JetStream client, replicated across replicas nodes. -func NewModelAccessStore(js jetstream.JetStream, replicas int) *ModelAccessStore { +// JetStream client. +func NewModelAccessStore(js jetstream.JetStream) *ModelAccessStore { return &ModelAccessStore{bucket: kvstore.NewBucket(js, kvstore.Config{ - Name: modelAccessBucket, - History: modelAccessHistory, - Replicas: replicas, - Missing: "bedrock: model access store has no JetStream client configured", + Name: modelAccessBucket, + History: modelAccessHistory, + Missing: "bedrock: model access store has no JetStream client configured", })} } diff --git a/spinifex/gateway/bedrock/access_test.go b/spinifex/gateway/bedrock/access_test.go index f45654c21..5141db04a 100644 --- a/spinifex/gateway/bedrock/access_test.go +++ b/spinifex/gateway/bedrock/access_test.go @@ -72,7 +72,7 @@ func TestAccessKey_EncodesModelID(t *testing.T) { // Revoke. func TestModelAccessStore_GrantResolveRevoke(t *testing.T) { _, _, js := testutil.StartTestJetStream(t) - store := NewModelAccessStore(js, 1) + store := NewModelAccessStore(js) ctx := context.Background() granted, err := store.Granted(ctx, "000000000001", selfHostTestModel) @@ -100,7 +100,7 @@ func TestModelAccessStore_GrantResolveRevoke(t *testing.T) { func TestModelAccessStore_GrantIsIdempotent(t *testing.T) { _, _, js := testutil.StartTestJetStream(t) - store := NewModelAccessStore(js, 1) + store := NewModelAccessStore(js) ctx := context.Background() require.NoError(t, store.Grant(ctx, "000000000001", selfHostTestModel)) @@ -116,7 +116,7 @@ func TestModelAccessStore_GrantIsIdempotent(t *testing.T) { // error. func TestModelAccessStore_RevokeMissingGrantSucceeds(t *testing.T) { _, _, js := testutil.StartTestJetStream(t) - store := NewModelAccessStore(js, 1) + store := NewModelAccessStore(js) assert.NoError(t, store.Revoke(context.Background(), "000000000001", selfHostTestModel)) } @@ -125,7 +125,7 @@ func TestModelAccessStore_RevokeMissingGrantSucceeds(t *testing.T) { // reversible, including the ':' that forced it. func TestModelAccessStore_List_RoundTripsModelIDs(t *testing.T) { _, _, js := testutil.StartTestJetStream(t) - store := NewModelAccessStore(js, 1) + store := NewModelAccessStore(js) ctx := context.Background() require.NoError(t, store.Grant(ctx, "000000000001", selfHostTestModel)) @@ -143,7 +143,7 @@ func TestModelAccessStore_List_RoundTripsModelIDs(t *testing.T) { func TestModelAccessStore_ListEmptyAccount(t *testing.T) { _, _, js := testutil.StartTestJetStream(t) - store := NewModelAccessStore(js, 1) + store := NewModelAccessStore(js) models, err := store.List(context.Background(), "000000000009") require.NoError(t, err) @@ -154,7 +154,7 @@ func TestModelAccessStore_ListEmptyAccount(t *testing.T) { // handlers_quota exempts the system account from every quota dimension. func TestModelAccessStore_SystemAccountBypassesGrants(t *testing.T) { _, _, js := testutil.StartTestJetStream(t) - store := NewModelAccessStore(js, 1) + store := NewModelAccessStore(js) granted, err := store.Granted(context.Background(), utils.GlobalAccountID, selfHostTestModel) require.NoError(t, err) @@ -189,7 +189,7 @@ func TestGrantedCatalogEntry_ErrorClasses(t *testing.T) { // who then revokes a grant does not have it restored by the next restart. func TestSeedAccountGrants_SeedsOnceThenRespectsRevoke(t *testing.T) { _, _, js := testutil.StartTestJetStream(t) - store := NewModelAccessStore(js, 1) + store := NewModelAccessStore(js) ctx := context.Background() const account = "000000000001" @@ -218,7 +218,7 @@ func TestSeedAccountGrants_SeedsOnceThenRespectsRevoke(t *testing.T) { // account to end up with the catalog. func TestSeedAccountGrants_LeavesOtherAccountsDenied(t *testing.T) { _, _, js := testutil.StartTestJetStream(t) - store := NewModelAccessStore(js, 1) + store := NewModelAccessStore(js) ctx := context.Background() _, err := store.SeedAccountGrants(ctx, "000000000001", CatalogModelIDs()) @@ -233,7 +233,7 @@ func TestSeedAccountGrants_LeavesOtherAccountsDenied(t *testing.T) { // read back as a grant would show up as a bogus model ID in List. func TestSeedAccountGrants_MarkerIsNotAGrant(t *testing.T) { _, _, js := testutil.StartTestJetStream(t) - store := NewModelAccessStore(js, 1) + store := NewModelAccessStore(js) ctx := context.Background() _, err := store.SeedAccountGrants(ctx, "000000000001", nil) @@ -248,7 +248,7 @@ func TestSeedAccountGrants_MarkerIsNotAGrant(t *testing.T) { // JetStream client: every accessor must report the misconfiguration rather // than panic on a nil handle. func TestModelAccessStore_RequiresJetStream(t *testing.T) { - store := NewModelAccessStore(nil, 1) + store := NewModelAccessStore(nil) ctx := context.Background() _, err := store.Granted(ctx, "000000000001", "anthropic.claude-3-5-sonnet-20240620-v1:0") diff --git a/spinifex/gateway/bedrock/admission_test.go b/spinifex/gateway/bedrock/admission_test.go index 574aaddd5..aac8a4d62 100644 --- a/spinifex/gateway/bedrock/admission_test.go +++ b/spinifex/gateway/bedrock/admission_test.go @@ -210,7 +210,7 @@ func TestAdmitSelfHost_OnDemandAdmitsThenThrottles(t *testing.T) { // resolving committed ModelUnits surfaces as that error, never masquerading as // a throttle or admitting the request. func TestAdmitSelfHost_CommittedUnitsErrorPropagates(t *testing.T) { - store := NewProvisionedStore(nil, 1, ptTestRegion, newStubEndpointProvisioner()) + store := NewProvisionedStore(nil, ptTestRegion, newStubEndpointProvisioner()) release, err := admitSelfHost(context.Background(), store, ptCallerAccount, selfHostTestModel, catalogEntry{MaxConcurrency: 1}) @@ -282,7 +282,7 @@ func TestCommittedModelUnits_EmptyAccountReturnsZero(t *testing.T) { // TestCommittedModelUnits_BucketErrorPropagates asserts an unusable store // surfaces its error rather than silently reading zero committed units. func TestCommittedModelUnits_BucketErrorPropagates(t *testing.T) { - store := NewProvisionedStore(nil, 1, ptTestRegion, newStubEndpointProvisioner()) + store := NewProvisionedStore(nil, ptTestRegion, newStubEndpointProvisioner()) _, err := committedModelUnits(context.Background(), store, ptCallerAccount, selfHostTestModel) require.Error(t, err) } diff --git a/spinifex/gateway/bedrock/credentials.go b/spinifex/gateway/bedrock/credentials.go index bf76e9f18..897a0a810 100644 --- a/spinifex/gateway/bedrock/credentials.go +++ b/spinifex/gateway/bedrock/credentials.go @@ -38,13 +38,12 @@ var _ CredentialResolver = (*CredentialStore)(nil) // NewCredentialStore constructs a CredentialStore. platformDefaults maps // vendor to a platform-wide API key used when an account has no key of its // own; pass an empty map to disable platform defaults entirely. -func NewCredentialStore(js jetstream.JetStream, masterKey []byte, replicas int, platformDefaults map[string]string) *CredentialStore { +func NewCredentialStore(js jetstream.JetStream, masterKey []byte, platformDefaults map[string]string) *CredentialStore { return &CredentialStore{ bucket: kvstore.NewBucket(js, kvstore.Config{ - Name: bedrockCredentialsBucket, - History: bedrockCredentialsHistory, - Replicas: replicas, - Missing: "bedrock: credential store has no JetStream client configured", + Name: bedrockCredentialsBucket, + History: bedrockCredentialsHistory, + Missing: "bedrock: credential store has no JetStream client configured", }), masterKey: masterKey, platformDefaults: platformDefaults, diff --git a/spinifex/gateway/bedrock/credentials_test.go b/spinifex/gateway/bedrock/credentials_test.go index 3714419f8..f0bb91435 100644 --- a/spinifex/gateway/bedrock/credentials_test.go +++ b/spinifex/gateway/bedrock/credentials_test.go @@ -21,7 +21,7 @@ func fixedMasterKey() []byte { // live NATS server is out of scope here). Resolve must consult // platformDefaults without dereferencing js. func TestCredentialStore_Resolve_NilJS(t *testing.T) { - store := NewCredentialStore(nil, fixedMasterKey(), 1, map[string]string{"anthropic": "sk-platform-default"}) + store := NewCredentialStore(nil, fixedMasterKey(), map[string]string{"anthropic": "sk-platform-default"}) cases := []struct { name string @@ -43,7 +43,7 @@ func TestCredentialStore_Resolve_NilJS(t *testing.T) { } func TestCredentialStore_Resolve_NoPlatformDefaults(t *testing.T) { - store := NewCredentialStore(nil, fixedMasterKey(), 1, nil) + store := NewCredentialStore(nil, fixedMasterKey(), nil) key, ok, err := store.Resolve(context.Background(), "000000000001", "anthropic") require.NoError(t, err) @@ -63,7 +63,7 @@ func TestNoopCredentialResolver_ResolvesNothing(t *testing.T) { // KV-hit branch of Resolve (Get+decrypt). func TestCredentialStore_PutAndResolve_KV(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) - store := NewCredentialStore(testutil.NewJetStream(t, nc), fixedMasterKey(), 1, nil) + store := NewCredentialStore(testutil.NewJetStream(t, nc), fixedMasterKey(), nil) ctx := context.Background() require.NoError(t, store.PutCredential(ctx, "000000000001", "anthropic", "sk-test")) @@ -78,7 +78,7 @@ func TestCredentialStore_PutAndResolve_KV(t *testing.T) { // miss (jetstream.ErrKeyNotFound) that falls through to the platform default. func TestCredentialStore_Resolve_KVMissFallsBackToPlatformDefault(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) - store := NewCredentialStore(testutil.NewJetStream(t, nc), fixedMasterKey(), 1, map[string]string{"anthropic": "sk-platform-default"}) + store := NewCredentialStore(testutil.NewJetStream(t, nc), fixedMasterKey(), map[string]string{"anthropic": "sk-platform-default"}) key, ok, err := store.Resolve(context.Background(), "999999999999", "anthropic") require.NoError(t, err) @@ -90,7 +90,7 @@ func TestCredentialStore_Resolve_KVMissFallsBackToPlatformDefault(t *testing.T) // unknown account with no platform defaults configured: ("", false, nil). func TestCredentialStore_Resolve_KVMissNoPlatformDefault(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) - store := NewCredentialStore(testutil.NewJetStream(t, nc), fixedMasterKey(), 1, nil) + store := NewCredentialStore(testutil.NewJetStream(t, nc), fixedMasterKey(), nil) key, ok, err := store.Resolve(context.Background(), "999999999999", "anthropic") require.NoError(t, err) @@ -119,7 +119,7 @@ func TestEncryptDecryptSecret_RoundTrip(t *testing.T) { // Resolve's tolerated nil: a write has no platform-default fallback to fall // back to, so it must report the misconfiguration. func TestCredentialStore_PutCredential_RequiresJetStream(t *testing.T) { - store := NewCredentialStore(nil, fixedMasterKey(), 1, map[string]string{"anthropic": "sk-default"}) + store := NewCredentialStore(nil, fixedMasterKey(), map[string]string{"anthropic": "sk-default"}) err := store.PutCredential(context.Background(), "000000000001", "anthropic", "sk-account") require.Error(t, err) diff --git a/spinifex/gateway/bedrock/guardrail.go b/spinifex/gateway/bedrock/guardrail.go index cb20af27f..52270f979 100644 --- a/spinifex/gateway/bedrock/guardrail.go +++ b/spinifex/gateway/bedrock/guardrail.go @@ -89,15 +89,14 @@ type GuardrailStore struct { region string } -// NewGuardrailStore constructs a GuardrailStore over js, replicated across -// replicas nodes, minting ARNs for region. -func NewGuardrailStore(js jetstream.JetStream, replicas int, region string) *GuardrailStore { +// NewGuardrailStore constructs a GuardrailStore over js, minting ARNs for +// region. +func NewGuardrailStore(js jetstream.JetStream, region string) *GuardrailStore { return &GuardrailStore{ store: kvstore.New[GuardrailRecord](js, kvstore.Config{ - Name: bedrockGuardrailBucket, - History: bedrockGuardrailHistory, - Replicas: replicas, - Missing: "bedrock: guardrail store has no JetStream client configured", + Name: bedrockGuardrailBucket, + History: bedrockGuardrailHistory, + Missing: "bedrock: guardrail store has no JetStream client configured", }), region: region, } diff --git a/spinifex/gateway/bedrock/guardrail_test.go b/spinifex/gateway/bedrock/guardrail_test.go index 5fffdc0e3..f2f1e74bf 100644 --- a/spinifex/gateway/bedrock/guardrail_test.go +++ b/spinifex/gateway/bedrock/guardrail_test.go @@ -20,7 +20,7 @@ const ( func newGuardrailTestStore(t *testing.T) *GuardrailStore { t.Helper() _, _, js := testutil.StartTestJetStream(t) - return NewGuardrailStore(js, 1, ptTestRegion) + return NewGuardrailStore(js, ptTestRegion) } // createGuardrailInput builds a minimal valid CreateGuardrailInput carrying diff --git a/spinifex/gateway/bedrock/guardrail_topic_embed.go b/spinifex/gateway/bedrock/guardrail_topic_embed.go index 3a37f1c07..b608c3301 100644 --- a/spinifex/gateway/bedrock/guardrail_topic_embed.go +++ b/spinifex/gateway/bedrock/guardrail_topic_embed.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "log/slog" "math" + "slices" "strings" "sync" @@ -162,10 +163,7 @@ func embedGuardrailTexts(ctx context.Context, embedder Embedder, modelID string, // not a single centroid, so one strong phrase match is enough even when the // topic's examples are otherwise diverse. func topicSemanticHit(textVectors, topicPhraseVectors [][]float32, threshold float64) bool { - for _, tv := range textVectors { - if maxCosineSimilarity(tv, topicPhraseVectors) >= threshold { - return true - } - } - return false + return slices.ContainsFunc(textVectors, func(tv []float32) bool { + return maxCosineSimilarity(tv, topicPhraseVectors) >= threshold + }) } diff --git a/spinifex/gateway/bedrock/logging.go b/spinifex/gateway/bedrock/logging.go index ba0a7d78d..4b8b8b8e5 100644 --- a/spinifex/gateway/bedrock/logging.go +++ b/spinifex/gateway/bedrock/logging.go @@ -15,6 +15,7 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/bedrock" "github.com/aws/aws-sdk-go/service/s3" + "github.com/mulgadc/spinifex/spinifex/clustersize" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore" "github.com/mulgadc/spinifex/spinifex/objectstore" @@ -51,11 +52,15 @@ const ( ) // EnsureInvocationStream idempotently creates (or updates) the invocation -// stream. Safe to call from every gateway node at boot. replicas must match -// the cluster's node count: at replicas=1, losing the one node holding this -// stream loses billing/audit records even though the control plane survives -// on the others, defeating the point of using JetStream over a lossy buffer. -func EnsureInvocationStream(ctx context.Context, js jetstream.JetStream, replicas int) (jetstream.Stream, error) { +// stream, replicated across the cluster. Safe to call from every gateway node +// at boot. On one replica, losing the one node holding this stream loses +// billing and audit records even though the control plane survives on the +// others, defeating the point of using JetStream over a lossy buffer. +func EnsureInvocationStream(ctx context.Context, js jetstream.JetStream) (jetstream.Stream, error) { + replicas, err := clustersize.Replicas() + if err != nil { + return nil, fmt.Errorf("ensure invocation stream: %w", err) + } stream, err := js.CreateOrUpdateStream(ctx, jetstream.StreamConfig{ Name: InvocationStreamName, Subjects: []string{InvocationStreamSubject}, @@ -310,12 +315,11 @@ type LoggingConfigStore struct { var _ LoggingConfigReader = (*LoggingConfigStore)(nil) // NewLoggingConfigStore constructs a LoggingConfigStore. -func NewLoggingConfigStore(js jetstream.JetStream, replicas int) *LoggingConfigStore { +func NewLoggingConfigStore(js jetstream.JetStream) *LoggingConfigStore { return &LoggingConfigStore{store: kvstore.New[LoggingConfig](js, kvstore.Config{ - Name: bedrockLoggingConfigBucket, - History: bedrockLoggingConfigHistory, - Replicas: replicas, - Missing: "bedrock: logging config store has no JetStream client configured", + Name: bedrockLoggingConfigBucket, + History: bedrockLoggingConfigHistory, + Missing: "bedrock: logging config store has no JetStream client configured", })} } diff --git a/spinifex/gateway/bedrock/logging_test.go b/spinifex/gateway/bedrock/logging_test.go index 7b02c62bc..576323dd7 100644 --- a/spinifex/gateway/bedrock/logging_test.go +++ b/spinifex/gateway/bedrock/logging_test.go @@ -33,7 +33,7 @@ func (s stubLoggingConfigReader) Get(context.Context, string) (LoggingConfig, bo func TestLoggingConfigStore_PutGetDelete(t *testing.T) { _, _, js := testutil.StartTestJetStream(t) - store := NewLoggingConfigStore(js, 1) + store := NewLoggingConfigStore(js) ctx := context.Background() _, ok, err := store.Get(ctx, "000000000001") @@ -59,7 +59,7 @@ func TestLoggingConfigStore_PutGetDelete(t *testing.T) { func TestPutModelInvocationLoggingConfiguration_RejectsCloudWatch(t *testing.T) { _, _, js := testutil.StartTestJetStream(t) - store := NewLoggingConfigStore(js, 1) + store := NewLoggingConfigStore(js) input := &bedrock.PutModelInvocationLoggingConfigurationInput{ LoggingConfig: &bedrock.LoggingConfig{ @@ -74,7 +74,7 @@ func TestPutModelInvocationLoggingConfiguration_RejectsCloudWatch(t *testing.T) func TestPutModelInvocationLoggingConfiguration_RequiresBucketWhenDeliveryEnabled(t *testing.T) { _, _, js := testutil.StartTestJetStream(t) - store := NewLoggingConfigStore(js, 1) + store := NewLoggingConfigStore(js) input := &bedrock.PutModelInvocationLoggingConfigurationInput{ LoggingConfig: &bedrock.LoggingConfig{TextDataDeliveryEnabled: aws.Bool(true)}, @@ -89,7 +89,7 @@ func TestPutModelInvocationLoggingConfiguration_RequiresBucketWhenDeliveryEnable // undeliverable as text or image, so it must not slip past the same gate. func TestPutModelInvocationLoggingConfiguration_RequiresBucketForEmbeddingDelivery(t *testing.T) { _, _, js := testutil.StartTestJetStream(t) - store := NewLoggingConfigStore(js, 1) + store := NewLoggingConfigStore(js) input := &bedrock.PutModelInvocationLoggingConfigurationInput{ LoggingConfig: &bedrock.LoggingConfig{EmbeddingDataDeliveryEnabled: aws.Bool(true)}, @@ -105,7 +105,7 @@ func TestPutModelInvocationLoggingConfiguration_RequiresBucketForEmbeddingDelive // internal LoggingConfig. func TestModelInvocationLoggingConfiguration_RoundTripsViaSDKStructs(t *testing.T) { _, _, js := testutil.StartTestJetStream(t) - store := NewLoggingConfigStore(js, 1) + store := NewLoggingConfigStore(js) ctx := context.Background() putInput := &bedrock.PutModelInvocationLoggingConfigurationInput{ @@ -159,7 +159,7 @@ func TestStreamRecorder_DropsAndCountsWhenStreamMissing(t *testing.T) { func TestStreamRecorder_Record_BodyOnlyWhenLoggingEnabled(t *testing.T) { _, _, js := testutil.StartTestJetStream(t) ctx := context.Background() - _, err := EnsureInvocationStream(ctx, js, 1) + _, err := EnsureInvocationStream(ctx, js) require.NoError(t, err) consumer, err := EnsureDeliveryConsumer(ctx, js) require.NoError(t, err) @@ -198,7 +198,7 @@ func TestDeliveryConsumer_Run_WritesS3AndNeverLogsBodyText(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - _, err := EnsureInvocationStream(ctx, js, 1) + _, err := EnsureInvocationStream(ctx, js) require.NoError(t, err) consumer, err := EnsureDeliveryConsumer(ctx, js) require.NoError(t, err) @@ -247,7 +247,7 @@ func TestDeliveryConsumer_Run_DeliversMetadataOnlyRecordToS3(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - _, err := EnsureInvocationStream(ctx, js, 1) + _, err := EnsureInvocationStream(ctx, js) require.NoError(t, err) consumer, err := EnsureDeliveryConsumer(ctx, js) require.NoError(t, err) @@ -294,7 +294,7 @@ func TestDeliveryConsumer_Run_DeliversMetadataOnlyRecordToS3(t *testing.T) { // JetStream client: every accessor must report the misconfiguration rather // than panic on a nil handle. func TestLoggingConfigStore_RequiresJetStream(t *testing.T) { - store := NewLoggingConfigStore(nil, 1) + store := NewLoggingConfigStore(nil) ctx := context.Background() _, _, err := store.Get(ctx, "000000000001") diff --git a/spinifex/gateway/bedrock/prices.go b/spinifex/gateway/bedrock/prices.go index 17711505d..6c94d5e7b 100644 --- a/spinifex/gateway/bedrock/prices.go +++ b/spinifex/gateway/bedrock/prices.go @@ -76,13 +76,12 @@ type PriceStore struct { var _ PriceResolver = (*PriceStore)(nil) // NewPriceStore constructs a PriceStore over the cluster's JetStream client, -// replicated across replicas nodes. -func NewPriceStore(js jetstream.JetStream, replicas int) *PriceStore { + +func NewPriceStore(js jetstream.JetStream) *PriceStore { return &PriceStore{store: kvstore.New[Price](js, kvstore.Config{ - Name: bedrockPricesBucket, - History: bedrockPricesHistory, - Replicas: replicas, - Missing: "bedrock: price store has no JetStream client configured", + Name: bedrockPricesBucket, + History: bedrockPricesHistory, + Missing: "bedrock: price store has no JetStream client configured", })} } diff --git a/spinifex/gateway/bedrock/prices_test.go b/spinifex/gateway/bedrock/prices_test.go index cfd28b2a5..619b01136 100644 --- a/spinifex/gateway/bedrock/prices_test.go +++ b/spinifex/gateway/bedrock/prices_test.go @@ -94,7 +94,7 @@ func TestResolvePrice_ResolverErrorPropagates(t *testing.T) { // TestWeightsStore_PutAndResolve_KV. func TestPriceStore_PutGetDelete(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) - store := NewPriceStore(testutil.NewJetStream(t, nc), 1) + store := NewPriceStore(testutil.NewJetStream(t, nc)) ctx := context.Background() _, ok, err := store.Resolve(ctx, priceTestProviderModelID) @@ -123,7 +123,7 @@ func TestPriceStore_PutGetDelete(t *testing.T) { // JetStream client: every accessor must report the misconfiguration rather // than panic on a nil handle. func TestPriceStore_RequiresJetStream(t *testing.T) { - store := NewPriceStore(nil, 1) + store := NewPriceStore(nil) ctx := context.Background() _, _, err := store.Resolve(ctx, priceTestProviderModelID) diff --git a/spinifex/gateway/bedrock/provisioned.go b/spinifex/gateway/bedrock/provisioned.go index 567aa6e7f..25aa2f6ad 100644 --- a/spinifex/gateway/bedrock/provisioned.go +++ b/spinifex/gateway/bedrock/provisioned.go @@ -81,16 +81,14 @@ type ProvisionedStore struct { endpoint EndpointProvisioner } -// NewProvisionedStore constructs a ProvisionedStore over js, replicated -// across replicas nodes, driving endpoint launches/teardowns through -// endpoint. -func NewProvisionedStore(js jetstream.JetStream, replicas int, region string, endpoint EndpointProvisioner) *ProvisionedStore { +// NewProvisionedStore constructs a ProvisionedStore over js, driving endpoint +// launches and teardowns through endpoint. +func NewProvisionedStore(js jetstream.JetStream, region string, endpoint EndpointProvisioner) *ProvisionedStore { return &ProvisionedStore{ store: kvstore.New[ProvisionedModelRecord](js, kvstore.Config{ - Name: bedrockProvisionedBucket, - History: bedrockProvisionedHistory, - Replicas: replicas, - Missing: "bedrock: provisioned store has no JetStream client configured", + Name: bedrockProvisionedBucket, + History: bedrockProvisionedHistory, + Missing: "bedrock: provisioned store has no JetStream client configured", }), region: region, endpoint: endpoint, diff --git a/spinifex/gateway/bedrock/provisioned_test.go b/spinifex/gateway/bedrock/provisioned_test.go index ef8017766..5aba55046 100644 --- a/spinifex/gateway/bedrock/provisioned_test.go +++ b/spinifex/gateway/bedrock/provisioned_test.go @@ -103,7 +103,7 @@ const ( func newProvisionedTestStore(t *testing.T, endpoint EndpointProvisioner) *ProvisionedStore { t.Helper() _, _, js := testutil.StartTestJetStream(t) - return NewProvisionedStore(js, 1, ptTestRegion, endpoint) + return NewProvisionedStore(js, ptTestRegion, endpoint) } func createInput(modelID, name string, units int64) *bedrock.CreateProvisionedModelThroughputInput { diff --git a/spinifex/gateway/bedrock/reranker.go b/spinifex/gateway/bedrock/reranker.go index c7ae22038..caf24a3af 100644 --- a/spinifex/gateway/bedrock/reranker.go +++ b/spinifex/gateway/bedrock/reranker.go @@ -58,9 +58,9 @@ type rerankProvider struct { var _ Reranker = (*rerankProvider)(nil) -// newRerankProvider constructs a rerankProvider resolving modelID's endpoint +// NewReranker constructs a rerankProvider resolving modelID's endpoint // via endpointResolver on every call, mirroring newEmbeddingsProvider. -func newRerankProvider(endpointResolver EndpointResolver, modelID string) *rerankProvider { +func NewReranker(endpointResolver EndpointResolver, modelID string) *rerankProvider { return &rerankProvider{ modelID: modelID, endpointResolver: endpointResolver, @@ -68,13 +68,6 @@ func newRerankProvider(endpointResolver EndpointResolver, modelID string) *reran } } -// NewReranker is newRerankProvider's exported constructor, for callers -// outside this package (e.g. the daemon's Ochre vector store wiring) that -// need a Reranker without reaching into gateway_bedrock's unexported types. -func NewReranker(endpointResolver EndpointResolver, modelID string) Reranker { - return newRerankProvider(endpointResolver, modelID) -} - // Rerank POSTs query and docs to the reranker model's resolved /rerank // endpoint, returning doc indices ordered best-match first. Empty docs skip // the call; an unresolved endpoint returns ModelNotReadyException. diff --git a/spinifex/gateway/bedrock/reranker_test.go b/spinifex/gateway/bedrock/reranker_test.go index be749f3c7..944d5325f 100644 --- a/spinifex/gateway/bedrock/reranker_test.go +++ b/spinifex/gateway/bedrock/reranker_test.go @@ -34,7 +34,7 @@ func TestRerankProvider_Rerank_RequestShape(t *testing.T) { defer ts.Close() modelID := DefaultRerankModel - p := newRerankProvider(NewStaticEndpointResolver(map[string]string{modelID: ts.URL}), modelID) + p := NewReranker(NewStaticEndpointResolver(map[string]string{modelID: ts.URL}), modelID) p.httpClient = ts.Client() _, err := p.Rerank(context.Background(), "what is spinifex?", []string{"spinifex is a control plane"}) @@ -60,7 +60,7 @@ func TestRerankProvider_Rerank_HappyPathOrdersByDescendingScore(t *testing.T) { defer ts.Close() modelID := DefaultRerankModel - p := newRerankProvider(NewStaticEndpointResolver(map[string]string{modelID: ts.URL}), modelID) + p := NewReranker(NewStaticEndpointResolver(map[string]string{modelID: ts.URL}), modelID) p.httpClient = ts.Client() order, err := p.Rerank(context.Background(), "query", []string{"doc0", "doc1", "doc2"}) @@ -77,7 +77,7 @@ func TestRerankProvider_Rerank_EmptyDocsSkipsHTTPCall(t *testing.T) { defer ts.Close() modelID := DefaultRerankModel - p := newRerankProvider(NewStaticEndpointResolver(map[string]string{modelID: ts.URL}), modelID) + p := NewReranker(NewStaticEndpointResolver(map[string]string{modelID: ts.URL}), modelID) p.httpClient = ts.Client() order, err := p.Rerank(context.Background(), "query", nil) @@ -94,7 +94,7 @@ func TestRerankProvider_Rerank_NonOKStatusReturnsError(t *testing.T) { defer ts.Close() modelID := DefaultRerankModel - p := newRerankProvider(NewStaticEndpointResolver(map[string]string{modelID: ts.URL}), modelID) + p := NewReranker(NewStaticEndpointResolver(map[string]string{modelID: ts.URL}), modelID) p.httpClient = ts.Client() _, err := p.Rerank(context.Background(), "query", []string{"doc0"}) @@ -111,7 +111,7 @@ func TestRerankProvider_Rerank_MalformedJSONReturnsError(t *testing.T) { defer ts.Close() modelID := DefaultRerankModel - p := newRerankProvider(NewStaticEndpointResolver(map[string]string{modelID: ts.URL}), modelID) + p := NewReranker(NewStaticEndpointResolver(map[string]string{modelID: ts.URL}), modelID) p.httpClient = ts.Client() _, err := p.Rerank(context.Background(), "query", []string{"doc0"}) @@ -128,7 +128,7 @@ func TestRerankProvider_Rerank_CountMismatchReturnsError(t *testing.T) { defer ts.Close() modelID := DefaultRerankModel - p := newRerankProvider(NewStaticEndpointResolver(map[string]string{modelID: ts.URL}), modelID) + p := NewReranker(NewStaticEndpointResolver(map[string]string{modelID: ts.URL}), modelID) p.httpClient = ts.Client() _, err := p.Rerank(context.Background(), "query", []string{"doc0", "doc1"}) @@ -145,7 +145,7 @@ func TestRerankProvider_Rerank_OutOfRangeIndexReturnsError(t *testing.T) { defer ts.Close() modelID := DefaultRerankModel - p := newRerankProvider(NewStaticEndpointResolver(map[string]string{modelID: ts.URL}), modelID) + p := NewReranker(NewStaticEndpointResolver(map[string]string{modelID: ts.URL}), modelID) p.httpClient = ts.Client() _, err := p.Rerank(context.Background(), "query", []string{"doc0"}) @@ -155,7 +155,7 @@ func TestRerankProvider_Rerank_OutOfRangeIndexReturnsError(t *testing.T) { func TestRerankProvider_Rerank_ConnectionRefusedReturnsServiceUnavailable(t *testing.T) { modelID := DefaultRerankModel - p := newRerankProvider(NewStaticEndpointResolver(map[string]string{modelID: "http://127.0.0.1:1"}), modelID) + p := NewReranker(NewStaticEndpointResolver(map[string]string{modelID: "http://127.0.0.1:1"}), modelID) _, err := p.Rerank(context.Background(), "query", []string{"doc0"}) require.Error(t, err) @@ -164,7 +164,7 @@ func TestRerankProvider_Rerank_ConnectionRefusedReturnsServiceUnavailable(t *tes func TestRerankProvider_Rerank_UnresolvedEndpointReturnsModelNotReady(t *testing.T) { modelID := DefaultRerankModel - p := newRerankProvider(NewStaticEndpointResolver(nil), modelID) + p := NewReranker(NewStaticEndpointResolver(nil), modelID) _, err := p.Rerank(context.Background(), "query", []string{"doc0"}) require.Error(t, err) diff --git a/spinifex/gateway/bedrock/usage.go b/spinifex/gateway/bedrock/usage.go index bdc5c94c3..9203a3edf 100644 --- a/spinifex/gateway/bedrock/usage.go +++ b/spinifex/gateway/bedrock/usage.go @@ -98,13 +98,12 @@ type UsageStore struct { var _ UsageReader = (*UsageStore)(nil) // NewUsageStore constructs a UsageStore over the cluster's JetStream client, -// replicated across replicas nodes. -func NewUsageStore(js jetstream.JetStream, replicas int) *UsageStore { + +func NewUsageStore(js jetstream.JetStream) *UsageStore { return &UsageStore{ store: kvstore.New[UsageCounters](js, kvstore.Config{ Name: bedrockUsageBucket, History: bedrockUsageHistory, - Replicas: replicas, Missing: "bedrock: usage store has no JetStream client configured", Attempts: usageCASRetries, Exhausted: usageCASExhausted, diff --git a/spinifex/gateway/bedrock/usage_test.go b/spinifex/gateway/bedrock/usage_test.go index 70e3ebc65..9976cd10a 100644 --- a/spinifex/gateway/bedrock/usage_test.go +++ b/spinifex/gateway/bedrock/usage_test.go @@ -21,7 +21,7 @@ const ( func newTestUsageStore(t *testing.T) *UsageStore { t.Helper() _, nc, _ := testutil.StartTestJetStream(t) - return NewUsageStore(testutil.NewJetStream(t, nc), 1) + return NewUsageStore(testutil.NewJetStream(t, nc)) } // TestMicroUSDCost_Arithmetic covers D12's integer accrual, including @@ -184,7 +184,7 @@ func TestUsageConsumer_Run_RedeliveredRecordDoesNotDoubleCount(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - _, err := EnsureInvocationStream(ctx, js, 1) + _, err := EnsureInvocationStream(ctx, js) require.NoError(t, err) consumer, err := EnsureUsageConsumer(ctx, js) require.NoError(t, err) @@ -206,7 +206,7 @@ func TestUsageConsumer_Run_RedeliveredRecordDoesNotDoubleCount(t *testing.T) { _, err = js.Publish(ctx, InvocationStreamSubject, payload) require.NoError(t, err) - usage := NewUsageStore(js, 1) + usage := NewUsageStore(js) uc := NewUsageConsumer(usage, nil) done := make(chan struct{}) go func() { @@ -243,7 +243,7 @@ func TestUsageConsumer_Run_UnknownModelStillRecordsTokensCostUnknown(t *testing. ctx, cancel := context.WithCancel(context.Background()) defer cancel() - _, err := EnsureInvocationStream(ctx, js, 1) + _, err := EnsureInvocationStream(ctx, js) require.NoError(t, err) consumer, err := EnsureUsageConsumer(ctx, js) require.NoError(t, err) @@ -258,7 +258,7 @@ func TestUsageConsumer_Run_UnknownModelStillRecordsTokensCostUnknown(t *testing. _, err = js.Publish(ctx, InvocationStreamSubject, payload) require.NoError(t, err) - usage := NewUsageStore(js, 1) + usage := NewUsageStore(js) uc := NewUsageConsumer(usage, nil) done := make(chan struct{}) go func() { @@ -293,7 +293,7 @@ func TestBillingPeriod_MonthlyFormat(t *testing.T) { // JetStream client: both the counter bucket and the dedupe bucket must report // the misconfiguration rather than panic on a nil handle. func TestUsageStore_RequiresJetStream(t *testing.T) { - store := NewUsageStore(nil, 1) + store := NewUsageStore(nil) ctx := context.Background() _, _, err := store.Get(ctx, "000000000001", "m", "2026-08") diff --git a/spinifex/gateway/bedrock/weights.go b/spinifex/gateway/bedrock/weights.go index ea39a1336..3588e0beb 100644 --- a/spinifex/gateway/bedrock/weights.go +++ b/spinifex/gateway/bedrock/weights.go @@ -57,13 +57,12 @@ type WeightsStore struct { var _ WeightsResolver = (*WeightsStore)(nil) // NewWeightsStore constructs a WeightsStore over the cluster's JetStream -// client, replicated across replicas nodes. -func NewWeightsStore(js jetstream.JetStream, replicas int) *WeightsStore { +// client. +func NewWeightsStore(js jetstream.JetStream) *WeightsStore { return &WeightsStore{store: kvstore.New[weightsRecord](js, kvstore.Config{ - Name: bedrockWeightsBucket, - History: bedrockWeightsHistory, - Replicas: replicas, - Missing: "bedrock: weights store has no JetStream client configured", + Name: bedrockWeightsBucket, + History: bedrockWeightsHistory, + Missing: "bedrock: weights store has no JetStream client configured", })} } diff --git a/spinifex/gateway/bedrock/weights_test.go b/spinifex/gateway/bedrock/weights_test.go index fd9b7402c..568372a50 100644 --- a/spinifex/gateway/bedrock/weights_test.go +++ b/spinifex/gateway/bedrock/weights_test.go @@ -20,7 +20,7 @@ func TestNoopWeightsResolver_ResolvesNothing(t *testing.T) { // snapshot: ("", false, nil), not an error. func TestWeightsStore_ResolveMiss(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) - store := NewWeightsStore(testutil.NewJetStream(t, nc), 1) + store := NewWeightsStore(testutil.NewJetStream(t, nc)) snapshotID, ok, err := store.Resolve(context.Background(), "meta.llama3-2-1b-instruct-v1:0") require.NoError(t, err) @@ -33,7 +33,7 @@ func TestWeightsStore_ResolveMiss(t *testing.T) { // Resolve. func TestWeightsStore_PutAndResolve_KV(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) - store := NewWeightsStore(testutil.NewJetStream(t, nc), 1) + store := NewWeightsStore(testutil.NewJetStream(t, nc)) ctx := context.Background() require.NoError(t, store.PutWeights(ctx, "meta.llama3-2-1b-instruct-v1:0", "s3://models/llama-3.2-1b/", "snap-0001")) @@ -49,7 +49,7 @@ func TestWeightsStore_PutAndResolve_KV(t *testing.T) { // history. func TestWeightsStore_PutOverwritesPrevious(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) - store := NewWeightsStore(testutil.NewJetStream(t, nc), 1) + store := NewWeightsStore(testutil.NewJetStream(t, nc)) ctx := context.Background() require.NoError(t, store.PutWeights(ctx, "meta.llama3-2-1b-instruct-v1:0", "s3://models/llama-3.2-1b/", "snap-0001")) @@ -66,7 +66,7 @@ func TestWeightsStore_PutOverwritesPrevious(t *testing.T) { // reports ok=false, not an error. func TestWeightsStore_GetWeights_Miss(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) - store := NewWeightsStore(testutil.NewJetStream(t, nc), 1) + store := NewWeightsStore(testutil.NewJetStream(t, nc)) entry, ok, err := store.GetWeights(context.Background(), "meta.llama3-2-1b-instruct-v1:0") require.NoError(t, err) @@ -80,7 +80,7 @@ func TestWeightsStore_GetWeights_Miss(t *testing.T) { // snapshot it is about to replace. func TestWeightsStore_GetWeights_ReturnsSourceURIAndSnapshot(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) - store := NewWeightsStore(testutil.NewJetStream(t, nc), 1) + store := NewWeightsStore(testutil.NewJetStream(t, nc)) ctx := context.Background() require.NoError(t, store.PutWeights(ctx, "meta.llama3-2-1b-instruct-v1:0", "s3://models/llama-3.2-1b/", "snap-0001")) @@ -100,7 +100,7 @@ func TestWeightsStore_GetWeights_ReturnsSourceURIAndSnapshot(t *testing.T) { // both GetWeights and ListWeights. func TestWeightsStore_PutWeightsWithRevision_RoundTrips(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) - store := NewWeightsStore(testutil.NewJetStream(t, nc), 1) + store := NewWeightsStore(testutil.NewJetStream(t, nc)) ctx := context.Background() require.NoError(t, store.PutWeightsWithRevision(ctx, "meta.llama3-2-1b-instruct-v1:0", "s3://ochre-weights/meta-llama/Llama-3.2-1B-Instruct/abc123/", "snap-0001", "abc123def456")) @@ -121,7 +121,7 @@ func TestWeightsStore_PutWeightsWithRevision_RoundTrips(t *testing.T) { // SourceRevision, not fail or fabricate one. func TestWeightsStore_PutWeights_LeavesSourceRevisionEmpty(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) - store := NewWeightsStore(testutil.NewJetStream(t, nc), 1) + store := NewWeightsStore(testutil.NewJetStream(t, nc)) ctx := context.Background() require.NoError(t, store.PutWeights(ctx, "meta.llama3-2-1b-instruct-v1:0", "s3://models/llama-3.2-1b/", "snap-0001")) @@ -136,7 +136,7 @@ func TestWeightsStore_PutWeights_LeavesSourceRevisionEmpty(t *testing.T) { // bucket must report an empty list, not an error (jetstream.ErrNoKeysFound). func TestWeightsStore_ListWeights_Empty(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) - store := NewWeightsStore(testutil.NewJetStream(t, nc), 1) + store := NewWeightsStore(testutil.NewJetStream(t, nc)) entries, err := store.ListWeights(context.Background()) require.NoError(t, err) @@ -148,7 +148,7 @@ func TestWeightsStore_ListWeights_Empty(t *testing.T) { // base64url KV key), its source URI and its snapshot ID, sorted by model ID. func TestWeightsStore_ListWeights_ReturnsAllStaged(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) - store := NewWeightsStore(testutil.NewJetStream(t, nc), 1) + store := NewWeightsStore(testutil.NewJetStream(t, nc)) ctx := context.Background() require.NoError(t, store.PutWeights(ctx, "meta.llama3-2-1b-instruct-v1:0", "s3://models/llama-3.2-1b/", "snap-0001")) @@ -167,7 +167,7 @@ func TestWeightsStore_ListWeights_ReturnsAllStaged(t *testing.T) { // DeleteWeights drops the record and Resolve reports not-found afterwards. func TestWeightsStore_DeleteWeights_RemovesEntry(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) - store := NewWeightsStore(testutil.NewJetStream(t, nc), 1) + store := NewWeightsStore(testutil.NewJetStream(t, nc)) ctx := context.Background() require.NoError(t, store.PutWeights(ctx, "meta.llama3-2-1b-instruct-v1:0", "s3://models/llama-3.2-1b/", "snap-0001")) @@ -184,7 +184,7 @@ func TestWeightsStore_DeleteWeights_RemovesEntry(t *testing.T) { // must not error on a missing key either. func TestWeightsStore_DeleteWeights_NeverStagedIsNotAnError(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) - store := NewWeightsStore(testutil.NewJetStream(t, nc), 1) + store := NewWeightsStore(testutil.NewJetStream(t, nc)) require.NoError(t, store.DeleteWeights(context.Background(), "meta.llama3-2-1b-instruct-v1:0")) } @@ -208,7 +208,7 @@ func TestSetWeightsResolver_NilRestoresNoop(t *testing.T) { // JetStream client: every accessor must report the misconfiguration rather // than panic on a nil handle. func TestWeightsStore_RequiresJetStream(t *testing.T) { - store := NewWeightsStore(nil, 1) + store := NewWeightsStore(nil) ctx := context.Background() _, _, err := store.Resolve(ctx, "meta.llama3-2-1b-instruct-v1:0") diff --git a/spinifex/gateway/bodyscope/bodyscope.go b/spinifex/gateway/bodyscope/bodyscope.go index 8842ebebd..1f52a1c1d 100644 --- a/spinifex/gateway/bodyscope/bodyscope.go +++ b/spinifex/gateway/bodyscope/bodyscope.go @@ -5,23 +5,25 @@ // Two properties matter here. A type mismatch on an unrelated field cannot // poison the parse and silently widen the request to "*", because every field // is decoded on demand. And AWS JSON 1.1 spells fields lower-camel while the -// SDK structs are upper-camel, so lookups are case-insensitive by design -// rather than by relying on encoding/json's own fallback. +// SDK structs are upper-camel, so lookups are case-insensitive by design. Names +// are ASCII-only, where strings.ToLower folds exactly as encoding/json does. package bodyscope import ( "encoding/json" + "encoding/json/jsontext" + jsonv2 "encoding/json/v2" "errors" "log/slog" + "slices" "strings" + "unicode" ) -// ErrAmbiguousBody reports a body carrying two spellings of one field that -// differ only in case. encoding/json resolves those in document order when the -// handler builds its typed input, while a case-folded map cannot resolve them -// at all, so the gate and the handler would name different objects. The caller -// must reject the request rather than resolve it or widen to "*". -var ErrAmbiguousBody = errors.New("bodyscope: field spelled two ways in one body") +// ErrAmbiguousBody reports a field the handler's decode could read differently: +// a non-ASCII name, which encoding/json may fold onto an ASCII one, or a name +// given twice, where it skips a repeated null and merges a repeated object. +var ErrAmbiguousBody = errors.New("bodyscope: body names a field ambiguously") // Scope is a parsed request body. The zero value is usable and reports every // field as absent, which is what a body the gate cannot parse resolves to. @@ -32,23 +34,32 @@ type Scope struct { // Parse reads body as a JSON object. A body that is empty or does not parse // yields an empty Scope and no error: it is the handler that rejects a // malformed request, so the caller sees a validation fault rather than a -// denial. A body whose fields collide under the case fold yields -// ErrAmbiguousBody. action names the caller for the log lines only. +// denial. A field named ambiguously yields ErrAmbiguousBody. action names the +// caller for the log lines only. func Parse(action string, body []byte) (Scope, error) { if len(body) == 0 { return Scope{}, nil } - var raw map[string]json.RawMessage - if err := json.Unmarshal(body, &raw); err != nil { + // v2 refuses a repeated name at any depth. Invalid UTF-8 stays accepted + // because the handler's v1 decode accepts it. + var raw map[string]jsontext.Value + err := jsonv2.Unmarshal(body, &raw, jsontext.AllowInvalidUTF8(true)) + if errors.Is(err, jsontext.ErrDuplicateName) { + slog.Error("bodyscope: body names a field ambiguously, refusing to resolve a scope", + "action", action, "err", err) + return Scope{}, ErrAmbiguousBody + } + if err != nil { slog.Debug("bodyscope: body does not parse, authorizing account-wide", "action", action, "err", err) return Scope{}, nil } fields := make(map[string]json.RawMessage, len(raw)) for name, value := range raw { lower := strings.ToLower(name) - if _, duplicate := fields[lower]; duplicate { - slog.Error("bodyscope: field spelled two ways in one body, refusing to resolve a scope", - "action", action, "field", lower) + nonASCII := strings.ContainsFunc(name, func(r rune) bool { return r > unicode.MaxASCII }) + if _, duplicate := fields[lower]; duplicate || nonASCII { + slog.Error("bodyscope: body names a field ambiguously, refusing to resolve a scope", + "action", action, "field", name) return Scope{}, ErrAmbiguousBody } fields[lower] = value @@ -126,10 +137,8 @@ func (s Scope) Object(names ...string) (Scope, error) { // where the presence of an optional field decides whether a scope applies at // all, rather than its value. func (s Scope) Has(names ...string) bool { - for _, name := range names { - if _, ok := s.fields[strings.ToLower(name)]; ok { - return true - } - } - return false + return slices.ContainsFunc(names, func(name string) bool { + _, ok := s.fields[strings.ToLower(name)] + return ok + }) } diff --git a/spinifex/gateway/bodyscope/bodyscope_test.go b/spinifex/gateway/bodyscope/bodyscope_test.go index 0561a9c40..75fd43c60 100644 --- a/spinifex/gateway/bodyscope/bodyscope_test.go +++ b/spinifex/gateway/bodyscope/bodyscope_test.go @@ -65,11 +65,39 @@ func TestObject_FieldSpelledTwoWaysInANestedObjectIsRejected(t *testing.T) { require.ErrorIs(t, err, bodyscope.ErrAmbiguousBody) } -// A field repeated under one spelling is not ambiguous: encoding/json takes the -// last occurrence and so does the map, so both sides agree. -func TestParse_RepeatedIdenticalSpellingIsNotAmbiguous(t *testing.T) { - scope := mustParse(t, "DeleteCluster", `{"cluster":"dev","cluster":"prod"}`) - assert.Equal(t, "prod", scope.String("cluster")) +// encoding/json skips a repeated null and merges a repeated object where a map +// keeps only the last, so any repeat can leave the handler with another value. +func TestParse_RepeatedFieldIsAmbiguous(t *testing.T) { + for _, body := range []string{ + `{"cluster":"dev","cluster":"prod"}`, + `{"cluster":"dev","cluster":"dev"}`, + `{"repositoryName":"victim","repositoryName":null}`, + `{"config":{"id":"victim"},"config":{"type":"x"}}`, + } { + _, err := bodyscope.Parse("DeleteRepository", []byte(body)) + require.ErrorIs(t, err, bodyscope.ErrAmbiguousBody, "body %q", body) + } + + _, err := bodyscope.Parse("RetrieveAndGenerate", []byte(`{"config":{"id":"a","id":null}}`)) + require.ErrorIs(t, err, bodyscope.ErrAmbiguousBody) +} + +// encoding/json folds some non-ASCII runes onto ASCII letters (U+017F onto +// "s", U+212A onto "k"), so a non-ASCII name can fill a field the gate never +// looked up. No AWS member name is non-ASCII, so any such name is refused. +func TestParse_NonASCIIFieldNameIsAmbiguous(t *testing.T) { + for _, body := range []string{ + `{"cluſter":"victim"}`, + `{"tas\u212a":"victim"}`, + `{"cluster":"dev","naïve":1}`, + } { + _, err := bodyscope.Parse("DeleteCluster", []byte(body)) + require.ErrorIs(t, err, bodyscope.ErrAmbiguousBody, "body %q", body) + } + + scope := mustParse(t, "RetrieveAndGenerate", `{"config":{"knowledgeBaſeId":"victim"}}`) + _, err := scope.Object("config") + require.ErrorIs(t, err, bodyscope.ErrAmbiguousBody) } func TestStrings_DropsEmptyElements(t *testing.T) { diff --git a/spinifex/gateway/bodyscope/differential_fuzz_test.go b/spinifex/gateway/bodyscope/differential_fuzz_test.go new file mode 100644 index 000000000..b5a3f0f96 --- /dev/null +++ b/spinifex/gateway/bodyscope/differential_fuzz_test.go @@ -0,0 +1,174 @@ +package bodyscope_test + +import ( + "encoding/json" + "reflect" + "slices" + "strings" + "testing" + + "github.com/mulgadc/spinifex/spinifex/gateway/bodyscope" +) + +// A lookup is one identifier an authz resolver reads through bodyscope, paired +// with the field shape the handler decodes the same body into. Every handler +// uses encoding/json, whose field matching depends only on name and type, so a +// one-field struct stands in for each action's request type. +type lookup struct { + path []string // Object hops, then the String/Strings field + list bool + sdk bool // aws-sdk-go shape: untagged pointer fields; else tagged values +} + +// Mirrors the String/Strings/Object calls in the ecrapi, ecs, eks, acm and +// bedrock resolvers. ECR's own request structs are tagged value types. +var lookups = []lookup{ + {path: []string{"repositoryName"}}, + {path: []string{"repositoryNames"}, list: true}, + {path: []string{"resourceArn"}}, + {path: []string{"accountId"}, sdk: true}, + {path: []string{"capacityProvider"}, sdk: true}, + {path: []string{"capacityProviders"}, list: true, sdk: true}, + {path: []string{"certificateArn"}, sdk: true}, + {path: []string{"cluster"}, sdk: true}, + {path: []string{"clusterName"}, sdk: true}, + {path: []string{"clusters"}, list: true, sdk: true}, + {path: []string{"containerInstance"}, sdk: true}, + {path: []string{"containerInstances"}, list: true, sdk: true}, + {path: []string{"modelId"}, sdk: true}, + {path: []string{"name"}, sdk: true}, + {path: []string{"resourceArn"}, sdk: true}, + {path: []string{"service"}, sdk: true}, + {path: []string{"serviceName"}, sdk: true}, + {path: []string{"services"}, list: true, sdk: true}, + {path: []string{"task"}, sdk: true}, + {path: []string{"taskDefinition"}, sdk: true}, + {path: []string{"tasks"}, list: true, sdk: true}, + {path: []string{"retrieveAndGenerateConfiguration", "knowledgeBaseConfiguration", "knowledgeBaseId"}, sdk: true}, +} + +// structFor builds the handler-side type for path: a chain of one-field +// structs ending in the identifier field. +func structFor(l lookup, path []string) reflect.Type { + name := path[0] + var leaf reflect.Type + switch { + case len(path) > 1: + leaf = structFor(l, path[1:]) + case l.list && l.sdk: + leaf = reflect.TypeFor[[]*string]() + case l.list: + leaf = reflect.TypeFor[[]string]() + case l.sdk: + leaf = reflect.TypeFor[*string]() + default: + leaf = reflect.TypeFor[string]() + } + field := reflect.StructField{Name: strings.ToUpper(name[:1]) + name[1:], Type: leaf} + if l.sdk { + if len(path) > 1 { + field.Type = reflect.PointerTo(leaf) + } + } else { + field.Tag = reflect.StructTag(`json:"` + name + `"`) + } + return reflect.StructOf([]reflect.StructField{field}) +} + +// handlerValues decodes body the way the handler does and returns the +// identifier it would act on, with empty and null list entries dropped to +// match Strings. ok is false when the handler would reject the body. +func handlerValues(l lookup, body []byte) (values []string, ok bool) { + v := reflect.New(structFor(l, l.path)) + if err := json.Unmarshal(body, v.Interface()); err != nil { + return nil, false + } + cur := v.Elem().Field(0) + for cur.Kind() == reflect.Pointer || cur.Kind() == reflect.Struct { + if cur.Kind() == reflect.Pointer { + if cur.IsNil() { + return nil, true + } + cur = cur.Elem() + continue + } + cur = cur.Field(0) + } + if cur.Kind() == reflect.String { + if s := cur.String(); s != "" { + return []string{s}, true + } + return nil, true + } + for i := range cur.Len() { + e := cur.Index(i) + if e.Kind() == reflect.Pointer { + if e.IsNil() { + continue + } + e = e.Elem() + } + if s := e.String(); s != "" { + values = append(values, s) + } + } + return values, true +} + +// gateValues resolves the identifier the way the authz resolvers do. ok is +// false when bodyscope refuses the body, which fails the request closed. +func gateValues(l lookup, body []byte) (values []string, ok bool) { + scope, err := bodyscope.Parse("Fuzz", body) + if err != nil { + return nil, false + } + for _, hop := range l.path[:len(l.path)-1] { + if scope, err = scope.Object(hop); err != nil { + return nil, false + } + } + field := l.path[len(l.path)-1] + if l.list { + return scope.Strings(field), true + } + if s := scope.String(field); s != "" { + return []string{s}, true + } + return nil, true +} + +// FuzzGateMatchesHandler pins the property the authz gate rests on: for any +// body both sides accept, the identifier bodyscope resolves is the one the +// handler decodes. A mismatch authorizes one resource and acts on another. +func FuzzGateMatchesHandler(f *testing.F) { + for _, seed := range []string{ + `{"repositoryName":"a"}`, + `{"RepositoryName":"a"}`, + `{"repositoryNames":["a","","b"]}`, + `{"cluster":"a","clusters":["b",null]}`, + `{"retrieveAndGenerateConfiguration":{"knowledgeBaseConfiguration":{"knowledgeBaseId":"kb"}}}`, + // U+017F folds to "s" in encoding/json but not in strings.ToLower. + `{"repositoryName":"allowed","repoſitoryName":"victim"}`, + `{"cluſter":"victim"}`, + // A repeated key set to null clears a map entry but not a value field. + `{"repositoryName":"victim","repositoryName":null}`, + // A repeated object replaces a map entry but merges into a struct. + `{"retrieveAndGenerateConfiguration":{"knowledgeBaseConfiguration":{"knowledgeBaseId":"victim"}},"retrieveAndGenerateConfiguration":{"type":"KNOWLEDGE_BASE"}}`, + } { + f.Add([]byte(seed)) + } + + f.Fuzz(func(t *testing.T, body []byte) { + for _, l := range lookups { + gate, gateOK := gateValues(l, body) + handler, handlerOK := handlerValues(l, body) + if !gateOK || !handlerOK { + continue + } + if !slices.Equal(gate, handler) { + t.Errorf("%s (sdk=%v): gate resolved %q, handler decoded %q from %q", + strings.Join(l.path, "."), l.sdk, gate, handler, body) + } + } + }) +} diff --git a/spinifex/gateway/conditionkeys_completeness_test.go b/spinifex/gateway/conditionkeys_completeness_test.go index 8e65fb99f..213669ad8 100644 --- a/spinifex/gateway/conditionkeys_completeness_test.go +++ b/spinifex/gateway/conditionkeys_completeness_test.go @@ -7,8 +7,10 @@ package gateway import ( "crypto/tls" "encoding/json" + "maps" "net/http" "net/http/httptest" + "slices" "testing" "github.com/mulgadc/bluebottle/pkg/iampolicy" @@ -26,6 +28,8 @@ var gatewayDoorKeys = []string{ iampolicy.KeyPrincipalAccount, iampolicy.KeySourceIP, iampolicy.KeyPrincipalType, + iampolicy.KeyCurrentTime, + iampolicy.KeyEpochTime, } // Every condition key the evaluator names, so the write-path gate below covers @@ -38,6 +42,9 @@ var allConditionKeys = []string{ iampolicy.KeyPrincipalAccount, iampolicy.KeyUserID, iampolicy.KeyPrincipalType, + iampolicy.KeyPassedToService, + iampolicy.KeyCurrentTime, + iampolicy.KeyEpochTime, // Deliberately outside the allowlist: there is no MFA in the stack, so the // key could never be true. It is here to prove the validator says no. "aws:MultiFactorAuthPresent", @@ -46,16 +53,41 @@ var allConditionKeys = []string{ // One value per operator that the leaf validator accepts, so a rejection can // only come from the operator/key allowlist and not from the value. var operatorValues = map[string]string{ - iampolicy.OpStringEquals: "alice", - iampolicy.OpStringLike: "ali*", - iampolicy.OpIPAddress: "10.0.0.0/8", - iampolicy.OpBool: "true", + iampolicy.OpStringEquals: "alice", + iampolicy.OpStringNotEquals: "alice", + iampolicy.OpStringEqualsIgnoreCase: "alice", + iampolicy.OpStringNotEqualsIgnoreCase: "alice", + iampolicy.OpStringLike: "ali*", + iampolicy.OpStringNotLike: "ali*", + iampolicy.OpIPAddress: "10.0.0.0/8", + iampolicy.OpNotIPAddress: "10.0.0.0/8", + iampolicy.OpBool: "true", + iampolicy.OpNull: "true", + iampolicy.OpDateEquals: "2026-01-01T00:00:00Z", + iampolicy.OpDateNotEquals: "2026-01-01T00:00:00Z", + iampolicy.OpDateLessThan: "2026-01-01T00:00:00Z", + iampolicy.OpDateLessThanEquals: "2026-01-01T00:00:00Z", + iampolicy.OpDateGreaterThan: "1767225600", + iampolicy.OpDateGreaterThanEquals: "1767225600", + // Implemented, but no supported key is ARN-valued, so every pair below is + // rejected until one is. + iampolicy.OpArnEquals: "arn:aws:iam::000000000001:user/alice", + iampolicy.OpArnLike: "arn:aws:iam::000000000001:user/*", + iampolicy.OpArnNotEquals: "arn:aws:iam::000000000001:user/alice", + iampolicy.OpArnNotLike: "arn:aws:iam::000000000001:user/*", // Operators the evaluator does not implement. Accepting one would store a // restriction that compares false forever. - "StringNotEquals": "alice", - "ArnLike": "arn:aws:iam::000000000001:user/alice", "NumericLessThan": "3", - "DateGreaterThan": "2026-01-01T00:00:00Z", +} + +// withIfExists adds the IfExists form of every operator, so the gate below covers +// the suffixed operators too, NullIfExists among the ones it must reject. +func withIfExists(values map[string]string) map[string]string { + all := maps.Clone(values) + for op, value := range values { + all[op+iampolicy.IfExistsSuffix] = value + } + return all } // emittedKeys drives requestConditionKeys with everything a request can carry, @@ -117,10 +149,7 @@ func TestRequestConditionKeys_EveryEmittedKeyIsUsableInAPolicy(t *testing.T) { // The door's key set as a whole, so a key gained or lost here is a deliberate // change made at both doors rather than a drift between them. func TestRequestConditionKeys_MatchesTheDoorKeySet(t *testing.T) { - emitted := make([]string, 0, len(gatewayDoorKeys)) - for key := range emittedKeys(t) { - emitted = append(emitted, key) - } + emitted := slices.Collect(maps.Keys(emittedKeys(t))) assert.ElementsMatch(t, gatewayDoorKeys, emitted, "the AWS gateway door key set changed: update predastore's mirror in "+ "internal/gate/conditionkeys_completeness_test.go and the door table in bluebottle's door_test.go") @@ -136,7 +165,7 @@ func TestRequestConditionKeys_MatchesTheDoorKeySet(t *testing.T) { // policy an operator cannot write. func TestValidatePolicyDocument_AcceptsExactlyTheSupportedConditions(t *testing.T) { for _, key := range allConditionKeys { - for op, value := range operatorValues { + for op, value := range withIfExists(operatorValues) { doc := conditionDocument(t, op, key, value) _, err := handlers_iam.ValidatePolicyDocument(doc) diff --git a/spinifex/gateway/conditionkeys_test.go b/spinifex/gateway/conditionkeys_test.go index 0f784d1a2..6cd2c5246 100644 --- a/spinifex/gateway/conditionkeys_test.go +++ b/spinifex/gateway/conditionkeys_test.go @@ -11,6 +11,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/iam" @@ -32,8 +33,17 @@ func TestRequestConditionKeys_PopulatesAvailableKeys(t *testing.T) { userID: "AIDAALICE", } + before := time.Now().Truncate(time.Second) keys := requestConditionKeys(r, principal) + after := time.Now() + // The request time is the server clock at the call, in both spellings. + for _, key := range []string{iampolicy.KeyCurrentTime, iampolicy.KeyEpochTime} { + at, err := iampolicy.ParseDate(keys[key]) + require.NoError(t, err, key) + assert.False(t, at.Before(before) || at.After(after), "%s is %s, outside the call", key, at) + delete(keys, key) + } assert.Equal(t, iampolicy.ConditionKeys{ iampolicy.KeySourceIP: "10.4.1.9", iampolicy.KeySecureTransport: "true", diff --git a/spinifex/gateway/ec2.go b/spinifex/gateway/ec2.go index 060ac9613..af5a82e5a 100644 --- a/spinifex/gateway/ec2.go +++ b/spinifex/gateway/ec2.go @@ -36,6 +36,7 @@ import ( gateway_ec2_zone "github.com/mulgadc/spinifex/spinifex/gateway/ec2/zone" handlers_quota "github.com/mulgadc/spinifex/spinifex/handlers/quota" "github.com/mulgadc/spinifex/spinifex/utils" + "github.com/nats-io/nats.go" ) // EC2Handler processes parsed query args and returns XML response bytes. @@ -105,13 +106,28 @@ func ec2Handler[In any](handler func(ctx context.Context, input *In, gw *Gateway } } +// ec2ListsAsSet names the list fields AWS omits when empty instead of +// rendering them. Each is rendered as its handler set it, so nil omits it. +var ec2ListsAsSet = map[reflect.Type][]string{ + reflect.TypeFor[ec2.CreateKeyPairOutput](): {"Tags"}, + reflect.TypeFor[ec2.Image](): {"Tags", "ProductCodes"}, + reflect.TypeFor[ec2.CreateSecurityGroupOutput](): {"Tags"}, + reflect.TypeFor[ec2.SecurityGroup](): {"Tags"}, + reflect.TypeFor[ec2.SecurityGroupRule](): {"Tags"}, + reflect.TypeFor[ec2.RevokeSecurityGroupIngressOutput](): {"UnknownIpPermissions"}, + reflect.TypeFor[ec2.RevokeSecurityGroupEgressOutput](): {"UnknownIpPermissions"}, + reflect.TypeFor[ec2.NetworkInterface](): {"Ipv4Prefixes", "Ipv6Prefixes"}, + reflect.TypeFor[ec2.Vpc](): {"Ipv6CidrBlockAssociationSet"}, + reflect.TypeFor[ec2.Subnet](): {"Tags"}, +} + // marshalEC2Response renders an EC2 handler's output into the action's XML // envelope. Every EC2 action funnels through here (via ec2Handler and // ec2HandlerWithReq), so wire-format fixes belong here, not in each handler. func marshalEC2Response(action string, output any) ([]byte, error) { // BuildXML omits a nil slice's container element entirely but renders an - // empty one for a non-nil empty slice; AWS always renders the latter. - normalized := utils.NormalizeXMLOutput(output) + // empty one for a non-nil empty slice; AWS mostly renders the latter. + normalized := utils.NormalizeXMLOutput(output, ec2ListsAsSet) // The SDK's generated output structs never carry a RequestId field. withRequestID := utils.WithRequestID(normalized, uuid.NewV4().String()) payload := utils.GenerateXMLPayload(action+"Response", withRequestID) @@ -174,7 +190,7 @@ var ec2Actions = map[string]ec2Action{ }), "RunInstances": ec2HandlerWithReq(func(ctx context.Context, input *ec2.RunInstancesInput, gw *GatewayConfig, accountID string, r *http.Request) (any, error) { passRoleCheck := func(roleARN string) error { - return gw.checkPolicyResources(r, "iam", "PassRole", []string{roleARN}) + return gw.checkPassRole(r, roleARN, ec2ServicePrincipal) } launchQuotaCheck := func() error { return gw.Quota.EnforceLaunch(ctx, accountID, aws.StringValue(input.InstanceType), int(aws.Int64Value(input.MaxCount))) @@ -192,7 +208,7 @@ var ec2Actions = map[string]ec2Action{ }), "AssociateIamInstanceProfile": ec2HandlerWithReq(func(ctx context.Context, input *ec2.AssociateIamInstanceProfileInput, gw *GatewayConfig, accountID string, r *http.Request) (any, error) { passRoleCheck := func(roleARN string) error { - return gw.checkPolicyResources(r, "iam", "PassRole", []string{roleARN}) + return gw.checkPassRole(r, roleARN, ec2ServicePrincipal) } return gateway_ec2_instance.AssociateIamInstanceProfile(ctx, input, gw.NATSConn, gw.IAMService, accountID, passRoleCheck) }), @@ -201,7 +217,7 @@ var ec2Actions = map[string]ec2Action{ }), "ReplaceIamInstanceProfileAssociation": ec2HandlerWithReq(func(ctx context.Context, input *ec2.ReplaceIamInstanceProfileAssociationInput, gw *GatewayConfig, accountID string, r *http.Request) (any, error) { passRoleCheck := func(roleARN string) error { - return gw.checkPolicyResources(r, "iam", "PassRole", []string{roleARN}) + return gw.checkPassRole(r, roleARN, ec2ServicePrincipal) } return gateway_ec2_instance.ReplaceIamInstanceProfileAssociation(ctx, input, gw.NATSConn, gw.IAMService, gw.DiscoverActiveNodes(ctx), accountID, passRoleCheck) }), @@ -227,7 +243,7 @@ var ec2Actions = map[string]ec2Action{ return gateway_ec2_instance.TerminateInstances(ctx, input, gw.NATSConn, accountID) }), "DescribeInstanceTypes": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstanceTypesInput, gw *GatewayConfig, accountID string) (any, error) { - return gateway_ec2_instance.DescribeInstanceTypes(ctx, input, gw.NATSConn, gw.ExpectedNodes, accountID) + return gateway_ec2_instance.DescribeInstanceTypes(ctx, input, gw.NATSConn, gw.ExpectedNodes, gw.NodeIDs, accountID) }), "DescribeInstanceTypeOfferings": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstanceTypeOfferingsInput, gw *GatewayConfig, accountID string) (any, error) { return gateway_ec2_instance.DescribeInstanceTypeOfferings(ctx, input, gw.NATSConn, gw.ExpectedNodes, accountID, gw.Region, gw.AZ) @@ -349,7 +365,9 @@ var ec2Actions = map[string]ec2Action{ return gateway_ec2_volume.DetachVolume(ctx, input, gw.NATSConn, accountID) }), "DescribeAccountAttributes": ec2Handler(func(ctx context.Context, input *ec2.DescribeAccountAttributesInput, gw *GatewayConfig, accountID string) (any, error) { - return gateway_ec2_account.DescribeAccountAttributes(input) + return gateway_ec2_account.DescribeAccountAttributes(input, func() (string, error) { + return defaultVPCID(ctx, gw.NATSConn, accountID) + }) }), "EnableEbsEncryptionByDefault": ec2Handler(func(ctx context.Context, input *ec2.EnableEbsEncryptionByDefaultInput, gw *GatewayConfig, accountID string) (any, error) { return gateway_ec2_account.EnableEbsEncryptionByDefault(ctx, input, gw.NATSConn, accountID) @@ -455,7 +473,7 @@ var ec2Actions = map[string]ec2Action{ }), "RequestSpotInstances": ec2HandlerWithReq(func(ctx context.Context, input *ec2.RequestSpotInstancesInput, gw *GatewayConfig, accountID string, r *http.Request) (any, error) { passRoleCheck := func(roleARN string) error { - return gw.checkPolicyResources(r, "iam", "PassRole", []string{roleARN}) + return gw.checkPassRole(r, roleARN, ec2ServicePrincipal) } return gateway_ec2_spotinstance.RequestSpotInstances(ctx, input, gw.NATSConn, gw.IAMService, accountID, gw.AZ, passRoleCheck, gw.Quota, gw.ExpectedNodes) }), @@ -619,7 +637,26 @@ var ec2Actions = map[string]ec2Action{ }), } -// ec2LocalActions are actions that don't require NATS. +// defaultVPCID returns the ID of accountID's default VPC, or "" when it has +// none, as the account's own DescribeVpcs sees it. +func defaultVPCID(ctx context.Context, natsConn *nats.Conn, accountID string) (string, error) { + if natsConn == nil { + return "", errors.New(awserrors.ErrorServerInternal) + } + out, err := gateway_ec2_vpc.DescribeVpcs(ctx, &ec2.DescribeVpcsInput{ + Filters: []*ec2.Filter{{Name: aws.String("is-default"), Values: aws.StringSlice([]string{"true"})}}, + }, natsConn, accountID) + if err != nil { + return "", err + } + if len(out.Vpcs) == 0 { + return "", nil + } + return aws.StringValue(out.Vpcs[0].VpcId), nil +} + +// ec2LocalActions are actions that don't require NATS. DescribeAccountAttributes +// needs it only for default-vpc. var ec2LocalActions = map[string]bool{ "DescribeRegions": true, "DescribeAvailabilityZones": true, @@ -674,11 +711,22 @@ func (gw *GatewayConfig) EC2_Request(w http.ResponseWriter, r *http.Request) err return err } if err := gw.checkPolicyResources(r, "ec2", action, resources); err != nil { + // EC2 answers a policy denial with UnauthorizedOperation, not AccessDenied. + if code, _ := awserrors.ResolveErrorCode(err); code == awserrors.ErrorAccessDenied { + return errors.New(awserrors.ErrorUnauthorizedOperation) + } + return err + } + // AWS refuses a bad tag resource type even on a DryRun request. + if err := validateTagSpecificationTypes(action, input); err != nil { return err } if dryRunRequested(input) { return errors.New(awserrors.ErrorDryRunOperation) } + if err := validateMaxResults(action, input); err != nil { + return err + } if gw.NATSConn == nil && !ec2LocalActions[action] { return errors.New(awserrors.ErrorServerInternal) @@ -686,6 +734,11 @@ func (gw *GatewayConfig) EC2_Request(w http.ResponseWriter, r *http.Request) err xmlOutput, err := handler.dispatch(action, input, gw, accountID, r) if err != nil { + // A gateway iam:PassRole denial is EC2's UnauthorizedOperation too; an + // AccessDenied a daemon returns is left as it is. + if _, denied := errors.AsType[*identityPolicyDenialError](err); denied { + return errors.New(awserrors.ErrorUnauthorizedOperation) + } return err } diff --git a/spinifex/gateway/ec2/account/DescribeAccountAttributes.go b/spinifex/gateway/ec2/account/DescribeAccountAttributes.go index 697d9ff08..0254411ad 100644 --- a/spinifex/gateway/ec2/account/DescribeAccountAttributes.go +++ b/spinifex/gateway/ec2/account/DescribeAccountAttributes.go @@ -7,8 +7,10 @@ import ( "github.com/aws/aws-sdk-go/service/ec2" ) -// DescribeAccountAttributes returns static account attributes for the Spinifex platform. -func DescribeAccountAttributes(input *ec2.DescribeAccountAttributesInput) (*ec2.DescribeAccountAttributesOutput, error) { +// DescribeAccountAttributes returns the account's attributes. Every attribute +// but default-vpc is static; defaultVPCID looks that one up, is called only +// when it is asked for, and returns "" for an account with no default VPC. +func DescribeAccountAttributes(input *ec2.DescribeAccountAttributesInput, defaultVPCID func() (string, error)) (*ec2.DescribeAccountAttributesOutput, error) { requestedAttrs := make(map[string]bool) for _, name := range input.AttributeNames { if name != nil { @@ -30,10 +32,17 @@ func DescribeAccountAttributes(input *ec2.DescribeAccountAttributesInput) (*ec2. } if returnAll || requestedAttrs["default-vpc"] { + vpcID, err := defaultVPCID() + if err != nil { + return nil, err + } + if vpcID == "" { + vpcID = "none" + } accountAttributes = append(accountAttributes, &ec2.AccountAttribute{ AttributeName: aws.String("default-vpc"), AttributeValues: []*ec2.AccountAttributeValue{ - {AttributeValue: aws.String("none")}, + {AttributeValue: aws.String(vpcID)}, }, }) } diff --git a/spinifex/gateway/ec2/account/account_test.go b/spinifex/gateway/ec2/account/account_test.go index 7aedb98da..40babd252 100644 --- a/spinifex/gateway/ec2/account/account_test.go +++ b/spinifex/gateway/ec2/account/account_test.go @@ -2,6 +2,7 @@ package gateway_ec2_account import ( "context" + "errors" "testing" "github.com/aws/aws-sdk-go/aws" @@ -14,7 +15,7 @@ import ( func TestDescribeAccountAttributes_AllAttributes(t *testing.T) { input := &ec2.DescribeAccountAttributesInput{} - output, err := DescribeAccountAttributes(input) + output, err := DescribeAccountAttributes(input, noDefaultVPC) require.NoError(t, err) require.NotNil(t, output) @@ -38,7 +39,7 @@ func TestDescribeAccountAttributes_FilterSingle(t *testing.T) { AttributeNames: []*string{aws.String("max-instances")}, } - output, err := DescribeAccountAttributes(input) + output, err := DescribeAccountAttributes(input, noDefaultVPC) require.NoError(t, err) require.NotNil(t, output) @@ -55,7 +56,7 @@ func TestDescribeAccountAttributes_FilterMultiple(t *testing.T) { }, } - output, err := DescribeAccountAttributes(input) + output, err := DescribeAccountAttributes(input, noDefaultVPC) require.NoError(t, err) require.NotNil(t, output) @@ -74,7 +75,7 @@ func TestDescribeAccountAttributes_FilterNonExistent(t *testing.T) { AttributeNames: []*string{aws.String("nonexistent-attribute")}, } - output, err := DescribeAccountAttributes(input) + output, err := DescribeAccountAttributes(input, noDefaultVPC) require.NoError(t, err) require.NotNil(t, output) @@ -86,7 +87,7 @@ func TestDescribeAccountAttributes_EmptyAttributeNames(t *testing.T) { AttributeNames: []*string{}, } - output, err := DescribeAccountAttributes(input) + output, err := DescribeAccountAttributes(input, noDefaultVPC) require.NoError(t, err) require.NotNil(t, output) @@ -94,6 +95,37 @@ func TestDescribeAccountAttributes_EmptyAttributeNames(t *testing.T) { assert.Len(t, output.AccountAttributes, 6) } +func noDefaultVPC() (string, error) { return "", nil } + +func TestDescribeAccountAttributes_DefaultVPC(t *testing.T) { + named := &ec2.DescribeAccountAttributesInput{AttributeNames: []*string{aws.String("default-vpc")}} + + output, err := DescribeAccountAttributes(named, func() (string, error) { return "vpc-0abc", nil }) + require.NoError(t, err) + require.Len(t, output.AccountAttributes, 1) + assert.Equal(t, "vpc-0abc", aws.StringValue(output.AccountAttributes[0].AttributeValues[0].AttributeValue)) + + output, err = DescribeAccountAttributes(named, noDefaultVPC) + require.NoError(t, err) + assert.Equal(t, "none", aws.StringValue(output.AccountAttributes[0].AttributeValues[0].AttributeValue)) +} + +// A failed lookup fails the request rather than reporting "none" for an +// account that may well have a default VPC; other attributes never look. +func TestDescribeAccountAttributes_DefaultVPCLookupFailure(t *testing.T) { + failing := func() (string, error) { return "", errors.New(awserrors.ErrorServerInternal) } + + _, err := DescribeAccountAttributes(&ec2.DescribeAccountAttributesInput{}, failing) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorServerInternal, err.Error()) + + output, err := DescribeAccountAttributes(&ec2.DescribeAccountAttributesInput{ + AttributeNames: []*string{aws.String("max-instances")}, + }, failing) + require.NoError(t, err) + assert.Len(t, output.AccountAttributes, 1) +} + func TestValidateEnableEbsEncryptionByDefaultInput(t *testing.T) { tests := []struct { name string diff --git a/spinifex/gateway/ec2/capacityreservation/fanout.go b/spinifex/gateway/ec2/capacityreservation/fanout.go index fc4f28aa3..d254e1521 100644 --- a/spinifex/gateway/ec2/capacityreservation/fanout.go +++ b/spinifex/gateway/ec2/capacityreservation/fanout.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "log/slog" + "slices" "time" clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1" @@ -89,22 +90,17 @@ func selectNode(census []nodeCensus, az, instanceType string, count int) string // azInCensus reports whether any node in the census lives in az. A requested AZ // that no node reports is treated as unknown (InvalidAvailabilityZone). func azInCensus(census []nodeCensus, az string) bool { - for _, n := range census { - if n.AZ == az { - return true - } - } - return false + return slices.ContainsFunc(census, func(n nodeCensus) bool { + return n.AZ == az + }) } // typeInCensus reports whether instanceType is in any node's catalog. Known types // appear in node status even at zero available count, so an absent key means the type // is unsupported (or GPU, excluded from schedulable capacity) — rejected as InvalidInstanceType. func typeInCensus(census []nodeCensus, instanceType string) bool { - for _, n := range census { - if _, ok := n.Available[instanceType]; ok { - return true - } - } - return false + return slices.ContainsFunc(census, func(n nodeCensus) bool { + _, ok := n.Available[instanceType] + return ok + }) } diff --git a/spinifex/gateway/ec2/instance/DescribeInstanceStatus.go b/spinifex/gateway/ec2/instance/DescribeInstanceStatus.go index d53101dc9..f55e3a5c5 100644 --- a/spinifex/gateway/ec2/instance/DescribeInstanceStatus.go +++ b/spinifex/gateway/ec2/instance/DescribeInstanceStatus.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log/slog" + "slices" "strconv" "time" @@ -161,12 +162,9 @@ func filterStoppedStatuses(in []*ec2.InstanceStatus, filters []*ec2.Filter, az s } func filterValueMatches(values []*string, field string) bool { - for _, v := range values { - if v != nil && *v == field { - return true - } - } - return false + return slices.ContainsFunc(values, func(v *string) bool { + return v != nil && *v == field + }) } func buildInstanceStatusFromInstance(inst *ec2.Instance, az string) *ec2.InstanceStatus { diff --git a/spinifex/gateway/ec2/instance/DescribeInstanceTypeOfferings.go b/spinifex/gateway/ec2/instance/DescribeInstanceTypeOfferings.go index 46994c3d0..3e996e358 100644 --- a/spinifex/gateway/ec2/instance/DescribeInstanceTypeOfferings.go +++ b/spinifex/gateway/ec2/instance/DescribeInstanceTypeOfferings.go @@ -15,8 +15,8 @@ import ( "github.com/nats-io/nats.go" ) -// defaultOfferingsPerPage caps a request that names no MaxResults. -const defaultOfferingsPerPage = 1000 +// defaultPageSize caps a paged request that names no MaxResults. +const defaultPageSize = 1000 var describeInstanceTypeOfferingsValidFilters = map[string]bool{ "instance-type": true, @@ -50,7 +50,7 @@ func DescribeInstanceTypeOfferings(ctx context.Context, input *ec2.DescribeInsta // No capacity filter: an offering is what the platform supports, not what // has a free slot at this instant. - catalogue, err := DescribeInstanceTypes(ctx, &ec2.DescribeInstanceTypesInput{}, natsConn, expectedNodes, accountID) + catalogue, err := DescribeInstanceTypes(ctx, &ec2.DescribeInstanceTypesInput{}, natsConn, expectedNodes, nil, accountID) if err != nil { return nil, err } @@ -83,7 +83,7 @@ func DescribeInstanceTypeOfferings(ctx context.Context, input *ec2.DescribeInsta return *offerings[i].Location < *offerings[j].Location }) - page, nextToken, err := pageOfferings(offerings, input.NextToken, input.MaxResults) + page, nextToken, err := pageByOffset(offerings, input.NextToken, input.MaxResults) if err != nil { slog.WarnContext(ctx, "DescribeInstanceTypeOfferings: invalid NextToken", "next_token", aws.StringValue(input.NextToken)) return nil, err @@ -121,9 +121,9 @@ func offeringLocations(locationType, region, az string) ([]string, error) { return locations, nil } -// pageOfferings slices one page out of a sorted offering set, using an opaque -// integer offset as the token. The last page carries a nil token. -func pageOfferings(offerings []*ec2.InstanceTypeOffering, token *string, maxResults *int64) ([]*ec2.InstanceTypeOffering, *string, error) { +// pageByOffset slices one page out of a sorted set, using an opaque integer +// offset as the token. The last page carries a nil token. +func pageByOffset[T any](items []T, token *string, maxResults *int64) ([]T, *string, error) { start := 0 if aws.StringValue(token) != "" { n, err := strconv.Atoi(*token) @@ -132,18 +132,18 @@ func pageOfferings(offerings []*ec2.InstanceTypeOffering, token *string, maxResu } start = n } - if start > len(offerings) { - start = len(offerings) + if start > len(items) { + start = len(items) } - size := defaultOfferingsPerPage + size := defaultPageSize if maxResults != nil && *maxResults > 0 { size = int(*maxResults) } end := start + size - if end >= len(offerings) { - return offerings[start:], nil, nil + if end >= len(items) { + return items[start:], nil, nil } - return offerings[start:end], aws.String(strconv.Itoa(end)), nil + return items[start:end], aws.String(strconv.Itoa(end)), nil } diff --git a/spinifex/gateway/ec2/instance/DescribeInstanceTypeOfferings_test.go b/spinifex/gateway/ec2/instance/DescribeInstanceTypeOfferings_test.go index 02e686d70..f432b42e9 100644 --- a/spinifex/gateway/ec2/instance/DescribeInstanceTypeOfferings_test.go +++ b/spinifex/gateway/ec2/instance/DescribeInstanceTypeOfferings_test.go @@ -44,7 +44,7 @@ func TestDescribeInstanceTypeOfferings_AvailabilityZone(t *testing.T) { _, nc := startTestNATSServer(t) serveInstanceTypes(t, nc, "t3.micro", "m5.large") - types, err := DescribeInstanceTypes(context.Background(), &ec2.DescribeInstanceTypesInput{}, nc, 1, "") + types, err := DescribeInstanceTypes(context.Background(), &ec2.DescribeInstanceTypesInput{}, nc, 1, nil, "") require.NoError(t, err) zones, err := gateway_ec2_zone.DescribeAvailabilityZones(&ec2.DescribeAvailabilityZonesInput{}, offeringsTestRegion, offeringsTestAZ) require.NoError(t, err) diff --git a/spinifex/gateway/ec2/instance/DescribeInstanceTypes.go b/spinifex/gateway/ec2/instance/DescribeInstanceTypes.go index 6216bd733..8919751af 100644 --- a/spinifex/gateway/ec2/instance/DescribeInstanceTypes.go +++ b/spinifex/gateway/ec2/instance/DescribeInstanceTypes.go @@ -3,34 +3,55 @@ package gateway_ec2_instance import ( "context" "encoding/json" + "errors" "fmt" "log/slog" + "sort" + "strings" "time" + "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ec2" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" + filterutil "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters" "github.com/mulgadc/spinifex/spinifex/utils" "github.com/nats-io/nats.go" ) // DescribeInstanceTypes fans out to all nodes and aggregates instance type info. -func DescribeInstanceTypes(ctx context.Context, input *ec2.DescribeInstanceTypesInput, natsConn *nats.Conn, expectedNodes int, accountID string) (*ec2.DescribeInstanceTypesOutput, error) { +// nodeIDs is the configured node set: a named type is reported as not existing +// only when every one of those nodes answered, since any of them may host it. +func DescribeInstanceTypes(ctx context.Context, input *ec2.DescribeInstanceTypesInput, natsConn *nats.Conn, expectedNodes int, nodeIDs []string, accountID string) (*ec2.DescribeInstanceTypesOutput, error) { jsonData, err := json.Marshal(input) if err != nil { slog.ErrorContext(ctx, "DescribeInstanceTypes: Failed to marshal input", "err", err) return nil, fmt.Errorf("failed to marshal input: %w", err) } - frames, _, err := utils.Gather(ctx, natsConn, "ec2.DescribeInstanceTypes", jsonData, - utils.GatherOpts{Timeout: 3 * time.Second, ExpectedNodes: expectedNodes, AccountID: accountID}) + // Only a request naming types can assert absence, so only it waits on + // every configured node by identity. + gatherOpts := utils.GatherOpts{Timeout: 3 * time.Second, AccountID: accountID} + proveAbsence := len(input.InstanceTypes) > 0 && len(nodeIDs) > 0 + if proveAbsence { + gatherOpts.ExpectedResponders = len(nodeIDs) + } else { + gatherOpts.ExpectedNodes = expectedNodes + } + + frames, sum, err := utils.Gather(ctx, natsConn, "ec2.DescribeInstanceTypes", jsonData, gatherOpts) if err != nil { return nil, err } var allInstanceTypes []*ec2.InstanceTypeInfo + answered := make(map[string]bool, len(frames)) for _, frame := range frames { var nodeOutput ec2.DescribeInstanceTypesOutput if json.Unmarshal(frame.Data, &nodeOutput) == nil { allInstanceTypes = append(allInstanceTypes, nodeOutput.InstanceTypes...) + if frame.NodeID != "" { + answered[frame.NodeID] = true + } } } @@ -77,12 +98,97 @@ func DescribeInstanceTypes(ctx context.Context, input *ec2.DescribeInstanceTypes } } finalInstanceTypes = filtered + + if missing := missingInstanceTypes(finalInstanceTypes, input.InstanceTypes); len(missing) > 0 { + if !proveAbsence || !everyNodeAnswered(nodeIDs, answered, sum) { + return nil, errors.Join( + awserrors.Errorf(awserrors.ErrorServiceUnavailable, + "cannot confirm instance type(s) %s: describe fan-out did not complete", strings.Join(missing, ", ")), + awserrors.RetryAfter(awserrors.ErrorServiceUnavailable, describeIncompleteRetryAfter), + ) + } + return nil, awserrors.Errorf(awserrors.ErrorInvalidInstanceType, + "The following supplied instance types do not exist: [%s]", strings.Join(missing, ", ")) + } } - output := &ec2.DescribeInstanceTypesOutput{ - InstanceTypes: finalInstanceTypes, + if values := instanceTypeFilterValues(input.Filters); values != nil { + var filtered []*ec2.InstanceTypeInfo + for _, it := range finalInstanceTypes { + if it != nil && it.InstanceType != nil && filterutil.MatchesAny(values, *it.InstanceType) { + filtered = append(filtered, it) + } + } + finalInstanceTypes = filtered + } + + // Fan-out replies arrive in any order; a page boundary needs a stable one. + sort.SliceStable(finalInstanceTypes, func(i, j int) bool { + return aws.StringValue(finalInstanceTypes[i].InstanceType) < aws.StringValue(finalInstanceTypes[j].InstanceType) + }) + + output := &ec2.DescribeInstanceTypesOutput{InstanceTypes: finalInstanceTypes} + + // Unpaged unless asked: the capacity listing the UI reads has one entry per + // free slot and does not follow NextToken. + if input.MaxResults != nil || aws.StringValue(input.NextToken) != "" { + page, nextToken, err := pageByOffset(finalInstanceTypes, input.NextToken, input.MaxResults) + if err != nil { + return nil, err + } + output.InstanceTypes = page + output.NextToken = nextToken } slog.InfoContext(ctx, "DescribeInstanceTypes: Aggregated response", "total_instance_types", len(finalInstanceTypes), "show_capacity", showCapacity) return output, nil } + +// missingInstanceTypes returns the requested types absent from found, in the +// caller's order and without repeats. +func missingInstanceTypes(found []*ec2.InstanceTypeInfo, requested []*string) []string { + present := make(map[string]bool, len(found)) + for _, it := range found { + present[aws.StringValue(it.InstanceType)] = true + } + var missing []string + for _, r := range requested { + if r == nil || present[*r] { + continue + } + present[*r] = true + missing = append(missing, *r) + } + return missing +} + +// everyNodeAnswered reports whether each configured node sent a usable reply +// and none sent an error, so a type absent from the aggregate is absent. +func everyNodeAnswered(nodeIDs []string, answered map[string]bool, sum utils.Summary) bool { + for _, id := range nodeIDs { + if !answered[id] || sum.ErrorResponders[id] { + return false + } + } + return true +} + +// instanceTypeFilterValues returns the values of every instance-type filter, or +// nil when there is none. Values may carry AWS wildcards. +func instanceTypeFilterValues(filters []*ec2.Filter) []string { + var values []string + for _, f := range filters { + if f == nil || aws.StringValue(f.Name) != "instance-type" { + continue + } + if values == nil { + values = []string{} + } + for _, v := range f.Values { + if v != nil { + values = append(values, *v) + } + } + } + return values +} diff --git a/spinifex/gateway/ec2/instance/DescribeInstanceTypes_test.go b/spinifex/gateway/ec2/instance/DescribeInstanceTypes_test.go index d920f5b15..edfdd78de 100644 --- a/spinifex/gateway/ec2/instance/DescribeInstanceTypes_test.go +++ b/spinifex/gateway/ec2/instance/DescribeInstanceTypes_test.go @@ -7,6 +7,7 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ec2" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "github.com/mulgadc/spinifex/spinifex/utils" "github.com/nats-io/nats.go" "github.com/stretchr/testify/assert" @@ -28,7 +29,7 @@ func TestDescribeInstanceTypes_SingleNode(t *testing.T) { }) input := &ec2.DescribeInstanceTypesInput{} - output, err := DescribeInstanceTypes(context.Background(), input, nc, 1, "") + output, err := DescribeInstanceTypes(context.Background(), input, nc, 1, nil, "") require.NoError(t, err) require.NotNil(t, output) @@ -71,7 +72,7 @@ func TestDescribeInstanceTypes_DeduplicatesAcrossNodes(t *testing.T) { nc2.Flush() input := &ec2.DescribeInstanceTypesInput{} - output, err := DescribeInstanceTypes(context.Background(), input, nc, 2, "") + output, err := DescribeInstanceTypes(context.Background(), input, nc, 2, nil, "") require.NoError(t, err) require.NotNil(t, output) @@ -127,7 +128,7 @@ func TestDescribeInstanceTypes_CapacityFilterShowsDuplicates(t *testing.T) { }, }, } - output, err := DescribeInstanceTypes(context.Background(), input, nc, 2, "") + output, err := DescribeInstanceTypes(context.Background(), input, nc, 2, nil, "") require.NoError(t, err) require.NotNil(t, output) @@ -158,7 +159,7 @@ func TestDescribeInstanceTypes_CapacityFilterFalseDeduplicates(t *testing.T) { }, }, } - output, err := DescribeInstanceTypes(context.Background(), input, nc, 1, "") + output, err := DescribeInstanceTypes(context.Background(), input, nc, 1, nil, "") require.NoError(t, err) require.NotNil(t, output) @@ -170,7 +171,7 @@ func TestDescribeInstanceTypes_NoSubscribers(t *testing.T) { _, nc := startTestNATSServer(t) input := &ec2.DescribeInstanceTypesInput{} - output, err := DescribeInstanceTypes(context.Background(), input, nc, 0, "") + output, err := DescribeInstanceTypes(context.Background(), input, nc, 0, nil, "") require.NoError(t, err) require.NotNil(t, output) @@ -187,7 +188,7 @@ func TestDescribeInstanceTypes_NodeReturnsError(t *testing.T) { }) input := &ec2.DescribeInstanceTypesInput{} - output, err := DescribeInstanceTypes(context.Background(), input, nc, 1, "") + output, err := DescribeInstanceTypes(context.Background(), input, nc, 1, nil, "") require.NoError(t, err) require.NotNil(t, output) @@ -203,7 +204,7 @@ func TestDescribeInstanceTypes_MalformedJSON(t *testing.T) { }) input := &ec2.DescribeInstanceTypesInput{} - output, err := DescribeInstanceTypes(context.Background(), input, nc, 1, "") + output, err := DescribeInstanceTypes(context.Background(), input, nc, 1, nil, "") require.NoError(t, err) require.NotNil(t, output) @@ -226,7 +227,7 @@ func TestDescribeInstanceTypes_NilInstanceTypeSkipped(t *testing.T) { }) input := &ec2.DescribeInstanceTypesInput{} - output, err := DescribeInstanceTypes(context.Background(), input, nc, 1, "") + output, err := DescribeInstanceTypes(context.Background(), input, nc, 1, nil, "") require.NoError(t, err) require.NotNil(t, output) @@ -243,7 +244,114 @@ func TestDescribeInstanceTypes_ClosedConnection(t *testing.T) { closedNC.Close() input := &ec2.DescribeInstanceTypesInput{} - _, err = DescribeInstanceTypes(context.Background(), input, closedNC, 1, "") + _, err = DescribeInstanceTypes(context.Background(), input, closedNC, 1, nil, "") require.Error(t, err) } + +func instanceTypesPayload(t *testing.T, names ...string) []byte { + t.Helper() + out := &ec2.DescribeInstanceTypesOutput{} + for _, n := range names { + out.InstanceTypes = append(out.InstanceTypes, &ec2.InstanceTypeInfo{InstanceType: aws.String(n)}) + } + data, err := json.Marshal(out) + require.NoError(t, err) + return data +} + +func instanceTypeNames(out *ec2.DescribeInstanceTypesOutput) []string { + var names []string + for _, it := range out.InstanceTypes { + names = append(names, *it.InstanceType) + } + return names +} + +// AWS pages on MaxResults and NextToken; the aggregate is sorted so a page +// boundary holds across calls whatever order the nodes reply in. +func TestDescribeInstanceTypes_Paging(t *testing.T) { + t.Parallel() + _, nc := startTestNATSServer(t) + subscribeAsNode(t, nc, "ec2.DescribeInstanceTypes", "n1", + instanceTypesPayload(t, "t3.small", "m5.large", "t3.micro", "c5.large", "t3.nano", "m5.xlarge", "c5.xlarge")) + + page1, err := DescribeInstanceTypes(context.Background(), &ec2.DescribeInstanceTypesInput{MaxResults: aws.Int64(5)}, nc, 1, nil, "") + require.NoError(t, err) + assert.Equal(t, []string{"c5.large", "c5.xlarge", "m5.large", "m5.xlarge", "t3.micro"}, instanceTypeNames(page1)) + require.NotNil(t, page1.NextToken) + + page2, err := DescribeInstanceTypes(context.Background(), &ec2.DescribeInstanceTypesInput{MaxResults: aws.Int64(5), NextToken: page1.NextToken}, nc, 1, nil, "") + require.NoError(t, err) + assert.Equal(t, []string{"t3.nano", "t3.small"}, instanceTypeNames(page2)) + assert.Nil(t, page2.NextToken) + + all, err := DescribeInstanceTypes(context.Background(), &ec2.DescribeInstanceTypesInput{}, nc, 1, nil, "") + require.NoError(t, err) + assert.Len(t, all.InstanceTypes, 7) + assert.Nil(t, all.NextToken) +} + +func TestDescribeInstanceTypes_InstanceTypeFilter(t *testing.T) { + t.Parallel() + _, nc := startTestNATSServer(t) + subscribeAsNode(t, nc, "ec2.DescribeInstanceTypes", "n1", + instanceTypesPayload(t, "t3.micro", "t3.small", "m5.large", "c5.large")) + + for _, tc := range []struct { + values []string + want []string + }{ + {[]string{"t3.micro"}, []string{"t3.micro"}}, + {[]string{"t3.*"}, []string{"t3.micro", "t3.small"}}, + {[]string{"m5.large", "c5.large"}, []string{"c5.large", "m5.large"}}, + {[]string{"zz9.bogus"}, nil}, + } { + out, err := DescribeInstanceTypes(context.Background(), &ec2.DescribeInstanceTypesInput{ + Filters: []*ec2.Filter{{Name: aws.String("instance-type"), Values: aws.StringSlice(tc.values)}}, + }, nc, 1, nil, "") + require.NoError(t, err) + assert.Equal(t, tc.want, instanceTypeNames(out), tc.values) + } +} + +// A named type no node has is InvalidInstanceType, as on AWS, once every +// configured node has answered. +func TestDescribeInstanceTypes_UnknownNamedType(t *testing.T) { + t.Parallel() + _, nc := startTestNATSServer(t) + subscribeAsNode(t, nc, "ec2.DescribeInstanceTypes", "n1", instanceTypesPayload(t, "t3.micro")) + subscribeAsNode(t, nc, "ec2.DescribeInstanceTypes", "n2", instanceTypesPayload(t, "m5.large")) + nodes := []string{"n1", "n2"} + + out, err := DescribeInstanceTypes(context.Background(), &ec2.DescribeInstanceTypesInput{ + InstanceTypes: aws.StringSlice([]string{"m5.large", "t3.micro"}), + }, nc, 0, nodes, "") + require.NoError(t, err) + assert.Equal(t, []string{"m5.large", "t3.micro"}, instanceTypeNames(out)) + + _, err = DescribeInstanceTypes(context.Background(), &ec2.DescribeInstanceTypesInput{ + InstanceTypes: aws.StringSlice([]string{"t3.micro", "zz9.bogus"}), + }, nc, 0, nodes, "") + code, msg, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok) + assert.Equal(t, awserrors.ErrorInvalidInstanceType, code) + assert.Equal(t, "The following supplied instance types do not exist: [zz9.bogus]", msg) +} + +// Without a reply from every configured node a missing type may be on the +// node that did not answer, so absence is not asserted. +func TestDescribeInstanceTypes_UnknownNamedTypeIncompleteFanout(t *testing.T) { + t.Parallel() + _, nc := startTestNATSServer(t) + subscribeAsNode(t, nc, "ec2.DescribeInstanceTypes", "n1", instanceTypesPayload(t, "t3.micro")) + subscribeAsNode(t, nc, "ec2.DescribeInstanceTypes", "n2", utils.GenerateErrorPayload("InternalError")) + input := &ec2.DescribeInstanceTypesInput{InstanceTypes: aws.StringSlice([]string{"m5.large"})} + + for _, nodes := range [][]string{{"n1", "n2"}, nil} { + _, err := DescribeInstanceTypes(context.Background(), input, nc, 2, nodes, "") + code, _, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok) + assert.Equal(t, awserrors.ErrorServiceUnavailable, code, nodes) + } +} diff --git a/spinifex/gateway/ec2/instance/RunInstances.go b/spinifex/gateway/ec2/instance/RunInstances.go index b6b71554b..1d11f1db4 100644 --- a/spinifex/gateway/ec2/instance/RunInstances.go +++ b/spinifex/gateway/ec2/instance/RunInstances.go @@ -4,6 +4,7 @@ import ( "context" "errors" "log/slog" + "slices" "strings" "time" @@ -96,12 +97,9 @@ func hasPinnedPrivateIP(input *ec2.RunInstancesInput) bool { if aws.StringValue(nic.PrivateIpAddress) != "" { return true } - for _, spec := range nic.PrivateIpAddresses { - if spec != nil && aws.StringValue(spec.PrivateIpAddress) != "" { - return true - } - } - return false + return slices.ContainsFunc(nic.PrivateIpAddresses, func(spec *ec2.PrivateIpAddressSpecification) bool { + return spec != nil && aws.StringValue(spec.PrivateIpAddress) != "" + }) } // RunInstances validates input, resolves any IAM instance profile (normalising @@ -313,10 +311,7 @@ func isKnownInstanceType(ctx context.Context, natsConn *nats.Conn, instanceType if err != nil || result == nil { return false } - for _, t := range result.InstanceTypes { - if t.InstanceType != nil && *t.InstanceType == instanceType { - return true - } - } - return false + return slices.ContainsFunc(result.InstanceTypes, func(t *ec2.InstanceTypeInfo) bool { + return t.InstanceType != nil && *t.InstanceType == instanceType + }) } diff --git a/spinifex/gateway/ec2/tags/CreateTags.go b/spinifex/gateway/ec2/tags/CreateTags.go index 2c2d7d17e..6abe199e4 100644 --- a/spinifex/gateway/ec2/tags/CreateTags.go +++ b/spinifex/gateway/ec2/tags/CreateTags.go @@ -3,6 +3,7 @@ package gateway_ec2_tags import ( "context" "errors" + "strings" "github.com/aws/aws-sdk-go/service/ec2" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" @@ -28,6 +29,11 @@ func ValidateCreateTagsInput(input *ec2.CreateTagsInput) error { if tag.Key == nil || *tag.Key == "" { return errors.New(awserrors.ErrorInvalidParameterValue) } + // AWS reserves the prefix in any case. + if strings.HasPrefix(strings.ToLower(*tag.Key), "aws:") { + return awserrors.Errorf(awserrors.ErrorInvalidParameterValue, + "Value ( %s ) for parameter key is invalid. Tag keys starting with 'aws:' are reserved for internal use", *tag.Key) + } } return nil diff --git a/spinifex/gateway/ec2/tags/CreateTags_test.go b/spinifex/gateway/ec2/tags/CreateTags_test.go index b88b136d1..8a4f94ffb 100644 --- a/spinifex/gateway/ec2/tags/CreateTags_test.go +++ b/spinifex/gateway/ec2/tags/CreateTags_test.go @@ -7,6 +7,7 @@ import ( "github.com/aws/aws-sdk-go/service/ec2" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestValidateCreateTagsInput(t *testing.T) { @@ -138,3 +139,21 @@ func TestValidateDeleteTagsInput(t *testing.T) { }) } } + +func TestValidateCreateTagsInput_RejectsReservedAWSPrefix(t *testing.T) { + for _, key := range []string{"aws:foo", "AWS:Foo"} { + err := ValidateCreateTagsInput(&ec2.CreateTagsInput{ + Resources: []*string{aws.String("vpc-1234567890abcdef0")}, + Tags: []*ec2.Tag{{Key: aws.String(key), Value: aws.String("v")}}, + }) + code, msg, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok, "error %v carries no AWS code", err) + assert.Equal(t, awserrors.ErrorInvalidParameterValue, code) + assert.Equal(t, "Value ( "+key+" ) for parameter key is invalid. Tag keys starting with 'aws:' are reserved for internal use", msg) + } + + assert.NoError(t, ValidateCreateTagsInput(&ec2.CreateTagsInput{ + Resources: []*string{aws.String("vpc-1234567890abcdef0")}, + Tags: []*ec2.Tag{{Key: aws.String("team:aws:owner"), Value: aws.String("v")}}, + })) +} diff --git a/spinifex/gateway/ec2/volume/AttachVolume.go b/spinifex/gateway/ec2/volume/AttachVolume.go index cf730d8a5..ed6b2d6f7 100644 --- a/spinifex/gateway/ec2/volume/AttachVolume.go +++ b/spinifex/gateway/ec2/volume/AttachVolume.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "log/slog" + "slices" "time" "github.com/aws/aws-sdk-go/aws" @@ -125,10 +126,7 @@ func isStoppedInstance(ctx context.Context, instanceID string, natsConn *nats.Co return false } - for _, res := range output.Reservations { - if len(res.Instances) > 0 { - return true - } - } - return false + return slices.ContainsFunc(output.Reservations, func(res *ec2.Reservation) bool { + return len(res.Instances) > 0 + }) } diff --git a/spinifex/gateway/ec2/vpc/ModifyNetworkInterfaceAttribute.go b/spinifex/gateway/ec2/vpc/ModifyNetworkInterfaceAttribute.go index be2925599..43c487592 100644 --- a/spinifex/gateway/ec2/vpc/ModifyNetworkInterfaceAttribute.go +++ b/spinifex/gateway/ec2/vpc/ModifyNetworkInterfaceAttribute.go @@ -17,14 +17,7 @@ func ValidateModifyNetworkInterfaceAttributeInput(input *ec2.ModifyNetworkInterf if input.NetworkInterfaceId == nil || *input.NetworkInterfaceId == "" { return errors.New(awserrors.ErrorMissingParameter) } - if len(input.Groups) == 0 && input.Description == nil && input.SourceDestCheck == nil { - return errors.New(awserrors.ErrorInvalidParameterValue) - } - // Disabling source/dest check is unsupported: OVN port security enforces it. - if input.SourceDestCheck != nil && input.SourceDestCheck.Value != nil && !*input.SourceDestCheck.Value { - return errors.New(awserrors.ErrorUnsupported) - } - return nil + return handlers_ec2_vpc.ValidateModifyNetworkInterfaceAttributeAttributes(input) } // ModifyNetworkInterfaceAttribute handles the EC2 ModifyNetworkInterfaceAttribute API call. diff --git a/spinifex/gateway/ec2/vpc/ModifyNetworkInterfaceAttribute_test.go b/spinifex/gateway/ec2/vpc/ModifyNetworkInterfaceAttribute_test.go index 9797a7a17..c183a7520 100644 --- a/spinifex/gateway/ec2/vpc/ModifyNetworkInterfaceAttribute_test.go +++ b/spinifex/gateway/ec2/vpc/ModifyNetworkInterfaceAttribute_test.go @@ -57,3 +57,16 @@ func TestModifyNetworkInterfaceAttribute_NilNATS(t *testing.T) { }, nil, "123456789012") assert.Error(t, err) } + +// AWS rejects the combination before judging any one attribute, so disabling +// source/dest check alongside a description is not reported as Unsupported. +func TestModifyNetworkInterfaceAttribute_CombinationBeforeUnsupported(t *testing.T) { + err := ValidateModifyNetworkInterfaceAttributeInput(&ec2.ModifyNetworkInterfaceAttributeInput{ + NetworkInterfaceId: aws.String("eni-abc123"), + SourceDestCheck: &ec2.AttributeBooleanValue{Value: aws.Bool(false)}, + Description: &ec2.AttributeValue{Value: aws.String("desc")}, + }) + code, ok := awserrors.ResolveErrorCode(err) + assert.True(t, ok, "error %v carries no AWS code", err) + assert.Equal(t, awserrors.ErrorInvalidParameterCombination, code) +} diff --git a/spinifex/gateway/ec2/zone/AvailabilityZone.go b/spinifex/gateway/ec2/zone/AvailabilityZone.go index 37a41e10f..7d713685a 100644 --- a/spinifex/gateway/ec2/zone/AvailabilityZone.go +++ b/spinifex/gateway/ec2/zone/AvailabilityZone.go @@ -1,28 +1,73 @@ package gateway_ec2_zone import ( + "slices" + "strings" + "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ec2" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" + filterutil "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters" handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc" ) +// The filter names AWS accepts on each call; any other, tag filters included, +// is refused. +var ( + describeAvailabilityZonesValidFilters = map[string]bool{ + "group-long-name": true, + "group-name": true, + "message": true, + "opt-in-status": true, + "parent-zone-id": true, + "parent-zone-name": true, + "region-name": true, + "state": true, + "zone-id": true, + "zone-name": true, + "zone-type": true, + } + describeRegionsValidFilters = map[string]bool{ + "endpoint": true, + "opt-in-status": true, + "region-name": true, + } +) + func DescribeAvailabilityZones(input *ec2.DescribeAvailabilityZonesInput, region string, az string) (output *ec2.DescribeAvailabilityZonesOutput, err error) { - output = &ec2.DescribeAvailabilityZonesOutput{ - AvailabilityZones: []*ec2.AvailabilityZone{ - { - State: aws.String("available"), - OptInStatus: aws.String("opt-in-not-required"), - RegionName: aws.String(region), - ZoneName: aws.String(az), - ZoneId: aws.String(handlers_ec2_vpc.SingleZoneID), - GroupName: aws.String(region), - NetworkBorderGroup: aws.String(region), - ZoneType: aws.String("availability-zone"), - Messages: []*ec2.AvailabilityZoneMessage{}, - }, - }, + filters, err := parseFilters(input.Filters, describeAvailabilityZonesValidFilters) + if err != nil { + return nil, err } + zone := &ec2.AvailabilityZone{ + State: aws.String("available"), + OptInStatus: aws.String("opt-in-not-required"), + RegionName: aws.String(region), + ZoneName: aws.String(az), + ZoneId: aws.String(handlers_ec2_vpc.SingleZoneID), + GroupName: aws.String(region), + NetworkBorderGroup: aws.String(region), + ZoneType: aws.String("availability-zone"), + Messages: []*ec2.AvailabilityZoneMessage{}, + } + + // AWS names the first unknown zone in the order the caller gave them. + for _, name := range aws.StringValueSlice(input.ZoneNames) { + if name != az { + return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "Invalid availability zone: [%s]", name) + } + } + for _, id := range aws.StringValueSlice(input.ZoneIds) { + if id != handlers_ec2_vpc.SingleZoneID { + return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "Invalid availability zone-id: [%s]", id) + } + } + + output = &ec2.DescribeAvailabilityZonesOutput{AvailabilityZones: []*ec2.AvailabilityZone{}} + if matchesFilters(filters, zoneFilterValues(zone)) { + output.AvailabilityZones = append(output.AvailabilityZones, zone) + } return output, nil } @@ -30,15 +75,74 @@ func DescribeAvailabilityZones(input *ec2.DescribeAvailabilityZonesInput, region // caller resolves endpoint from the gateway's advertised host so a remote // workload is never pointed at its own loopback. func DescribeRegions(input *ec2.DescribeRegionsInput, region string, endpoint string) (output *ec2.DescribeRegionsOutput, err error) { - output = &ec2.DescribeRegionsOutput{ - Regions: []*ec2.Region{ - { - Endpoint: aws.String(endpoint), - RegionName: aws.String(region), - OptInStatus: aws.String("opt-in-not-required"), - }, - }, + filters, err := parseFilters(input.Filters, describeRegionsValidFilters) + if err != nil { + return nil, err } + // AWS names the alphabetically first unknown Region, not the first given. + var unknown []string + for _, name := range aws.StringValueSlice(input.RegionNames) { + if name != region { + unknown = append(unknown, name) + } + } + if len(unknown) > 0 { + return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "Invalid region: [%s]", slices.Min(unknown)) + } + + r := &ec2.Region{ + Endpoint: aws.String(endpoint), + RegionName: aws.String(region), + OptInStatus: aws.String("opt-in-not-required"), + } + output = &ec2.DescribeRegionsOutput{Regions: []*ec2.Region{}} + if matchesFilters(filters, map[string][]string{ + "endpoint": {endpoint}, + "opt-in-status": {aws.StringValue(r.OptInStatus)}, + "region-name": {region}, + }) { + output.Regions = append(output.Regions, r) + } return output, nil } + +// parseFilters is filterutil.ParseFilters without its blanket acceptance of +// tag filters, which AWS refuses on both calls. +func parseFilters(filters []*ec2.Filter, valid map[string]bool) (map[string][]string, error) { + for _, f := range filters { + if name := aws.StringValue(f.Name); strings.HasPrefix(name, "tag:") { + return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "The filter '%s' is invalid", name) + } + } + return filterutil.ParseFilters(filters, valid) +} + +// zoneFilterValues maps each filter name to the zone's values for it. A name +// with no values, such as parent-zone-id on a zone with no parent, matches nothing. +func zoneFilterValues(zone *ec2.AvailabilityZone) map[string][]string { + values := map[string][]string{ + "group-name": {aws.StringValue(zone.GroupName)}, + "opt-in-status": {aws.StringValue(zone.OptInStatus)}, + "region-name": {aws.StringValue(zone.RegionName)}, + "state": {aws.StringValue(zone.State)}, + "zone-id": {aws.StringValue(zone.ZoneId)}, + "zone-name": {aws.StringValue(zone.ZoneName)}, + "zone-type": {aws.StringValue(zone.ZoneType)}, + } + for _, m := range zone.Messages { + values["message"] = append(values["message"], aws.StringValue(m.Message)) + } + return values +} + +// matchesFilters reports whether every filter matches one of the resource's +// values for that name. +func matchesFilters(filters map[string][]string, values map[string][]string) bool { + for name, patterns := range filters { + if !slices.ContainsFunc(values[name], func(v string) bool { return filterutil.MatchesAny(patterns, v) }) { + return false + } + } + return true +} diff --git a/spinifex/gateway/ec2/zone/zone_test.go b/spinifex/gateway/ec2/zone/zone_test.go index 458457bd6..257dcd7fd 100644 --- a/spinifex/gateway/ec2/zone/zone_test.go +++ b/spinifex/gateway/ec2/zone/zone_test.go @@ -3,7 +3,9 @@ package gateway_ec2_zone import ( "testing" + "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ec2" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -85,3 +87,96 @@ func TestDescribeRegions(t *testing.T) { }) } } + +func filter(name string, values ...string) []*ec2.Filter { + return []*ec2.Filter{{Name: aws.String(name), Values: aws.StringSlice(values)}} +} + +func requireInvalidParameterValue(t *testing.T, err error, msg string) { + t.Helper() + code, got, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok) + assert.Equal(t, awserrors.ErrorInvalidParameterValue, code) + assert.Equal(t, msg, got) +} + +// Names are checked against the configured catalogue, so a custom Region name +// is as valid as an AWS one. The messages are AWS's own. +func TestDescribeAvailabilityZones_Selection(t *testing.T) { + const region, az = "mulga-west", "mulga-west-a" + + for _, tc := range []struct { + name string + input *ec2.DescribeAvailabilityZonesInput + want int + }{ + {"zone name", &ec2.DescribeAvailabilityZonesInput{ZoneNames: aws.StringSlice([]string{az})}, 1}, + {"zone id", &ec2.DescribeAvailabilityZonesInput{ZoneIds: aws.StringSlice([]string{"spinifexz1"})}, 1}, + {"zone-name filter", &ec2.DescribeAvailabilityZonesInput{Filters: filter("zone-name", az)}, 1}, + {"wildcard filter", &ec2.DescribeAvailabilityZonesInput{Filters: filter("region-name", "mulga-*")}, 1}, + {"state filter", &ec2.DescribeAvailabilityZonesInput{Filters: filter("state", "available")}, 1}, + {"filter matching nothing", &ec2.DescribeAvailabilityZonesInput{Filters: filter("zone-name", "other")}, 0}, + {"filter on an absent field", &ec2.DescribeAvailabilityZonesInput{Filters: filter("parent-zone-id", "*")}, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + out, err := DescribeAvailabilityZones(tc.input, region, az) + require.NoError(t, err) + assert.Len(t, out.AvailabilityZones, tc.want) + }) + } + + for _, tc := range []struct { + name string + input *ec2.DescribeAvailabilityZonesInput + msg string + }{ + {"unknown filter", &ec2.DescribeAvailabilityZonesInput{Filters: filter("bogus-filter", "x")}, "The filter 'bogus-filter' is invalid"}, + {"tag filter", &ec2.DescribeAvailabilityZonesInput{Filters: filter("tag:Name", "x")}, "The filter 'tag:Name' is invalid"}, + {"network-border-group filter", &ec2.DescribeAvailabilityZonesInput{Filters: filter("network-border-group", "x")}, "The filter 'network-border-group' is invalid"}, + {"first unknown name in request order", &ec2.DescribeAvailabilityZonesInput{ZoneNames: aws.StringSlice([]string{az, "zz-nowhere-9z", "yy-nowhere-8y"})}, "Invalid availability zone: [zz-nowhere-9z]"}, + {"unknown zone id", &ec2.DescribeAvailabilityZonesInput{ZoneIds: aws.StringSlice([]string{"spinifexz1", "zz-az9"})}, "Invalid availability zone-id: [zz-az9]"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := DescribeAvailabilityZones(tc.input, region, az) + requireInvalidParameterValue(t, err, tc.msg) + }) + } +} + +func TestDescribeRegions_Selection(t *testing.T) { + const region, endpoint = "mulga-west", "https://10.0.0.1:9999" + + for _, tc := range []struct { + name string + input *ec2.DescribeRegionsInput + want int + }{ + {"region name", &ec2.DescribeRegionsInput{RegionNames: aws.StringSlice([]string{region})}, 1}, + {"region-name filter", &ec2.DescribeRegionsInput{Filters: filter("region-name", region)}, 1}, + {"endpoint filter", &ec2.DescribeRegionsInput{Filters: filter("endpoint", "*10.0.0.1*")}, 1}, + {"filter matching nothing", &ec2.DescribeRegionsInput{Filters: filter("region-name", "zz-nowhere-9")}, 0}, + {"names and a disagreeing filter", &ec2.DescribeRegionsInput{RegionNames: aws.StringSlice([]string{region}), Filters: filter("region-name", "us-east-1")}, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + out, err := DescribeRegions(tc.input, region, endpoint) + require.NoError(t, err) + assert.Len(t, out.Regions, tc.want) + }) + } + + for _, tc := range []struct { + name string + input *ec2.DescribeRegionsInput + msg string + }{ + {"unknown filter", &ec2.DescribeRegionsInput{Filters: filter("bogus-filter", "x")}, "The filter 'bogus-filter' is invalid"}, + {"tag filter", &ec2.DescribeRegionsInput{Filters: filter("tag:Name", "x")}, "The filter 'tag:Name' is invalid"}, + {"an AWS region this cluster is not", &ec2.DescribeRegionsInput{RegionNames: aws.StringSlice([]string{"us-east-1"})}, "Invalid region: [us-east-1]"}, + {"alphabetically first unknown name", &ec2.DescribeRegionsInput{RegionNames: aws.StringSlice([]string{region, "zz-nowhere-9", "yy-nowhere-8"})}, "Invalid region: [yy-nowhere-8]"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := DescribeRegions(tc.input, region, endpoint) + requireInvalidParameterValue(t, err, tc.msg) + }) + } +} diff --git a/spinifex/gateway/ec2_authz_test.go b/spinifex/gateway/ec2_authz_test.go index 3aa5779ca..a9d2039a0 100644 --- a/spinifex/gateway/ec2_authz_test.go +++ b/spinifex/gateway/ec2_authz_test.go @@ -4,14 +4,16 @@ package gateway import ( + "errors" "net/http/httptest" "strconv" "strings" "testing" - "github.com/mulgadc/spinifex/spinifex/ingress/aws/query" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/arn" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" + "github.com/mulgadc/spinifex/spinifex/ingress/aws/query" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -60,6 +62,13 @@ func assertDenied(t *testing.T, err error) { assert.Equal(t, awserrors.ErrorAccessDenied, code) } +// assertUnauthorized asserts EC2's policy denial, UnauthorizedOperation. +func assertUnauthorized(t *testing.T, err error) { + t.Helper() + require.Error(t, err) + assert.Equal(t, awserrors.ErrorUnauthorizedOperation, err.Error()) +} + // assertNotDenied asserts the policy gate passed where the handler failure that // follows is not fixed. It resolves the code for the same reason assertDenied // does: the denial message is no longer the bare code. @@ -88,7 +97,7 @@ func TestEC2Request_ScopedDenyFires(t *testing.T) { statement("Deny", "ec2:TerminateInstances", "arn:aws:ec2:*:*:instance/i-prod"), ) - assertDenied(t, dispatchEC2(t, gw, "Action=TerminateInstances&InstanceId.1=i-prod")) + assertUnauthorized(t, dispatchEC2(t, gw, "Action=TerminateInstances&InstanceId.1=i-prod")) assertPermitted(t, dispatchEC2(t, gw, "Action=TerminateInstances&InstanceId.1=i-dev")) } @@ -100,7 +109,7 @@ func TestEC2Request_ScopedAllowGrants(t *testing.T) { ) assertPermitted(t, dispatchEC2(t, gw, "Action=TerminateInstances&InstanceId.1=i-dev")) - assertDenied(t, dispatchEC2(t, gw, "Action=TerminateInstances&InstanceId.1=i-prod")) + assertUnauthorized(t, dispatchEC2(t, gw, "Action=TerminateInstances&InstanceId.1=i-prod")) } // TestEC2Request_DenyOneMemberFailsTheBatch pins the AWS combining rule: a batch @@ -111,7 +120,7 @@ func TestEC2Request_DenyOneMemberFailsTheBatch(t *testing.T) { statement("Deny", "ec2:TerminateInstances", "arn:aws:ec2:*:*:instance/i-prod"), ) - assertDenied(t, dispatchEC2(t, gw, + assertUnauthorized(t, dispatchEC2(t, gw, "Action=TerminateInstances&InstanceId.1=i-dev&InstanceId.2=i-prod&InstanceId.3=i-other")) assertPermitted(t, dispatchEC2(t, gw, "Action=TerminateInstances&InstanceId.1=i-dev&InstanceId.2=i-other")) @@ -127,8 +136,8 @@ func TestEC2Request_MultiResourceParity(t *testing.T) { statement("Deny", "ec2:RunInstances", "arn:aws:ec2:*:*:subnet/subnet-restricted"), ) - assertDenied(t, dispatchEC2(t, gw, "Action=RunInstances&ImageId=ami-restricted&MinCount=1&MaxCount=1")) - assertDenied(t, dispatchEC2(t, gw, + assertUnauthorized(t, dispatchEC2(t, gw, "Action=RunInstances&ImageId=ami-restricted&MinCount=1&MaxCount=1")) + assertUnauthorized(t, dispatchEC2(t, gw, "Action=RunInstances&ImageId=ami-allowed&NetworkInterface.1.SubnetId=subnet-restricted&MinCount=1&MaxCount=1")) assertPermitted(t, dispatchEC2(t, gw, "Action=RunInstances&ImageId=ami-allowed&MinCount=1&MaxCount=1")) } @@ -139,7 +148,7 @@ func TestEC2Request_UsesCanonicalParsedResources(t *testing.T) { statement("Allow", "ec2:*", "*"), statement("Deny", "ec2:ModifyInstanceAttribute", "arn:aws:ec2:*:*:instance/i-prod"), ) - assertDenied(t, dispatchEC2(t, gw, "Action=ModifyInstanceAttribute&instanceId=i-prod")) + assertUnauthorized(t, dispatchEC2(t, gw, "Action=ModifyInstanceAttribute&instanceId=i-prod")) }) t.Run("location name list wrapper", func(t *testing.T) { @@ -147,7 +156,7 @@ func TestEC2Request_UsesCanonicalParsedResources(t *testing.T) { statement("Allow", "ec2:*", "*"), statement("Deny", "ec2:TerminateInstances", "arn:aws:ec2:*:*:instance/i-prod"), ) - assertDenied(t, dispatchEC2(t, gw, "Action=TerminateInstances&InstanceId.InstanceId.1=i-prod")) + assertUnauthorized(t, dispatchEC2(t, gw, "Action=TerminateInstances&InstanceId.InstanceId.1=i-prod")) }) t.Run("handler field precedence", func(t *testing.T) { @@ -155,7 +164,7 @@ func TestEC2Request_UsesCanonicalParsedResources(t *testing.T) { statement("Allow", "ec2:*", "*"), statement("Deny", "ec2:DeleteKeyPair", "arn:aws:ec2:*:*:key-pair/key-prod"), ) - assertDenied(t, dispatchEC2(t, gw, "Action=DeleteKeyPair&KeyName=dev&KeyPairId=key-prod")) + assertUnauthorized(t, dispatchEC2(t, gw, "Action=DeleteKeyPair&KeyName=dev&KeyPairId=key-prod")) }) } @@ -164,7 +173,7 @@ func TestEC2Request_CreateRouteChecksNATGateway(t *testing.T) { statement("Allow", "ec2:*", "*"), statement("Deny", "ec2:CreateRoute", "arn:aws:ec2:*:*:natgateway/nat-prod"), ) - assertDenied(t, dispatchEC2(t, gw, "Action=CreateRoute&RouteTableId=rtb-dev&NatGatewayId=nat-prod")) + assertUnauthorized(t, dispatchEC2(t, gw, "Action=CreateRoute&RouteTableId=rtb-dev&NatGatewayId=nat-prod")) } func TestEC2Request_RequestSpotInstancesChecksLaunchResources(t *testing.T) { @@ -174,7 +183,7 @@ func TestEC2Request_RequestSpotInstancesChecksLaunchResources(t *testing.T) { ) body := "Action=RequestSpotInstances&LaunchSpecification.ImageId=ami-1&" + "LaunchSpecification.InstanceType=t3.micro&LaunchSpecification.SubnetId=subnet-prod" - assertDenied(t, dispatchEC2(t, gw, body)) + assertUnauthorized(t, dispatchEC2(t, gw, body)) } // TestEC2Request_GetSecurityGroupsForVpcIsVpcScoped pins the one read action @@ -186,7 +195,7 @@ func TestEC2Request_GetSecurityGroupsForVpcIsVpcScoped(t *testing.T) { statement("Deny", "ec2:GetSecurityGroupsForVpc", "arn:aws:ec2:*:*:vpc/vpc-prod"), ) - assertDenied(t, dispatchEC2(t, gw, "Action=GetSecurityGroupsForVpc&VpcId=vpc-prod")) + assertUnauthorized(t, dispatchEC2(t, gw, "Action=GetSecurityGroupsForVpc&VpcId=vpc-prod")) assertPermitted(t, dispatchEC2(t, gw, "Action=GetSecurityGroupsForVpc&VpcId=vpc-dev")) } @@ -200,7 +209,7 @@ func TestEC2Request_UpdateSecurityGroupRuleDescriptionsIsGroupScoped(t *testing. ) for _, action := range []string{"UpdateSecurityGroupRuleDescriptionsIngress", "UpdateSecurityGroupRuleDescriptionsEgress"} { - assertDenied(t, dispatchEC2(t, gw, "Action="+action+"&GroupId=sg-prod")) + assertUnauthorized(t, dispatchEC2(t, gw, "Action="+action+"&GroupId=sg-prod")) assertPermitted(t, dispatchEC2(t, gw, "Action="+action+"&GroupId=sg-dev")) } } @@ -217,7 +226,7 @@ func TestEC2Request_ModifySecurityGroupRulesIsGroupScoped(t *testing.T) { "&SecurityGroupRule.1.SecurityGroupRule.IpProtocol=tcp" + "&SecurityGroupRule.1.SecurityGroupRule.FromPort=22&SecurityGroupRule.1.SecurityGroupRule.ToPort=22" + "&SecurityGroupRule.1.SecurityGroupRule.CidrIpv4=10.0.0.0/24" - assertDenied(t, dispatchEC2(t, gw, "Action=ModifySecurityGroupRules&GroupId=sg-prod"+update)) + assertUnauthorized(t, dispatchEC2(t, gw, "Action=ModifySecurityGroupRules&GroupId=sg-prod"+update)) assertPermitted(t, dispatchEC2(t, gw, "Action=ModifySecurityGroupRules&GroupId=sg-dev"+update)) } @@ -285,7 +294,7 @@ func TestEC2Request_IdentifierIsAValueNotAPattern(t *testing.T) { // reverted STS work built an ARN from the segment after the final /, so a // Deny on i-prod fenced i-prod/admin and a grant fenced the wrong object. assertPermitted(t, dispatchEC2(t, fenced, "Action=TerminateInstances&InstanceId.1=i-prod/admin")) - assertDenied(t, dispatchEC2(t, fenced, "Action=TerminateInstances&InstanceId.1=i-prod")) + assertUnauthorized(t, dispatchEC2(t, fenced, "Action=TerminateInstances&InstanceId.1=i-prod")) } // TestEC2Request_ARNIsBuiltFromTheNodeRegion: the credential-scope region is @@ -298,5 +307,60 @@ func TestEC2Request_ARNIsBuiltFromTheNodeRegion(t *testing.T) { statement("Deny", "ec2:TerminateInstances", "arn:aws:ec2:"+authzRegion+":*:instance/i-prod"), ) - assertDenied(t, dispatchEC2(t, gw, "Action=TerminateInstances&InstanceId.1=i-prod")) + assertUnauthorized(t, dispatchEC2(t, gw, "Action=TerminateInstances&InstanceId.1=i-prod")) +} + +// profileIAMService resolves every instance profile to one holding role app, or +// fails resolution with resolveErr. +type profileIAMService struct { + policyMockIAMService + + resolveErr error +} + +func (s *profileIAMService) ResolveInstanceProfile(accountID, _ string) (*handlers_iam.InstanceProfile, error) { + if s.resolveErr != nil { + return nil, s.resolveErr + } + return &handlers_iam.InstanceProfile{AccountID: accountID, RoleName: "app"}, nil +} + +func (s *profileIAMService) CanonicalResourceARN(accountID string, _ arn.IAMResourceType, name string) (string, error) { + return "arn:aws:iam::" + accountID + ":role/" + name, nil +} + +func passRoleDeniedGateway(t *testing.T, resolveErr error) *GatewayConfig { + t.Helper() + docs := []handlers_iam.PolicyDocument{{Version: "2012-10-17", Statement: []handlers_iam.Statement{ + statement("Allow", "ec2:*", "*"), + statement("Deny", "iam:PassRole", "*"), + }}} + return &GatewayConfig{ + DisableLogging: true, + Region: authzRegion, + NATSConn: startTestNATS(t), + IAMService: &profileIAMService{ + policyMockIAMService: policyMockIAMService{ + getUserPoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) { return docs, nil }, + }, + resolveErr: resolveErr, + }, + } +} + +// An iam:PassRole denial raised during dispatch is EC2's UnauthorizedOperation, +// the same as a denial at the action gate. +func TestEC2Request_PassRoleDenialIsUnauthorizedOperation(t *testing.T) { + gw := passRoleDeniedGateway(t, nil) + assertUnauthorized(t, dispatchEC2(t, gw, + "Action=AssociateIamInstanceProfile&InstanceId=i-dev&IamInstanceProfile.Name=web")) +} + +// Only the gateway's own policy denial is translated: an AccessDenied from +// instance-profile resolution reaches the caller unchanged. +func TestEC2Request_ResolverAccessDeniedPassesThrough(t *testing.T) { + gw := passRoleDeniedGateway(t, errors.New(awserrors.ErrorAccessDenied)) + err := dispatchEC2(t, gw, "Action=AssociateIamInstanceProfile&InstanceId=i-dev&IamInstanceProfile.Name=web") + require.Error(t, err) + assert.Equal(t, awserrors.ErrorAccessDenied, err.Error()) } diff --git a/spinifex/gateway/ec2_dryrun_test.go b/spinifex/gateway/ec2_dryrun_test.go index 38d351570..aa822d308 100644 --- a/spinifex/gateway/ec2_dryrun_test.go +++ b/spinifex/gateway/ec2_dryrun_test.go @@ -4,6 +4,7 @@ package gateway import ( + "maps" "net/http" "net/http/httptest" "reflect" @@ -43,11 +44,7 @@ func serveEC2(gw *GatewayConfig, body string) *httptest.ResponseRecorder { func TestEC2Request_DryRunAnswersEveryAction(t *testing.T) { gw := &GatewayConfig{DisableLogging: true, Region: authzRegion, IAMService: allowAllIAMService()} - actions := make([]string, 0, len(ec2Actions)) - for action := range ec2Actions { - actions = append(actions, action) - } - slices.Sort(actions) + actions := slices.Sorted(maps.Keys(ec2Actions)) for _, action := range actions { t.Run(action, func(t *testing.T) { @@ -75,7 +72,7 @@ func TestEC2Request_DryRunDeniedCaller(t *testing.T) { statement("Allow", "ec2:*", "*"), statement("Deny", "ec2:CreateVpc", "*"), ) - assertDenied(t, dispatchEC2(t, gw, "Action=CreateVpc&CidrBlock=10.0.0.0/16&DryRun=true")) + assertUnauthorized(t, dispatchEC2(t, gw, "Action=CreateVpc&CidrBlock=10.0.0.0/16&DryRun=true")) assertDryRun(t, dispatchEC2(t, gw, "Action=CreateSecurityGroup&GroupName=g&GroupDescription=d&DryRun=true")) } diff --git a/spinifex/gateway/ec2_emptyresult_test.go b/spinifex/gateway/ec2_emptyresult_test.go index ad7a37146..29fd0eef1 100644 --- a/spinifex/gateway/ec2_emptyresult_test.go +++ b/spinifex/gateway/ec2_emptyresult_test.go @@ -116,3 +116,73 @@ func TestEC2Describe_NonEmptyResult_Unchanged(t *testing.T) { assert.Contains(t, body, "") assert.NotContains(t, body, "", "non-empty KeyPairs must not render as an empty container") } + +// AWS omits some empty lists rather than rendering them: an untagged +// CreateKeyPair's tagSet, and an image's tagSet and productCodes. Other empty +// lists in the same responses are still rendered. +func TestEC2_ListsAWSOmitsWhenEmpty(t *testing.T) { + key := render[ec2.CreateKeyPairInput](t, "CreateKeyPair", ec2.CreateKeyPairOutput{KeyName: aws.String("k")}) + assert.NotContains(t, key, "tagSet") + + images := render[ec2.DescribeImagesInput](t, "DescribeImages", ec2.DescribeImagesOutput{ + Images: []*ec2.Image{{ImageId: aws.String("ami-1")}}, + }) + assert.NotContains(t, images, "tagSet") + assert.NotContains(t, images, "productCodes") + assert.Contains(t, images, "") + + tagged := render[ec2.DescribeImagesInput](t, "DescribeImages", ec2.DescribeImagesOutput{ + Images: []*ec2.Image{{ImageId: aws.String("ami-1"), Tags: []*ec2.Tag{{Key: aws.String("k"), Value: aws.String("v")}}}}, + }) + // BuildXML emits sibling elements in map order, so key/value may swap. + assert.Regexp(t, `(kv|vk)`, tagged) +} + +// The network lists AWS omits when empty: an untagged security group's and +// rule's tagSet, Revoke*'s unknownIpPermissionSet, an interface's prefix +// lists, and a described VPC's IPv6 association set. +func TestEC2_NetworkListsAWSOmitsWhenEmpty(t *testing.T) { + sg := render[ec2.CreateSecurityGroupInput](t, "CreateSecurityGroup", ec2.CreateSecurityGroupOutput{GroupId: aws.String("sg-1")}) + assert.NotContains(t, sg, "tagSet") + + groups := render[ec2.DescribeSecurityGroupsInput](t, "DescribeSecurityGroups", ec2.DescribeSecurityGroupsOutput{ + SecurityGroups: []*ec2.SecurityGroup{{GroupId: aws.String("sg-1")}}, + }) + assert.NotContains(t, groups, "tagSet") + assert.Contains(t, groups, "") + + auth := render[ec2.AuthorizeSecurityGroupIngressInput](t, "AuthorizeSecurityGroupIngress", ec2.AuthorizeSecurityGroupIngressOutput{ + SecurityGroupRules: []*ec2.SecurityGroupRule{{SecurityGroupRuleId: aws.String("sgr-1")}}, + }) + assert.NotContains(t, auth, "tagSet") + + rules := render[ec2.DescribeSecurityGroupRulesInput](t, "DescribeSecurityGroupRules", ec2.DescribeSecurityGroupRulesOutput{ + SecurityGroupRules: []*ec2.SecurityGroupRule{{SecurityGroupRuleId: aws.String("sgr-1"), Tags: []*ec2.Tag{}}}, + }) + assert.Contains(t, rules, "") + + revoke := render[ec2.RevokeSecurityGroupIngressInput](t, "RevokeSecurityGroupIngress", ec2.RevokeSecurityGroupIngressOutput{Return: aws.Bool(true)}) + assert.NotContains(t, revoke, "unknownIpPermissionSet") + revokeEgress := render[ec2.RevokeSecurityGroupEgressInput](t, "RevokeSecurityGroupEgress", ec2.RevokeSecurityGroupEgressOutput{Return: aws.Bool(true)}) + assert.NotContains(t, revokeEgress, "unknownIpPermissionSet") + + enis := render[ec2.DescribeNetworkInterfacesInput](t, "DescribeNetworkInterfaces", ec2.DescribeNetworkInterfacesOutput{ + NetworkInterfaces: []*ec2.NetworkInterface{{NetworkInterfaceId: aws.String("eni-1")}}, + }) + assert.NotContains(t, enis, "ipv4PrefixSet") + assert.NotContains(t, enis, "ipv6PrefixSet") + assert.Contains(t, enis, "") + + vpcs := render[ec2.DescribeVpcsInput](t, "DescribeVpcs", ec2.DescribeVpcsOutput{Vpcs: []*ec2.Vpc{{VpcId: aws.String("vpc-1")}}}) + assert.NotContains(t, vpcs, "ipv6CidrBlockAssociationSet") + + created := render[ec2.CreateVpcInput](t, "CreateVpc", ec2.CreateVpcOutput{Vpc: &ec2.Vpc{ + VpcId: aws.String("vpc-1"), + Ipv6CidrBlockAssociationSet: []*ec2.VpcIpv6CidrBlockAssociation{}, + }}) + assert.Contains(t, created, "") + + subnet := render[ec2.CreateSubnetInput](t, "CreateSubnet", ec2.CreateSubnetOutput{Subnet: &ec2.Subnet{SubnetId: aws.String("subnet-1")}}) + assert.NotContains(t, subnet, "tagSet") + assert.Contains(t, subnet, "") +} diff --git a/spinifex/gateway/ec2_validation.go b/spinifex/gateway/ec2_validation.go new file mode 100644 index 000000000..537190fd1 --- /dev/null +++ b/spinifex/gateway/ec2_validation.go @@ -0,0 +1,166 @@ +package gateway + +import ( + "fmt" + "math" + "reflect" + "slices" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" +) + +// taggableTypes lists the TagSpecifications resource types an action accepts, +// and the code AWS refuses any other type with. +type taggableTypes struct { + types []string + code string +} + +func taggable(types ...string) taggableTypes { + return taggableTypes{types: types, code: awserrors.ErrorInvalidParameterValue} +} + +// ec2TaggableTypes covers every dispatched action whose input carries +// TagSpecifications; TestEC2TaggableTypes_CoverEveryAction keeps it complete. +// AWS answers some actions with InvalidParameter rather than InvalidParameterValue. +var ec2TaggableTypes = map[string]taggableTypes{ + "AllocateAddress": taggable("elastic-ip"), + "AuthorizeSecurityGroupEgress": {types: []string{"security-group-rule"}, code: awserrors.ErrorInvalidParameter}, + "AuthorizeSecurityGroupIngress": {types: []string{"security-group-rule"}, code: awserrors.ErrorInvalidParameter}, + "CopyImage": taggable("image", "snapshot"), + "CopySnapshot": taggable("snapshot"), + "CreateCapacityReservation": taggable("capacity-reservation"), + "CreateEgressOnlyInternetGateway": taggable("egress-only-internet-gateway"), + "CreateImage": taggable("image", "snapshot"), + "CreateInternetGateway": taggable("internet-gateway"), + "CreateKeyPair": taggable("key-pair"), + "CreateLaunchTemplate": taggable("launch-template"), + "CreateNatGateway": taggable("natgateway"), + "CreateNetworkInterface": {types: []string{"network-interface"}, code: awserrors.ErrorInvalidParameter}, + "CreatePlacementGroup": taggable("placement-group"), + "CreateRouteTable": taggable("route-table"), + "CreateSecurityGroup": taggable("security-group"), + "CreateSnapshot": taggable("snapshot"), + "CreateSubnet": {types: []string{"subnet"}, code: awserrors.ErrorInvalidParameter}, + "CreateVolume": taggable("volume"), + "CreateVpc": {types: []string{"vpc"}, code: awserrors.ErrorInvalidParameter}, + "ImportKeyPair": taggable("key-pair"), + "RegisterImage": taggable("image"), + "RequestSpotInstances": taggable("spot-instances-request"), + "RunInstances": taggable("instance", "volume", "network-interface", "spot-instances-request"), +} + +// validateTagSpecificationTypes refuses a TagSpecifications entry whose +// ResourceType the action cannot tag, rather than silently dropping its tags. +func validateTagSpecificationTypes(action string, input any) error { + allowed, ok := ec2TaggableTypes[action] + if !ok { + return nil + } + for _, spec := range tagSpecifications(input) { + if spec == nil || spec.ResourceType == nil { + continue + } + if rt := aws.StringValue(spec.ResourceType); !slices.Contains(allowed.types, rt) { + return awserrors.Errorf(allowed.code, "'%s' is not a valid taggable resource type for this operation.", rt) + } + } + return nil +} + +// tagSpecifications reads an EC2 input's TagSpecifications, or nil if it has none. +func tagSpecifications(input any) []*ec2.TagSpecification { + v := reflect.ValueOf(input) + if v.Kind() != reflect.Pointer || v.IsNil() || v.Elem().Kind() != reflect.Struct { + return nil + } + field := v.Elem().FieldByName("TagSpecifications") + if !field.IsValid() { + return nil + } + specs, _ := reflect.TypeAssert[[]*ec2.TagSpecification](field) + return specs +} + +// maxResultsRange is the MaxResults range one action enforces, and the code and +// text it refuses a value outside it with; AWS words each action differently. +type maxResultsRange struct { + min, max int64 + code string + message func(n int64) string +} + +func natGatewaysMaxResultsMessage(n int64) string { + bound := "less than or equal to 1000" + if n < 5 { + bound = "greater than or equal to 5" + } + return fmt.Sprintf("1 validation error detected: Value '%d' at 'maxResults' failed to satisfy constraint: Member must have value %s", n, bound) +} + +var ec2MaxResultsRanges = map[string]maxResultsRange{ + "DescribeCapacityReservations": {1, 1000, awserrors.ErrorInvalidParameterValue, func(n int64) string { + return fmt.Sprintf("%d is an invalid value for MaxResults. MaxResults must be null, or between 1 and 1000.", n) + }}, + "DescribeEgressOnlyInternetGateways": {5, 255, awserrors.ErrorInvalidParameterValue, func(n int64) string { + if n < 5 { + return fmt.Sprintf("Value (%d) for parameter maxResults is invalid. Expecting a value greater than 5.", n) + } + return fmt.Sprintf("Value (%d) for parameter maxResults is invalid. Expecting a value less than 255.", n) + }}, + // AWS accepts any value from 5 up, despite the 1000 its API reference gives. + "DescribeInstances": {5, math.MaxInt64, awserrors.ErrorInvalidParameterValue, func(n int64) string { + return fmt.Sprintf("Value ( %d ) for parameter maxResults is invalid. Expecting a value greater than 5.", n) + }}, + "DescribeInstanceCreditSpecifications": {5, 1000, awserrors.ErrorInvalidRequest, func(n int64) string { + return fmt.Sprintf("The value %d for the maxResults parameter must be between 5 and 1000. Change the value and try again.", n) + }}, + "DescribeInstanceTypes": {5, 100, awserrors.ErrorInvalidMaxResults, func(n int64) string { + return fmt.Sprintf("Value ( %d ) for parameter maxResults is invalid. Expecting a value from ( 5 ) to ( 100 ).", n) + }}, + "DescribeInstanceTypeOfferings": {5, 1000, awserrors.ErrorInvalidMaxResults, func(n int64) string { + return fmt.Sprintf("Value ( %d ) for parameter maxResults is invalid. Expecting a value from ( 5 ) to ( 1000 ).", n) + }}, + "DescribeLaunchTemplates": {1, 200, awserrors.ErrorInvalidParameterValue, func(int64) string { + return "Maximum results allowed are between 1 and 200" + }}, + "DescribeNatGateways": {5, 1000, awserrors.ErrorInvalidParameter, natGatewaysMaxResultsMessage}, +} + +// validateMaxResults refuses a MaxResults outside the action's range. It does +// not page; an in-range value is still answered with every result. +func validateMaxResults(action string, input any) error { + // AWS refuses the combination before it checks the range. + if in, ok := input.(*ec2.DescribeInstancesInput); ok && in.MaxResults != nil && len(in.InstanceIds) > 0 { + return awserrors.Errorf(awserrors.ErrorInvalidParameterCombination, + "The parameter instancesSet cannot be used with the parameter maxResults") + } + r, ok := ec2MaxResultsRanges[action] + if !ok { + return nil + } + n, ok := int64Field(input, "MaxResults") + if !ok || (n >= r.min && n <= r.max) { + return nil + } + return awserrors.Errorf(r.code, "%s", r.message(n)) +} + +// int64Field reads a set *int64 field from an EC2 input. +func int64Field(input any, name string) (int64, bool) { + v := reflect.ValueOf(input) + if v.Kind() != reflect.Pointer || v.IsNil() || v.Elem().Kind() != reflect.Struct { + return 0, false + } + field := v.Elem().FieldByName(name) + if !field.IsValid() { + return 0, false + } + p, ok := reflect.TypeAssert[*int64](field) + if !ok || p == nil { + return 0, false + } + return *p, true +} diff --git a/spinifex/gateway/ec2_validation_test.go b/spinifex/gateway/ec2_validation_test.go new file mode 100644 index 000000000..a249d7615 --- /dev/null +++ b/spinifex/gateway/ec2_validation_test.go @@ -0,0 +1,154 @@ +//test:in-package — walks the unexported ec2Actions route table and drives +// EC2_Request through the gateway's unexported test helpers. + +package gateway + +import ( + "encoding/xml" + "net/http" + "reflect" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// An action added to the route table with TagSpecifications must say which +// resource types it tags, or a wrong type would be silently dropped again. +func TestEC2TaggableTypes_CoverEveryAction(t *testing.T) { + for action, handler := range ec2Actions { + input, err := handler.parse(map[string]string{"Action": action}) + require.NoError(t, err, action) + _, tags := reflect.TypeOf(input).Elem().FieldByName("TagSpecifications") + _, listed := ec2TaggableTypes[action] + assert.Equal(t, tags, listed, "%s: TagSpecifications modelled=%v, listed in ec2TaggableTypes=%v", action, tags, listed) + } + for action := range ec2TaggableTypes { + assert.Contains(t, ec2Actions, action, "ec2TaggableTypes lists an action the gateway does not route") + } +} + +func tagSpecBody(action, resourceType string) string { + return "Action=" + action + "&TagSpecification.1.ResourceType=" + resourceType + + "&TagSpecification.1.Tag.1.Key=k&TagSpecification.1.Tag.1.Value=v" +} + +func TestEC2Request_TagSpecificationResourceType(t *testing.T) { + gw := &GatewayConfig{DisableLogging: true, Region: authzRegion, IAMService: allowAllIAMService()} + + cases := []struct { + name, body, code, resourceType string + }{ + {"out of enum", tagSpecBody("CreateKeyPair", "bogus-type") + "&KeyName=k", "InvalidParameterValue", "bogus-type"}, + {"another resource's type", tagSpecBody("CreateInternetGateway", "instance"), "InvalidParameterValue", "instance"}, + {"InvalidParameter action", tagSpecBody("CreateVpc", "instance") + "&CidrBlock=10.0.0.0/16", "InvalidParameter", "instance"}, + {"checked before DryRun", tagSpecBody("CreateVpc", "subnet") + "&CidrBlock=10.0.0.0/16&DryRun=true", "InvalidParameter", "subnet"}, + {"second spec checked", tagSpecBody("RunInstances", "volume") + + "&TagSpecification.2.ResourceType=image&ImageId=ami-1&MinCount=1&MaxCount=1", "InvalidParameterValue", "image"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := serveEC2(gw, tc.body) + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), ""+tc.code+"") + assert.Contains(t, w.Body.String(), + "'"+tc.resourceType+"' is not a valid taggable resource type for this operation.") + }) + } +} + +// Each of an action's own types reaches the handler, which fails only on the +// absent NATS connection. +func TestEC2Request_TagSpecificationAllowedTypes(t *testing.T) { + gw := &GatewayConfig{DisableLogging: true, Region: authzRegion, IAMService: allowAllIAMService()} + + for _, rt := range []string{"instance", "volume", "network-interface", "spot-instances-request"} { + assertPermitted(t, dispatchEC2(t, gw, tagSpecBody("RunInstances", rt)+"&ImageId=ami-1&MinCount=1&MaxCount=1")) + } + assertPermitted(t, dispatchEC2(t, gw, tagSpecBody("CreateVpc", "vpc")+"&CidrBlock=10.0.0.0/16")) +} + +// xmlText escapes s as the error body renders it. +func xmlText(t *testing.T, s string) string { + t.Helper() + var b strings.Builder + require.NoError(t, xml.EscapeText(&b, []byte(s))) + return b.String() +} + +// The refusals are AWS's own, captured by scripts/aws-compliance/aws-response-diff/scen_e.py. +func TestEC2Request_MaxResultsOutOfRange(t *testing.T) { + gw := &GatewayConfig{DisableLogging: true, Region: authzRegion, IAMService: allowAllIAMService()} + + cases := []struct { + action string + n int + code string + msg string + }{ + {"DescribeCapacityReservations", 0, "InvalidParameterValue", "0 is an invalid value for MaxResults. MaxResults must be null, or between 1 and 1000."}, + {"DescribeCapacityReservations", 1001, "InvalidParameterValue", "1001 is an invalid value for MaxResults. MaxResults must be null, or between 1 and 1000."}, + {"DescribeEgressOnlyInternetGateways", 4, "InvalidParameterValue", "Value (4) for parameter maxResults is invalid. Expecting a value greater than 5."}, + {"DescribeEgressOnlyInternetGateways", 256, "InvalidParameterValue", "Value (256) for parameter maxResults is invalid. Expecting a value less than 255."}, + {"DescribeInstances", 2, "InvalidParameterValue", "Value ( 2 ) for parameter maxResults is invalid. Expecting a value greater than 5."}, + {"DescribeInstanceCreditSpecifications", 4, "InvalidRequest", "The value 4 for the maxResults parameter must be between 5 and 1000. Change the value and try again."}, + {"DescribeInstanceCreditSpecifications", 1001, "InvalidRequest", "The value 1001 for the maxResults parameter must be between 5 and 1000. Change the value and try again."}, + {"DescribeInstanceTypes", 4, "InvalidMaxResults", "Value ( 4 ) for parameter maxResults is invalid. Expecting a value from ( 5 ) to ( 100 )."}, + {"DescribeInstanceTypes", 101, "InvalidMaxResults", "Value ( 101 ) for parameter maxResults is invalid. Expecting a value from ( 5 ) to ( 100 )."}, + {"DescribeInstanceTypeOfferings", 4, "InvalidMaxResults", "Value ( 4 ) for parameter maxResults is invalid. Expecting a value from ( 5 ) to ( 1000 )."}, + {"DescribeInstanceTypeOfferings", 1001, "InvalidMaxResults", "Value ( 1001 ) for parameter maxResults is invalid. Expecting a value from ( 5 ) to ( 1000 )."}, + {"DescribeLaunchTemplates", 0, "InvalidParameterValue", "Maximum results allowed are between 1 and 200"}, + {"DescribeLaunchTemplates", 201, "InvalidParameterValue", "Maximum results allowed are between 1 and 200"}, + {"DescribeNatGateways", 4, "InvalidParameter", "1 validation error detected: Value '4' at 'maxResults' failed to satisfy constraint: Member must have value greater than or equal to 5"}, + {"DescribeNatGateways", 1001, "InvalidParameter", "1 validation error detected: Value '1001' at 'maxResults' failed to satisfy constraint: Member must have value less than or equal to 1000"}, + } + for _, tc := range cases { + t.Run(tc.action+"/"+strconv.Itoa(tc.n), func(t *testing.T) { + w := serveEC2(gw, "Action="+tc.action+"&MaxResults="+strconv.Itoa(tc.n)) + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), ""+tc.code+"") + assert.Contains(t, w.Body.String(), ""+xmlText(t, tc.msg)+"") + }) + } +} + +// Captured from AWS: the combination is refused even when MaxResults is also +// out of range. +func TestEC2Request_DescribeInstancesMaxResultsWithInstanceIds(t *testing.T) { + gw := &GatewayConfig{DisableLogging: true, Region: authzRegion, IAMService: allowAllIAMService()} + + for _, n := range []string{"2", "5"} { + w := serveEC2(gw, "Action=DescribeInstances&InstanceId.1=i-0123456789abcdef0&MaxResults="+n) + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "InvalidParameterCombination") + assert.Contains(t, w.Body.String(), "The parameter instancesSet cannot be used with the parameter maxResults") + } +} + +func TestEC2Request_MaxResultsInRange(t *testing.T) { + gw := &GatewayConfig{DisableLogging: true, Region: authzRegion, IAMService: allowAllIAMService()} + + for _, body := range []string{ + "Action=DescribeCapacityReservations&MaxResults=1", + "Action=DescribeEgressOnlyInternetGateways&MaxResults=255", + "Action=DescribeInstances&MaxResults=5", + "Action=DescribeInstances&MaxResults=5000", + "Action=DescribeInstanceTypes&MaxResults=100", + "Action=DescribeInstanceTypeOfferings&MaxResults=5", + "Action=DescribeLaunchTemplates&MaxResults=200", + "Action=DescribeNatGateways&MaxResults=1000", + // AWS accepted 4 and 1001 here although the model bounds it 5-1000. + "Action=DescribeIamInstanceProfileAssociations&MaxResults=4", + "Action=DescribeIamInstanceProfileAssociations&MaxResults=1001", + } { + assertPermitted(t, dispatchEC2(t, gw, body)) + } +} + +// AWS answers DryRun ahead of a MaxResults out of range. +func TestEC2Request_MaxResultsAfterDryRun(t *testing.T) { + gw := &GatewayConfig{DisableLogging: true, Region: authzRegion, IAMService: allowAllIAMService()} + assertDryRun(t, dispatchEC2(t, gw, "Action=DescribeLaunchTemplates&MaxResults=0&DryRun=true")) +} diff --git a/spinifex/gateway/ecrapi/authz.go b/spinifex/gateway/ecrapi/authz.go index 576f5b59a..cf57e1661 100644 --- a/spinifex/gateway/ecrapi/authz.go +++ b/spinifex/gateway/ecrapi/authz.go @@ -8,9 +8,10 @@ import ( "slices" "strings" - "github.com/mulgadc/spinifex/spinifex/ingress/aws/query" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/arn" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "github.com/mulgadc/spinifex/spinifex/gateway/bodyscope" + "github.com/mulgadc/spinifex/spinifex/ingress/aws/query" ) // The resource a policy check evaluates against when the request names nothing @@ -214,11 +215,11 @@ func tagARN(region, accountID, resourceARN string) string { // resource ARN (arn:aws:ecr:::repository/). tagARN and // the tag handlers share this one parser for that shape. func RepositoryNameFromResourceARN(resourceARN string) (string, error) { - parts := strings.SplitN(resourceARN, ":", 6) - if len(parts) != 6 || parts[0] != "arn" || parts[2] != "ecr" { + service, _, _, resource, ok := arn.Split(resourceARN) + if !ok || service != "ecr" { return "", invalidResourceARNError() } - kind, name, found := strings.Cut(parts[5], "/") + kind, name, found := strings.Cut(resource, "/") if !found || kind != repositoryResourceType || name == "" { return "", invalidResourceARNError() } diff --git a/spinifex/gateway/ecrapi/tags.go b/spinifex/gateway/ecrapi/tags.go index 104fdc0b8..dd5824284 100644 --- a/spinifex/gateway/ecrapi/tags.go +++ b/spinifex/gateway/ecrapi/tags.go @@ -4,7 +4,8 @@ import ( "context" "encoding/json" "errors" - "sort" + "maps" + "slices" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ecr" @@ -123,11 +124,7 @@ func ListTagsForResource(ctx context.Context, nc *nats.Conn, accountID string, b if err != nil { return nil, err } - keys := make([]string, 0, len(meta.Tags)) - for k := range meta.Tags { - keys = append(keys, k) - } - sort.Strings(keys) + keys := slices.Sorted(maps.Keys(meta.Tags)) tags := make([]*ecr.Tag, 0, len(keys)) for _, k := range keys { tags = append(tags, &ecr.Tag{Key: aws.String(k), Value: aws.String(meta.Tags[k])}) diff --git a/spinifex/gateway/ecrauth/ecrauth_test.go b/spinifex/gateway/ecrauth/ecrauth_test.go index e7e869bb2..58a4160dc 100644 --- a/spinifex/gateway/ecrauth/ecrauth_test.go +++ b/spinifex/gateway/ecrauth/ecrauth_test.go @@ -41,13 +41,13 @@ func TestLoadOrCreateSigningKey_CreatesThenReloads(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - key1, verify1, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1) + key1, verify1, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey) require.NoError(t, err) require.NotEmpty(t, key1.Kid) require.Contains(t, verify1, key1.Kid) // Second call must reload the same persisted key, not mint a new one. - key2, verify2, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1) + key2, verify2, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey) require.NoError(t, err) assert.Equal(t, key1.Kid, key2.Kid, "persisted signing key must be reused") assert.Len(t, verify2, 1) @@ -60,7 +60,7 @@ func TestLoadOrCreateSigningKey_SkipsAnUnreadableKey(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - good, _, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1) + good, _, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey) require.NoError(t, err) kv, err := js.KeyValue(t.Context(), SigningBucket) @@ -68,7 +68,7 @@ func TestLoadOrCreateSigningKey_SkipsAnUnreadableKey(t *testing.T) { _, err = kv.Put(t.Context(), signingKeyName("stale-kid"), []byte("not decryptable with this master key")) require.NoError(t, err) - key, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1) + key, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey) require.NoError(t, err) assert.Equal(t, good.Kid, key.Kid, "the readable key must still be active") assert.Len(t, verify, 1, "an unreadable key contributes no verify key") @@ -80,14 +80,14 @@ func TestLoadOrCreateSigningKey_SkipsAKeyDeletedWhileReading(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - good, _, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1) + good, _, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey) require.NoError(t, err) kv, err := js.KeyValue(t.Context(), SigningBucket) require.NoError(t, err) require.NoError(t, kv.Delete(t.Context(), signingKeyName("never-existed"))) - key, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1) + key, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey) require.NoError(t, err) assert.Equal(t, good.Kid, key.Kid) assert.Len(t, verify, 1) @@ -100,14 +100,14 @@ func TestLoadOrCreateSigningKey_FailsWhenNoKeyDecrypts(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - _, err := openSigningBucket(t.Context(), js, testMasterKey, 1) + _, err := openSigningBucket(t.Context(), js, testMasterKey) require.NoError(t, err) kv, err := js.KeyValue(t.Context(), SigningBucket) require.NoError(t, err) _, err = kv.Put(t.Context(), signingKeyName("stale-kid"), []byte("not decryptable with this master key")) require.NoError(t, err) - _, _, err = LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1) + _, _, err = LoadOrCreateSigningKey(t.Context(), js, testMasterKey) require.Error(t, err) assert.Contains(t, err.Error(), "master key does not match") @@ -119,7 +119,7 @@ func TestLoadOrCreateSigningKey_FailsWhenNoKeyDecrypts(t *testing.T) { func TestLoadOrCreateSigningKey_StoresEncrypted(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - key, _, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1) + key, _, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey) require.NoError(t, err) kv, err := js.KeyValue(t.Context(), SigningBucket) @@ -133,14 +133,14 @@ func TestLoadOrCreateSigningKey_StoresEncrypted(t *testing.T) { func TestLoadOrCreateSigningKey_EmptyMasterKeyRejected(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - _, _, err := LoadOrCreateSigningKey(t.Context(), js, nil, 1) + _, _, err := LoadOrCreateSigningKey(t.Context(), js, nil) require.Error(t, err) } func TestIssuerVerifier_RoundTrip(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - key, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1) + key, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey) require.NoError(t, err) iss := NewIssuer(key, testAudience) @@ -158,7 +158,7 @@ func TestIssuerVerifier_RoundTrip(t *testing.T) { func TestIssuer_MintRequiresAccountID(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - key, _, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1) + key, _, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey) require.NoError(t, err) p := samplePrincipal() @@ -174,7 +174,7 @@ func TestIssuer_MintRequiresAccountID(t *testing.T) { func TestMint_RequiresCompleteIdentityPointer(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - key, _, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1) + key, _, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey) require.NoError(t, err) iss := NewIssuer(key, testAudience) @@ -208,7 +208,7 @@ func TestSupportedPrincipalType(t *testing.T) { func TestVerifier_RejectsWrongAudience(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - key, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1) + key, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey) require.NoError(t, err) tok, _, err := NewIssuer(key, testAudience).Mint(samplePrincipal()) @@ -221,7 +221,7 @@ func TestVerifier_RejectsWrongAudience(t *testing.T) { func TestVerifier_RejectsUnknownKid(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - key, _, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1) + key, _, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey) require.NoError(t, err) tok, _, err := NewIssuer(key, testAudience).Mint(samplePrincipal()) @@ -235,7 +235,7 @@ func TestVerifier_RejectsUnknownKid(t *testing.T) { func TestVerifier_RejectsExpiredToken(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - key, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1) + key, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey) require.NoError(t, err) // Hand-mint an already-expired token with the same signing key. @@ -263,7 +263,7 @@ func TestVerifier_RejectsExpiredToken(t *testing.T) { func TestVerifier_RejectsIncompleteIdentityPointer(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - key, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1) + key, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey) require.NoError(t, err) base := func() Claims { @@ -310,7 +310,7 @@ func TestVerifier_RejectsIncompleteIdentityPointer(t *testing.T) { func TestVerifier_RejectsNonES256(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - _, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1) + _, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey) require.NoError(t, err) // HS256 token must be refused regardless of signature. diff --git a/spinifex/gateway/ecrauth/rotator.go b/spinifex/gateway/ecrauth/rotator.go index 7b9f97fac..b97719cff 100644 --- a/spinifex/gateway/ecrauth/rotator.go +++ b/spinifex/gateway/ecrauth/rotator.go @@ -78,8 +78,8 @@ type Rotator struct { // NewRotator opens the signing-key bucket and builds a rotator that keeps issuer // and verifier current. replicas matches the cluster size for first-run bucket // creation. -func NewRotator(ctx context.Context, js jetstream.JetStream, masterKey []byte, replicas int, issuer *Issuer, verifier *Verifier) (*Rotator, error) { - kv, err := openSigningBucket(ctx, js, masterKey, replicas) +func NewRotator(ctx context.Context, js jetstream.JetStream, masterKey []byte, issuer *Issuer, verifier *Verifier) (*Rotator, error) { + kv, err := openSigningBucket(ctx, js, masterKey) if err != nil { return nil, err } diff --git a/spinifex/gateway/ecrauth/rotator_test.go b/spinifex/gateway/ecrauth/rotator_test.go index 559613b84..352211c30 100644 --- a/spinifex/gateway/ecrauth/rotator_test.go +++ b/spinifex/gateway/ecrauth/rotator_test.go @@ -79,12 +79,12 @@ func TestPlanRotation(t *testing.T) { func TestRotateOnce_MintsAndSwaps(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - key, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1) + key, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey) require.NoError(t, err) issuer := NewIssuer(key, testAudience) verifier := NewVerifier(verify, testAudience) - kv, err := openSigningBucket(t.Context(), js, testMasterKey, 1) + kv, err := openSigningBucket(t.Context(), js, testMasterKey) require.NoError(t, err) // Token minted under the original key before rotation. @@ -120,9 +120,9 @@ func TestRotateOnce_MintsAndSwaps(t *testing.T) { func TestRotateOnce_PrunesExpiredKey(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - first, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1) + first, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey) require.NoError(t, err) - kv, err := openSigningBucket(t.Context(), js, testMasterKey, 1) + kv, err := openSigningBucket(t.Context(), js, testMasterKey) require.NoError(t, err) second, err := generateSigningKey(t.Context(), kv, testMasterKey) require.NoError(t, err) @@ -151,11 +151,11 @@ func TestRotateOnce_PrunesExpiredKey(t *testing.T) { func TestRotator_RunRotatesThenStops(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - key, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1) + key, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey) require.NoError(t, err) issuer := NewIssuer(key, testAudience) verifier := NewVerifier(verify, testAudience) - kv, err := openSigningBucket(t.Context(), js, testMasterKey, 1) + kv, err := openSigningBucket(t.Context(), js, testMasterKey) require.NoError(t, err) r := &Rotator{ @@ -185,9 +185,9 @@ func TestRotator_RunRotatesThenStops(t *testing.T) { func TestIssuerVerifier_ConcurrentSwap(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - k1, v1, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1) + k1, v1, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey) require.NoError(t, err) - kv, err := openSigningBucket(t.Context(), js, testMasterKey, 1) + kv, err := openSigningBucket(t.Context(), js, testMasterKey) require.NoError(t, err) k2, err := generateSigningKey(t.Context(), kv, testMasterKey) require.NoError(t, err) diff --git a/spinifex/gateway/ecrauth/signingkey.go b/spinifex/gateway/ecrauth/signingkey.go index 93ee7e243..b16a21d1d 100644 --- a/spinifex/gateway/ecrauth/signingkey.go +++ b/spinifex/gateway/ecrauth/signingkey.go @@ -76,8 +76,8 @@ func kidFor(pub *ecdsa.PublicKey) (string, error) { // returns the active signing key. On first run it generates and persists one. // The active key is the newest by KV creation time (ties broken by kid), so all // nodes converge on the same signer and a freshly rotated key takes over. -func LoadOrCreateSigningKey(ctx context.Context, js jetstream.JetStream, masterKey []byte, replicas int) (*SigningKey, map[string]*ecdsa.PublicKey, error) { - kv, err := openSigningBucket(ctx, js, masterKey, replicas) +func LoadOrCreateSigningKey(ctx context.Context, js jetstream.JetStream, masterKey []byte) (*SigningKey, map[string]*ecdsa.PublicKey, error) { + kv, err := openSigningBucket(ctx, js, masterKey) if err != nil { return nil, nil, err } @@ -104,14 +104,14 @@ func LoadOrCreateSigningKey(ctx context.Context, js jetstream.JetStream, masterK // openSigningBucket validates inputs and returns the awsgw-keys KV handle, // creating the cluster-replicated bucket on first use. -func openSigningBucket(ctx context.Context, js jetstream.JetStream, masterKey []byte, replicas int) (jetstream.KeyValue, error) { +func openSigningBucket(ctx context.Context, js jetstream.JetStream, masterKey []byte) (jetstream.KeyValue, error) { if js == nil { return nil, errors.New("ecrauth: nil JetStream context") } if len(masterKey) == 0 { return nil, errors.New("ecrauth: empty master key") } - return kvutil.GetOrCreateBucketWithReplicas(ctx, js, SigningBucket, signingKeyHistory, replicas) + return kvutil.GetOrCreateBucket(ctx, js, SigningBucket, signingKeyHistory) } // reloadKeys reads every stored signing key, returning the verify set, each diff --git a/spinifex/gateway/ecrauth_middleware_test.go b/spinifex/gateway/ecrauth_middleware_test.go index b1be10e5a..6e0719949 100644 --- a/spinifex/gateway/ecrauth_middleware_test.go +++ b/spinifex/gateway/ecrauth_middleware_test.go @@ -34,7 +34,7 @@ func newECRAuth(t *testing.T) (*gateway_ecrauth.Issuer, *gateway_ecrauth.Verifie t.Helper() _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - key, verify, err := gateway_ecrauth.LoadOrCreateSigningKey(t.Context(), js, ecrTestMasterKey(t), 1) + key, verify, err := gateway_ecrauth.LoadOrCreateSigningKey(t.Context(), js, ecrTestMasterKey(t)) require.NoError(t, err) return gateway_ecrauth.NewIssuer(key, ecrTestAudience), gateway_ecrauth.NewVerifier(verify, ecrTestAudience) } diff --git a/spinifex/gateway/ecs.go b/spinifex/gateway/ecs.go index 8d4647cac..34bc383c6 100644 --- a/spinifex/gateway/ecs.go +++ b/spinifex/gateway/ecs.go @@ -63,7 +63,7 @@ func (gw *GatewayConfig) ECS_Request(w http.ResponseWriter, r *http.Request) err // enforce iam:PassRole against the caller's identity on this request, for // whichever role ARN the action later resolves. passRoleCheck := func(roleARN string) error { - return gw.checkPolicyResources(r, "iam", "PassRole", []string{roleARN}) + return gw.checkPassRole(r, roleARN, ecsTasksServicePrincipal) } output, err := handler(r.Context(), gw.NATSConn, accountID, body, passRoleCheck) diff --git a/spinifex/gateway/eks.go b/spinifex/gateway/eks.go index be5be76a8..42d8a0a53 100644 --- a/spinifex/gateway/eks.go +++ b/spinifex/gateway/eks.go @@ -10,18 +10,13 @@ import ( "strings" "github.com/mulgadc/bluebottle/pkg/auth" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/arn" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" gateway_eks "github.com/mulgadc/spinifex/spinifex/gateway/eks" ) -// GenerateEKSErrorResponse returns a JSON {"__type":"Exception","message":""} body -// for use by writeClusterUnavailable, writeThrottleError, and ErrorHandler. -func GenerateEKSErrorResponse(code, message, _ string) []byte { - return gateway_eks.GenerateEKSErrorResponse(code, message) -} - // jsonErrorType derives the X-Amzn-Errortype header value for code, mirroring -// GenerateEKSErrorResponse's own "Exception" suffixing so the header and the +// gateway_eks.GenerateEKSErrorResponse's own "Exception" suffixing so the header and the // body's __type always agree. func jsonErrorType(code string) string { if strings.HasSuffix(code, "Exception") { @@ -305,6 +300,9 @@ func (gw *GatewayConfig) EKS_Request(w http.ResponseWriter, r *http.Request) err if err := gw.checkPolicyResources(r, "eks", action, resources); err != nil { return err } + if err := gw.checkEKSPassRole(r, action, accountID, body); err != nil { + return err + } if gw.NATSConn == nil { return errors.New(awserrors.ErrorServerInternal) @@ -322,6 +320,39 @@ func (gw *GatewayConfig) EKS_Request(w http.ResponseWriter, r *http.Request) err return nil } +// errForeignRole marks a role ARN naming an account other than the caller's. +var errForeignRole = errors.New("role is not in the caller's account") + +// checkEKSPassRole enforces iam:PassRole on each role action hands to EKS. Only +// the exact ARN the store holds resolves, so the grant is evaluated against the +// role's real path and an invented one cannot match a narrower Resource. +func (gw *GatewayConfig) checkEKSPassRole(r *http.Request, action, accountID string, body []byte) error { + for _, roleARN := range gateway_eks.PassedRoleARNs(action, body) { + _, _, resolveErr := auth.ResolveRoleARN(roleARN, func(roleAccount, roleName string) (string, error) { + if roleAccount != accountID { + return "", errForeignRole + } + return gw.IAMService.CanonicalResourceARN(roleAccount, arn.IAMRole, roleName) + }) + if errors.Is(resolveErr, auth.ErrInvalidRoleARN) || errors.Is(resolveErr, errForeignRole) { + return awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "%s is not a role ARN in account %s", roleARN, accountID) + } + unknown := errors.Is(resolveErr, auth.ErrRoleARNMismatch) || awserrors.IsErrorCode(resolveErr, awserrors.ErrorIAMNoSuchEntity) + if resolveErr != nil && !unknown { + return resolveErr + } + // Evaluated before an unknown role is reported, so a caller without + // iam:PassRole cannot use the reply to probe which roles exist. + if err := gw.checkPassRole(r, roleARN, eksServicePrincipal); err != nil { + return err + } + if unknown { + return awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "role %s does not exist", roleARN) + } + } + return nil +} + // eksCaller reads the principal behind the request. 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. diff --git a/spinifex/gateway/eks/passrole.go b/spinifex/gateway/eks/passrole.go new file mode 100644 index 000000000..8ed6b2962 --- /dev/null +++ b/spinifex/gateway/eks/passrole.go @@ -0,0 +1,31 @@ +package gateway_eks + +import ( + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/eks" +) + +// PassedRoleARNs returns the role ARNs action hands to EKS, each needing +// iam:PassRole. An undecodable body passes nothing: the handler decodes the +// same bytes the same way and refuses it before acting. +func PassedRoleARNs(action string, body []byte) []string { + var roleARN *string + switch action { + case "CreateCluster": + input := new(eks.CreateClusterInput) + if unmarshalIfBody(body, input) != nil { + return nil + } + roleARN = input.RoleArn + case "CreateNodegroup": + input := new(eks.CreateNodegroupInput) + if unmarshalIfBody(body, input) != nil { + return nil + } + roleARN = input.NodeRole + } + if aws.StringValue(roleARN) == "" { + return nil + } + return []string{*roleARN} +} diff --git a/spinifex/gateway/eks_passrole_test.go b/spinifex/gateway/eks_passrole_test.go new file mode 100644 index 000000000..1f1a21ab9 --- /dev/null +++ b/spinifex/gateway/eks_passrole_test.go @@ -0,0 +1,162 @@ +package gateway + +import ( + "errors" + "net/http" + "testing" + + "github.com/mulgadc/spinifex/spinifex/foundation/aws/arn" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" + handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const eksNodeRoleARN = "arn:aws:iam::" + authzAccountID + ":role/eks/node-role" + +const foreignAccountID = "999999999999" + +// roleStoreIAMService evaluates policy from docs and resolves role names to the +// ARNs in roles, keyed by account then name, the way the IAM store does. +type roleStoreIAMService struct { + policyMockIAMService + + roles map[string]map[string]string + canonicalErr error +} + +func (s *roleStoreIAMService) CanonicalResourceARN(account string, _ arn.IAMResourceType, name string) (string, error) { + if s.canonicalErr != nil { + return "", s.canonicalErr + } + if roleARN, ok := s.roles[account][name]; ok { + return roleARN, nil + } + return "", errors.New(awserrors.ErrorIAMNoSuchEntity) +} + +// The foreign account holds a role of the same name, so only the gateway's +// account check stops its ARN from resolving. +func eksPassRoleService(statements ...handlers_iam.Statement) *roleStoreIAMService { + docs := []handlers_iam.PolicyDocument{{Version: "2012-10-17", Statement: statements}} + return &roleStoreIAMService{ + policyMockIAMService: policyMockIAMService{ + getUserPoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) { return docs, nil }, + }, + roles: map[string]map[string]string{ + authzAccountID: {"node-role": eksNodeRoleARN}, + foreignAccountID: {"node-role": "arn:aws:iam::" + foreignAccountID + ":role/eks/node-role"}, + }, + } +} + +func eksPassRoleGateway(statements ...handlers_iam.Statement) *GatewayConfig { + return eksGatewayWithIAM(eksPassRoleService(statements...)) +} + +func eksGatewayWithIAM(iamService *roleStoreIAMService) *GatewayConfig { + return &GatewayConfig{DisableLogging: true, Region: authzRegion, IAMService: iamService} +} + +func createNodegroupBody(nodeRole string) string { + return `{"nodegroupName":"workers","nodeRole":"` + nodeRole + `"}` +} + +func assertInvalidParameter(t *testing.T, err error) { + t.Helper() + code, ok := awserrors.ResolveErrorCode(err) + require.True(t, ok, "unresolvable error code: %v", err) + assert.Equal(t, awserrors.ErrorInvalidParameterValue, code) +} + +// eks:* alone must not let a caller launch nodes under a role it cannot pass. +func TestEKSCreateNodegroup_DeniedWithoutPassRole(t *testing.T) { + gw := eksPassRoleGateway(statement("Allow", "eks:*", "*")) + assertDenied(t, dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups", createNodegroupBody(eksNodeRoleARN))) +} + +func TestEKSCreateNodegroup_AllowedWithPassRoleOnRole(t *testing.T) { + gw := eksPassRoleGateway( + statement("Allow", "eks:*", "*"), + statement("Allow", "iam:PassRole", eksNodeRoleARN), + ) + assertPermitted(t, dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups", createNodegroupBody(eksNodeRoleARN))) +} + +// A grant scoped to a path the role is not under must not be satisfied by +// spelling the role's name under that path. +func TestEKSCreateNodegroup_InventedPathIsRefused(t *testing.T) { + gw := eksPassRoleGateway( + statement("Allow", "eks:*", "*"), + statement("Allow", "iam:PassRole", "arn:aws:iam::"+authzAccountID+":role/sandbox/*"), + ) + err := dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups", + createNodegroupBody("arn:aws:iam::"+authzAccountID+":role/sandbox/node-role")) + assertInvalidParameter(t, err) +} + +func TestEKSCreateNodegroup_ForeignAccountRoleIsRefused(t *testing.T) { + gw := eksPassRoleGateway( + statement("Allow", "eks:*", "*"), + statement("Allow", "iam:PassRole", "*"), + ) + err := dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups", + createNodegroupBody("arn:aws:iam::"+foreignAccountID+":role/eks/node-role")) + assertInvalidParameter(t, err) +} + +func TestEKSCreateNodegroup_NonRoleARNIsRefused(t *testing.T) { + gw := eksPassRoleGateway( + statement("Allow", "eks:*", "*"), + statement("Allow", "iam:PassRole", "*"), + ) + err := dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups", + createNodegroupBody("arn:aws:iam::"+authzAccountID+":user/node-role")) + assertInvalidParameter(t, err) +} + +// A store fault is not evidence the role is absent, so it must not surface as +// a caller error or fall through to dispatch. +func TestEKSCreateNodegroup_LookupFaultPassesThrough(t *testing.T) { + storeErr := errors.New("get role: nats: timeout") + iamService := eksPassRoleService( + statement("Allow", "eks:*", "*"), + statement("Allow", "iam:PassRole", "*"), + ) + iamService.canonicalErr = storeErr + err := dispatchEKS(t, eksGatewayWithIAM(iamService), http.MethodPost, "/clusters/prod/node-groups", createNodegroupBody(eksNodeRoleARN)) + require.ErrorIs(t, err, storeErr) +} + +// An unknown role is reported only to a caller allowed to pass it, so the +// reply cannot be used to enumerate roles. +func TestEKSCreateNodegroup_UnknownRole(t *testing.T) { + body := createNodegroupBody("arn:aws:iam::" + authzAccountID + ":role/ghost") + + denied := eksPassRoleGateway(statement("Allow", "eks:*", "*")) + assertDenied(t, dispatchEKS(t, denied, http.MethodPost, "/clusters/prod/node-groups", body)) + + allowed := eksPassRoleGateway( + statement("Allow", "eks:*", "*"), + statement("Allow", "iam:PassRole", "*"), + ) + assertInvalidParameter(t, dispatchEKS(t, allowed, http.MethodPost, "/clusters/prod/node-groups", body)) +} + +func TestEKSCreateNodegroup_NoRoleSkipsPassRole(t *testing.T) { + gw := eksPassRoleGateway(statement("Allow", "eks:*", "*")) + assertPermitted(t, dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups", `{"nodegroupName":"workers"}`)) +} + +func TestEKSCreateCluster_PassRole(t *testing.T) { + body := `{"name":"prod","roleArn":"` + eksNodeRoleARN + `"}` + + denied := eksPassRoleGateway(statement("Allow", "eks:*", "*")) + assertDenied(t, dispatchEKS(t, denied, http.MethodPost, "/clusters", body)) + + allowed := eksPassRoleGateway( + statement("Allow", "eks:*", "*"), + statement("Allow", "iam:PassRole", eksNodeRoleARN), + ) + assertPermitted(t, dispatchEKS(t, allowed, http.MethodPost, "/clusters", body)) +} diff --git a/spinifex/gateway/gateway.go b/spinifex/gateway/gateway.go index cb15467ab..de18144d2 100644 --- a/spinifex/gateway/gateway.go +++ b/spinifex/gateway/gateway.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "log/slog" + "maps" "net/http" "net/url" "strconv" @@ -32,6 +33,7 @@ import ( gateway_ec2_instance "github.com/mulgadc/spinifex/spinifex/gateway/ec2/instance" gateway_ecr "github.com/mulgadc/spinifex/spinifex/gateway/ecr" gateway_ecrauth "github.com/mulgadc/spinifex/spinifex/gateway/ecrauth" + gateway_eks "github.com/mulgadc/spinifex/spinifex/gateway/eks" "github.com/mulgadc/spinifex/spinifex/gateway/policy" gateway_sts "github.com/mulgadc/spinifex/spinifex/gateway/sts" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" @@ -396,7 +398,7 @@ func (gw *GatewayConfig) writeClusterUnavailable(w http.ResponseWriter, r *http. // AWS JSON 1.1 services (EKS/ECS/bedrock family, …) get a JSON body. if jsonErrorService(svc) { - body := GenerateEKSErrorResponse(awserrors.ErrorServiceUnavailable, clusterUnavailableMsg, requestID) + body := gateway_eks.GenerateEKSErrorResponse(awserrors.ErrorServiceUnavailable, clusterUnavailableMsg) w.Header().Set("Content-Type", eksJSONContentType) w.Header().Set("X-Amzn-Errortype", jsonErrorType(awserrors.ErrorServiceUnavailable)) w.WriteHeader(http.StatusServiceUnavailable) @@ -428,7 +430,7 @@ func (gw *GatewayConfig) writeThrottleError(w http.ResponseWriter, r *http.Reque // AWS JSON 1.1 services (EKS/ECS/bedrock family, …) get a JSON body. if jsonErrorService(svc) { - body := GenerateEKSErrorResponse(errorCode, errorMsg.Message, requestID) + body := gateway_eks.GenerateEKSErrorResponse(errorCode, errorMsg.Message) w.Header().Set("Content-Type", eksJSONContentType) w.Header().Set("X-Amzn-Errortype", jsonErrorType(errorCode)) w.WriteHeader(errorMsg.HTTPCode) @@ -577,6 +579,26 @@ func (gw *GatewayConfig) checkPolicy(r *http.Request, service, action string) er // checkPolicyResources evaluates every resource against one resolved policy // snapshot. Used when one API request authorizes multiple resource ARNs. func (gw *GatewayConfig) checkPolicyResources(r *http.Request, service, action string, resources []string) error { + return gw.checkPolicyResourcesWithKeys(r, service, action, resources, nil) +} + +// Service principals an iam:PassRole check reports as iam:PassedToService. +const ( + ec2ServicePrincipal = "ec2.amazonaws.com" + ecsTasksServicePrincipal = "ecs-tasks.amazonaws.com" + eksServicePrincipal = "eks.amazonaws.com" +) + +// checkPassRole enforces iam:PassRole on roleARN. iam:PassedToService is fixed +// by the call site handing the role over, never read from the request. +func (gw *GatewayConfig) checkPassRole(r *http.Request, roleARN, passedTo string) error { + return gw.checkPolicyResourcesWithKeys(r, "iam", "PassRole", []string{roleARN}, + iampolicy.ConditionKeys{iampolicy.KeyPassedToService: passedTo}) +} + +// checkPolicyResourcesWithKeys is checkPolicyResources with action-scoped +// condition keys layered over the request's own. +func (gw *GatewayConfig) checkPolicyResourcesWithKeys(r *http.Request, service, action string, resources []string, actionKeys iampolicy.ConditionKeys) error { // Every dispatcher — query-protocol and REST-JSON alike — reaches this // point with its resolved action, so telemetry enrichment lives here. recordResolvedAction(r.Context(), service, action) @@ -616,8 +638,9 @@ func (gw *GatewayConfig) checkPolicyResources(r *http.Request, service, action s underlyingRoleARN: mustCtxString(r, ctxUnderlyingRoleARN), userID: mustCtxString(r, ctxUserID), } - return gw.evaluatePrincipalPolicyResources(principal, policy.IAMAction(service, action), resources, - requestConditionKeys(r, principal)) + keys := requestConditionKeys(r, principal) + maps.Copy(keys, actionKeys) + return gw.evaluatePrincipalPolicyResources(principal, policy.IAMAction(service, action), resources, keys) } // requestConditionKeys resolves the IAM condition context keys available on the @@ -656,6 +679,8 @@ func requestConditionKeys(r *http.Request, principal principalContext) iampolicy if v, ok := principalTypeCondition(principal.principalType); ok { keys[iampolicy.KeyPrincipalType] = v } + // The server clock, never a request header, as aws:CurrentTime is in AWS. + keys.SetRequestTime(time.Now()) return keys } @@ -855,7 +880,7 @@ func (gw *GatewayConfig) ErrorHandler(w http.ResponseWriter, r *http.Request, er // EKS, ECR, ACM, ECS, tagging, and the bedrock family use AWS JSON 1.1; // query/XML services fall through. if jsonErrorService(svc) { - body := GenerateEKSErrorResponse(code, errorMsg.Message, requestId) + body := gateway_eks.GenerateEKSErrorResponse(code, errorMsg.Message) slog.Debug("Generated JSON error response", "service", svc, "error", err, "code", code, "json", string(body), "requestId", requestId) w.Header().Set("Content-Type", eksJSONContentType) w.Header().Set("X-Amzn-Errortype", jsonErrorType(code)) diff --git a/spinifex/gateway/gateway_test.go b/spinifex/gateway/gateway_test.go index b573c51ac..83e61da83 100644 --- a/spinifex/gateway/gateway_test.go +++ b/spinifex/gateway/gateway_test.go @@ -22,6 +22,7 @@ import ( "github.com/mulgadc/spinifex/internal/testkit" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" + "github.com/mulgadc/spinifex/spinifex/utils" "github.com/nats-io/nats.go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -1124,9 +1125,10 @@ func TestEC2Request_MissingAccountID(t *testing.T) { assert.Equal(t, awserrors.ErrorInternalError, err.Error()) } +// Static attributes are served without NATS; only default-vpc needs a lookup. func TestEC2Request_DescribeAccountAttributes(t *testing.T) { gw := &GatewayConfig{DisableLogging: true, NATSConn: nil, IAMService: allowAllIAMService()} - req := setupEC2Request("Action=DescribeAccountAttributes", "123456789012") + req := setupEC2Request("Action=DescribeAccountAttributes&AttributeName.1=max-instances", "123456789012") w := httptest.NewRecorder() err := gw.EC2_Request(w, req) @@ -1140,6 +1142,32 @@ func TestEC2Request_DescribeAccountAttributes(t *testing.T) { assert.Contains(t, string(body), "DescribeAccountAttributesResponse") } +// default-vpc is the caller's own default VPC, asked for with the is-default +// filter so a deleted default VPC reads as none. +func TestEC2Request_DescribeAccountAttributes_DefaultVPC(t *testing.T) { + nc := startTestNATS(t) + sub, err := nc.Subscribe("ec2.DescribeVpcs", func(msg *nats.Msg) { + var in awsec2.DescribeVpcsInput + if err := json.Unmarshal(msg.Data, &in); err != nil || len(in.Filters) != 1 || + aws.StringValue(in.Filters[0].Name) != "is-default" || msg.Header.Get(utils.AccountIDHeader) != "123456789012" { + _ = msg.Respond([]byte(`{}`)) + return + } + data, _ := json.Marshal(awsec2.DescribeVpcsOutput{Vpcs: []*awsec2.Vpc{{VpcId: aws.String("vpc-0default"), IsDefault: aws.Bool(true)}}}) + _ = msg.Respond(data) + }) + require.NoError(t, err) + t.Cleanup(func() { _ = sub.Unsubscribe() }) + + gw := &GatewayConfig{DisableLogging: true, NATSConn: nc, IAMService: allowAllIAMService()} + req := setupEC2Request("Action=DescribeAccountAttributes&AttributeName.1=default-vpc", "123456789012") + w := httptest.NewRecorder() + + require.NoError(t, gw.EC2_Request(w, req)) + body, _ := io.ReadAll(w.Result().Body) + assert.Contains(t, string(body), "vpc-0default") +} + func TestEC2Request_DescribeAvailabilityZones(t *testing.T) { gw := &GatewayConfig{ DisableLogging: true, diff --git a/spinifex/gateway/iam/group.go b/spinifex/gateway/iam/group.go index e5f3e9dba..352f88989 100644 --- a/spinifex/gateway/iam/group.go +++ b/spinifex/gateway/iam/group.go @@ -34,6 +34,9 @@ func GetGroup(accountID string, input *iam.GetGroupInput, svc handlers_iam.IAMSe } func ListGroups(accountID string, input *iam.ListGroupsInput, svc handlers_iam.IAMService) (*iam.ListGroupsOutput, error) { + if err := validatePathPrefix(input.PathPrefix, identityPathPrefix); err != nil { + return nil, err + } p, err := newPager(input.Marker, input.MaxItems) if err != nil { return nil, err @@ -115,6 +118,9 @@ func ListAttachedGroupPolicies(accountID string, input *iam.ListAttachedGroupPol if input.GroupName == nil || *input.GroupName == "" { return nil, errors.New(awserrors.ErrorMissingParameter) } + if err := validatePathPrefix(input.PathPrefix, policyPathPrefix); err != nil { + return nil, err + } p, err := newPager(input.Marker, input.MaxItems) if err != nil { return nil, err diff --git a/spinifex/gateway/iam/instance_profile.go b/spinifex/gateway/iam/instance_profile.go index 860395249..e07ea57b4 100644 --- a/spinifex/gateway/iam/instance_profile.go +++ b/spinifex/gateway/iam/instance_profile.go @@ -34,6 +34,9 @@ func GetInstanceProfile(accountID string, input *iam.GetInstanceProfileInput, sv } func ListInstanceProfiles(accountID string, input *iam.ListInstanceProfilesInput, svc handlers_iam.IAMService) (*iam.ListInstanceProfilesOutput, error) { + if err := validatePathPrefix(input.PathPrefix, identityPathPrefix); err != nil { + return nil, err + } p, err := newPager(input.Marker, input.MaxItems) if err != nil { return nil, err diff --git a/spinifex/gateway/iam/pagination_test.go b/spinifex/gateway/iam/pagination_test.go index 877114560..c246ccc3c 100644 --- a/spinifex/gateway/iam/pagination_test.go +++ b/spinifex/gateway/iam/pagination_test.go @@ -148,12 +148,12 @@ func TestListPolicyVersions_PagesNewestFirst(t *testing.T) { return got } - first, err := gateway_iam.ListPolicyVersions(testAccountID, &iam.ListPolicyVersionsInput{PolicyArn: aws.String("arn"), MaxItems: aws.Int64(2)}, svc) + first, err := gateway_iam.ListPolicyVersions(testAccountID, &iam.ListPolicyVersionsInput{PolicyArn: aws.String("arn:aws:iam::aws:policy/ReadOnlyAccess"), MaxItems: aws.Int64(2)}, svc) require.NoError(t, err) assert.Equal(t, []string{"v10", "v9"}, ids(first)) assert.True(t, aws.BoolValue(first.IsTruncated)) - second, err := gateway_iam.ListPolicyVersions(testAccountID, &iam.ListPolicyVersionsInput{PolicyArn: aws.String("arn"), MaxItems: aws.Int64(2), Marker: first.Marker}, svc) + second, err := gateway_iam.ListPolicyVersions(testAccountID, &iam.ListPolicyVersionsInput{PolicyArn: aws.String("arn:aws:iam::aws:policy/ReadOnlyAccess"), MaxItems: aws.Int64(2), Marker: first.Marker}, svc) require.NoError(t, err) assert.Equal(t, []string{"v2"}, ids(second)) assert.False(t, aws.BoolValue(second.IsTruncated)) @@ -169,7 +169,7 @@ func TestListEntitiesForPolicy_PagesAcrossAllThreeLists(t *testing.T) { PolicyGroups: []*iam.PolicyGroup{{GroupName: aws.String("g1")}}, } }} - input := &iam.ListEntitiesForPolicyInput{PolicyArn: aws.String("arn"), MaxItems: aws.Int64(2)} + input := &iam.ListEntitiesForPolicyInput{PolicyArn: aws.String("arn:aws:iam::aws:policy/ReadOnlyAccess"), MaxItems: aws.Int64(2)} first, err := gateway_iam.ListEntitiesForPolicy(testAccountID, input, svc) require.NoError(t, err) diff --git a/spinifex/gateway/iam/pathprefix.go b/spinifex/gateway/iam/pathprefix.go new file mode 100644 index 000000000..51b456554 --- /dev/null +++ b/spinifex/gateway/iam/pathprefix.go @@ -0,0 +1,34 @@ +package gateway_iam + +import ( + "regexp" + + "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" +) + +const maxPathPrefixLength = 512 + +// The three PathPrefix shapes in the IAM model: identity lists, policy lists +// (a whole path, trailing slash included), and ListEntitiesForPolicy. +var ( + identityPathPrefix = regexp.MustCompile(`^/[\x21-\x7F]*$`) + policyPathPrefix = regexp.MustCompile(`^(/[A-Za-z0-9.,+@=_-]+)*/$`) + entityPathPrefix = regexp.MustCompile(`^(/|/[\x21-\x7E]+/)$`) +) + +// validatePathPrefix checks an optional PathPrefix as AWS does: the pattern +// first, with IAM's own message, then the model's length limit. +func validatePathPrefix(prefix *string, pattern *regexp.Regexp) error { + if prefix == nil { + return nil + } + if !pattern.MatchString(*prefix) { + return awserrors.Errorf(awserrors.ErrorValidationError, + "The specified value for pathPrefix is invalid. It must begin with the / character and contain only alphanumeric characters and/or / characters.") + } + if len(*prefix) > maxPathPrefixLength { + return awserrors.Errorf(awserrors.ErrorValidationError, + "1 validation error detected: Value at 'pathPrefix' failed to satisfy constraint: Member must have length less than or equal to %d", maxPathPrefixLength) + } + return nil +} diff --git a/spinifex/gateway/iam/pathprefix_test.go b/spinifex/gateway/iam/pathprefix_test.go new file mode 100644 index 000000000..36d89d620 --- /dev/null +++ b/spinifex/gateway/iam/pathprefix_test.go @@ -0,0 +1,118 @@ +package gateway_iam_test + +import ( + "strings" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/iam" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" + gateway_iam "github.com/mulgadc/spinifex/spinifex/gateway/iam" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The messages are the ones AWS returned for the same PathPrefix. +const ( + badPathPrefix = "The specified value for pathPrefix is invalid. It must begin with the / character and contain only alphanumeric characters and/or / characters." + longPathPrefix = "1 validation error detected: Value at 'pathPrefix' failed to satisfy constraint: Member must have length less than or equal to 512" +) + +// Each list is refused before the service runs; the stub panics if called. +func TestListCalls_RejectPathPrefixBeforeListing(t *testing.T) { + lists := map[string]func(prefix string) error{ + "ListUsers": func(p string) error { + _, err := gateway_iam.ListUsers(testAccountID, &iam.ListUsersInput{PathPrefix: aws.String(p)}, &listStub{}) + return err + }, + "ListRoles": func(p string) error { + _, err := gateway_iam.ListRoles(testAccountID, &iam.ListRolesInput{PathPrefix: aws.String(p)}, &listStub{}) + return err + }, + "ListGroups": func(p string) error { + _, err := gateway_iam.ListGroups(testAccountID, &iam.ListGroupsInput{PathPrefix: aws.String(p)}, &listStub{}) + return err + }, + "ListInstanceProfiles": func(p string) error { + _, err := gateway_iam.ListInstanceProfiles(testAccountID, &iam.ListInstanceProfilesInput{PathPrefix: aws.String(p)}, &listStub{}) + return err + }, + "ListPolicies": func(p string) error { + _, err := gateway_iam.ListPolicies(testAccountID, &iam.ListPoliciesInput{PathPrefix: aws.String(p)}, &listStub{}) + return err + }, + "ListEntitiesForPolicy": func(p string) error { + _, err := gateway_iam.ListEntitiesForPolicy(testAccountID, &iam.ListEntitiesForPolicyInput{ + PolicyArn: aws.String("arn:aws:iam::aws:policy/ReadOnlyAccess"), PathPrefix: aws.String(p), + }, &listStub{}) + return err + }, + "ListAttachedUserPolicies": func(p string) error { + _, err := gateway_iam.ListAttachedUserPolicies(testAccountID, &iam.ListAttachedUserPoliciesInput{UserName: aws.String("u"), PathPrefix: aws.String(p)}, &listStub{}) + return err + }, + "ListAttachedRolePolicies": func(p string) error { + _, err := gateway_iam.ListAttachedRolePolicies(testAccountID, &iam.ListAttachedRolePoliciesInput{RoleName: aws.String("r"), PathPrefix: aws.String(p)}, &listStub{}) + return err + }, + "ListAttachedGroupPolicies": func(p string) error { + _, err := gateway_iam.ListAttachedGroupPolicies(testAccountID, &iam.ListAttachedGroupPoliciesInput{GroupName: aws.String("g"), PathPrefix: aws.String(p)}, &listStub{}) + return err + }, + } + cases := []struct { + name string + prefix string + wantMsg string + }{ + {"empty", "", badPathPrefix}, + {"no leading slash", "awsdiff", badPathPrefix}, + {"space", "/bad path/", badPathPrefix}, + {"over 512 characters", "/" + strings.Repeat("a", 511) + "/", longPathPrefix}, + } + for op, list := range lists { + for _, tc := range cases { + t.Run(op+"/"+tc.name, func(t *testing.T) { + err := list(tc.prefix) + require.Error(t, err) + code, msg, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok, "error must carry a registered code: %v", err) + assert.Equal(t, awserrors.ErrorValidationError, code) + assert.Equal(t, tc.wantMsg, msg) + }) + } + } +} + +func TestListUsers_PathPrefixAccepted(t *testing.T) { + for _, prefix := range []string{"/", "/~/", "/team", "/" + strings.Repeat("a", 511)} { + svc := &listStub{users: []string{"a"}} + _, err := gateway_iam.ListUsers(testAccountID, &iam.ListUsersInput{PathPrefix: aws.String(prefix)}, svc) + require.NoError(t, err, prefix) + assert.Equal(t, 1, svc.calls) + } +} + +// Policy paths end in a slash, so a prefix without one breaks the pattern +// before its length is checked. +func TestListPolicies_PathPrefixPatternBeforeLength(t *testing.T) { + _, err := gateway_iam.ListPolicies(testAccountID, &iam.ListPoliciesInput{PathPrefix: aws.String("/" + strings.Repeat("a", 512))}, &listStub{}) + _, msg, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok, "error must carry a registered code: %v", err) + assert.Equal(t, badPathPrefix, msg) +} + +// The messages are the ones AWS returned for a 19- and a 2049-character ARN. +func TestListEntitiesForPolicy_PolicyArnLength(t *testing.T) { + cases := map[string]string{ + "arn:aws:iam::aws:x": "1 validation error detected: Value at 'policyArn' failed to satisfy constraint: Member must have length greater than or equal to 20", + "arn:aws:iam::aws:policy/" + strings.Repeat("p", 2025): "1 validation error detected: Value at 'policyArn' failed to satisfy constraint: Member must have length less than or equal to 2048", + } + for arn, want := range cases { + _, err := gateway_iam.ListEntitiesForPolicy(testAccountID, &iam.ListEntitiesForPolicyInput{PolicyArn: aws.String(arn)}, &listStub{}) + code, msg, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok, "error must carry a registered code: %v", err) + assert.Equal(t, awserrors.ErrorValidationError, code) + assert.Equal(t, want, msg) + } +} diff --git a/spinifex/gateway/iam/policy.go b/spinifex/gateway/iam/policy.go index 8df2d3051..2a20fa68e 100644 --- a/spinifex/gateway/iam/policy.go +++ b/spinifex/gateway/iam/policy.go @@ -2,6 +2,7 @@ package gateway_iam import ( "errors" + "fmt" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/iam" @@ -9,6 +10,12 @@ import ( handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" ) +// The model's arnType length bounds. +const ( + minARNLength = 20 + maxARNLength = 2048 +) + func CreatePolicy(accountID string, input *iam.CreatePolicyInput, svc handlers_iam.IAMService) (*iam.CreatePolicyOutput, error) { if input.PolicyName == nil || *input.PolicyName == "" { return nil, errors.New(awserrors.ErrorMissingParameter) @@ -91,6 +98,9 @@ func DeletePolicyVersion(accountID string, input *iam.DeletePolicyVersionInput, } func ListPolicies(accountID string, input *iam.ListPoliciesInput, svc handlers_iam.IAMService) (*iam.ListPoliciesOutput, error) { + if err := validatePathPrefix(input.PathPrefix, policyPathPrefix); err != nil { + return nil, err + } p, err := newPager(input.Marker, input.MaxItems) if err != nil { return nil, err @@ -115,6 +125,17 @@ func ListEntitiesForPolicy(accountID string, input *iam.ListEntitiesForPolicyInp if input.PolicyArn == nil || *input.PolicyArn == "" { return nil, errors.New(awserrors.ErrorMissingParameter) } + if n := len(*input.PolicyArn); n < minARNLength || n > maxARNLength { + bound := fmt.Sprintf("greater than or equal to %d", minARNLength) + if n > maxARNLength { + bound = fmt.Sprintf("less than or equal to %d", maxARNLength) + } + return nil, awserrors.Errorf(awserrors.ErrorValidationError, + "1 validation error detected: Value at 'policyArn' failed to satisfy constraint: Member must have length %s", bound) + } + if err := validatePathPrefix(input.PathPrefix, entityPathPrefix); err != nil { + return nil, err + } p, err := newPager(input.Marker, input.MaxItems) if err != nil { return nil, err @@ -187,6 +208,9 @@ func ListAttachedUserPolicies(accountID string, input *iam.ListAttachedUserPolic if input.UserName == nil || *input.UserName == "" { return nil, errors.New(awserrors.ErrorMissingParameter) } + if err := validatePathPrefix(input.PathPrefix, policyPathPrefix); err != nil { + return nil, err + } p, err := newPager(input.Marker, input.MaxItems) if err != nil { return nil, err diff --git a/spinifex/gateway/iam/role.go b/spinifex/gateway/iam/role.go index 7d86e7a07..237efa6fc 100644 --- a/spinifex/gateway/iam/role.go +++ b/spinifex/gateway/iam/role.go @@ -37,6 +37,9 @@ func GetRole(accountID string, input *iam.GetRoleInput, svc handlers_iam.IAMServ } func ListRoles(accountID string, input *iam.ListRolesInput, svc handlers_iam.IAMService) (*iam.ListRolesOutput, error) { + if err := validatePathPrefix(input.PathPrefix, identityPathPrefix); err != nil { + return nil, err + } p, err := newPager(input.Marker, input.MaxItems) if err != nil { return nil, err @@ -99,6 +102,9 @@ func ListAttachedRolePolicies(accountID string, input *iam.ListAttachedRolePolic if input.RoleName == nil || *input.RoleName == "" { return nil, errors.New(awserrors.ErrorMissingParameter) } + if err := validatePathPrefix(input.PathPrefix, policyPathPrefix); err != nil { + return nil, err + } p, err := newPager(input.Marker, input.MaxItems) if err != nil { return nil, err diff --git a/spinifex/gateway/iam/user.go b/spinifex/gateway/iam/user.go index be5042d91..22f8d8cc7 100644 --- a/spinifex/gateway/iam/user.go +++ b/spinifex/gateway/iam/user.go @@ -24,6 +24,9 @@ func GetUser(accountID string, input *iam.GetUserInput, svc handlers_iam.IAMServ } func ListUsers(accountID string, input *iam.ListUsersInput, svc handlers_iam.IAMService) (*iam.ListUsersOutput, error) { + if err := validatePathPrefix(input.PathPrefix, identityPathPrefix); err != nil { + return nil, err + } p, err := newPager(input.Marker, input.MaxItems) if err != nil { return nil, err diff --git a/spinifex/gateway/notselectors_test.go b/spinifex/gateway/notselectors_test.go new file mode 100644 index 000000000..86f4b673e --- /dev/null +++ b/spinifex/gateway/notselectors_test.go @@ -0,0 +1,37 @@ +//test:in-package — drives EC2_Request through the gateway's unexported test +// helpers (scopedPolicyGateway, dispatchEC2). + +package gateway + +import ( + "testing" + + handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" +) + +// The PowerUserAccess shape: an Allow on everything except the listed actions. +func TestEC2Request_AllowNotActionGrantsTheComplement(t *testing.T) { + gw := scopedPolicyGateway(handlers_iam.Statement{ + Effect: "Allow", + NotAction: handlers_iam.StringOrArr{"iam:*", "ec2:TerminateInstances"}, + Resource: handlers_iam.StringOrArr{"*"}, + }) + + assertPermitted(t, dispatchEC2(t, gw, "Action=StopInstances&InstanceId.1=i-dev")) + assertUnauthorized(t, dispatchEC2(t, gw, "Action=TerminateInstances&InstanceId.1=i-dev")) +} + +// A Deny on NotResource fences every instance but the named one. +func TestEC2Request_DenyNotResourceFencesTheComplement(t *testing.T) { + gw := scopedPolicyGateway( + statement("Allow", "ec2:*", "*"), + handlers_iam.Statement{ + Effect: "Deny", + Action: handlers_iam.StringOrArr{"ec2:TerminateInstances"}, + NotResource: handlers_iam.StringOrArr{"arn:aws:ec2:*:*:instance/i-dev"}, + }, + ) + + assertPermitted(t, dispatchEC2(t, gw, "Action=TerminateInstances&InstanceId.1=i-dev")) + assertUnauthorized(t, dispatchEC2(t, gw, "Action=TerminateInstances&InstanceId.1=i-prod")) +} diff --git a/spinifex/gateway/oidc_discovery_test.go b/spinifex/gateway/oidc_discovery_test.go index a86a23cac..ad1d370ac 100644 --- a/spinifex/gateway/oidc_discovery_test.go +++ b/spinifex/gateway/oidc_discovery_test.go @@ -23,7 +23,7 @@ func seedDiscoveryCluster(t *testing.T) http.Handler { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - kv, err := handlers_eks.GetOrCreateAccountBucket(t.Context(), js, testDiscAccount, 1) + kv, err := handlers_eks.GetOrCreateAccountBucket(t.Context(), js, testDiscAccount) if err != nil { t.Fatalf("account bucket: %v", err) } diff --git a/spinifex/gateway/passedtoservice_test.go b/spinifex/gateway/passedtoservice_test.go new file mode 100644 index 000000000..a8ad74056 --- /dev/null +++ b/spinifex/gateway/passedtoservice_test.go @@ -0,0 +1,143 @@ +package gateway + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/mulgadc/bluebottle/pkg/iampolicy" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" + handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// passRoleTo is a PassRole statement on every role, conditioned on the +// consuming service the way AWS's documented examples scope it. +func passRoleTo(effect, operator, service string) handlers_iam.Statement { + s := statement(effect, "iam:PassRole", "*") + s.Condition = map[string]map[string]handlers_iam.ConditionValue{ + operator: {iampolicy.KeyPassedToService: {service}}, + } + return s +} + +func passRoleToGateway(t *testing.T, statements ...handlers_iam.Statement) *GatewayConfig { + t.Helper() + docs := []handlers_iam.PolicyDocument{{Version: "2012-10-17", Statement: statements}} + return &GatewayConfig{ + DisableLogging: true, + Region: authzRegion, + NATSConn: startTestNATS(t), + IAMService: &profileIAMService{ + policyMockIAMService: policyMockIAMService{ + getUserPoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) { return docs, nil }, + }, + }, + } +} + +func associateProfile(t *testing.T, gw *GatewayConfig) error { + t.Helper() + return dispatchEC2(t, gw, "Action=AssociateIamInstanceProfile&InstanceId=i-dev&IamInstanceProfile.Name=web") +} + +func registerTaskDefinition(t *testing.T, gw *GatewayConfig) error { + t.Helper() + req := setupECSUserRequest("AmazonEC2ContainerServiceV20141113.RegisterTaskDefinition", + registerTaskDefinitionBody(t, testECSPassRoleTargetARN)) + return gw.ECS_Request(httptest.NewRecorder(), req) +} + +func createNodegroup(t *testing.T, gw *GatewayConfig) error { + t.Helper() + return dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups", createNodegroupBody(eksNodeRoleARN)) +} + +func assertNotUnauthorized(t *testing.T, err error) { + t.Helper() + require.Error(t, err) + assert.NotEqual(t, awserrors.ErrorUnauthorizedOperation, err.Error()) +} + +// Each EC2 action that hands over an instance profile builds its own PassRole +// check, so every one is pinned to ec2.amazonaws.com. +func TestPassedToService_EC2(t *testing.T) { + tests := []struct { + action string + body string + }{ + {"RunInstances", "Action=RunInstances&ImageId=ami-1&InstanceType=t3.micro&MinCount=1&MaxCount=1&IamInstanceProfile.Name=web"}, + {"AssociateIamInstanceProfile", "Action=AssociateIamInstanceProfile&InstanceId=i-dev&IamInstanceProfile.Name=web"}, + {"ReplaceIamInstanceProfileAssociation", "Action=ReplaceIamInstanceProfileAssociation" + + "&AssociationId=iip-assoc-dev&IamInstanceProfile.Name=web"}, + {"RequestSpotInstances", "Action=RequestSpotInstances&LaunchSpecification.ImageId=ami-1" + + "&LaunchSpecification.InstanceType=t3.micro&LaunchSpecification.IamInstanceProfile.Name=web"}, + } + for _, tt := range tests { + t.Run(tt.action, func(t *testing.T) { + assertNotUnauthorized(t, dispatchEC2(t, passRoleToGateway(t, + statement("Allow", "ec2:*", "*"), + passRoleTo("Allow", iampolicy.OpStringEquals, "ec2.amazonaws.com"), + ), tt.body)) + assertUnauthorized(t, dispatchEC2(t, passRoleToGateway(t, + statement("Allow", "ec2:*", "*"), + passRoleTo("Allow", iampolicy.OpStringEquals, "ecs-tasks.amazonaws.com"), + ), tt.body)) + }) + } +} + +func TestPassedToService_ECS(t *testing.T) { + assertNotDenied(t, registerTaskDefinition(t, passRoleToGateway(t, + statement("Allow", "ecs:*", "*"), + passRoleTo("Allow", iampolicy.OpStringLike, "ecs-tasks.*"), + ))) + assertDenied(t, registerTaskDefinition(t, passRoleToGateway(t, + statement("Allow", "ecs:*", "*"), + passRoleTo("Allow", iampolicy.OpStringEquals, "ec2.amazonaws.com"), + ))) +} + +// AWS reports a nodegroup's node role as passed to EKS, not to the EC2 +// instances that end up running under it. +func TestPassedToService_EKS(t *testing.T) { + allowed := eksPassRoleGateway( + statement("Allow", "eks:*", "*"), + passRoleTo("Allow", iampolicy.OpStringEquals, "eks.amazonaws.com"), + ) + assertPermitted(t, createNodegroup(t, allowed)) + assertPermitted(t, dispatchEKS(t, allowed, http.MethodPost, "/clusters", + `{"name":"prod","roleArn":"`+eksNodeRoleARN+`"}`)) + + denied := eksPassRoleGateway( + statement("Allow", "eks:*", "*"), + passRoleTo("Allow", iampolicy.OpStringEquals, "ec2.amazonaws.com"), + ) + assertDenied(t, createNodegroup(t, denied)) + assertDenied(t, dispatchEKS(t, denied, http.MethodPost, "/clusters", + `{"name":"prod","roleArn":"`+eksNodeRoleARN+`"}`)) +} + +// A Deny scoped to one service fences that service and leaves the others to +// the unconditional grant. +func TestPassedToService_DenyFencesOneService(t *testing.T) { + statements := []handlers_iam.Statement{ + statement("Allow", "ec2:*", "*"), + statement("Allow", "ecs:*", "*"), + statement("Allow", "iam:PassRole", "*"), + passRoleTo("Deny", iampolicy.OpStringEquals, "ecs-tasks.amazonaws.com"), + } + assertDenied(t, registerTaskDefinition(t, passRoleToGateway(t, statements...))) + assertNotUnauthorized(t, associateProfile(t, passRoleToGateway(t, statements...))) +} + +// The key exists only on a PassRole check, so a condition on it attached to +// any other action does not hold. +func TestPassedToService_AbsentOutsidePassRole(t *testing.T) { + grant := statement("Allow", "ec2:*", "*") + grant.Condition = map[string]map[string]handlers_iam.ConditionValue{ + iampolicy.OpStringEquals: {iampolicy.KeyPassedToService: {"ec2.amazonaws.com"}}, + } + assertUnauthorized(t, dispatchEC2(t, scopedPolicyGateway(grant), "Action=DeleteVolume")) +} diff --git a/spinifex/gateway/rds/enginecatalog.go b/spinifex/gateway/rds/enginecatalog.go index dec2f7410..0a4fc4fbb 100644 --- a/spinifex/gateway/rds/enginecatalog.go +++ b/spinifex/gateway/rds/enginecatalog.go @@ -17,9 +17,8 @@ import ( "github.com/nats-io/nats.go" ) -// The filter names each action recognises. Rejecting an unknown name is only -// implementable against a closed vocabulary, and the two actions take disjoint -// typed parameters, so each keeps its own list rather than sharing one. +// The filter names each action recognises, compared case-sensitively as AWS +// does. The two actions take disjoint typed parameters, so each keeps its own list. const ( filterNameEngine = "engine" filterNameEngineVersion = "engine-version" @@ -43,46 +42,42 @@ var ( // filter here rather than a required parameter, and an unknown one is an empty // list rather than the rejection create-db-instance gives it. func DescribeDBEngineVersions(ctx context.Context, input *rds.DescribeDBEngineVersionsInput, _ *nats.Conn, _ Caller) (any, error) { - if err := rejectMarker(input.Marker, "DescribeDBEngineVersions"); err != nil { - return nil, err - } - filter := handlers_rds.EngineVersionFilter{} filter.Engine.AddParam(aws.StringValue(input.Engine)) filter.EngineVersion.AddParam(aws.StringValue(input.EngineVersion)) filter.ParameterGroupFamily.AddParam(aws.StringValue(input.DBParameterGroupFamily)) - for _, entry := range input.Filters { - name, values, err := filterEntry(entry) - if err != nil { - return nil, err - } - switch name { + entries, err := handlers_rds.ReadFilters(input.Filters, engineVersionFilterNames...) + if err != nil { + return nil, err + } + for _, entry := range entries { + switch entry.Name { case filterNameEngine: - filter.Engine.AddFilter(values) + filter.Engine.AddFilter(entry.Values) case filterNameEngineVersion: - filter.EngineVersion.AddFilter(values) + filter.EngineVersion.AddFilter(entry.Values) case filterNameParameterGroupFamily: - filter.ParameterGroupFamily.AddFilter(values) + filter.ParameterGroupFamily.AddFilter(entry.Values) case filterNameStatus: - filter.Status.AddFilter(values) - default: - return nil, unknownFilterName(name, engineVersionFilterNames) + filter.Status.AddFilter(entry.Values) } } // DefaultOnly, IncludeAll, ListSupportedCharacterSets and ListSupportedTimezones // are accepted and not read: each is an identity on a catalog of one available // version per engine with no character-set or timezone list to populate. - return &rds.DescribeDBEngineVersionsOutput{DBEngineVersions: handlers_rds.EngineVersions(filter)}, nil + versions, marker, err := handlers_rds.Page(handlers_rds.EngineVersions(filter), + handlers_rds.EngineVersionPageKey, input.MaxRecords, input.Marker) + if err != nil { + return nil, err + } + return &rds.DescribeDBEngineVersionsOutput{DBEngineVersions: versions, Marker: marker}, nil } // Engine is required, so an absent one is MissingParameter and an unknown one is // the InvalidParameterValue LookupEngine already words. Everything else narrows. func DescribeOrderableDBInstanceOptions(ctx context.Context, input *rds.DescribeOrderableDBInstanceOptionsInput, nc *nats.Conn, _ Caller, env Env) (any, error) { - if err := rejectMarker(input.Marker, "DescribeOrderableDBInstanceOptions"); err != nil { - return nil, err - } if aws.StringValue(input.AvailabilityZoneGroup) != "" { return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "AvailabilityZoneGroup is not supported: this platform exposes a single zone and names none") @@ -104,28 +99,26 @@ func DescribeOrderableDBInstanceOptions(ctx context.Context, input *rds.Describe filter.Vpc.AddParam(strconv.FormatBool(aws.BoolValue(input.Vpc))) } - for _, entry := range input.Filters { - name, values, err := filterEntry(entry) - if err != nil { - return nil, err - } - switch name { + entries, err := handlers_rds.ReadFilters(input.Filters, orderableFilterNames...) + if err != nil { + return nil, err + } + for _, entry := range entries { + switch entry.Name { case filterNameEngine: - filter.Engine.AddFilter(values) + filter.Engine.AddFilter(entry.Values) case filterNameEngineVersion: - filter.EngineVersion.AddFilter(values) + filter.EngineVersion.AddFilter(entry.Values) case filterNameDBInstanceClass: - filter.DBInstanceClass.AddFilter(values) + filter.DBInstanceClass.AddFilter(entry.Values) case filterNameLicenseModel: - filter.LicenseModel.AddFilter(values) + filter.LicenseModel.AddFilter(entry.Values) case filterNameVpc: - parsed, perr := boolFilterValues(name, values) + parsed, perr := boolFilterValues(entry.Name, entry.Values) if perr != nil { return nil, perr } filter.Vpc.AddFilter(parsed) - default: - return nil, unknownFilterName(name, orderableFilterNames) } } @@ -133,9 +126,12 @@ func DescribeOrderableDBInstanceOptions(ctx context.Context, input *rds.Describe if err != nil { return nil, err } - return &rds.DescribeOrderableDBInstanceOptionsOutput{ - OrderableDBInstanceOptions: handlers_rds.OrderableOptions(filter, runnable), - }, nil + options, marker, err := handlers_rds.Page(handlers_rds.OrderableOptions(filter, runnable), + handlers_rds.OrderableOptionPageKey, input.MaxRecords, input.Marker) + if err != nil { + return nil, err + } + return &rds.DescribeOrderableDBInstanceOptionsOutput{OrderableDBInstanceOptions: options, Marker: marker}, nil } // Which EC2 instance types the cluster's nodes report they can run, as a @@ -150,7 +146,7 @@ func DescribeOrderableDBInstanceOptions(ctx context.Context, input *rds.Describe // collapse both onto the same answer. func clusterRunnableTypes(ctx context.Context, nc *nats.Conn, env Env) (func(string) bool, error) { out, err := gateway_ec2_instance.DescribeInstanceTypes(ctx, &ec2.DescribeInstanceTypesInput{}, - nc, env.ExpectedNodes, utils.GlobalAccountID) + nc, env.ExpectedNodes, nil, utils.GlobalAccountID) if err != nil { slog.ErrorContext(ctx, "RDS: instance-type capability probe failed", "err", err) return nil, errors.New(awserrors.ErrorServerInternal) @@ -171,31 +167,6 @@ func clusterRunnableTypes(ctx context.Context, nc *nats.Conn, env Env) (func(str return func(instanceType string) bool { return supported[instanceType] }, nil } -// Neither action ever issues a Marker, so one in a request can only have been -// fabricated. Answering it as page one would report the whole catalog as if it -// were a later page. -func rejectMarker(marker *string, action string) error { - if aws.StringValue(marker) == "" { - return nil - } - return awserrors.Errorf(awserrors.ErrorInvalidParameterValue, - "Marker is not supported: %s returns every row in a single page", action) -} - -// Both members are required by the shape, and treating either as absent would -// silently widen the result rather than narrow it. -func filterEntry(entry *rds.Filter) (string, []string, error) { - if entry == nil || strings.TrimSpace(aws.StringValue(entry.Name)) == "" { - return "", nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "each filter must carry a name") - } - name := strings.ToLower(strings.TrimSpace(aws.StringValue(entry.Name))) - if len(entry.Values) == 0 { - return "", nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, - "filter %q must carry at least one value", name) - } - return name, aws.StringValueSlice(entry.Values), nil -} - // A bool-shaped filter is parsed rather than compared as text, so it accepts // every spelling the typed parameter does. Matching only "true" and "false" // would answer "1" with an empty catalog the caller cannot tell from a real one. @@ -211,10 +182,3 @@ func boolFilterValues(name string, values []string) ([]string, error) { } return parsed, nil } - -// Rejected rather than ignored: these two actions exist only to be filtered, so -// a dropped filter returns rows the caller asked not to see and cannot detect. -func unknownFilterName(name string, recognised []string) error { - return awserrors.Errorf(awserrors.ErrorInvalidParameterValue, - "filter %q is not recognised; supported filters are %s", name, strings.Join(recognised, ", ")) -} diff --git a/spinifex/gateway/rds/enginecatalog_test.go b/spinifex/gateway/rds/enginecatalog_test.go index 11738190f..8e11b2acb 100644 --- a/spinifex/gateway/rds/enginecatalog_test.go +++ b/spinifex/gateway/rds/enginecatalog_test.go @@ -219,13 +219,25 @@ func TestDescribeOrderableDBInstanceOptions_RejectANonBooleanVpcFilter(t *testin } } +// AWS's answers: names are compared case-sensitively and checked before the +// values, and a missing name is reported as "null". func TestDescribeCatalogs_RejectMalformedFilters(t *testing.T) { cases := []struct { name string query map[string]string + code string + msg string }{ - {"no values", map[string]string{"Filters.Filter.1.Name": "engine"}}, - {"no name", map[string]string{"Filters.Filter.1.Values.Value.1": "postgres"}}, + {"no values", map[string]string{"Filters.Filter.1.Name": "engine"}, + awserrors.ErrorInvalidParameterCombination, "The values list cannot be null for the filter engine."}, + {"empty values", map[string]string{"Filters.Filter.1.Name": "engine", "Filters.Filter.1.Values": ""}, + awserrors.ErrorInvalidParameterCombination, "The values list cannot be null for the filter engine."}, + {"no name", map[string]string{"Filters.Filter.1.Values.Value.1": "postgres"}, + awserrors.ErrorInvalidParameterValue, "Unrecognized filter name: null"}, + {"name case", map[string]string{"Filters.Filter.1.Name": "Engine", "Filters.Filter.1.Values.Value.1": "postgres"}, + awserrors.ErrorInvalidParameterValue, "Unrecognized filter name: Engine"}, + {"unknown name without values", map[string]string{"Filters.Filter.1.Name": "bogus"}, + awserrors.ErrorInvalidParameterValue, "Unrecognized filter name: bogus"}, } for _, tc := range cases { @@ -233,15 +245,38 @@ func TestDescribeCatalogs_RejectMalformedFilters(t *testing.T) { tc.query["Action"] = "DescribeDBEngineVersions" _, err := Dispatch(t.Context(), "DescribeDBEngineVersions", tc.query, nil, testCaller, testEnv) require.Error(t, err) - assert.Contains(t, err.Error(), awserrors.ErrorInvalidParameterValue) + assert.Contains(t, err.Error(), tc.code) + assert.Contains(t, err.Error(), tc.msg) }) } } -// Neither action ever issues a Marker, so one in a request was fabricated by the -// caller. This diverges from the other RDS describes, which parse and ignore it, -// because those may one day paginate and these two never will. -func TestDescribeCatalogs_RejectAMarker(t *testing.T) { +// Following each Marker returns the whole catalog once, in the unpaged order, +// with the Marker surviving the XML round trip. +func TestDescribeOrderableDBInstanceOptions_PagesTheCatalog(t *testing.T) { + nc := newStubbedNATS(t) + var classes []string + marker := "" + for { + query := map[string]string{"Action": "DescribeOrderableDBInstanceOptions", "Engine": "postgres", "MaxRecords": "2"} + if marker != "" { + query["Marker"] = marker + } + body, err := Dispatch(t.Context(), "DescribeOrderableDBInstanceOptions", query, nc, testCaller, testEnv) + require.NoError(t, err) + var out rds.DescribeOrderableDBInstanceOptionsOutput + require.NoError(t, xmlutil.UnmarshalXML(&out, xml.NewDecoder(bytes.NewReader(body)), + "DescribeOrderableDBInstanceOptionsResult")) + require.LessOrEqual(t, len(out.OrderableDBInstanceOptions), 2) + classes = append(classes, classNames(out.OrderableDBInstanceOptions)...) + if marker = aws.StringValue(out.Marker); marker == "" { + break + } + } + assert.Equal(t, handlers_rds.SupportedInstanceClasses(), classes) +} + +func TestDescribeCatalogs_RejectAMarkerTheyDidNotIssue(t *testing.T) { nc := newStubbedNATS(t) for _, action := range []string{"DescribeDBEngineVersions", "DescribeOrderableDBInstanceOptions"} { t.Run(action, func(t *testing.T) { @@ -250,7 +285,6 @@ func TestDescribeCatalogs_RejectAMarker(t *testing.T) { }, nc, testCaller, testEnv) require.Error(t, err) assert.Contains(t, err.Error(), awserrors.ErrorInvalidParameterValue) - assert.Contains(t, err.Error(), "Marker") }) } } diff --git a/spinifex/gateway/spinifex_modelaccess_test.go b/spinifex/gateway/spinifex_modelaccess_test.go index 2b561e06c..b357fe423 100644 --- a/spinifex/gateway/spinifex_modelaccess_test.go +++ b/spinifex/gateway/spinifex_modelaccess_test.go @@ -48,7 +48,7 @@ func accessGateway(t *testing.T) *GatewayConfig { _, _, js := testutil.StartTestJetStream(t) return &GatewayConfig{ DisableLogging: true, - BedrockAccessAdmin: gateway_bedrock.NewModelAccessStore(js, 1), + BedrockAccessAdmin: gateway_bedrock.NewModelAccessStore(js), IAMService: allowAllIAMService(), } } diff --git a/spinifex/gateway/tagging/GetResources.go b/spinifex/gateway/tagging/GetResources.go index e9ebf944c..b686668ec 100644 --- a/spinifex/gateway/tagging/GetResources.go +++ b/spinifex/gateway/tagging/GetResources.go @@ -250,14 +250,9 @@ func matchesAllTagFilters(tags []*rgt.Tag, filters []*rgt.TagFilter) bool { if len(f.Values) == 0 { continue } - matched := false - for _, v := range f.Values { - if aws.StringValue(v) == val { - matched = true - break - } - } - if !matched { + if !slices.ContainsFunc(f.Values, func(v *string) bool { + return aws.StringValue(v) == val + }) { return false } } diff --git a/spinifex/handlers/bedrock/service.go b/spinifex/handlers/bedrock/service.go index 14bf45780..2fff5c196 100644 --- a/spinifex/handlers/bedrock/service.go +++ b/spinifex/handlers/bedrock/service.go @@ -90,7 +90,7 @@ func NewService(nc *nats.Conn, deps ServiceDeps) *Service { return &Service{ nc: nc, deps: deps, - store: newEndpointStore(js, deps.Replicas), + store: newEndpointStore(js), leader: newLeaderBucket(js), } } diff --git a/spinifex/handlers/bedrock/store.go b/spinifex/handlers/bedrock/store.go index 5026a5e0f..bebce6cb6 100644 --- a/spinifex/handlers/bedrock/store.go +++ b/spinifex/handlers/bedrock/store.go @@ -66,15 +66,13 @@ type endpointStore struct { *kvstore.Store[EndpointRecord] } -// newEndpointStore returns the endpoints store over js, replicated across -// replicas nodes. A nil js is permitted: every accessor then reports -// missingJetStream rather than panicking. -func newEndpointStore(js jetstream.JetStream, replicas int) *endpointStore { +// newEndpointStore returns the endpoints store over js. A nil js is permitted: +// every accessor then reports missingJetStream rather than panicking. +func newEndpointStore(js jetstream.JetStream) *endpointStore { return &endpointStore{Store: kvstore.New[EndpointRecord](js, kvstore.Config{ - Name: KVBucketEndpoints, - History: KVBucketEndpointsHistory, - Replicas: replicas, - Missing: missingJetStream, + Name: KVBucketEndpoints, + History: KVBucketEndpointsHistory, + Missing: missingJetStream, })} } diff --git a/spinifex/handlers/bedrock/store_test.go b/spinifex/handlers/bedrock/store_test.go index c6e511015..2715a1c58 100644 --- a/spinifex/handlers/bedrock/store_test.go +++ b/spinifex/handlers/bedrock/store_test.go @@ -17,7 +17,7 @@ func TestEndpointKey_Base64EncodesColonBearingModelID(t *testing.T) { func newTestBucket(t *testing.T) *endpointStore { t.Helper() _, _, js := testutil.StartTestJetStream(t) - return newEndpointStore(js, 1) + return newEndpointStore(js) } func TestStore_CreateGetUpdateDelete(t *testing.T) { diff --git a/spinifex/handlers/bedrock/userdata.go b/spinifex/handlers/bedrock/userdata.go index b00c6a0be..41f7903a2 100644 --- a/spinifex/handlers/bedrock/userdata.go +++ b/spinifex/handlers/bedrock/userdata.go @@ -18,15 +18,11 @@ const bundleAgentEnvDir = "/etc/conf.d/bedrock-bundle" // instance reads from. A model id's ':' and '.' are not valid systemd // instance-name characters, so the id is folded to a safe one first. func memberEnvPath(modelID string) string { - return bundleAgentEnvDir + "/" + sanitizeMemberInstanceName(modelID) + ".env" + return bundleAgentEnvDir + "/" + memberInstanceNameReplacer.Replace(modelID) + ".env" } var memberInstanceNameReplacer = strings.NewReplacer(":", "_", "/", "_", ".", "_") -func sanitizeMemberInstanceName(modelID string) string { - return memberInstanceNameReplacer.Replace(modelID) -} - // engineForFamily selects which serving engine a family runs under: familyMeta // always runs vLLM, every other family (familyTEI included) runs TEI. Mirrors // the invoke-side dispatch in gateway_bedrock, so a family unknown there is diff --git a/spinifex/handlers/ec2/eigw/service_impl.go b/spinifex/handlers/ec2/eigw/service_impl.go index 0efabf2c0..a722e8291 100644 --- a/spinifex/handlers/ec2/eigw/service_impl.go +++ b/spinifex/handlers/ec2/eigw/service_impl.go @@ -2,6 +2,7 @@ package handlers_ec2_eigw import ( "context" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -89,7 +90,7 @@ func (s *EgressOnlyIGWServiceImpl) CreateEgressOnlyInternetGateway(ctx context.C } if _, err := s.vpcKV.Get(ctx, utils.AccountKey(accountID, *input.VpcId)); err != nil { slog.WarnContext(ctx, "CreateEgressOnlyInternetGateway: VPC not found for account", "vpcId", *input.VpcId, "accountID", accountID) - return nil, errors.New(awserrors.ErrorInvalidVpcIDNotFound) + return nil, awserrors.IDNotFound(awserrors.ErrorInvalidVpcIDNotFound, "vpc", *input.VpcId) } eigwID := utils.GenerateResourceID("eigw") @@ -129,7 +130,7 @@ func (s *EgressOnlyIGWServiceImpl) DeleteEgressOnlyInternetGateway(ctx context.C // Verify the EIGW exists before deleting if _, err := s.eigwKV.Get(ctx, key); err != nil { if errors.Is(err, jetstream.ErrKeyNotFound) { - return nil, errors.New(awserrors.ErrorInvalidEgressOnlyInternetGatewayIdNotFound) + return nil, awserrors.Errorf(awserrors.ErrorInvalidGatewayIDNotFound, "The eigw ID '%s' does not exist", eigwID) } return nil, errors.New(awserrors.ErrorServerInternal) } @@ -147,6 +148,8 @@ func (s *EgressOnlyIGWServiceImpl) DeleteEgressOnlyInternetGateway(ctx context.C // describeEIGWValidFilters defines the set of filter names accepted by DescribeEgressOnlyInternetGateways. var describeEIGWValidFilters = map[string]bool{ "egress-only-internet-gateway-id": true, + "tag-key": true, + "tag-value": true, } // DescribeEgressOnlyInternetGateways describes Egress-only Internet Gateways. @@ -165,6 +168,11 @@ func (s *EgressOnlyIGWServiceImpl) DescribeEgressOnlyInternetGateways(ctx contex slog.WarnContext(ctx, "DescribeEgressOnlyInternetGateways: invalid filter", "err", err) return nil, err } + for _, id := range input.EgressOnlyInternetGatewayIds { + if id != nil && !eigwIDWellFormed(*id) { + return nil, awserrors.Errorf(awserrors.ErrorInvalidEgressOnlyInternetGatewayIdMalformed, "The eigw ID %s is malformed", *id) + } + } prefix := accountID + "." keys, err := s.eigwKV.Keys(ctx) @@ -210,10 +218,22 @@ func (s *EgressOnlyIGWServiceImpl) DescribeEgressOnlyInternetGateways(ctx contex }, nil } +// eigwIDWellFormed reports whether AWS accepts id as an egress-only gateway ID: +// "eigw-" and exactly 17 hex digits of either case. A well-formed unknown ID is +// not an error; the describe returns no gateway for it. +func eigwIDWellFormed(id string) bool { + suffix, ok := strings.CutPrefix(id, "eigw-") + if !ok || len(suffix) != 17 { + return false + } + _, err := hex.DecodeString("0" + suffix) + return err == nil +} + // eigwMatchesFilters checks whether an EgressOnlyIGWRecord satisfies all parsed awsfilters. func eigwMatchesFilters(record *EgressOnlyIGWRecord, filters map[string][]string) bool { for name, values := range filters { - if strings.HasPrefix(name, "tag:") { + if awsfilters.IsTagFilter(name) { continue } diff --git a/spinifex/handlers/ec2/eigw/service_impl_test.go b/spinifex/handlers/ec2/eigw/service_impl_test.go index 76b48420f..b49b45d8d 100644 --- a/spinifex/handlers/ec2/eigw/service_impl_test.go +++ b/spinifex/handlers/ec2/eigw/service_impl_test.go @@ -6,8 +6,9 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ec2" - handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc" "github.com/mulgadc/spinifex/internal/testkit" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" + handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc" "github.com/mulgadc/spinifex/spinifex/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -133,7 +134,10 @@ func TestDeleteEgressOnlyInternetGateway_NotFound(t *testing.T) { _, err := svc.DeleteEgressOnlyInternetGateway(context.Background(), &ec2.DeleteEgressOnlyInternetGatewayInput{ EgressOnlyInternetGatewayId: aws.String("eigw-nonexistent"), }, testAccountID) - assert.ErrorContains(t, err, "InvalidEgressOnlyInternetGatewayId.NotFound") + code, msg, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok, "error %v carries no AWS code", err) + assert.Equal(t, awserrors.ErrorInvalidGatewayIDNotFound, code) + assert.Equal(t, "The eigw ID 'eigw-nonexistent' does not exist", msg) } func TestDeleteEgressOnlyInternetGateway_MissingID(t *testing.T) { @@ -270,6 +274,36 @@ func TestDescribeEgressOnlyInternetGateways_FilterByTag(t *testing.T) { assert.Equal(t, *out.EgressOnlyInternetGateway.EgressOnlyInternetGatewayId, *desc.EgressOnlyInternetGateways[0].EgressOnlyInternetGatewayId) } +func TestDescribeEgressOnlyInternetGateways_FilterByTagKeyAndTagValue(t *testing.T) { + svc := setupTestEIGWService(t) + out, err := svc.CreateEgressOnlyInternetGateway(context.Background(), &ec2.CreateEgressOnlyInternetGatewayInput{ + VpcId: aws.String("vpc-tagged"), + TagSpecifications: []*ec2.TagSpecification{ + { + ResourceType: aws.String("egress-only-internet-gateway"), + Tags: []*ec2.Tag{{Key: aws.String("Env"), Value: aws.String("prod")}}, + }, + }, + }, testAccountID) + require.NoError(t, err) + createTestEIGW(t, svc) // untagged + + for _, tc := range []struct{ name, value string }{{"tag-key", "Env"}, {"tag-value", "prod"}} { + desc, err := svc.DescribeEgressOnlyInternetGateways(context.Background(), &ec2.DescribeEgressOnlyInternetGatewaysInput{ + Filters: []*ec2.Filter{{Name: aws.String(tc.name), Values: []*string{aws.String(tc.value)}}}, + }, testAccountID) + require.NoError(t, err, tc.name) + require.Len(t, desc.EgressOnlyInternetGateways, 1, tc.name) + assert.Equal(t, *out.EgressOnlyInternetGateway.EgressOnlyInternetGatewayId, *desc.EgressOnlyInternetGateways[0].EgressOnlyInternetGatewayId, tc.name) + + desc, err = svc.DescribeEgressOnlyInternetGateways(context.Background(), &ec2.DescribeEgressOnlyInternetGatewaysInput{ + Filters: []*ec2.Filter{{Name: aws.String(tc.name), Values: []*string{aws.String("zz-awsdiff-none")}}}, + }, testAccountID) + require.NoError(t, err, tc.name) + assert.Empty(t, desc.EgressOnlyInternetGateways, tc.name) + } +} + // TestCreateEgressOnlyInternetGateway_CrossAccountVPCRejected tests that creating an EIGW in another account's VPC is rejected. func TestCreateEgressOnlyInternetGateway_CrossAccountVPCRejected(t *testing.T) { // Set up with manual NATS to get VPC KV access @@ -303,3 +337,24 @@ func TestCreateEgressOnlyInternetGateway_CrossAccountVPCRejected(t *testing.T) { require.NoError(t, err) assert.NotNil(t, out.EgressOnlyInternetGateway) } + +func TestDescribeEgressOnlyInternetGateways_MalformedID(t *testing.T) { + svc := setupTestEIGWService(t) + describe := func(ids ...string) error { + _, err := svc.DescribeEgressOnlyInternetGateways(context.Background(), &ec2.DescribeEgressOnlyInternetGatewaysInput{ + EgressOnlyInternetGatewayIds: aws.StringSlice(ids), + }, testAccountID) + return err + } + + for _, id := range []string{"eigw-xyz", "eigw-12345", "eigw-0000000000000001", "eigw-000000000000000001", "foo"} { + err := describe(id) + code, msg, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok, "error %v carries no AWS code", err) + assert.Equal(t, awserrors.ErrorInvalidEgressOnlyInternetGatewayIdMalformed, code) + assert.Equal(t, "The eigw ID "+id+" is malformed", msg) + } + // Exactly 17 hex digits of either case is well formed, and unknown is not an error. + assert.NoError(t, describe("eigw-00000000000000001")) + assert.NoError(t, describe("eigw-0AAAAAAAAAAAAAAA1")) +} diff --git a/spinifex/handlers/ec2/eip/associate_test.go b/spinifex/handlers/ec2/eip/associate_test.go index b54a7e3eb..865245bfa 100644 --- a/spinifex/handlers/ec2/eip/associate_test.go +++ b/spinifex/handlers/ec2/eip/associate_test.go @@ -117,10 +117,11 @@ func TestEIP_AssociateByNetworkInterfaceID_NotFound(t *testing.T) { _, err := svc.AssociateAddress(context.Background(), &ec2.AssociateAddressInput{ AllocationId: aws.String(allocID), - NetworkInterfaceId: aws.String("eni-doesnotexist"), + NetworkInterfaceId: aws.String("eni-0000000000000dead"), }, testAccountID) require.Error(t, err) - assert.Equal(t, awserrors.ErrorInvalidNetworkInterfaceIDNotFound, err.Error()) + code, _ := awserrors.ResolveErrorCode(err) + assert.Equal(t, awserrors.ErrorInvalidNetworkInterfaceIDNotFound, code) // The allocation must survive a failed association. addr := describeEIP(t, svc, allocID) diff --git a/spinifex/handlers/ec2/eip/service_impl.go b/spinifex/handlers/ec2/eip/service_impl.go index a6025d832..9ae615e8f 100644 --- a/spinifex/handlers/ec2/eip/service_impl.go +++ b/spinifex/handlers/ec2/eip/service_impl.go @@ -596,9 +596,21 @@ func (s *EIPServiceImpl) DescribeAddresses(ctx context.Context, input *ec2.Descr found[*addr.AllocationId] = true } } - for id := range allocIDs { - if !found[id] { - return nil, errors.New(awserrors.ErrorInvalidAllocationIDNotFound) + for _, id := range input.AllocationIds { + if id != nil && !found[*id] { + return nil, awserrors.Errorf(awserrors.ErrorInvalidAllocationIDNotFound, "The allocation ID '%s' does not exist", *id) + } + } + } + + if len(publicIPs) > 0 { + found := make(map[string]bool) + for _, addr := range addresses { + found[aws.StringValue(addr.PublicIp)] = true + } + for _, ip := range input.PublicIps { + if ip != nil && !found[*ip] { + return nil, awserrors.Errorf(awserrors.ErrorInvalidAddressNotFound, "Address '%s' not found.", *ip) } } } diff --git a/spinifex/handlers/ec2/eip/service_impl_test.go b/spinifex/handlers/ec2/eip/service_impl_test.go index 87ea427fb..879e1ae1a 100644 --- a/spinifex/handlers/ec2/eip/service_impl_test.go +++ b/spinifex/handlers/ec2/eip/service_impl_test.go @@ -544,6 +544,52 @@ func TestEIP_DescribeAddresses(t *testing.T) { _ = out3 } +// AWS answers a named public IP the account does not hold with +// InvalidAddress.NotFound, including one it has just released. +func TestEIP_DescribeAddresses_PublicIpNotFound(t *testing.T) { + svc, _, _ := setupTestEIP(t) + + kept, err := svc.AllocateAddress(context.Background(), &ec2.AllocateAddressInput{}, testAccountID) + require.NoError(t, err) + released, err := svc.AllocateAddress(context.Background(), &ec2.AllocateAddressInput{}, testAccountID) + require.NoError(t, err) + _, err = svc.ReleaseAddress(context.Background(), &ec2.ReleaseAddressInput{AllocationId: released.AllocationId}, testAccountID) + require.NoError(t, err) + + desc, err := svc.DescribeAddresses(context.Background(), &ec2.DescribeAddressesInput{ + PublicIps: []*string{kept.PublicIp}, + }, testAccountID) + require.NoError(t, err) + require.Len(t, desc.Addresses, 1) + + _, err = svc.DescribeAddresses(context.Background(), &ec2.DescribeAddressesInput{ + PublicIps: []*string{kept.PublicIp, released.PublicIp}, + }, testAccountID) + code, msg, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok) + assert.Equal(t, awserrors.ErrorInvalidAddressNotFound, code) + assert.Equal(t, "Address '"+*released.PublicIp+"' not found.", msg) +} + +func TestEIP_DescribeAddresses_AllocationIdNotFound(t *testing.T) { + svc, _, _ := setupTestEIP(t) + + kept, err := svc.AllocateAddress(context.Background(), &ec2.AllocateAddressInput{}, testAccountID) + require.NoError(t, err) + released, err := svc.AllocateAddress(context.Background(), &ec2.AllocateAddressInput{}, testAccountID) + require.NoError(t, err) + _, err = svc.ReleaseAddress(context.Background(), &ec2.ReleaseAddressInput{AllocationId: released.AllocationId}, testAccountID) + require.NoError(t, err) + + _, err = svc.DescribeAddresses(context.Background(), &ec2.DescribeAddressesInput{ + AllocationIds: []*string{kept.AllocationId, released.AllocationId}, + }, testAccountID) + code, msg, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok) + assert.Equal(t, awserrors.ErrorInvalidAllocationIDNotFound, code) + assert.Equal(t, "The allocation ID '"+*released.AllocationId+"' does not exist", msg) +} + func TestEIP_DescribeAddresses_FilterByAllocationId(t *testing.T) { svc, _, _ := setupTestEIP(t) diff --git a/spinifex/handlers/ec2/igw/service_impl.go b/spinifex/handlers/ec2/igw/service_impl.go index 907624822..76521db17 100644 --- a/spinifex/handlers/ec2/igw/service_impl.go +++ b/spinifex/handlers/ec2/igw/service_impl.go @@ -18,6 +18,7 @@ import ( "github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil" "github.com/mulgadc/spinifex/spinifex/foundation/state/migrate" handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc" + "github.com/mulgadc/spinifex/spinifex/paging" "github.com/mulgadc/spinifex/spinifex/utils" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" @@ -136,7 +137,7 @@ func (s *IGWServiceImpl) createIGW(ctx context.Context, input *ec2.CreateInterne slog.InfoContext(ctx, "CreateInternetGateway completed", "internetGatewayId", igwID, "accountID", accountID) return &ec2.CreateInternetGatewayOutput{ - InternetGateway: s.recordToEC2(&record), + InternetGateway: s.recordToEC2(&record, accountID), }, nil } @@ -155,7 +156,7 @@ func (s *IGWServiceImpl) DeleteInternetGateway(ctx context.Context, input *ec2.D // tolerates it on destroy); destroy orchestration tolerates it too. // A transient read error stays a server error. if errors.Is(err, jetstream.ErrKeyNotFound) { - return nil, errors.New(awserrors.ErrorInvalidInternetGatewayIDNotFound) + return nil, igwNotFoundError(igwID) } return nil, errors.New(awserrors.ErrorServerInternal) } @@ -165,12 +166,11 @@ func (s *IGWServiceImpl) DeleteInternetGateway(ctx context.Context, input *ec2.D return nil, errors.New(awserrors.ErrorServerInternal) } - // Cannot delete an attached IGW. The VPC is named because a pending - // attachment is hidden from describes, so the caller has no other way to - // learn what it must detach from. + // Cannot delete an attached IGW. AWS's message does not name the VPC, and + // a pending attachment is hidden from describes, so the log names it. if record.VpcId != "" { - return nil, awserrors.Errorf(awserrors.ErrorDependencyViolation, - "the internet gateway is attached to %s and must be detached first", record.VpcId) + slog.WarnContext(ctx, "DeleteInternetGateway: gateway is attached", "internetGatewayId", igwID, "vpcId", record.VpcId) + return nil, awserrors.HasDependencies("internetGateway", igwID) } if err := s.igwKV.Delete(ctx, key); err != nil { @@ -182,17 +182,31 @@ func (s *IGWServiceImpl) DeleteInternetGateway(ctx context.Context, input *ec2.D return &ec2.DeleteInternetGatewayOutput{}, nil } +var describeIGWPaging = paging.EC2{ + MaxResults: 1000, + TooLarge: "Value ( %d ) for parameter MaxResults is invalid. Expecting a value smaller than or equal to 1000.", + TooSmall: "Value ( %d ) for parameter MaxResults is invalid. Expecting a value greater than or equal to 5.", + WithIDs: "The parameter InternetGatewayIds cannot be used with the parameter MaxResults", +} + // describeIGWValidFilters defines the set of filter names accepted by DescribeInternetGateways. var describeIGWValidFilters = map[string]bool{ "internet-gateway-id": true, "attachment.vpc-id": true, "attachment.state": true, + "tag-key": true, + "tag-value": true, } // DescribeInternetGateways lists Internet Gateways, optionally filtered by ID. func (s *IGWServiceImpl) DescribeInternetGateways(ctx context.Context, input *ec2.DescribeInternetGatewaysInput, accountID string) (*ec2.DescribeInternetGatewaysOutput, error) { var igws []*ec2.InternetGateway + // Validated but not paged: AWS's paging of DescribeInternetGateways is unobserved. + if _, err := describeIGWPaging.Parse(input.MaxResults, input.NextToken, len(input.InternetGatewayIds)); err != nil { + return nil, err + } + igwIDs := make(map[string]bool) for _, id := range input.InternetGatewayIds { if id != nil { @@ -242,14 +256,14 @@ func (s *IGWServiceImpl) DescribeInternetGateways(ctx context.Context, input *ec continue } - igws = append(igws, s.recordToEC2(&record)) + igws = append(igws, s.recordToEC2(&record, accountID)) foundIDs[record.InternetGatewayId] = true } // Return error if specific IDs were requested but not found - for id := range igwIDs { - if !foundIDs[id] { - return nil, errors.New(awserrors.ErrorInvalidInternetGatewayIDNotFound) + for _, id := range input.InternetGatewayIds { + if id != nil && !foundIDs[*id] { + return nil, igwNotFoundError(*id) } } @@ -263,7 +277,7 @@ func (s *IGWServiceImpl) DescribeInternetGateways(ctx context.Context, input *ec // igwMatchesFilters checks whether an IGWRecord satisfies all parsed awsfilters. func igwMatchesFilters(record *IGWRecord, filters map[string][]string) bool { for name, values := range filters { - if strings.HasPrefix(name, "tag:") { + if awsfilters.IsTagFilter(name) { continue } @@ -309,7 +323,7 @@ func (s *IGWServiceImpl) AttachInternetGateway(ctx context.Context, input *ec2.A entry, err := s.igwKV.Get(ctx, key) if err != nil { - return nil, errors.New(awserrors.ErrorInvalidInternetGatewayIDNotFound) + return nil, igwNotFoundError(igwID) } var record IGWRecord @@ -318,7 +332,7 @@ func (s *IGWServiceImpl) AttachInternetGateway(ctx context.Context, input *ec2.A } if record.VpcId != "" { - return nil, errors.New(awserrors.ErrorResourceAlreadyAssociated) + return nil, awserrors.Errorf(awserrors.ErrorResourceAlreadyAssociated, "resource %s is already attached to network %s", igwID, record.VpcId) } // Verify the caller owns the target VPC (fail-closed if KV unavailable) @@ -328,7 +342,7 @@ func (s *IGWServiceImpl) AttachInternetGateway(ctx context.Context, input *ec2.A } if _, err := s.vpcKV.Get(ctx, utils.AccountKey(accountID, vpcID)); err != nil { slog.WarnContext(ctx, "AttachInternetGateway: VPC not found for account", "vpcId", vpcID, "accountID", accountID) - return nil, errors.New(awserrors.ErrorInvalidVpcIDNotFound) + return nil, awserrors.IDNotFound(awserrors.ErrorInvalidVpcIDNotFound, "vpc", vpcID) } record.VpcId = vpcID @@ -371,7 +385,7 @@ func (s *IGWServiceImpl) DetachInternetGateway(ctx context.Context, input *ec2.D entry, err := s.igwKV.Get(ctx, key) if err != nil { - return nil, errors.New(awserrors.ErrorInvalidInternetGatewayIDNotFound) + return nil, igwNotFoundError(igwID) } var record IGWRecord @@ -443,7 +457,7 @@ func (s *IGWServiceImpl) CreateAttachedInternetGateway(ctx context.Context, acco return false, errors.New(awserrors.ErrorServerInternal) } if _, err := s.vpcKV.Get(ctx, utils.AccountKey(accountID, vpcID)); err != nil { - return false, errors.New(awserrors.ErrorInvalidVpcIDNotFound) + return false, awserrors.IDNotFound(awserrors.ErrorInvalidVpcIDNotFound, "vpc", vpcID) } key := utils.AccountKey(accountID, igwID) @@ -569,9 +583,10 @@ func MarkAttached(ctx context.Context, kv jetstream.KeyValue, recordKey, vpcID s return nil } -func (s *IGWServiceImpl) recordToEC2(record *IGWRecord) *ec2.InternetGateway { +func (s *IGWServiceImpl) recordToEC2(record *IGWRecord, accountID string) *ec2.InternetGateway { igw := &ec2.InternetGateway{ InternetGatewayId: aws.String(record.InternetGatewayId), + OwnerId: aws.String(accountID), } // AWS returns no attachment at all unless one exists, so a requested but @@ -612,3 +627,7 @@ func (s *IGWServiceImpl) RemoveRecordTags(input *ec2.DeleteTagsInput, accountID func(r *IGWRecord) *map[string]string { return &r.Tags }, utils.RemoveTagsMut(input)) } + +func igwNotFoundError(id string) error { + return awserrors.IDNotFound(awserrors.ErrorInvalidInternetGatewayIDNotFound, "internetGateway", id) +} diff --git a/spinifex/handlers/ec2/igw/service_impl_test.go b/spinifex/handlers/ec2/igw/service_impl_test.go index 34de1a585..324b9be68 100644 --- a/spinifex/handlers/ec2/igw/service_impl_test.go +++ b/spinifex/handlers/ec2/igw/service_impl_test.go @@ -9,8 +9,9 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ec2" - handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc" "github.com/mulgadc/spinifex/internal/testkit" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" + handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc" "github.com/mulgadc/spinifex/spinifex/utils" "github.com/nats-io/nats.go" "github.com/stretchr/testify/assert" @@ -84,6 +85,7 @@ func TestCreateInternetGateway(t *testing.T) { assert.Equal(t, "igw-", (*out.InternetGateway.InternetGatewayId)[:4]) // Should not have attachments when created assert.Empty(t, out.InternetGateway.Attachments) + assert.Equal(t, testAccountID, aws.StringValue(out.InternetGateway.OwnerId)) } func TestCreateInternetGateway_WithTags(t *testing.T) { @@ -171,7 +173,37 @@ func TestDeleteInternetGateway_WhileAttached(t *testing.T) { _, err = svc.DeleteInternetGateway(context.Background(), &ec2.DeleteInternetGatewayInput{ InternetGatewayId: aws.String(igwID), }, testAccountID) - assert.ErrorContains(t, err, "DependencyViolation") + requireAWSError(t, err, awserrors.ErrorDependencyViolation, "The internetGateway '"+igwID+"' has dependencies and cannot be deleted.") + + // Attaching again names the gateway and the network it is on + _, err = svc.AttachInternetGateway(context.Background(), &ec2.AttachInternetGatewayInput{ + InternetGatewayId: aws.String(igwID), + VpcId: aws.String("vpc-test123"), + }, testAccountID) + requireAWSError(t, err, awserrors.ErrorResourceAlreadyAssociated, "resource "+igwID+" is already attached to network vpc-test123") +} + +func TestInternetGateway_NotFoundNamesTheID(t *testing.T) { + svc, _ := setupTestIGWService(t) + createTestIGW(t, svc) + + _, err := svc.DescribeInternetGateways(context.Background(), &ec2.DescribeInternetGatewaysInput{ + InternetGatewayIds: []*string{aws.String("igw-0000000000000dead")}, + }, testAccountID) + requireAWSError(t, err, awserrors.ErrorInvalidInternetGatewayIDNotFound, "The internetGateway ID 'igw-0000000000000dead' does not exist") + + _, err = svc.DeleteInternetGateway(context.Background(), &ec2.DeleteInternetGatewayInput{ + InternetGatewayId: aws.String("igw-0000000000000dead"), + }, testAccountID) + requireAWSError(t, err, awserrors.ErrorInvalidInternetGatewayIDNotFound, "The internetGateway ID 'igw-0000000000000dead' does not exist") +} + +func requireAWSError(t *testing.T, err error, code, message string) { + t.Helper() + got, msg, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok, "error %v carries no AWS code", err) + assert.Equal(t, code, got) + assert.Equal(t, message, msg) } func TestDescribeInternetGateways_All(t *testing.T) { @@ -195,6 +227,7 @@ func TestDescribeInternetGateways_ByID(t *testing.T) { require.NoError(t, err) require.Len(t, desc.InternetGateways, 1) assert.Equal(t, igwID, *desc.InternetGateways[0].InternetGatewayId) + assert.Equal(t, testAccountID, aws.StringValue(desc.InternetGateways[0].OwnerId)) } func TestDescribeInternetGateways_Empty(t *testing.T) { @@ -948,6 +981,35 @@ func TestDescribeInternetGateways_FilterByTag(t *testing.T) { assert.Equal(t, *out.InternetGateway.InternetGatewayId, *desc.InternetGateways[0].InternetGatewayId) } +func TestDescribeInternetGateways_FilterByTagKeyAndTagValue(t *testing.T) { + svc, _ := setupTestIGWService(t) + out, err := svc.CreateInternetGateway(context.Background(), &ec2.CreateInternetGatewayInput{ + TagSpecifications: []*ec2.TagSpecification{ + { + ResourceType: aws.String("internet-gateway"), + Tags: []*ec2.Tag{{Key: aws.String("Env"), Value: aws.String("prod")}}, + }, + }, + }, testAccountID) + require.NoError(t, err) + createTestIGW(t, svc) // untagged + + for _, tc := range []struct{ name, value string }{{"tag-key", "Env"}, {"tag-value", "prod"}} { + desc, err := svc.DescribeInternetGateways(context.Background(), &ec2.DescribeInternetGatewaysInput{ + Filters: []*ec2.Filter{{Name: aws.String(tc.name), Values: []*string{aws.String(tc.value)}}}, + }, testAccountID) + require.NoError(t, err, tc.name) + require.Len(t, desc.InternetGateways, 1, tc.name) + assert.Equal(t, *out.InternetGateway.InternetGatewayId, *desc.InternetGateways[0].InternetGatewayId, tc.name) + + desc, err = svc.DescribeInternetGateways(context.Background(), &ec2.DescribeInternetGatewaysInput{ + Filters: []*ec2.Filter{{Name: aws.String(tc.name), Values: []*string{aws.String("zz-awsdiff-none")}}}, + }, testAccountID) + require.NoError(t, err, tc.name) + assert.Empty(t, desc.InternetGateways, tc.name) + } +} + func TestDeleteInternetGateway_PublishesNoEvent(t *testing.T) { svc, nc := setupTestIGWService(t) igwID := createTestIGW(t, svc) @@ -1029,3 +1091,22 @@ func TestAttachInternetGateway_NoGatePublisher_NoOp(t *testing.T) { }, testAccountID) require.NoError(t, err) } + +func TestDescribeInternetGateways_PagingValidation(t *testing.T) { + svc, _ := setupTestIGWService(t) + + _, err := svc.DescribeInternetGateways(context.Background(), &ec2.DescribeInternetGatewaysInput{ + MaxResults: aws.Int64(5), InternetGatewayIds: []*string{aws.String("igw-0123456789abcdef0")}, + }, testAccountID) + code, message, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok) + assert.Equal(t, awserrors.ErrorInvalidParameterCombination, code) + assert.Equal(t, "The parameter InternetGatewayIds cannot be used with the parameter MaxResults", message) + + _, err = svc.DescribeInternetGateways(context.Background(), &ec2.DescribeInternetGatewaysInput{ + NextToken: aws.String("garbage"), + }, testAccountID) + code, _, ok = awserrors.ResolveErrorDetail(err) + require.True(t, ok) + assert.Equal(t, awserrors.ErrorInvalidParameterValue, code) +} diff --git a/spinifex/handlers/ec2/image/describe_byid_test.go b/spinifex/handlers/ec2/image/describe_byid_test.go index 049596882..eb539aef9 100644 --- a/spinifex/handlers/ec2/image/describe_byid_test.go +++ b/spinifex/handlers/ec2/image/describe_byid_test.go @@ -160,6 +160,41 @@ func TestDescribeImagesByID_SystemAMIIsVisibleToEveryAccount(t *testing.T) { assert.Equal(t, "amazon", aws.StringValue(out.Images[0].ImageOwnerAlias)) } +// A system image renders as AWS's public, Amazon-owned images do, and the +// is-public filter reads the rendered value; a private image stays private. +func TestDescribeImagesByID_SystemAMIRendersPublicAmazon(t *testing.T) { + tests := []struct { + name string + ownerAlias string + wantPublic bool + wantAlias string + }{ + {name: "system image", ownerAlias: "system", wantPublic: true, wantAlias: "amazon"}, + {name: "own image", ownerAlias: byIDAccount, wantPublic: false, wantAlias: byIDAccount}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := recordingstore.New() + svc := handlers_ec2_image.NewImageServiceImplWithStore(store, byIDBucket) + putByIDAMI(t, store, byIDAMIDoc(byIDImage, tt.ownerAlias)) + + out, err := svc.DescribeImages(t.Context(), &ec2.DescribeImagesInput{ + ImageIds: aws.StringSlice([]string{byIDImage}), + Filters: []*ec2.Filter{{ + Name: aws.String("is-public"), Values: aws.StringSlice([]string{strconv.FormatBool(tt.wantPublic)}), + }}, + }, byIDAccount) + + require.NoError(t, err) + require.Len(t, out.Images, 1) + img := out.Images[0] + assert.Equal(t, tt.wantPublic, aws.BoolValue(img.Public)) + assert.Equal(t, tt.wantAlias, aws.StringValue(img.ImageOwnerAlias)) + assert.Equal(t, "RunInstances", aws.StringValue(img.UsageOperation)) + }) + } +} + // The Owners parameter is matched against the resolved owner, and a named image // that no owner selects is not-found rather than an empty success. func TestDescribeImagesByID_OwnerFilters(t *testing.T) { diff --git a/spinifex/handlers/ec2/image/service_impl.go b/spinifex/handlers/ec2/image/service_impl.go index 29d5cb41d..479a84340 100644 --- a/spinifex/handlers/ec2/image/service_impl.go +++ b/spinifex/handlers/ec2/image/service_impl.go @@ -295,6 +295,7 @@ func renderVisibleImage(amiMeta ebsmetadata.AMI, input *ec2.DescribeImagesInput, Architecture: aws.String(amiMeta.Architecture), PlatformDetails: aws.String(amiMeta.PlatformDetails), Platform: utils.PlatformFromDetails(amiMeta.PlatformDetails), + UsageOperation: utils.UsageOperationFromDetails(amiMeta.PlatformDetails), CreationDate: aws.String(amiMeta.CreationDate.UTC().Format("2006-01-02T15:04:05.000Z")), RootDeviceType: aws.String(amiMeta.RootDeviceType), VirtualizationType: aws.String(amiMeta.Virtualization), @@ -306,6 +307,13 @@ func renderVisibleImage(amiMeta ebsmetadata.AMI, input *ec2.DescribeImagesInput, Hypervisor: aws.String("xen"), } + // A system image stands where AWS's public, Amazon-owned images do. Only the + // rendered values change: the stored alias still identifies it as system. + if isSystemAMI { + image.Public = aws.Bool(true) + image.ImageOwnerAlias = aws.String("amazon") + } + // RegisterImage takes BootMode as an optional input, so an AMI can // record none. The enum has no empty member: omit it, don't emit "". if amiMeta.BootMode != "" { diff --git a/spinifex/handlers/ec2/instance/service_impl.go b/spinifex/handlers/ec2/instance/service_impl.go index e76966a1a..739f2d80b 100644 --- a/spinifex/handlers/ec2/instance/service_impl.go +++ b/spinifex/handlers/ec2/instance/service_impl.go @@ -9,6 +9,7 @@ import ( "log/slog" "maps" "os" + "slices" "sort" "strconv" "strings" @@ -2031,22 +2032,16 @@ func instanceMatchesFilters(inst *vm.VM, ic *ec2.Instance, filters map[string][] // matchTagKey returns true if any tag key on the resource matches any of the filter values. func matchTagKey(tags []*ec2.Tag, values []string) bool { - for _, t := range tags { - if t.Key != nil && awsfilters.MatchesAny(values, *t.Key) { - return true - } - } - return false + return slices.ContainsFunc(tags, func(t *ec2.Tag) bool { + return t.Key != nil && awsfilters.MatchesAny(values, *t.Key) + }) } // matchTagValue returns true if any tag value on the resource matches any of the filter values. func matchTagValue(tags []*ec2.Tag, values []string) bool { - for _, t := range tags { - if t.Value != nil && awsfilters.MatchesAny(values, *t.Value) { - return true - } - } - return false + return slices.ContainsFunc(tags, func(t *ec2.Tag) bool { + return t.Value != nil && awsfilters.MatchesAny(values, *t.Value) + }) } // DescribeInstances returns instances on this node visible to the caller's account. @@ -2123,10 +2118,7 @@ func (s *InstanceServiceImpl) DescribeInstances(ctx context.Context, input *ec2. } }) - reservations := make([]*ec2.Reservation, 0, len(reservationMap)) - for _, reservation := range reservationMap { - reservations = append(reservations, reservation) - } + reservations := slices.Collect(maps.Values(reservationMap)) slog.InfoContext(ctx, "DescribeInstances completed", "count", len(reservations)) return &ec2.DescribeInstancesOutput{Reservations: reservations}, nil @@ -2249,10 +2241,7 @@ func (s *InstanceServiceImpl) describeInstancesFromKV(ctx context.Context, input reservationMap[resID].Instances = append(reservationMap[resID].Instances, projected) } - reservations := make([]*ec2.Reservation, 0, len(reservationMap)) - for _, reservation := range reservationMap { - reservations = append(reservations, reservation) - } + reservations := slices.Collect(maps.Values(reservationMap)) slog.InfoContext(ctx, opName+" completed", "count", len(reservations)) return &ec2.DescribeInstancesOutput{Reservations: reservations}, nil diff --git a/spinifex/handlers/ec2/instance/service_impl_test.go b/spinifex/handlers/ec2/instance/service_impl_test.go index e94fee1ca..9fce80de7 100644 --- a/spinifex/handlers/ec2/instance/service_impl_test.go +++ b/spinifex/handlers/ec2/instance/service_impl_test.go @@ -3883,6 +3883,7 @@ func TestPrepareRunInstances_NATFailureRollsBackPublicIP(t *testing.T) { // half-committed rule from a waitForFlowsHV timeout. deleteNATCh := make(chan natWirePayload, 1) delSub, err := svc.natsConn.Subscribe("vpc.delete-nat", func(msg *nats.Msg) { + _ = msg.Respond([]byte(`{"success":true}`)) var p natWirePayload _ = json.Unmarshal(msg.Data, &p) select { @@ -3990,6 +3991,7 @@ func TestPrepareRunInstances_PartialLaunchReleasesSucceededInstances(t *testing. deleteNATCh := make(chan natWirePayload, 2) delSub, err := svc.natsConn.Subscribe("vpc.delete-nat", func(msg *nats.Msg) { + _ = msg.Respond([]byte(`{"success":true}`)) var p natWirePayload _ = json.Unmarshal(msg.Data, &p) select { diff --git a/spinifex/handlers/ec2/key/service_impl.go b/spinifex/handlers/ec2/key/service_impl.go index 298da0017..7a0e44953 100644 --- a/spinifex/handlers/ec2/key/service_impl.go +++ b/spinifex/handlers/ec2/key/service_impl.go @@ -19,6 +19,7 @@ import ( "fmt" "io" "log/slog" + "slices" "strings" "github.com/aws/aws-sdk-go/aws" @@ -41,6 +42,45 @@ type KeyServiceImpl struct { config *config.Config store objectstore.ObjectStore bucketName string + + // Optional: injected after construction. Nil leaves the central tag store + // untouched by create, import and delete. + centralTags CentralTagStore +} + +// CentralTagStore keeps the central tag index in step with key-pair records, so +// DescribeTags sees creation tags. Implemented by handlers/ec2/tags.TagsServiceImpl. +type CentralTagStore interface { + PutResourceTags(ctx context.Context, accountID, resourceID string, tags map[string]string) error + DeleteAllTags(ctx context.Context, accountID, resourceID string) error +} + +// SetCentralTagStore injects the central tag store that create and import +// project their tags into and delete clears. +func (s *KeyServiceImpl) SetCentralTagStore(st CentralTagStore) { + s.centralTags = st +} + +// projectRecordTags writes creation tags into the central tag store. A failure +// is logged, not returned: the key pair already exists. +func (s *KeyServiceImpl) projectRecordTags(ctx context.Context, accountID, keyPairID string, tags map[string]string) { + if s.centralTags == nil || len(tags) == 0 { + return + } + if err := s.centralTags.PutResourceTags(ctx, accountID, keyPairID, tags); err != nil { + slog.ErrorContext(ctx, "central tag store write failed", "keyPairId", keyPairID, "err", err) + } +} + +// clearRecordTags drops a deleted key pair's central tags. Call it only once the +// delete has succeeded; a failure is logged, since the key pair is already gone. +func (s *KeyServiceImpl) clearRecordTags(ctx context.Context, accountID, keyPairID string) { + if s.centralTags == nil { + return + } + if err := s.centralTags.DeleteAllTags(ctx, accountID, keyPairID); err != nil { + slog.ErrorContext(ctx, "central tag store clear failed", "keyPairId", keyPairID, "err", err) + } } // NewKeyServiceImpl creates a new daemon-side key service. @@ -93,7 +133,7 @@ func (s *KeyServiceImpl) CreateKeyPair(ctx context.Context, input *ec2.CreateKey if err == nil { // Object exists - return duplicate error slog.ErrorContext(ctx, "Key pair already exists", "keyName", keyName) - return nil, errors.New(awserrors.ErrorInvalidKeyPairDuplicate) + return nil, errDuplicateKeyPair() } // Determine key type (default: ed25519, optional: rsa). This deviates from @@ -112,6 +152,20 @@ func (s *KeyServiceImpl) CreateKeyPair(ctx context.Context, input *ec2.CreateKey } } + // A declared divergence: AWS returns a PuTTY key for ppk, Spinifex refuses + // it rather than silently returning a PEM the caller did not ask for. + if input.KeyFormat != nil { + switch *input.KeyFormat { + case ec2.KeyFormatPem: + case ec2.KeyFormatPpk: + return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, + "The ppk key format is not supported; use pem.") + default: + return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, + "1 validation error detected: Value '%s' at 'keyFormat' failed to satisfy constraint: Member must satisfy enum value set: [pem, ppk]", *input.KeyFormat) + } + } + // Generate the key in process; the private half never reaches local disk. privateKeyData, publicKey, err := generateKeyPair(keyType) if err != nil { @@ -156,10 +210,17 @@ func (s *KeyServiceImpl) CreateKeyPair(ctx context.Context, input *ec2.CreateKey // Build response (similar to AWS EC2) keyPairID := utils.GenerateResourceID("key") - tags := utils.MapToEC2Tags(utils.ExtractTags(input.TagSpecifications, "key-pair")) + tagMap := utils.ExtractTags(input.TagSpecifications, "key-pair") + tags := utils.MapToEC2Tags(tagMap) + // AWS ends RSA material at its END line, while OpenSSH-format Ed25519 + // material keeps the trailing newline. + keyMaterial := string(privateKeyData) + if keyType == "rsa" { + keyMaterial = strings.TrimSuffix(keyMaterial, "\n") + } output := &ec2.CreateKeyPairOutput{ KeyFingerprint: aws.String(fingerprint), - KeyMaterial: aws.String(string(privateKeyData)), + KeyMaterial: aws.String(keyMaterial), KeyName: aws.String(keyName), KeyPairId: aws.String(keyPairID), Tags: tags, @@ -185,6 +246,8 @@ func (s *KeyServiceImpl) CreateKeyPair(ctx context.Context, input *ec2.CreateKey return nil, errors.New(awserrors.ErrorServerInternal) } + s.projectRecordTags(ctx, accountID, keyPairID, tagMap) + slog.InfoContext(ctx, "Key pair created successfully", "keyName", keyName, "fingerprint", fingerprint, "keyPairId", keyPairID) return output, nil @@ -353,6 +416,17 @@ func (s *KeyServiceImpl) storeKeyPairMetadata(ctx context.Context, accountID, ke return nil } +// errDuplicateKeyPair is AWS's error for a name already in use, on create and +// import alike. +func errDuplicateKeyPair() error { + return awserrors.Errorf(awserrors.ErrorInvalidKeyPairDuplicate, "The keypair already exists") +} + +// errInvalidKeyFormat is AWS's error for import material it cannot use. +func errInvalidKeyFormat() error { + return awserrors.Errorf(awserrors.ErrorInvalidKeyFormat, "Key is not in valid OpenSSH public key format") +} + // getKeyNameFromKeyPairId retrieves the key name by directly reading the metadata file for a given keyPairId. func (s *KeyServiceImpl) getKeyNameFromKeyPairId(ctx context.Context, accountID, keyPairID string) (string, error) { metadataPath := fmt.Sprintf("keys/%s/%s.json", accountID, keyPairID) @@ -511,6 +585,20 @@ func (s *KeyServiceImpl) GetPublicKeyMaterial(accountID, keyName string) (string return material, nil } +// describedPublicKey returns the stored key as AWS reports it: one OpenSSH +// line whose comment is the key pair's name, whatever comment was imported. +func (s *KeyServiceImpl) describedPublicKey(accountID, keyName string) (string, error) { + material, err := s.GetPublicKeyMaterial(accountID, keyName) + if err != nil { + return "", err + } + publicKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(material)) + if err != nil { + return "", fmt.Errorf("parse public key %s: %w", keyName, err) + } + return fmt.Sprintf("%s %s\n", bytes.TrimSpace(ssh.MarshalAuthorizedKey(publicKey)), keyName), nil +} + // DeleteKeyPair removes a key pair (both public key and metadata from S3). func (s *KeyServiceImpl) DeleteKeyPair(ctx context.Context, input *ec2.DeleteKeyPairInput, accountID string) (*ec2.DeleteKeyPairOutput, error) { if input == nil { @@ -538,7 +626,7 @@ func (s *KeyServiceImpl) DeleteKeyPair(ctx context.Context, input *ec2.DeleteKey // AWS DeleteKeyPair is idempotent — return success for non-existent keys if err.Error() == awserrors.ErrorInvalidKeyPairNotFound { slog.DebugContext(ctx, "DeleteKeyPair: key pair not found, returning success (idempotent)", "keyPairId", keyPairID) - return &ec2.DeleteKeyPairOutput{}, nil + return &ec2.DeleteKeyPairOutput{Return: aws.Bool(true)}, nil } slog.ErrorContext(ctx, "Failed to get keyName from keyPairId", "keyPairId", keyPairID, "err", err) return nil, err @@ -558,7 +646,7 @@ func (s *KeyServiceImpl) DeleteKeyPair(ctx context.Context, input *ec2.DeleteKey // AWS DeleteKeyPair is idempotent — return success for non-existent keys if err.Error() == awserrors.ErrorInvalidKeyPairNotFound { slog.DebugContext(ctx, "DeleteKeyPair: key pair not found, returning success (idempotent)", "keyName", keyName) - return &ec2.DeleteKeyPairOutput{}, nil + return &ec2.DeleteKeyPairOutput{Return: aws.Bool(true)}, nil } slog.ErrorContext(ctx, "Failed to find keyPairId from keyName", "keyName", keyName, "err", err) return nil, err @@ -591,9 +679,12 @@ func (s *KeyServiceImpl) DeleteKeyPair(ctx context.Context, input *ec2.DeleteKey return nil, errors.New(awserrors.ErrorServerInternal) } + s.clearRecordTags(ctx, accountID, keyPairID) + slog.InfoContext(ctx, "Key pair deleted successfully", "keyName", keyName, "keyPairId", keyPairID) - return &ec2.DeleteKeyPairOutput{}, nil + // AWS names the key only when one was actually deleted. + return &ec2.DeleteKeyPairOutput{Return: aws.Bool(true), KeyPairId: aws.String(keyPairID)}, nil } // DescribeKeyPairs lists available key pairs by reading metadata files from S3 @@ -687,28 +778,18 @@ func (s *KeyServiceImpl) DescribeKeyPairs(ctx context.Context, input *ec2.Descri // Filter by KeyName if specified if len(input.KeyNames) > 0 { - found := false - for _, filterName := range input.KeyNames { - if filterName != nil && metadata.KeyName != nil && *filterName == *metadata.KeyName { - found = true - break - } - } - if !found { + if !slices.ContainsFunc(input.KeyNames, func(filterName *string) bool { + return filterName != nil && metadata.KeyName != nil && *filterName == *metadata.KeyName + }) { continue } } // Filter by KeyPairId if specified if len(input.KeyPairIds) > 0 { - found := false - for _, filterID := range input.KeyPairIds { - if filterID != nil && metadata.KeyPairId != nil && *filterID == *metadata.KeyPairId { - found = true - break - } - } - if !found { + if !slices.ContainsFunc(input.KeyPairIds, func(filterID *string) bool { + return filterID != nil && metadata.KeyPairId != nil && *filterID == *metadata.KeyPairId + }) { continue } } @@ -735,6 +816,15 @@ func (s *KeyServiceImpl) DescribeKeyPairs(ctx context.Context, input *ec2.Descri continue } + if aws.BoolValue(input.IncludePublicKey) && metadata.KeyName != nil { + publicKey, err := s.describedPublicKey(accountID, *metadata.KeyName) + if err != nil { + slog.ErrorContext(ctx, "DescribeKeyPairs: failed to read public key", "keyName", *metadata.KeyName, "err", err) + return nil, errors.New(awserrors.ErrorServerInternal) + } + keyPairInfo.PublicKey = aws.String(publicKey) + } + keyPairs = append(keyPairs, keyPairInfo) } @@ -753,12 +843,12 @@ func (s *KeyServiceImpl) DescribeKeyPairs(ctx context.Context, input *ec2.Descri } for _, name := range input.KeyNames { if name != nil && !foundNames[*name] { - return nil, errors.New(awserrors.ErrorInvalidKeyPairNotFound) + return nil, awserrors.Errorf(awserrors.ErrorInvalidKeyPairNotFound, "The key pair '%s' does not exist", *name) } } for _, id := range input.KeyPairIds { if id != nil && !foundIDs[*id] { - return nil, errors.New(awserrors.ErrorInvalidKeyPairNotFound) + return nil, awserrors.Errorf(awserrors.ErrorInvalidKeyPairNotFound, "The keyPairId '%s' does not exist", *id) } } } @@ -834,7 +924,7 @@ func (s *KeyServiceImpl) ImportKeyPair(ctx context.Context, input *ec2.ImportKey if err == nil { // Object exists - return duplicate error slog.ErrorContext(ctx, "Key pair already exists", "keyName", keyName) - return nil, errors.New(awserrors.ErrorInvalidKeyPairDuplicate) + return nil, errDuplicateKeyPair() } // The material is stored verbatim and later served to instances as their @@ -852,7 +942,7 @@ func (s *KeyServiceImpl) ImportKeyPair(ctx context.Context, input *ec2.ImportKey publicKeyData := bytes.TrimSpace(input.PublicKeyMaterial) if bytes.ContainsAny(publicKeyData, "\r\n") { slog.ErrorContext(ctx, "Public key material is not a single key", "keyName", keyName) - return nil, errors.New(awserrors.ErrorInvalidKeyFormat) + return nil, errInvalidKeyFormat() } // Parse the authorized-key line ("ssh-rsa AAAAB... comment"), which also @@ -860,7 +950,7 @@ func (s *KeyServiceImpl) ImportKeyPair(ctx context.Context, input *ec2.ImportKey publicKey, _, options, _, err := ssh.ParseAuthorizedKey(publicKeyData) if err != nil { slog.ErrorContext(ctx, "Invalid public key format", "keyName", keyName, "err", err) - return nil, errors.New(awserrors.ErrorInvalidKeyFormat) + return nil, errInvalidKeyFormat() } // An option prefix ("command=...", "from=...") is not covered by the @@ -868,13 +958,13 @@ func (s *KeyServiceImpl) ImportKeyPair(ctx context.Context, input *ec2.ImportKey // launched with this key pair. Refuse to import access the API cannot report. if len(options) > 0 { slog.ErrorContext(ctx, "Public key material carries authorized_keys options", "keyName", keyName, "options", options) - return nil, errors.New(awserrors.ErrorInvalidKeyFormat) + return nil, errInvalidKeyFormat() } keyType, err := keyPairType(publicKey) if err != nil { slog.ErrorContext(ctx, "Unsupported key type", "algorithm", publicKey.Type(), "keyName", keyName, "err", err) - return nil, errors.New(awserrors.ErrorInvalidKeyFormat) + return nil, errInvalidKeyFormat() } fingerprint, err := importedKeyFingerprint(publicKey) @@ -898,7 +988,8 @@ func (s *KeyServiceImpl) ImportKeyPair(ctx context.Context, input *ec2.ImportKey keyPairID := utils.GenerateResourceID("key") // Build response output - tags := utils.MapToEC2Tags(utils.ExtractTags(input.TagSpecifications, "key-pair")) + tagMap := utils.ExtractTags(input.TagSpecifications, "key-pair") + tags := utils.MapToEC2Tags(tagMap) output := &ec2.ImportKeyPairOutput{ KeyFingerprint: aws.String(fingerprint), KeyName: aws.String(keyName), @@ -926,6 +1017,8 @@ func (s *KeyServiceImpl) ImportKeyPair(ctx context.Context, input *ec2.ImportKey return nil, errors.New(awserrors.ErrorServerInternal) } + s.projectRecordTags(ctx, accountID, keyPairID, tagMap) + slog.InfoContext(ctx, "Key pair imported successfully", "keyName", keyName, "fingerprint", fingerprint, "keyPairId", keyPairID, "keyType", keyType) return output, nil diff --git a/spinifex/handlers/ec2/key/service_impl_test.go b/spinifex/handlers/ec2/key/service_impl_test.go index b4108a1c7..d35091f30 100644 --- a/spinifex/handlers/ec2/key/service_impl_test.go +++ b/spinifex/handlers/ec2/key/service_impl_test.go @@ -7,6 +7,7 @@ import ( "crypto/x509" "encoding/base64" "encoding/json" + "errors" "io" "strings" "testing" @@ -178,6 +179,7 @@ func TestCreateKeyPair_ED25519(t *testing.T) { // ED25519 has no PEM representation, so it stays in the OpenSSH container. assert.Contains(t, *out.KeyMaterial, "BEGIN OPENSSH PRIVATE KEY") + assert.True(t, strings.HasSuffix(*out.KeyMaterial, "-----END OPENSSH PRIVATE KEY-----\n")) } func TestCreateKeyPair_RSA(t *testing.T) { @@ -195,6 +197,7 @@ func TestCreateKeyPair_RSA(t *testing.T) { // PKCS#1 PEM, as AWS returns it: get-password-data --priv-launch-key cannot // read the OpenSSH container ssh-keygen writes by default. assert.Contains(t, *out.KeyMaterial, "BEGIN RSA PRIVATE KEY") + assert.True(t, strings.HasSuffix(*out.KeyMaterial, "-----END RSA PRIVATE KEY-----"), "AWS ends RSA material without a newline") // The key is generated per-run, so the digest is not knowable in advance. // Recompute it from the private key the caller was handed: that is what pins @@ -252,7 +255,7 @@ func TestCreateKeyPair_Duplicate(t *testing.T) { }, testAccountID) require.Error(t, err) assert.Nil(t, out) - assert.Equal(t, awserrors.ErrorInvalidKeyPairDuplicate, err.Error()) + assertAWSError(t, err, awserrors.ErrorInvalidKeyPairDuplicate, "The keypair already exists") } func TestCreateKeyPair_InvalidKeyType(t *testing.T) { @@ -269,6 +272,36 @@ func TestCreateKeyPair_InvalidKeyType(t *testing.T) { assert.Equal(t, "1 validation error detected: Value 'dsa' at 'keyType' failed to satisfy constraint: Member must satisfy enum value set: [rsa, ed25519]", msg) } +func TestCreateKeyPair_KeyFormat(t *testing.T) { + svc, store := newTestKeyService() + + for _, tc := range []struct { + format, msg string + }{ + {"ppk", "The ppk key format is not supported; use pem."}, + {"der", "1 validation error detected: Value 'der' at 'keyFormat' failed to satisfy constraint: Member must satisfy enum value set: [pem, ppk]"}, + } { + out, err := svc.CreateKeyPair(context.Background(), &ec2.CreateKeyPairInput{ + KeyName: aws.String("fmt-" + tc.format), + KeyFormat: aws.String(tc.format), + }, testAccountID) + assert.Nil(t, out) + code, msg, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok) + assert.Equal(t, awserrors.ErrorInvalidParameterValue, code) + assert.Equal(t, tc.msg, msg) + } + assert.Zero(t, store.Count(), "a rejected format must not store a key") + + out, err := svc.CreateKeyPair(context.Background(), &ec2.CreateKeyPairInput{ + KeyName: aws.String("fmt-pem"), + KeyFormat: aws.String("pem"), + KeyType: aws.String("rsa"), + }, testAccountID) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(*out.KeyMaterial, "-----BEGIN RSA PRIVATE KEY-----")) +} + // ============================================================ // ImportKeyPair Tests // ============================================================ @@ -344,7 +377,7 @@ func TestImportKeyPair_Duplicate(t *testing.T) { }, testAccountID) require.Error(t, err) assert.Nil(t, out) - assert.Equal(t, awserrors.ErrorInvalidKeyPairDuplicate, err.Error()) + assertAWSError(t, err, awserrors.ErrorInvalidKeyPairDuplicate, "The keypair already exists") } func TestImportKeyPair_InvalidKeyName(t *testing.T) { @@ -433,7 +466,7 @@ func TestImportKeyPairInvalidKeyFormat(t *testing.T) { PublicKeyMaterial: []byte(tt.publicKey), }, testAccountID) require.Error(t, err) - assert.Equal(t, tt.expectedErrMsg, err.Error()) + assertAWSError(t, err, tt.expectedErrMsg, "Key is not in valid OpenSSH public key format") // Rejection must precede the upload, or the guest is served material // the API refused. @@ -457,7 +490,8 @@ func TestDeleteKeyPair_ByKeyName(t *testing.T) { KeyName: aws.String("to-delete-by-name"), }, testAccountID) require.NoError(t, err) - assert.NotNil(t, result) + assert.True(t, aws.BoolValue(result.Return)) + assert.Equal(t, aws.StringValue(imported.KeyPairId), aws.StringValue(result.KeyPairId)) // Verify public key removed from S3 keyPath := "keys/" + testAccountID + "/to-delete-by-name" @@ -485,7 +519,8 @@ func TestDeleteKeyPair_ByKeyPairId(t *testing.T) { KeyPairId: imported.KeyPairId, }, testAccountID) require.NoError(t, err) - assert.NotNil(t, result) + assert.True(t, aws.BoolValue(result.Return)) + assert.Equal(t, aws.StringValue(imported.KeyPairId), aws.StringValue(result.KeyPairId)) // Verify public key removed from S3 keyPath := "keys/" + testAccountID + "/to-delete-by-id" @@ -512,7 +547,8 @@ func TestDeleteKeyPairIdempotent(t *testing.T) { KeyName: aws.String("no-such-key"), }, testAccountID) require.NoError(t, err) - assert.NotNil(t, result) + assert.True(t, aws.BoolValue(result.Return)) + assert.Nil(t, result.KeyPairId, "no key was deleted, so none is named") }) t.Run("NonExistentKeyPairId", func(t *testing.T) { @@ -520,7 +556,8 @@ func TestDeleteKeyPairIdempotent(t *testing.T) { KeyPairId: aws.String("key-0123456789abcdef0"), }, testAccountID) require.NoError(t, err) - assert.NotNil(t, result) + assert.True(t, aws.BoolValue(result.Return)) + assert.Nil(t, result.KeyPairId, "no key was deleted, so none is named") }) } @@ -649,6 +686,73 @@ func TestDescribeKeyPairs_LegacyED25519Record(t *testing.T) { testLegacyED25519Fingerprint) } +// AWS returns PublicKey only when asked, as one line ending in a newline whose +// comment is the key pair's name, replacing any comment the caller imported. +func TestDescribeKeyPairs_IncludePublicKey_Imported(t *testing.T) { + svc, _ := newTestKeyService() + + _, err := svc.ImportKeyPair(context.Background(), &ec2.ImportKeyPairInput{ + KeyName: aws.String("imported"), + PublicKeyMaterial: []byte(testED25519PubKey + " tf-user@julian-wattle"), + }, testAccountID) + require.NoError(t, err) + + out, err := svc.DescribeKeyPairs(context.Background(), &ec2.DescribeKeyPairsInput{ + IncludePublicKey: aws.Bool(true), + }, testAccountID) + require.NoError(t, err) + require.Len(t, out.KeyPairs, 1) + assert.Equal(t, testED25519PubKey+" imported\n", aws.StringValue(out.KeyPairs[0].PublicKey)) + + for _, input := range []*ec2.DescribeKeyPairsInput{{}, {IncludePublicKey: aws.Bool(false)}} { + out, err := svc.DescribeKeyPairs(context.Background(), input, testAccountID) + require.NoError(t, err) + require.Len(t, out.KeyPairs, 1) + assert.Nil(t, out.KeyPairs[0].PublicKey) + } +} + +func TestDescribeKeyPairs_IncludePublicKey_Created(t *testing.T) { + svc, _ := newTestKeyService() + + for _, keyType := range []string{"ed25519", "rsa"} { + created, err := svc.CreateKeyPair(context.Background(), &ec2.CreateKeyPairInput{ + KeyName: aws.String(keyType + "-created"), + KeyType: aws.String(keyType), + }, testAccountID) + require.NoError(t, err) + signer, err := ssh.ParsePrivateKey([]byte(aws.StringValue(created.KeyMaterial))) + require.NoError(t, err) + + out, err := svc.DescribeKeyPairs(context.Background(), &ec2.DescribeKeyPairsInput{ + KeyNames: []*string{created.KeyName}, + IncludePublicKey: aws.Bool(true), + }, testAccountID) + require.NoError(t, err) + require.Len(t, out.KeyPairs, 1) + + want := strings.TrimSuffix(string(ssh.MarshalAuthorizedKey(signer.PublicKey())), "\n") + " " + keyType + "-created\n" + assert.Equal(t, want, aws.StringValue(out.KeyPairs[0].PublicKey)) + } +} + +// A listed key whose public half cannot be read fails the call rather than +// describing it without the PublicKey that was asked for. +func TestDescribeKeyPairs_IncludePublicKey_MissingMaterial(t *testing.T) { + svc, store := newTestKeyService() + importTestKey(t, svc, "half-deleted") + _, err := store.DeleteObject(context.Background(), &s3.DeleteObjectInput{ + Bucket: aws.String(testBucket), + Key: aws.String("keys/" + testAccountID + "/half-deleted"), + }) + require.NoError(t, err) + + _, err = svc.DescribeKeyPairs(context.Background(), &ec2.DescribeKeyPairsInput{ + IncludePublicKey: aws.Bool(true), + }, testAccountID) + require.EqualError(t, err, awserrors.ErrorServerInternal) +} + func TestDescribeKeyPairs_FilterByKeyName(t *testing.T) { svc, _ := newTestKeyService() @@ -691,7 +795,7 @@ func TestDescribeKeyPairs_NotFound_ByKeyName(t *testing.T) { KeyNames: []*string{aws.String("does-not-exist")}, }, testAccountID) require.Error(t, err) - assert.Contains(t, err.Error(), "InvalidKeyPair.NotFound") + assertAWSError(t, err, awserrors.ErrorInvalidKeyPairNotFound, "The key pair 'does-not-exist' does not exist") } // TestDescribeKeyPairs_NotFound_ByKeyPairId is the KeyPairIds counterpart of @@ -705,7 +809,7 @@ func TestDescribeKeyPairs_NotFound_ByKeyPairId(t *testing.T) { KeyPairIds: []*string{aws.String("key-doesnotexist")}, }, testAccountID) require.Error(t, err) - assert.Contains(t, err.Error(), "InvalidKeyPair.NotFound") + assertAWSError(t, err, awserrors.ErrorInvalidKeyPairNotFound, "The keyPairId 'key-doesnotexist' does not exist") } // TestDescribeKeyPairs_NotFound_PartialMatch asserts that naming one existing @@ -1101,3 +1205,50 @@ func TestDescribeKeyPairs_AWSFilterWildcard(t *testing.T) { require.NoError(t, err) assert.Len(t, out.KeyPairs, 2) } + +type failingCentralTagStore struct{ puts, deletes int } + +func (f *failingCentralTagStore) PutResourceTags(context.Context, string, string, map[string]string) error { + f.puts++ + return errors.New("tag store down") +} + +func (f *failingCentralTagStore) DeleteAllTags(context.Context, string, string) error { + f.deletes++ + return errors.New("tag store down") +} + +// The key pair exists once its records are written, so a central tag store +// failure is logged and never fails create, import or delete. +func TestKeyPair_CentralTagStoreFailureDoesNotFailOperation(t *testing.T) { + svc, _ := newTestKeyService() + central := &failingCentralTagStore{} + svc.SetCentralTagStore(central) + spec := []*ec2.TagSpecification{{ + ResourceType: aws.String("key-pair"), + Tags: []*ec2.Tag{{Key: aws.String("Name"), Value: aws.String("k")}}, + }} + + _, err := svc.CreateKeyPair(context.Background(), &ec2.CreateKeyPairInput{ + KeyName: aws.String("created"), TagSpecifications: spec, + }, testAccountID) + require.NoError(t, err) + _, err = svc.ImportKeyPair(context.Background(), &ec2.ImportKeyPairInput{ + KeyName: aws.String("imported"), PublicKeyMaterial: []byte(testED25519PubKey), TagSpecifications: spec, + }, testAccountID) + require.NoError(t, err) + _, err = svc.DeleteKeyPair(context.Background(), &ec2.DeleteKeyPairInput{KeyName: aws.String("created")}, testAccountID) + require.NoError(t, err) + + assert.Equal(t, 2, central.puts) + assert.Equal(t, 1, central.deletes) +} + +// assertAWSError checks the code a client receives and the message with it. +func assertAWSError(t *testing.T, err error, wantCode, wantMessage string) { + t.Helper() + code, msg, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok, "unresolvable error: %v", err) + assert.Equal(t, wantCode, code) + assert.Equal(t, wantMessage, msg) +} diff --git a/spinifex/handlers/ec2/launchtemplate/expand_test.go b/spinifex/handlers/ec2/launchtemplate/expand_test.go index 431ecafa0..3574b4fc6 100644 --- a/spinifex/handlers/ec2/launchtemplate/expand_test.go +++ b/spinifex/handlers/ec2/launchtemplate/expand_test.go @@ -84,7 +84,7 @@ func TestExpandRunInstances_MissingVersion(t *testing.T) { } err := ExpandRunInstances(context.Background(), svc, input, testAccountID) require.Error(t, err) - assert.Equal(t, awserrors.ErrorInvalidLaunchTemplateIdVersionNotFound, err.Error()) + assertCode(t, err, awserrors.ErrorInvalidLaunchTemplateIdVersionNotFound) } func TestExpandRunInstances_IdNameConflict(t *testing.T) { @@ -115,5 +115,14 @@ func TestExpandRunInstances_UnknownTemplate(t *testing.T) { } err := ExpandRunInstances(context.Background(), svc, input, testAccountID) require.Error(t, err) - assert.Equal(t, awserrors.ErrorInvalidLaunchTemplateIdNotFound, err.Error()) + assertCode(t, err, awserrors.ErrorInvalidLaunchTemplateIdNotFound) +} + +// assertCode checks the code a client receives; the message is the +// DescribeLaunchTemplateVersions one the expansion passes through. +func assertCode(t *testing.T, err error, want string) { + t.Helper() + code, ok := awserrors.ResolveErrorCode(err) + require.True(t, ok, "unresolvable error: %v", err) + assert.Equal(t, want, code) } diff --git a/spinifex/handlers/ec2/launchtemplate/service_impl.go b/spinifex/handlers/ec2/launchtemplate/service_impl.go index a450e300c..d29dddc0f 100644 --- a/spinifex/handlers/ec2/launchtemplate/service_impl.go +++ b/spinifex/handlers/ec2/launchtemplate/service_impl.go @@ -79,11 +79,6 @@ func NewLaunchTemplateServiceImplWithNATS(ctx context.Context, cfg *config.Confi // --- key helpers --- -// headerKey returns the header key: account.lt-. -func headerKey(accountID, ltID string) string { - return utils.AccountKey(accountID, ltID) -} - // nameKey returns the name-index key: account.name.. // Launch template names allow characters such as parentheses that are not valid // in NATS KV keys, so the name must not be used in its raw form here. @@ -105,7 +100,7 @@ func versionPrefix(accountID, ltID string) string { // getHeaderByID reads a header and its KV entry (for CAS) by launch template id. func (s *LaunchTemplateServiceImpl) getHeaderByID(ctx context.Context, accountID, ltID string) (*LaunchTemplateHeader, jetstream.KeyValueEntry, error) { - entry, err := s.kv.Get(ctx, headerKey(accountID, ltID)) + entry, err := s.kv.Get(ctx, utils.AccountKey(accountID, ltID)) if err != nil { return nil, nil, errors.New(awserrors.ErrorInvalidLaunchTemplateIdNotFound) } @@ -143,9 +138,19 @@ func (s *LaunchTemplateServiceImpl) resolveHeader(ctx context.Context, accountID if err := validateTemplateID(id); err != nil { return nil, nil, err } - return s.getHeaderByID(ctx, accountID, id) + h, entry, err := s.getHeaderByID(ctx, accountID, id) + if err != nil && err.Error() == awserrors.ErrorInvalidLaunchTemplateIdNotFound { + return nil, nil, awserrors.Errorf(awserrors.ErrorInvalidLaunchTemplateIdNotFound, + "The specified launch template, with template ID %s, does not exist.", id) + } + return h, entry, err case nm != "": - return s.getHeaderByName(ctx, accountID, nm) + h, entry, err := s.getHeaderByName(ctx, accountID, nm) + if err != nil && err.Error() == awserrors.ErrorInvalidLaunchTemplateNameNotFoundException { + return nil, nil, awserrors.Errorf(awserrors.ErrorInvalidLaunchTemplateNameNotFoundException, + "The specified launch template, with template name %s, does not exist.", nm) + } + return h, entry, err default: return nil, nil, errors.New(awserrors.ErrorMissingParameter) } @@ -308,6 +313,11 @@ func (s *LaunchTemplateServiceImpl) CreateLaunchTemplate(ctx context.Context, in }, nil } +// errNameInUse is AWS's error for a launch template name already taken. +func errNameInUse() error { + return awserrors.Errorf(awserrors.ErrorInvalidLaunchTemplateNameAlreadyExistsException, "Launch template name already in use.") +} + // claimName atomically reserves a template name via kv.Create, with // repair-on-write: if the name already exists but its header is gone (a crash // orphan), reclaim it via a revision-guarded CAS update. Only a name whose header @@ -325,15 +335,15 @@ func (s *LaunchTemplateServiceImpl) claimName(ctx context.Context, accountID, na // header means the name is taken; any other read error (transient fault, or a // concurrent in-flight create whose header is not yet written) fails closed so // the name is never stolen from a possibly-live template. - _, herr := s.kv.Get(ctx, headerKey(accountID, string(entry.Value()))) + _, herr := s.kv.Get(ctx, utils.AccountKey(accountID, string(entry.Value()))) switch { case herr == nil: - return errors.New(awserrors.ErrorInvalidLaunchTemplateNameAlreadyExistsException) + return errNameInUse() case !errors.Is(herr, jetstream.ErrKeyNotFound): return errors.New(awserrors.ErrorServerInternal) } if _, err := s.kv.Update(ctx, key, []byte(ltID), entry.Revision()); err != nil { - return errors.New(awserrors.ErrorInvalidLaunchTemplateNameAlreadyExistsException) + return errNameInUse() } return nil } @@ -343,7 +353,7 @@ func (s *LaunchTemplateServiceImpl) putHeader(ctx context.Context, accountID str if err != nil { return errors.New(awserrors.ErrorServerInternal) } - if _, err := s.kv.Put(ctx, headerKey(accountID, h.LaunchTemplateId), data); err != nil { + if _, err := s.kv.Put(ctx, utils.AccountKey(accountID, h.LaunchTemplateId), data); err != nil { return errors.New(awserrors.ErrorServerInternal) } return nil @@ -381,11 +391,11 @@ func (s *LaunchTemplateServiceImpl) CreateLaunchTemplateVersion(ctx context.Cont if src := aws.StringValue(input.SourceVersion); src != "" { n, err := s.resolveVersionNumber(ctx, accountID, header, src) if err != nil { - return nil, err + return nil, withVersionNotFoundMessage(err, "Launch template version does not exist.") } base, err := s.getVersion(ctx, accountID, header.LaunchTemplateId, n) if err != nil { - return nil, err + return nil, withVersionNotFoundMessage(err, "Launch template version does not exist.") } data = mergeResponseData(base.Data, override) } @@ -440,21 +450,22 @@ func (s *LaunchTemplateServiceImpl) ModifyLaunchTemplate(ctx context.Context, in } if sel := aws.StringValue(input.DefaultVersion); sel != "" { + notFound := fmt.Sprintf("The launch template version %s is not found for the specified launch template.", sel) n, err := s.resolveVersionNumber(ctx, accountID, header, sel) if err != nil { - return nil, err + return nil, withVersionNotFoundMessage(err, notFound) } // Verify the target body exists immediately before the header CAS so a // concurrent delete degrades to VersionNotFound, never a dangling default. if _, err := s.getVersion(ctx, accountID, header.LaunchTemplateId, n); err != nil { - return nil, err + return nil, withVersionNotFoundMessage(err, notFound) } header.DefaultVersionNumber = n data, err := json.Marshal(header) if err != nil { return nil, errors.New(awserrors.ErrorServerInternal) } - if _, err := s.kv.Update(ctx, headerKey(accountID, header.LaunchTemplateId), data, entry.Revision()); err != nil { + if _, err := s.kv.Update(ctx, utils.AccountKey(accountID, header.LaunchTemplateId), data, entry.Revision()); err != nil { return nil, errors.New(awserrors.ErrorServerInternal) } } @@ -483,7 +494,7 @@ func (s *LaunchTemplateServiceImpl) DeleteLaunchTemplate(ctx context.Context, in // Delete the header first: the template immediately vanishes from every // describe. Version bodies and the name index are best-effort cleanup. - if err := s.kv.Delete(ctx, headerKey(accountID, header.LaunchTemplateId)); err != nil { + if err := s.kv.Delete(ctx, utils.AccountKey(accountID, header.LaunchTemplateId)); err != nil { return nil, errors.New(awserrors.ErrorServerInternal) } if err := s.kv.Delete(ctx, nameKey(accountID, header.LaunchTemplateName)); err != nil { @@ -514,6 +525,13 @@ func (s *LaunchTemplateServiceImpl) DeleteLaunchTemplateVersions(ctx context.Con return nil, errors.New(awserrors.ErrorMissingParameter) } + // Naming the default version fails the whole request, so nothing is deleted. + for _, v := range input.Versions { + if n, perr := strconv.ParseInt(aws.StringValue(v), 10, 64); perr == nil && n == header.DefaultVersionNumber { + return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "The default version cannot be deleted. Either specify another version as default or delete the launch template.") + } + } + out := &ec2.DeleteLaunchTemplateVersionsOutput{} for _, v := range input.Versions { @@ -524,14 +542,9 @@ func (s *LaunchTemplateServiceImpl) DeleteLaunchTemplateVersions(ctx context.Con deleteErrorItem(header, 0, awserrors.ErrorInvalidLaunchTemplateIdVersionNotFound, "invalid version number")) continue } - if n == header.DefaultVersionNumber { - out.UnsuccessfullyDeletedLaunchTemplateVersions = append(out.UnsuccessfullyDeletedLaunchTemplateVersions, - deleteErrorItem(header, n, awserrors.ErrorInvalidParameterValue, "cannot delete the default version of a launch template")) - continue - } if _, err := s.getVersion(ctx, accountID, header.LaunchTemplateId, n); err != nil { out.UnsuccessfullyDeletedLaunchTemplateVersions = append(out.UnsuccessfullyDeletedLaunchTemplateVersions, - deleteErrorItem(header, n, awserrors.ErrorInvalidLaunchTemplateIdVersionNotFound, "version does not exist")) + deleteErrorItem(header, n, launchTemplateVersionDoesNotExist, "The launch template version does not exist.")) continue } if err := s.kv.Delete(ctx, versionKey(accountID, header.LaunchTemplateId, n)); err != nil { @@ -551,6 +564,10 @@ func (s *LaunchTemplateServiceImpl) DeleteLaunchTemplateVersions(ctx context.Con return out, nil } +// launchTemplateVersionDoesNotExist is the per-version code AWS reports in a +// DeleteLaunchTemplateVersions result; it is never a request-level error. +const launchTemplateVersionDoesNotExist = "launchTemplateVersionDoesNotExist" + func deleteErrorItem(h *LaunchTemplateHeader, n int64, code, msg string) *ec2.DeleteLaunchTemplateVersionsResponseErrorItem { item := &ec2.DeleteLaunchTemplateVersionsResponseErrorItem{ LaunchTemplateId: aws.String(h.LaunchTemplateId), @@ -629,14 +646,15 @@ func (s *LaunchTemplateServiceImpl) DescribeLaunchTemplates(ctx context.Context, templates = append(templates, headerToEC2(&h, latestByID[h.LaunchTemplateId])) } + const missing = "At least one of the launch templates specified in the request does not exist." for name := range nameSet { if !foundNames[name] { - return nil, errors.New(awserrors.ErrorInvalidLaunchTemplateNameNotFoundException) + return nil, awserrors.Errorf(awserrors.ErrorInvalidLaunchTemplateNameNotFoundException, missing) } } for id := range idSet { if !foundIDs[id] { - return nil, errors.New(awserrors.ErrorInvalidLaunchTemplateIdNotFound) + return nil, awserrors.Errorf(awserrors.ErrorInvalidLaunchTemplateIdNotFound, missing) } } @@ -732,12 +750,14 @@ func (s *LaunchTemplateServiceImpl) selectVersionNumbers(ctx context.Context, ac seen := make(map[int64]bool) var out []int64 for _, v := range input.Versions { + notFound := fmt.Sprintf("Could not find the specified version %s for the launch template with ID %s.", + aws.StringValue(v), header.LaunchTemplateId) n, err := s.resolveVersionNumber(ctx, accountID, header, aws.StringValue(v)) if err != nil { - return nil, err + return nil, withVersionNotFoundMessage(err, notFound) } if _, err := s.getVersion(ctx, accountID, header.LaunchTemplateId, n); err != nil { - return nil, err + return nil, withVersionNotFoundMessage(err, notFound) } if !seen[n] { seen[n] = true @@ -858,6 +878,15 @@ func versionRecToEC2(h *LaunchTemplateHeader, rec *LaunchTemplateVersionRec, isD // --- misc helpers --- +// withVersionNotFoundMessage attaches AWS's message, which differs by action, +// to a VersionNotFound error and passes any other error through. +func withVersionNotFoundMessage(err error, message string) error { + if err.Error() != awserrors.ErrorInvalidLaunchTemplateIdVersionNotFound { + return err + } + return awserrors.Errorf(awserrors.ErrorInvalidLaunchTemplateIdVersionNotFound, "%s", message) +} + // isHeaderKey reports whether k is a template header key (account.lt-) as // opposed to a name index, version body (account.lt-.v), or _version key. func isHeaderKey(k, prefix string) bool { diff --git a/spinifex/handlers/ec2/launchtemplate/service_impl_test.go b/spinifex/handlers/ec2/launchtemplate/service_impl_test.go index adc203c3c..30e7ede3b 100644 --- a/spinifex/handlers/ec2/launchtemplate/service_impl_test.go +++ b/spinifex/handlers/ec2/launchtemplate/service_impl_test.go @@ -7,8 +7,9 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ec2" - "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "github.com/mulgadc/spinifex/internal/testkit" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" + "github.com/mulgadc/spinifex/spinifex/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -84,7 +85,7 @@ func TestCreateLaunchTemplate_DuplicateName(t *testing.T) { LaunchTemplateData: &ec2.RequestLaunchTemplateData{ImageId: aws.String("ami-1")}, }, testAccountID) require.Error(t, err) - assert.Equal(t, awserrors.ErrorInvalidLaunchTemplateNameAlreadyExistsException, err.Error()) + assertAWSError(t, err, awserrors.ErrorInvalidLaunchTemplateNameAlreadyExistsException, "Launch template name already in use.") } func TestLaunchTemplateNameWithKVUnsafeCharacters(t *testing.T) { @@ -115,7 +116,7 @@ func TestCreateLaunchTemplate_OrphanNameReclaim(t *testing.T) { lt := createTemplate(t, svc, "orphan", "t3.micro") // Simulate a crash after the name claim but with the header lost. - require.NoError(t, svc.kv.Delete(t.Context(), headerKey(testAccountID, aws.StringValue(lt.LaunchTemplateId)))) + require.NoError(t, svc.kv.Delete(t.Context(), utils.AccountKey(testAccountID, aws.StringValue(lt.LaunchTemplateId)))) // The name index still points at the now-orphaned id; create must reclaim it. out, err := svc.CreateLaunchTemplate(context.Background(), &ec2.CreateLaunchTemplateInput{ @@ -158,7 +159,8 @@ func TestDescribeLaunchTemplates_UnknownName(t *testing.T) { LaunchTemplateNames: []*string{aws.String("missing")}, }, testAccountID) require.Error(t, err) - assert.Equal(t, awserrors.ErrorInvalidLaunchTemplateNameNotFoundException, err.Error()) + assertAWSError(t, err, awserrors.ErrorInvalidLaunchTemplateNameNotFoundException, + "At least one of the launch templates specified in the request does not exist.") } func TestDescribeLaunchTemplates_TagFilter(t *testing.T) { @@ -331,7 +333,39 @@ func TestDescribeLaunchTemplateVersions_MissingVersion(t *testing.T) { Versions: []*string{aws.String("99")}, }, testAccountID) require.Error(t, err) - assert.Equal(t, awserrors.ErrorInvalidLaunchTemplateIdVersionNotFound, err.Error()) + assertAWSError(t, err, awserrors.ErrorInvalidLaunchTemplateIdVersionNotFound, + "Could not find the specified version 99 for the launch template with ID "+aws.StringValue(lt.LaunchTemplateId)+".") +} + +func TestLaunchTemplate_NotFoundMessagesNameTheTemplate(t *testing.T) { + svc := setupTestService(t) + lt := createTemplate(t, svc, "web", "t3.micro") + + _, err := svc.DeleteLaunchTemplate(context.Background(), &ec2.DeleteLaunchTemplateInput{ + LaunchTemplateName: aws.String("missing"), + }, testAccountID) + assertAWSError(t, err, awserrors.ErrorInvalidLaunchTemplateNameNotFoundException, + "The specified launch template, with template name missing, does not exist.") + + _, err = svc.DescribeLaunchTemplateVersions(context.Background(), &ec2.DescribeLaunchTemplateVersionsInput{ + LaunchTemplateId: aws.String("lt-0123456789abcdef0"), + }, testAccountID) + assertAWSError(t, err, awserrors.ErrorInvalidLaunchTemplateIdNotFound, + "The specified launch template, with template ID lt-0123456789abcdef0, does not exist.") + + _, err = svc.ModifyLaunchTemplate(context.Background(), &ec2.ModifyLaunchTemplateInput{ + LaunchTemplateId: lt.LaunchTemplateId, + DefaultVersion: aws.String("42"), + }, testAccountID) + assertAWSError(t, err, awserrors.ErrorInvalidLaunchTemplateIdVersionNotFound, + "The launch template version 42 is not found for the specified launch template.") + + _, err = svc.CreateLaunchTemplateVersion(context.Background(), &ec2.CreateLaunchTemplateVersionInput{ + LaunchTemplateId: lt.LaunchTemplateId, + SourceVersion: aws.String("42"), + LaunchTemplateData: &ec2.RequestLaunchTemplateData{InstanceType: aws.String("t3.small")}, + }, testAccountID) + assertAWSError(t, err, awserrors.ErrorInvalidLaunchTemplateIdVersionNotFound, "Launch template version does not exist.") } func TestDescribeLaunchTemplateVersions_LatestAfterTailDelete(t *testing.T) { @@ -388,7 +422,8 @@ func TestModifyLaunchTemplate_DefaultToMissingVersion(t *testing.T) { DefaultVersion: aws.String("42"), }, testAccountID) require.Error(t, err) - assert.Equal(t, awserrors.ErrorInvalidLaunchTemplateIdVersionNotFound, err.Error()) + code, _ := awserrors.ResolveErrorCode(err) + assert.Equal(t, awserrors.ErrorInvalidLaunchTemplateIdVersionNotFound, code) } // --- DeleteLaunchTemplateVersions --- @@ -396,15 +431,23 @@ func TestModifyLaunchTemplate_DefaultToMissingVersion(t *testing.T) { func TestDeleteLaunchTemplateVersions_RejectDefault(t *testing.T) { svc := setupTestService(t) lt := createTemplate(t, svc, "web", "t3.micro") // default = v1 - out, err := svc.DeleteLaunchTemplateVersions(context.Background(), &ec2.DeleteLaunchTemplateVersionsInput{ - LaunchTemplateId: lt.LaunchTemplateId, - Versions: []*string{aws.String("1")}, + id := aws.StringValue(lt.LaunchTemplateId) + addVersion(t, svc, id, "a", "") // v2 + + // The default alongside a deletable version still fails the whole request. + _, err := svc.DeleteLaunchTemplateVersions(context.Background(), &ec2.DeleteLaunchTemplateVersionsInput{ + LaunchTemplateId: aws.String(id), + Versions: []*string{aws.String("2"), aws.String("1")}, }, testAccountID) + require.Error(t, err) + code, msg, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok) + assert.Equal(t, awserrors.ErrorInvalidParameterValue, code) + assert.Equal(t, "The default version cannot be deleted. Either specify another version as default or delete the launch template.", msg) + + nums, err := svc.listVersionNumbers(t.Context(), testAccountID, id) require.NoError(t, err) - assert.Empty(t, out.SuccessfullyDeletedLaunchTemplateVersions) - require.Len(t, out.UnsuccessfullyDeletedLaunchTemplateVersions, 1) - assert.Equal(t, awserrors.ErrorInvalidParameterValue, - aws.StringValue(out.UnsuccessfullyDeletedLaunchTemplateVersions[0].ResponseError.Code)) + assert.ElementsMatch(t, []int64{1, 2}, nums) } func TestDeleteLaunchTemplateVersions_SuccessAndMissing(t *testing.T) { @@ -421,7 +464,10 @@ func TestDeleteLaunchTemplateVersions_SuccessAndMissing(t *testing.T) { require.Len(t, out.SuccessfullyDeletedLaunchTemplateVersions, 1) assert.Equal(t, int64(2), aws.Int64Value(out.SuccessfullyDeletedLaunchTemplateVersions[0].VersionNumber)) require.Len(t, out.UnsuccessfullyDeletedLaunchTemplateVersions, 1) - assert.Equal(t, int64(9), aws.Int64Value(out.UnsuccessfullyDeletedLaunchTemplateVersions[0].VersionNumber)) + missing := out.UnsuccessfullyDeletedLaunchTemplateVersions[0] + assert.Equal(t, int64(9), aws.Int64Value(missing.VersionNumber)) + assert.Equal(t, "launchTemplateVersionDoesNotExist", aws.StringValue(missing.ResponseError.Code)) + assert.Equal(t, "The launch template version does not exist.", aws.StringValue(missing.ResponseError.Message)) } // --- DeleteLaunchTemplate --- @@ -442,7 +488,8 @@ func TestDeleteLaunchTemplate_RemovesEverything(t *testing.T) { LaunchTemplateIds: []*string{aws.String(id)}, }, testAccountID) require.Error(t, err) - assert.Equal(t, awserrors.ErrorInvalidLaunchTemplateIdNotFound, err.Error()) + code, _ := awserrors.ResolveErrorCode(err) + assert.Equal(t, awserrors.ErrorInvalidLaunchTemplateIdNotFound, code) // Version bodies and name index gone. nums, err := svc.listVersionNumbers(t.Context(), testAccountID, id) @@ -461,7 +508,8 @@ func TestDeleteLaunchTemplate_Unknown(t *testing.T) { LaunchTemplateId: aws.String("lt-doesnotexist000"), }, testAccountID) require.Error(t, err) - assert.Equal(t, awserrors.ErrorInvalidLaunchTemplateIdNotFound, err.Error()) + code, _ := awserrors.ResolveErrorCode(err) + assert.Equal(t, awserrors.ErrorInvalidLaunchTemplateIdNotFound, code) } func TestResolveHeader_IdNameConflict(t *testing.T) { @@ -492,3 +540,13 @@ func TestDescribeLaunchTemplates_MalformedId(t *testing.T) { require.Error(t, err) assert.Equal(t, awserrors.ErrorInvalidLaunchTemplateIdMalformed, err.Error()) } + +// assertAWSError checks the code a client receives and the message with it. +func assertAWSError(t *testing.T, err error, wantCode, wantMessage string) { + t.Helper() + require.Error(t, err) + code, msg, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok, "unresolvable error: %v", err) + assert.Equal(t, wantCode, code) + assert.Equal(t, wantMessage, msg) +} diff --git a/spinifex/handlers/ec2/natgw/service_impl.go b/spinifex/handlers/ec2/natgw/service_impl.go index 535921f8b..52201451b 100644 --- a/spinifex/handlers/ec2/natgw/service_impl.go +++ b/spinifex/handlers/ec2/natgw/service_impl.go @@ -84,23 +84,16 @@ func NewNatGatewayServiceImplWithNATS(ctx context.Context, natsConn *nats.Conn) }, nil } -// getOrCreateDeletedBucket creates the expiring bucket without changing an -// existing bucket's replica or placement configuration. +// getOrCreateDeletedBucket creates the expiring bucket that records a deletion +// for as long as a client may still ask about it. func getOrCreateDeletedBucket(ctx context.Context, js jetstream.KeyValueManager) (jetstream.KeyValue, error) { // Terraform polls DescribeNatGateways after delete and expects state=deleted. - kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{ - Bucket: KVBucketDeletedNatGateways, + return kvutil.GetOrCreateBucketWithOptions(ctx, js, kvutil.BucketOptions{ + Name: KVBucketDeletedNatGateways, Description: "Deleted NAT Gateways (auto-expire after 1 hour)", History: 1, TTL: time.Hour, }) - if err == nil { - return kv, nil - } - if !errors.Is(err, jetstream.ErrBucketExists) { - return nil, err - } - return js.KeyValue(ctx, KVBucketDeletedNatGateways) } // natGatewayEvent is published on vpc.add-nat-gateway / vpc.delete-nat-gateway topics. diff --git a/spinifex/handlers/ec2/placementgroup/service_impl.go b/spinifex/handlers/ec2/placementgroup/service_impl.go index 8ad56fc9f..246ea3f18 100644 --- a/spinifex/handlers/ec2/placementgroup/service_impl.go +++ b/spinifex/handlers/ec2/placementgroup/service_impl.go @@ -12,6 +12,7 @@ import ( "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/foundation/aws/arn" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters" "github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil" @@ -49,6 +50,7 @@ type PlacementGroupServiceImpl struct { config *config.Config natsConn *nats.Conn kv jetstream.KeyValue + region string } // NewPlacementGroupServiceImplWithNATS creates a placement group service with NATS JetStream. @@ -66,12 +68,18 @@ func NewPlacementGroupServiceImplWithNATS(ctx context.Context, cfg *config.Confi return nil, fmt.Errorf("migrate %s: %w", KVBucketPlacementGroups, err) } + region := config.DefaultAWSRegion + if cfg != nil && cfg.Region != "" { + region = cfg.Region + } + slog.Info("Placement group service initialized with JetStream KV", "bucket", KVBucketPlacementGroups) return &PlacementGroupServiceImpl{ config: cfg, natsConn: natsConn, kv: kv, + region: region, }, nil } @@ -123,7 +131,7 @@ func (s *PlacementGroupServiceImpl) CreatePlacementGroup(ctx context.Context, in // Atomic create-if-not-exists to prevent TOCTOU race on duplicate names if _, err := s.kv.Create(ctx, key, data); err != nil { // Create fails if key already exists - return nil, errors.New(awserrors.ErrorInvalidPlacementGroupDuplicate) + return nil, awserrors.Errorf(awserrors.ErrorInvalidPlacementGroupDuplicate, "The placement group '%s' already exists.", groupName) } slog.InfoContext(ctx, "CreatePlacementGroup completed", "groupId", groupID, "groupName", groupName, "strategy", strategy, "accountID", accountID) @@ -144,7 +152,8 @@ func (s *PlacementGroupServiceImpl) DeletePlacementGroup(ctx context.Context, in entry, err := s.kv.Get(ctx, key) if err != nil { - return nil, errors.New(awserrors.ErrorInvalidPlacementGroupUnknown) + // Lower case, unlike DescribePlacementGroups' message for the same code. + return nil, awserrors.Errorf(awserrors.ErrorInvalidPlacementGroupUnknown, "The placement group '%s' is unknown.", groupName) } var record PlacementGroupRecord @@ -252,9 +261,20 @@ func (s *PlacementGroupServiceImpl) DescribePlacementGroups(ctx context.Context, found[*g.GroupName] = true } } - for name := range nameSet { - if !found[name] { - return nil, errors.New(awserrors.ErrorInvalidPlacementGroupUnknown) + for _, name := range input.GroupNames { + if name != nil && !found[*name] { + return nil, awserrors.Errorf(awserrors.ErrorInvalidPlacementGroupUnknown, "The Placement Group '%s' is unknown.", *name) + } + } + } + if len(idSet) > 0 { + found := make(map[string]bool) + for _, g := range groups { + found[aws.StringValue(g.GroupId)] = true + } + for _, id := range input.GroupIds { + if id != nil && !found[*id] { + return nil, awserrors.Errorf(awserrors.ErrorInvalidPlacementGroupUnknown, "The Placement Group '%s' is unknown.", *id) } } } @@ -269,7 +289,7 @@ func (s *PlacementGroupServiceImpl) DescribePlacementGroups(ctx context.Context, // pgMatchesFilters checks whether a placement group record matches all parsed awsfilters. func pgMatchesFilters(record *PlacementGroupRecord, filters map[string][]string) bool { for name, values := range filters { - if strings.HasPrefix(name, "tag:") { + if awsfilters.IsTagFilter(name) { continue } switch name { @@ -293,14 +313,6 @@ func pgMatchesFilters(record *PlacementGroupRecord, filters map[string][]string) if !awsfilters.MatchesAny(values, record.GroupName) { return false } - case "tag-key": - if !pgMatchesAnyTag(record.Tags, values, func(k, _ string) string { return k }) { - return false - } - case "tag-value": - if !pgMatchesAnyTag(record.Tags, values, func(_, v string) string { return v }) { - return false - } default: return false } @@ -308,17 +320,6 @@ func pgMatchesFilters(record *PlacementGroupRecord, filters map[string][]string) return awsfilters.MatchesTags(filters, record.Tags) } -// pgMatchesAnyTag reports whether any tag's selected field (key or value) -// matches any of the filter values. -func pgMatchesAnyTag(tags map[string]string, values []string, field func(k, v string) string) bool { - for k, v := range tags { - if awsfilters.MatchesAny(values, field(k, v)) { - return true - } - } - return false -} - const maxCASRetries = 5 // errPGAbsent and errPGContended mark the two kvutil outcomes that map to @@ -568,6 +569,7 @@ func (s *PlacementGroupServiceImpl) FinalizeClusterInstances(ctx context.Context // recordToEC2 converts an internal record to the AWS SDK PlacementGroup type. func (s *PlacementGroupServiceImpl) recordToEC2(record *PlacementGroupRecord) *ec2.PlacementGroup { pg := &ec2.PlacementGroup{ + GroupArn: aws.String(arn.FormatEC2(arn.EC2PlacementGroup, s.region, record.AccountID, record.GroupName)), GroupId: aws.String(record.GroupId), GroupName: aws.String(record.GroupName), Strategy: aws.String(record.Strategy), diff --git a/spinifex/handlers/ec2/placementgroup/service_impl_test.go b/spinifex/handlers/ec2/placementgroup/service_impl_test.go index e4c85a522..8aff8e8ba 100644 --- a/spinifex/handlers/ec2/placementgroup/service_impl_test.go +++ b/spinifex/handlers/ec2/placementgroup/service_impl_test.go @@ -7,9 +7,10 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ec2" + "github.com/mulgadc/spinifex/internal/testkit" + "github.com/mulgadc/spinifex/spinifex/config" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil" - "github.com/mulgadc/spinifex/internal/testkit" "github.com/mulgadc/spinifex/spinifex/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -83,6 +84,34 @@ func TestCreatePlacementGroup_Cluster(t *testing.T) { assert.Nil(t, pg.SpreadLevel) } +// AWS builds GroupArn from the group name, not its ID, on create and describe. +func TestPlacementGroup_GroupArnFromName(t *testing.T) { + _, nc, _ := testutil.StartTestJetStream(t) + svc, err := NewPlacementGroupServiceImplWithNATS(t.Context(), &config.Config{Region: "ap-southeast-2"}, nc) + require.NoError(t, err) + + for _, strategy := range []string{"cluster", "spread"} { + name := "arn-" + strategy + want := "arn:aws:ec2:ap-southeast-2:123456789012:placement-group/" + name + + pg := createTestGroup(t, svc, name, strategy) + assert.Equal(t, want, aws.StringValue(pg.GroupArn)) + + out, err := svc.DescribePlacementGroups(context.Background(), &ec2.DescribePlacementGroupsInput{ + GroupNames: []*string{aws.String(name)}, + }, testAccountID) + require.NoError(t, err) + require.Len(t, out.PlacementGroups, 1) + assert.Equal(t, want, aws.StringValue(out.PlacementGroups[0].GroupArn)) + } +} + +func TestPlacementGroup_GroupArnDefaultRegion(t *testing.T) { + svc := setupTestService(t) + pg := createTestGroup(t, svc, "no-region", "spread") + assert.Equal(t, "arn:aws:ec2:us-east-1:123456789012:placement-group/no-region", aws.StringValue(pg.GroupArn)) +} + func TestCreatePlacementGroup_DuplicateName(t *testing.T) { svc := setupTestService(t) createTestGroup(t, svc, "dup-group", "spread") @@ -92,7 +121,7 @@ func TestCreatePlacementGroup_DuplicateName(t *testing.T) { Strategy: aws.String("spread"), }, testAccountID) require.Error(t, err) - assert.Equal(t, awserrors.ErrorInvalidPlacementGroupDuplicate, err.Error()) + assertAWSError(t, err, awserrors.ErrorInvalidPlacementGroupDuplicate, "The placement group 'dup-group' already exists.") } func TestCreatePlacementGroup_PartitionRejected(t *testing.T) { @@ -190,13 +219,31 @@ func TestDeletePlacementGroup_Success(t *testing.T) { assert.Empty(t, out.PlacementGroups) } +// AWS answers the ID of a deleted group with InvalidPlacementGroup.Unknown +// naming that ID, not an empty list. +func TestDescribePlacementGroups_DeletedGroupId(t *testing.T) { + svc := setupTestService(t) + kept := createTestGroup(t, svc, "kept", "spread") + deleted := createTestGroup(t, svc, "deleted", "spread") + _, err := svc.DeletePlacementGroup(context.Background(), &ec2.DeletePlacementGroupInput{GroupName: deleted.GroupName}, testAccountID) + require.NoError(t, err) + + _, err = svc.DescribePlacementGroups(context.Background(), &ec2.DescribePlacementGroupsInput{ + GroupIds: []*string{kept.GroupId, deleted.GroupId}, + }, testAccountID) + code, msg, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok) + assert.Equal(t, awserrors.ErrorInvalidPlacementGroupUnknown, code) + assert.Equal(t, "The Placement Group '"+*deleted.GroupId+"' is unknown.", msg) +} + func TestDeletePlacementGroup_NotFound(t *testing.T) { svc := setupTestService(t) _, err := svc.DeletePlacementGroup(context.Background(), &ec2.DeletePlacementGroupInput{ GroupName: aws.String("nonexistent"), }, testAccountID) require.Error(t, err) - assert.Equal(t, awserrors.ErrorInvalidPlacementGroupUnknown, err.Error()) + assertAWSError(t, err, awserrors.ErrorInvalidPlacementGroupUnknown, "The placement group 'nonexistent' is unknown.") } func TestDeletePlacementGroup_InUse(t *testing.T) { @@ -333,7 +380,7 @@ func TestDescribePlacementGroups_NameNotFound(t *testing.T) { GroupNames: []*string{aws.String("ghost")}, }, testAccountID) require.Error(t, err) - assert.Equal(t, awserrors.ErrorInvalidPlacementGroupUnknown, err.Error()) + assertAWSError(t, err, awserrors.ErrorInvalidPlacementGroupUnknown, "The Placement Group 'ghost' is unknown.") } func TestDescribePlacementGroups_AccountScoped(t *testing.T) { @@ -859,3 +906,12 @@ func TestRemoveInstance_ReadFailureIsNotSuccess(t *testing.T) { require.Error(t, err) assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorServerInternal), "got %v", err) } + +// assertAWSError checks the code a client receives and the message with it. +func assertAWSError(t *testing.T, err error, wantCode, wantMessage string) { + t.Helper() + code, msg, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok, "unresolvable error: %v", err) + assert.Equal(t, wantCode, code) + assert.Equal(t, wantMessage, msg) +} diff --git a/spinifex/handlers/ec2/routetable/service_impl.go b/spinifex/handlers/ec2/routetable/service_impl.go index fa6d0fc53..f5a4d023d 100644 --- a/spinifex/handlers/ec2/routetable/service_impl.go +++ b/spinifex/handlers/ec2/routetable/service_impl.go @@ -6,6 +6,8 @@ import ( "errors" "fmt" "log/slog" + "net/netip" + "slices" "strings" "time" @@ -19,6 +21,7 @@ import ( handlers_ec2_igw "github.com/mulgadc/spinifex/spinifex/handlers/ec2/igw" handlers_ec2_natgw "github.com/mulgadc/spinifex/spinifex/handlers/ec2/natgw" handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc" + "github.com/mulgadc/spinifex/spinifex/paging" "github.com/mulgadc/spinifex/spinifex/utils" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" @@ -91,7 +94,7 @@ func (s *RouteTableServiceImpl) getRouteTable(ctx context.Context, accountID, rt entry, err := s.rtbKV.Get(ctx, utils.AccountKey(accountID, rtbID)) if err != nil { if errors.Is(err, jetstream.ErrKeyNotFound) { - return nil, errors.New(awserrors.ErrorInvalidRouteTableIDNotFound) + return nil, rtbNotFoundError(rtbID) } slog.ErrorContext(ctx, "Failed to read route table from KV", "routeTableId", rtbID, "err", err) return nil, errors.New(awserrors.ErrorServerInternal) @@ -145,7 +148,7 @@ func (s *RouteTableServiceImpl) mutateRouteTableCAS(ctx context.Context, account case err == nil: return nil case errors.Is(err, errRTBAbsent): - return errors.New(awserrors.ErrorInvalidRouteTableIDNotFound) + return rtbNotFoundError(rtbID) case errors.Is(err, errRTBContended): // Contended rather than broken. slog.ErrorContext(ctx, "Route table CAS retries exhausted under contention", @@ -168,7 +171,7 @@ func (s *RouteTableServiceImpl) mutateRouteTableCAS(ctx context.Context, account func (s *RouteTableServiceImpl) getVPCCidr(ctx context.Context, accountID, vpcID string) (string, error) { entry, err := s.vpcKV.Get(ctx, utils.AccountKey(accountID, vpcID)) if err != nil { - return "", errors.New(awserrors.ErrorInvalidVpcIDNotFound) + return "", awserrors.IDNotFound(awserrors.ErrorInvalidVpcIDNotFound, "vpc", vpcID) } var vpcRecord handlers_ec2_vpc.VPCRecord if err := json.Unmarshal(entry.Value(), &vpcRecord); err != nil { @@ -550,7 +553,8 @@ func (s *RouteTableServiceImpl) CreateRouteTable(ctx context.Context, input *ec2 } return &ec2.CreateRouteTableOutput{ - RouteTable: recordToEC2(record), + RouteTable: recordToEC2(record), + ClientToken: input.ClientToken, }, nil } @@ -569,13 +573,13 @@ func (s *RouteTableServiceImpl) DeleteRouteTable(ctx context.Context, input *ec2 } if record.IsMain { - return nil, errors.New(awserrors.ErrorDependencyViolation) + return nil, awserrors.HasDependencies("routeTable", rtbID) } // Check for non-main associations (subnets still using this table) for _, assoc := range record.Associations { if !assoc.Main && assoc.SubnetId != "" { - return nil, errors.New(awserrors.ErrorDependencyViolation) + return nil, awserrors.HasDependencies("routeTable", rtbID) } } @@ -587,6 +591,13 @@ func (s *RouteTableServiceImpl) DeleteRouteTable(ctx context.Context, input *ec2 return &ec2.DeleteRouteTableOutput{}, nil } +var describeRouteTablesPaging = paging.EC2{ + MaxResults: 100, + TooLarge: "Value ( %d ) for parameter MaxResults is invalid. Expecting a value smaller than or equal to 100.", + TooSmall: "Value ( %d ) for parameter MaxResults is invalid. Expecting a value greater than or equal to 5.", + WithIDs: "The parameter RouteTableIds cannot be used with the parameter MaxResults", +} + var describeRouteTablesValidFilters = map[string]bool{ "vpc-id": true, "route-table-id": true, @@ -599,10 +610,17 @@ var describeRouteTablesValidFilters = map[string]bool{ "route.state": true, "route.origin": true, "owner-id": true, + "tag-key": true, + "tag-value": true, } // DescribeRouteTables lists route tables, optionally filtered. func (s *RouteTableServiceImpl) DescribeRouteTables(ctx context.Context, input *ec2.DescribeRouteTablesInput, accountID string) (*ec2.DescribeRouteTablesOutput, error) { + // Validated but not paged: AWS's paging of DescribeRouteTables is unobserved. + if _, err := describeRouteTablesPaging.Parse(input.MaxResults, input.NextToken, len(input.RouteTableIds)); err != nil { + return nil, err + } + rtbIDs := make(map[string]bool) for _, id := range input.RouteTableIds { if id != nil { @@ -658,9 +676,9 @@ func (s *RouteTableServiceImpl) DescribeRouteTables(ctx context.Context, input * } // Return error if specific IDs were requested but not found - for id := range rtbIDs { - if !foundIDs[id] { - return nil, errors.New(awserrors.ErrorInvalidRouteTableIDNotFound) + for _, id := range input.RouteTableIds { + if id != nil && !foundIDs[*id] { + return nil, rtbNotFoundError(*id) } } @@ -670,6 +688,44 @@ func (s *RouteTableServiceImpl) DescribeRouteTables(ctx context.Context, input * } // CreateRoute adds a route to a route table. +// gatewayNotFoundError is AWS's answer to a route naming a gateway that does +// not exist, whatever the gateway's type. +// checkRouteOutsideVPC rejects a gateway route whose destination is, or lies +// inside, one of the VPC's CIDR blocks (the table's local routes). AWS allows +// only an interface or instance there, which Spinifex does not route to. +func checkRouteOutsideVPC(record *RouteTableRecord, destCidr string) error { + dest, parseErr := netip.ParsePrefix(destCidr) + if parseErr != nil { + return nil //nolint:nilerr // an unparseable destination is not a VPC-range conflict + } + for _, r := range record.Routes { + if r.GatewayId != "local" { + continue + } + vpc, err := netip.ParsePrefix(r.DestinationCidrBlock) + if err != nil { + continue + } + if dest.Bits() >= vpc.Bits() && vpc.Contains(dest.Addr()) { + return awserrors.Errorf(awserrors.ErrorInvalidParameterValue, + "The destination CIDR block %s is equal to or more specific than one of this VPC's CIDR blocks. This route can target only an interface or an instance.", destCidr) + } + } + return nil +} + +func rtbNotFoundError(id string) error { + return awserrors.IDNotFound(awserrors.ErrorInvalidRouteTableIDNotFound, "routeTable", id) +} + +func associationNotFoundError(id string) error { + return awserrors.IDNotFound(awserrors.ErrorInvalidAssociationIDNotFound, "association", id) +} + +func gatewayNotFoundError(id string) error { + return awserrors.Errorf(awserrors.ErrorInvalidGatewayIDNotFound, "The gateway ID '%s' does not exist", id) +} + func (s *RouteTableServiceImpl) CreateRoute(ctx context.Context, input *ec2.CreateRouteInput, accountID string) (*ec2.CreateRouteOutput, error) { if input.RouteTableId == nil || *input.RouteTableId == "" { return nil, errors.New(awserrors.ErrorMissingParameter) @@ -686,14 +742,10 @@ func (s *RouteTableServiceImpl) CreateRoute(ctx context.Context, input *ec2.Crea return nil, err } - // Check for duplicate destination - for _, r := range record.Routes { - if r.DestinationCidrBlock == destCidr { - return nil, errors.New(awserrors.ErrorRouteAlreadyExists) - } - } - + // AWS validates the target, then the destination, then looks for an + // existing route, so events are held back until all three pass. var route RouteRecord + var publish func() switch { case input.GatewayId != nil && *input.GatewayId != "": @@ -701,7 +753,7 @@ func (s *RouteTableServiceImpl) CreateRoute(ctx context.Context, input *ec2.Crea // Verify IGW exists and is attached to the same VPC igwEntry, err := s.igwKV.Get(ctx, utils.AccountKey(accountID, igwID)) if err != nil { - return nil, errors.New(awserrors.ErrorInvalidInternetGatewayIDNotFound) + return nil, gatewayNotFoundError(igwID) } var igwRecord handlers_ec2_igw.IGWRecord if err := json.Unmarshal(igwEntry.Value(), &igwRecord); err != nil { @@ -722,7 +774,9 @@ func (s *RouteTableServiceImpl) CreateRoute(ctx context.Context, input *ec2.Crea // Publish vpc.add-igw-route events for each subnet associated with this // route table so the network subscriber installs per-subnet egress policies. - s.publishIGWRouteEvents(ctx, accountID, "vpc.add-igw-route", record, igwRecord.VpcId, igwID, destCidr) + publish = func() { + s.publishIGWRouteEvents(ctx, accountID, "vpc.add-igw-route", record, igwRecord.VpcId, igwID, destCidr) + } case input.NatGatewayId != nil && *input.NatGatewayId != "": natgwID := *input.NatGatewayId @@ -750,12 +804,32 @@ func (s *RouteTableServiceImpl) CreateRoute(ctx context.Context, input *ec2.Crea } // Publish vpc.add-nat-gateway events for each subnet associated with this route table - s.publishNatGatewayEvents(ctx, accountID, record, natgwRecord.VpcId, natgwID, natgwRecord.PublicIp, destCidr) + publish = func() { + s.publishNatGatewayEvents(ctx, accountID, record, natgwRecord.VpcId, natgwID, natgwRecord.PublicIp, destCidr) + } default: return nil, errors.New(awserrors.ErrorInvalidParameterValue) } + if err := checkRouteOutsideVPC(record, destCidr); err != nil { + return nil, err + } + + // A repeat of an existing route (same destination, same target) is a + // successful no-op; a different target is a conflict. + for _, r := range record.Routes { + if r.DestinationCidrBlock != destCidr { + continue + } + if r.GatewayId == route.GatewayId && r.NatGatewayId == route.NatGatewayId { + return &ec2.CreateRouteOutput{Return: aws.Bool(true)}, nil + } + return nil, awserrors.Errorf(awserrors.ErrorRouteAlreadyExists, "The route identified by %s already exists.", destCidr) + } + + publish() + record.Routes = append(record.Routes, route) if err := s.putRouteTable(ctx, accountID, record); err != nil { @@ -800,7 +874,7 @@ func (s *RouteTableServiceImpl) DeleteRoute(ctx context.Context, input *ec2.Dele } } if idx < 0 { - return nil, errors.New(awserrors.ErrorInvalidRouteNotFound) + return nil, awserrors.Errorf(awserrors.ErrorInvalidRouteNotFound, "no route with destination-cidr-block %s in route table %s", destCidr, rtbID) } departing := record.Routes[idx] @@ -853,6 +927,23 @@ func (s *RouteTableServiceImpl) ReplaceRoute(ctx context.Context, input *ec2.Rep return nil, err } + // V1: only GatewayId target supported + if input.GatewayId == nil || *input.GatewayId == "" { + return nil, errors.New(awserrors.ErrorInvalidParameterValue) + } + + igwID := *input.GatewayId + + // AWS reports a missing gateway ahead of a missing route. + igwEntry, err := s.igwKV.Get(ctx, utils.AccountKey(accountID, igwID)) + if err != nil { + return nil, gatewayNotFoundError(igwID) + } + var igwRecord handlers_ec2_igw.IGWRecord + if err := json.Unmarshal(igwEntry.Value(), &igwRecord); err != nil { + return nil, errors.New(awserrors.ErrorServerInternal) + } + idx := -1 for i, r := range record.Routes { if r.DestinationCidrBlock == destCidr { @@ -861,29 +952,13 @@ func (s *RouteTableServiceImpl) ReplaceRoute(ctx context.Context, input *ec2.Rep } } if idx < 0 { - return nil, errors.New(awserrors.ErrorInvalidRouteNotFound) + return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, + "There is no route defined for '%s' in the route table. Use CreateRoute instead.", destCidr) } if record.Routes[idx].GatewayId == "local" { return nil, errors.New(awserrors.ErrorInvalidParameterValue) } - - // V1: only GatewayId target supported - if input.GatewayId == nil || *input.GatewayId == "" { - return nil, errors.New(awserrors.ErrorInvalidParameterValue) - } - - igwID := *input.GatewayId - - // Verify IGW exists and is attached to same VPC - igwEntry, err := s.igwKV.Get(ctx, utils.AccountKey(accountID, igwID)) - if err != nil { - return nil, errors.New(awserrors.ErrorInvalidInternetGatewayIDNotFound) - } - var igwRecord handlers_ec2_igw.IGWRecord - if err := json.Unmarshal(igwEntry.Value(), &igwRecord); err != nil { - return nil, errors.New(awserrors.ErrorServerInternal) - } if igwRecord.VpcId != record.VpcId { return nil, errors.New(awserrors.ErrorInvalidParameterValue) } @@ -1006,7 +1081,7 @@ func (s *RouteTableServiceImpl) DisassociateRouteTable(ctx context.Context, inpu keys, err := s.rtbKV.Keys(ctx) if err != nil { if errors.Is(err, jetstream.ErrNoKeysFound) { - return nil, errors.New(awserrors.ErrorInvalidAssociationIDNotFound) + return nil, associationNotFoundError(assocID) } slog.ErrorContext(ctx, "Failed to list route table keys", "err", err) return nil, errors.New(awserrors.ErrorServerInternal) @@ -1071,7 +1146,7 @@ func (s *RouteTableServiceImpl) DisassociateRouteTable(ctx context.Context, inpu } } - return nil, errors.New(awserrors.ErrorInvalidAssociationIDNotFound) + return nil, associationNotFoundError(assocID) } // ReplaceRouteTableAssociation atomically moves a subnet from one route table to another. @@ -1097,7 +1172,7 @@ func (s *RouteTableServiceImpl) ReplaceRouteTableAssociation(ctx context.Context keys, err := s.rtbKV.Keys(ctx) if err != nil { if errors.Is(err, jetstream.ErrNoKeysFound) { - return nil, errors.New(awserrors.ErrorInvalidAssociationIDNotFound) + return nil, associationNotFoundError(assocID) } slog.ErrorContext(ctx, "Failed to list route table keys", "err", err) return nil, errors.New(awserrors.ErrorServerInternal) @@ -1189,13 +1264,13 @@ func (s *RouteTableServiceImpl) ReplaceRouteTableAssociation(ctx context.Context } } - return nil, errors.New(awserrors.ErrorInvalidAssociationIDNotFound) + return nil, associationNotFoundError(assocID) } // rtbMatchesFilters checks if a route table record matches all parsed awsfilters. func rtbMatchesFilters(record *RouteTableRecord, filters map[string][]string) bool { for name, values := range filters { - if strings.HasPrefix(name, "tag:") { + if awsfilters.IsTagFilter(name) { continue } switch name { @@ -1208,92 +1283,53 @@ func rtbMatchesFilters(record *RouteTableRecord, filters map[string][]string) bo return false } case "association.main": - hasMain := false - for _, a := range record.Associations { - if a.Main { - hasMain = true - break - } - } + hasMain := slices.ContainsFunc(record.Associations, func(a AssociationRecord) bool { + return a.Main + }) wantMain := awsfilters.MatchesAny(values, "true") if wantMain != hasMain { return false } case "association.route-table-association-id": - found := false - for _, a := range record.Associations { - if awsfilters.MatchesAny(values, a.AssociationId) { - found = true - break - } - } - if !found { + if !slices.ContainsFunc(record.Associations, func(a AssociationRecord) bool { + return awsfilters.MatchesAny(values, a.AssociationId) + }) { return false } case "association.subnet-id": - found := false - for _, a := range record.Associations { - if awsfilters.MatchesAny(values, a.SubnetId) { - found = true - break - } - } - if !found { + if !slices.ContainsFunc(record.Associations, func(a AssociationRecord) bool { + return awsfilters.MatchesAny(values, a.SubnetId) + }) { return false } case "route.destination-cidr-block": - found := false - for _, r := range record.Routes { - if awsfilters.MatchesAny(values, r.DestinationCidrBlock) { - found = true - break - } - } - if !found { + if !slices.ContainsFunc(record.Routes, func(r RouteRecord) bool { + return awsfilters.MatchesAny(values, r.DestinationCidrBlock) + }) { return false } case "route.gateway-id": - found := false - for _, r := range record.Routes { - if awsfilters.MatchesAny(values, r.GatewayId) { - found = true - break - } - } - if !found { + if !slices.ContainsFunc(record.Routes, func(r RouteRecord) bool { + return awsfilters.MatchesAny(values, r.GatewayId) + }) { return false } case "route.nat-gateway-id": - found := false - for _, r := range record.Routes { - if awsfilters.MatchesAny(values, r.NatGatewayId) { - found = true - break - } - } - if !found { + if !slices.ContainsFunc(record.Routes, func(r RouteRecord) bool { + return awsfilters.MatchesAny(values, r.NatGatewayId) + }) { return false } case "route.state": - found := false - for _, r := range record.Routes { - if awsfilters.MatchesAny(values, r.State) { - found = true - break - } - } - if !found { + if !slices.ContainsFunc(record.Routes, func(r RouteRecord) bool { + return awsfilters.MatchesAny(values, r.State) + }) { return false } case "route.origin": - found := false - for _, r := range record.Routes { - if awsfilters.MatchesAny(values, r.Origin) { - found = true - break - } - } - if !found { + if !slices.ContainsFunc(record.Routes, func(r RouteRecord) bool { + return awsfilters.MatchesAny(values, r.Origin) + }) { return false } case "owner-id": diff --git a/spinifex/handlers/ec2/routetable/service_impl_test.go b/spinifex/handlers/ec2/routetable/service_impl_test.go index 7dfd29cb2..2de2bfdf0 100644 --- a/spinifex/handlers/ec2/routetable/service_impl_test.go +++ b/spinifex/handlers/ec2/routetable/service_impl_test.go @@ -103,9 +103,11 @@ func TestCreateRouteTable(t *testing.T) { t.Parallel() svc := setupTestService(t) out, err := svc.CreateRouteTable(t.Context(), &ec2.CreateRouteTableInput{ - VpcId: aws.String("vpc-test1"), + VpcId: aws.String("vpc-test1"), + ClientToken: aws.String("token-1"), }, testAccountID) require.NoError(t, err) + assert.Equal(t, "token-1", aws.StringValue(out.ClientToken), "AWS echoes the request's ClientToken") rtb := out.RouteTable assert.NotEmpty(t, *rtb.RouteTableId) @@ -155,13 +157,42 @@ func TestCreateRouteTable_PersistsTagsForTagFilterDiscovery(t *testing.T) { assert.Equal(t, "cp-private-rt", tags["spinifex:eks-role"]) } +func TestDescribeRouteTables_FilterByTagKeyAndTagValue(t *testing.T) { + t.Parallel() + svc := setupTestService(t) + out, err := svc.CreateRouteTable(t.Context(), &ec2.CreateRouteTableInput{ + VpcId: aws.String("vpc-test1"), + TagSpecifications: []*ec2.TagSpecification{{ + ResourceType: aws.String(ec2.ResourceTypeRouteTable), + Tags: []*ec2.Tag{{Key: aws.String("Env"), Value: aws.String("prod")}}, + }}, + }, testAccountID) + require.NoError(t, err) + createTestRtb(t, svc) // untagged + + for _, tc := range []struct{ name, value string }{{"tag-key", "Env"}, {"tag-value", "prod"}} { + desc, err := svc.DescribeRouteTables(t.Context(), &ec2.DescribeRouteTablesInput{ + Filters: []*ec2.Filter{{Name: aws.String(tc.name), Values: aws.StringSlice([]string{tc.value})}}, + }, testAccountID) + require.NoError(t, err, tc.name) + require.Len(t, desc.RouteTables, 1, tc.name) + assert.Equal(t, *out.RouteTable.RouteTableId, *desc.RouteTables[0].RouteTableId, tc.name) + + desc, err = svc.DescribeRouteTables(t.Context(), &ec2.DescribeRouteTablesInput{ + Filters: []*ec2.Filter{{Name: aws.String(tc.name), Values: aws.StringSlice([]string{"zz-awsdiff-none"})}}, + }, testAccountID) + require.NoError(t, err, tc.name) + assert.Empty(t, desc.RouteTables, tc.name) + } +} + func TestCreateRouteTable_VpcNotFound(t *testing.T) { t.Parallel() svc := setupTestService(t) _, err := svc.CreateRouteTable(t.Context(), &ec2.CreateRouteTableInput{ VpcId: aws.String("vpc-nonexistent"), }, testAccountID) - assert.EqualError(t, err, awserrors.ErrorInvalidVpcIDNotFound) + requireAWSError(t, err, awserrors.ErrorInvalidVpcIDNotFound, "The vpc ID 'vpc-nonexistent' does not exist") } func TestDeleteRouteTable(t *testing.T) { @@ -176,7 +207,7 @@ func TestDeleteRouteTable(t *testing.T) { // Should be gone _, err = svc.getRouteTable(t.Context(), testAccountID, rtbID) - assert.EqualError(t, err, awserrors.ErrorInvalidRouteTableIDNotFound) + requireAWSError(t, err, awserrors.ErrorInvalidRouteTableIDNotFound, "The routeTable ID '"+rtbID+"' does not exist") } func TestDeleteRouteTable_Main(t *testing.T) { @@ -188,7 +219,7 @@ func TestDeleteRouteTable_Main(t *testing.T) { _, err = svc.DeleteRouteTable(t.Context(), &ec2.DeleteRouteTableInput{ RouteTableId: aws.String(record.RouteTableId), }, testAccountID) - assert.EqualError(t, err, awserrors.ErrorDependencyViolation) + requireAWSError(t, err, awserrors.ErrorDependencyViolation, "The routeTable '"+record.RouteTableId+"' has dependencies and cannot be deleted.") } func TestDeleteRouteTable_WithAssociations(t *testing.T) { @@ -207,7 +238,7 @@ func TestDeleteRouteTable_WithAssociations(t *testing.T) { _, err = svc.DeleteRouteTable(t.Context(), &ec2.DeleteRouteTableInput{ RouteTableId: aws.String(rtbID), }, testAccountID) - assert.EqualError(t, err, awserrors.ErrorDependencyViolation) + requireAWSError(t, err, awserrors.ErrorDependencyViolation, "The routeTable '"+rtbID+"' has dependencies and cannot be deleted.") } func TestDescribeRouteTables(t *testing.T) { @@ -381,13 +412,47 @@ func TestCreateRoute_DuplicateDestination(t *testing.T) { }, testAccountID) require.NoError(t, err) - // Duplicate should fail - _, err = svc.CreateRoute(t.Context(), &ec2.CreateRouteInput{ + // Same destination and target: AWS returns success without a second route + out, err := svc.CreateRoute(t.Context(), &ec2.CreateRouteInput{ RouteTableId: aws.String(rtbID), DestinationCidrBlock: aws.String("0.0.0.0/0"), GatewayId: aws.String("igw-test1"), }, testAccountID) - assert.EqualError(t, err, awserrors.ErrorRouteAlreadyExists) + require.NoError(t, err) + assert.True(t, aws.BoolValue(out.Return)) + + // Same destination, different target: still a conflict + _, err = svc.CreateRoute(t.Context(), &ec2.CreateRouteInput{ + RouteTableId: aws.String(rtbID), + DestinationCidrBlock: aws.String("0.0.0.0/0"), + NatGatewayId: aws.String("nat-test1"), + }, testAccountID) + requireAWSError(t, err, awserrors.ErrorRouteAlreadyExists, "The route identified by 0.0.0.0/0 already exists.") + + // AWS checks the target before the destination: "local" is no gateway + _, err = svc.CreateRoute(t.Context(), &ec2.CreateRouteInput{ + RouteTableId: aws.String(rtbID), + DestinationCidrBlock: aws.String("10.0.0.0/16"), + GatewayId: aws.String("local"), + }, testAccountID) + requireAWSError(t, err, awserrors.ErrorInvalidGatewayIDNotFound, "The gateway ID 'local' does not exist") + + // A gateway route to the VPC's CIDR, or inside it, is refused before the + // existing local route is considered + for _, dest := range []string{"10.0.0.0/16", "10.0.5.0/24"} { + _, err = svc.CreateRoute(t.Context(), &ec2.CreateRouteInput{ + RouteTableId: aws.String(rtbID), + DestinationCidrBlock: aws.String(dest), + GatewayId: aws.String("igw-test1"), + }, testAccountID) + requireAWSError(t, err, awserrors.ErrorInvalidParameterValue, + "The destination CIDR block "+dest+" is equal to or more specific than one of this VPC's CIDR blocks. This route can target only an interface or an instance.") + } + + record, err := svc.getRouteTable(t.Context(), testAccountID, rtbID) + require.NoError(t, err) + require.Len(t, record.Routes, 2) + assert.Equal(t, "igw-test1", record.Routes[1].GatewayId) } func TestDeleteRoute(t *testing.T) { @@ -437,7 +502,7 @@ func TestDeleteRoute_NotFound(t *testing.T) { RouteTableId: aws.String(rtbID), DestinationCidrBlock: aws.String("192.168.0.0/16"), }, testAccountID) - assert.EqualError(t, err, awserrors.ErrorInvalidRouteNotFound) + requireAWSError(t, err, awserrors.ErrorInvalidRouteNotFound, "no route with destination-cidr-block 192.168.0.0/16 in route table "+rtbID) } func TestReplaceRoute(t *testing.T) { @@ -461,6 +526,49 @@ func TestReplaceRoute(t *testing.T) { require.NoError(t, err) } +func requireAWSError(t *testing.T, err error, code, message string) { + t.Helper() + got, msg, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok, "error %v carries no AWS code", err) + assert.Equal(t, code, got) + assert.Equal(t, message, msg) +} + +func TestCreateRoute_MissingGateway(t *testing.T) { + t.Parallel() + svc := setupTestService(t) + rtbID := createTestRtb(t, svc) + + _, err := svc.CreateRoute(t.Context(), &ec2.CreateRouteInput{ + RouteTableId: aws.String(rtbID), + DestinationCidrBlock: aws.String("0.0.0.0/0"), + GatewayId: aws.String("igw-0000000000000dead"), + }, testAccountID) + requireAWSError(t, err, awserrors.ErrorInvalidGatewayIDNotFound, "The gateway ID 'igw-0000000000000dead' does not exist") +} + +func TestReplaceRoute_MissingRouteOrGateway(t *testing.T) { + t.Parallel() + svc := setupTestService(t) + rtbID := createTestRtb(t, svc) + + _, err := svc.ReplaceRoute(t.Context(), &ec2.ReplaceRouteInput{ + RouteTableId: aws.String(rtbID), + DestinationCidrBlock: aws.String("198.51.100.0/24"), + GatewayId: aws.String("igw-test1"), + }, testAccountID) + requireAWSError(t, err, awserrors.ErrorInvalidParameterValue, + "There is no route defined for '198.51.100.0/24' in the route table. Use CreateRoute instead.") + + // AWS reports the missing gateway ahead of the missing route. + _, err = svc.ReplaceRoute(t.Context(), &ec2.ReplaceRouteInput{ + RouteTableId: aws.String(rtbID), + DestinationCidrBlock: aws.String("198.51.100.0/24"), + GatewayId: aws.String("igw-0000000000000dead"), + }, testAccountID) + requireAWSError(t, err, awserrors.ErrorInvalidGatewayIDNotFound, "The gateway ID 'igw-0000000000000dead' does not exist") +} + func TestAssociateRouteTable(t *testing.T) { t.Parallel() svc := setupTestService(t) @@ -1466,3 +1574,22 @@ func TestPublishGateDecisionsForVPC_EmptyVPCNoOp(t *testing.T) { assertNoEvents(t, svc, gate, "expected no gate event") assertNoEvents(t, svc, ungate, "expected no ungate event") } + +func TestDescribeRouteTables_PagingValidation(t *testing.T) { + t.Parallel() + svc := setupTestService(t) + + _, err := svc.DescribeRouteTables(t.Context(), &ec2.DescribeRouteTablesInput{MaxResults: aws.Int64(101)}, testAccountID) + code, message, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok) + assert.Equal(t, awserrors.ErrorInvalidParameterValue, code) + assert.Equal(t, "Value ( 101 ) for parameter MaxResults is invalid. Expecting a value smaller than or equal to 100.", message) + + _, err = svc.DescribeRouteTables(t.Context(), &ec2.DescribeRouteTablesInput{ + MaxResults: aws.Int64(5), RouteTableIds: []*string{aws.String("rtb-0123456789abcdef0")}, + }, testAccountID) + code, message, ok = awserrors.ResolveErrorDetail(err) + require.True(t, ok) + assert.Equal(t, awserrors.ErrorInvalidParameterCombination, code) + assert.Equal(t, "The parameter RouteTableIds cannot be used with the parameter MaxResults", message) +} diff --git a/spinifex/handlers/ec2/spotinstance/service_impl.go b/spinifex/handlers/ec2/spotinstance/service_impl.go index 1570cc5c6..6bc2ae698 100644 --- a/spinifex/handlers/ec2/spotinstance/service_impl.go +++ b/spinifex/handlers/ec2/spotinstance/service_impl.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "log/slog" + "slices" "strings" "time" @@ -71,24 +72,12 @@ func NewSpotInstanceServiceImplWithNATS(ctx context.Context, cfg *config.Config, // getOrCreateTerminalBucket opens the terminal bucket without changing its // existing replica or placement configuration, creating it only when absent. func getOrCreateTerminalBucket(ctx context.Context, js jetstream.KeyValueManager) (jetstream.KeyValue, error) { - kv, err := js.KeyValue(ctx, KVBucketSpotRequestsTerminal) - if err == nil { - return kv, nil - } - if !errors.Is(err, jetstream.ErrBucketNotFound) { - return nil, err - } - - kv, err = js.CreateKeyValue(ctx, jetstream.KeyValueConfig{ - Bucket: KVBucketSpotRequestsTerminal, + return kvutil.GetOrCreateBucketWithOptions(ctx, js, kvutil.BucketOptions{ + Name: KVBucketSpotRequestsTerminal, Description: "Terminal Spot Instance Requests (auto-expire after 1 hour)", History: 1, TTL: spotTerminalTTL, }) - if errors.Is(err, jetstream.ErrBucketExists) { - return js.KeyValue(ctx, KVBucketSpotRequestsTerminal) - } - return kv, err } // PutSpotInstanceRequests stores each request in the active bucket. @@ -372,12 +361,9 @@ func sirMatchesFilters(req *ec2.SpotInstanceRequest, filters map[string][]string } func sirMatchesTagKey(tags []*ec2.Tag, values []string) bool { - for _, t := range tags { - if t.Key != nil && awsfilters.MatchesAny(values, *t.Key) { - return true - } - } - return false + return slices.ContainsFunc(tags, func(t *ec2.Tag) bool { + return t.Key != nil && awsfilters.MatchesAny(values, *t.Key) + }) } // launchSpec returns the request's launch specification, or an empty one when diff --git a/spinifex/handlers/ec2/tags/key_pair_tags_test.go b/spinifex/handlers/ec2/tags/key_pair_tags_test.go new file mode 100644 index 000000000..b2f6d05fd --- /dev/null +++ b/spinifex/handlers/ec2/tags/key_pair_tags_test.go @@ -0,0 +1,86 @@ +package handlers_ec2_tags_test + +import ( + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/mulgadc/spinifex/internal/testkit" + "github.com/mulgadc/spinifex/spinifex/config" + handlers_ec2_key "github.com/mulgadc/spinifex/spinifex/handlers/ec2/key" + handlers_ec2_tags "github.com/mulgadc/spinifex/spinifex/handlers/ec2/tags" + "github.com/mulgadc/spinifex/spinifex/objectstore" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testAccountID = "111111111111" + testED25519PubKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl" +) + +func newTagsService(t *testing.T) *handlers_ec2_tags.TagsServiceImpl { + t.Helper() + _, nc, _ := testutil.StartTestJetStream(t) + kv, err := handlers_ec2_tags.GetOrCreateTagsBucket(t.Context(), testutil.NewJetStream(t, nc)) + require.NoError(t, err) + cfg := &config.Config{Predastore: config.PredastoreConfig{Bucket: "test-bucket"}} + return handlers_ec2_tags.NewTagsServiceImplWithStore(cfg, objectstore.NewMemoryObjectStore(), kv) +} + +func keyPairTagSpec(tags map[string]string) []*ec2.TagSpecification { + spec := &ec2.TagSpecification{ResourceType: aws.String("key-pair")} + for k, v := range tags { + spec.Tags = append(spec.Tags, &ec2.Tag{Key: aws.String(k), Value: aws.String(v)}) + } + return []*ec2.TagSpecification{spec} +} + +func describeKeyPairTags(t *testing.T, svc *handlers_ec2_tags.TagsServiceImpl) map[string]map[string]string { + t.Helper() + out, err := svc.DescribeTags(t.Context(), &ec2.DescribeTagsInput{ + Filters: []*ec2.Filter{{Name: aws.String("resource-type"), Values: []*string{aws.String("key-pair")}}}, + }, testAccountID) + require.NoError(t, err) + got := map[string]map[string]string{} + for _, td := range out.Tags { + if got[*td.ResourceId] == nil { + got[*td.ResourceId] = map[string]string{} + } + got[*td.ResourceId][*td.Key] = *td.Value + } + return got +} + +// AWS lists the TagSpecifications tags of a created or imported key pair in +// DescribeTags under resource type key-pair, and drops them when it is deleted. +func TestKeyPairCreationTags_VisibleToDescribeTags(t *testing.T) { + tagsSvc := newTagsService(t) + keySvc := handlers_ec2_key.NewKeyServiceImplWithStore(objectstore.NewMemoryObjectStore(), "test-bucket") + keySvc.SetCentralTagStore(tagsSvc) + + created, err := keySvc.CreateKeyPair(t.Context(), &ec2.CreateKeyPairInput{ + KeyName: aws.String("created"), + TagSpecifications: keyPairTagSpec(map[string]string{"Name": "created", "awsdiff": "1"}), + }, testAccountID) + require.NoError(t, err) + + imported, err := keySvc.ImportKeyPair(t.Context(), &ec2.ImportKeyPairInput{ + KeyName: aws.String("imported"), + PublicKeyMaterial: []byte(testED25519PubKey), + TagSpecifications: keyPairTagSpec(map[string]string{"Name": "imported"}), + }, testAccountID) + require.NoError(t, err) + + assert.Equal(t, map[string]map[string]string{ + *created.KeyPairId: {"Name": "created", "awsdiff": "1"}, + *imported.KeyPairId: {"Name": "imported"}, + }, describeKeyPairTags(t, tagsSvc)) + + _, err = keySvc.DeleteKeyPair(t.Context(), &ec2.DeleteKeyPairInput{KeyName: aws.String("created")}, testAccountID) + require.NoError(t, err) + + assert.Equal(t, map[string]map[string]string{ + *imported.KeyPairId: {"Name": "imported"}, + }, describeKeyPairTags(t, tagsSvc)) +} diff --git a/spinifex/handlers/ec2/tags/service_impl.go b/spinifex/handlers/ec2/tags/service_impl.go index e11d8b820..a82ca4026 100644 --- a/spinifex/handlers/ec2/tags/service_impl.go +++ b/spinifex/handlers/ec2/tags/service_impl.go @@ -14,13 +14,14 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/s3" + "github.com/mulgadc/spinifex/spinifex/config" "github.com/mulgadc/spinifex/spinifex/foundation/aws/arn" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" - "github.com/mulgadc/spinifex/spinifex/config" awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters" - handlers_ec2_instance "github.com/mulgadc/spinifex/spinifex/handlers/ec2/instance" - handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc" "github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil" + handlers_ec2_instance "github.com/mulgadc/spinifex/spinifex/handlers/ec2/instance" + handlers_ec2_key "github.com/mulgadc/spinifex/spinifex/handlers/ec2/key" + handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc" "github.com/mulgadc/spinifex/spinifex/objectstore" "github.com/mulgadc/spinifex/spinifex/utils" "github.com/nats-io/nats.go/jetstream" @@ -35,6 +36,9 @@ var _ handlers_ec2_instance.InstanceTagWriter = (*TagsServiceImpl)(nil) // Ensure TagsServiceImpl can both project and clear vpc/subnet/sg/eni record tags. var _ handlers_ec2_vpc.CentralTagStore = (*TagsServiceImpl)(nil) +// Ensure TagsServiceImpl can both project and clear key-pair record tags. +var _ handlers_ec2_key.CentralTagStore = (*TagsServiceImpl)(nil) + // TagsServiceImpl implements TagsService over a JetStream KV bucket, one entry // per resource, scoped by account in the key. // @@ -176,13 +180,6 @@ func (s *TagsServiceImpl) getResourceTags(ctx context.Context, accountID, resour return s.legacyTags(ctx, accountID, resourceID) } -// PutResourceTags overwrites the stored tag set for a resource. Used to -// project an instance record's tags (the source of truth) into the central -// store so describe-tags agrees with describe-instances. -func (s *TagsServiceImpl) PutResourceTags(ctx context.Context, accountID, resourceID string, tags map[string]string) error { - return s.putTags(ctx, accountID, resourceID, tags) -} - // DeleteAllTags removes the stored tags for a resource, so describe-tags stops // reporting a resource that is gone. Instance terminate uses it while the // terminated record keeps its own tags until TTL; the vpc delete paths use it @@ -285,6 +282,8 @@ var describeTagsValidFilters = map[string]bool{ "resource-type": true, "key": true, "value": true, + "tag-key": true, + "tag-value": true, } // DescribeTags returns tags matching the specified awsfilters. @@ -333,6 +332,14 @@ func (s *TagsServiceImpl) DescribeTags(ctx context.Context, input *ec2.DescribeT if !awsfilters.MatchesAny(filters["value"], value) { continue } + // On DescribeTags, AWS applies tag-key and tag-value to each tag row, + // exactly as it applies key and value. + if !awsfilters.MatchesAny(filters["tag-key"], key) || !awsfilters.MatchesAny(filters["tag-value"], value) { + continue + } + if !rowMatchesTagFilters(filters, key, value) { + continue + } tags = append(tags, &ec2.TagDescription{ ResourceId: aws.String(resourceID), @@ -350,6 +357,22 @@ func (s *TagsServiceImpl) DescribeTags(ctx context.Context, input *ec2.DescribeT }, nil } +// rowMatchesTagFilters applies each tag: filter to one tag row, as +// tag-key and tag-value are applied here: the row must be that key, with a +// value the filter names. +func rowMatchesTagFilters(filters map[string][]string, key, value string) bool { + for name, values := range filters { + tagKey, ok := strings.CutPrefix(name, "tag:") + if !ok { + continue + } + if key != tagKey || !awsfilters.MatchesAny(values, value) { + return false + } + } + return true +} + // DeleteTags removes tags from the specified resources. func (s *TagsServiceImpl) DeleteTags(ctx context.Context, input *ec2.DeleteTagsInput, accountID string) (*ec2.DeleteTagsOutput, error) { if input == nil { diff --git a/spinifex/handlers/ec2/tags/service_impl_test.go b/spinifex/handlers/ec2/tags/service_impl_test.go index ec8346c93..ca4145181 100644 --- a/spinifex/handlers/ec2/tags/service_impl_test.go +++ b/spinifex/handlers/ec2/tags/service_impl_test.go @@ -287,6 +287,76 @@ func TestDescribeTags_FilterByValue(t *testing.T) { assert.Equal(t, "production", *result.Tags[0].Value) } +// TestDescribeTags_FilterByTagKeyAndTagValue checks that tag-key and tag-value +// select tag rows, as key and value do, rather than whole resources. +func TestDescribeTags_FilterByTagKeyAndTagValue(t *testing.T) { + svc, _ := setupTestTagsService(t) + + _, err := svc.CreateTags(context.Background(), &ec2.CreateTagsInput{ + Resources: []*string{aws.String("vpc-test1")}, + Tags: []*ec2.Tag{ + {Key: aws.String("Name"), Value: aws.String("web")}, + {Key: aws.String("awsdiff"), Value: aws.String("yes")}, + }, + }, testAccountID) + require.NoError(t, err) + + describe := func(name, value string) []*ec2.TagDescription { + t.Helper() + result, err := svc.DescribeTags(context.Background(), &ec2.DescribeTagsInput{ + Filters: []*ec2.Filter{{Name: aws.String(name), Values: []*string{aws.String(value)}}}, + }, testAccountID) + require.NoError(t, err) + return result.Tags + } + + byKey := describe("tag-key", "awsdiff") + require.Len(t, byKey, 1) + assert.Equal(t, "awsdiff", *byKey[0].Key) + + byValue := describe("tag-value", "web") + require.Len(t, byValue, 1) + assert.Equal(t, "Name", *byValue[0].Key) + + assert.Empty(t, describe("tag-key", "zz-awsdiff-none")) + assert.Empty(t, describe("tag-value", "zz-awsdiff-none")) +} + +// TestDescribeTags_FilterByTagKeyName checks that a tag: filter selects +// the matching tag row, and only rows of resources that carry it. +func TestDescribeTags_FilterByTagKeyName(t *testing.T) { + svc, _ := setupTestTagsService(t) + + for id, run := range map[string]string{"subnet-test1": "run-1", "subnet-test2": "run-2"} { + _, err := svc.CreateTags(context.Background(), &ec2.CreateTagsInput{ + Resources: []*string{aws.String(id)}, + Tags: []*ec2.Tag{ + {Key: aws.String("env"), Value: aws.String("prod")}, + {Key: aws.String("awsdiff"), Value: aws.String(run)}, + }, + }, testAccountID) + require.NoError(t, err) + } + + result, err := svc.DescribeTags(context.Background(), &ec2.DescribeTagsInput{ + Filters: []*ec2.Filter{ + {Name: aws.String("resource-type"), Values: []*string{aws.String("subnet")}}, + {Name: aws.String("tag:awsdiff"), Values: []*string{aws.String("run-1")}}, + }, + }, testAccountID) + require.NoError(t, err) + require.Len(t, result.Tags, 1) + assert.Equal(t, "subnet-test1", aws.StringValue(result.Tags[0].ResourceId)) + assert.Equal(t, "awsdiff", aws.StringValue(result.Tags[0].Key)) + assert.Equal(t, "run-1", aws.StringValue(result.Tags[0].Value)) + + result, err = svc.DescribeTags(context.Background(), &ec2.DescribeTagsInput{ + Filters: []*ec2.Filter{{Name: aws.String("tag:awsdiff"), Values: []*string{aws.String("zz-awsdiff-none")}}}, + }, testAccountID) + require.NoError(t, err) + assert.Empty(t, result.Tags) +} + // TestDescribeTags_Empty tests listing tags when none exist. func TestDescribeTags_Empty(t *testing.T) { svc, _ := setupTestTagsService(t) diff --git a/spinifex/handlers/ec2/tags/store.go b/spinifex/handlers/ec2/tags/store.go index 2bc411088..e75ce4899 100644 --- a/spinifex/handlers/ec2/tags/store.go +++ b/spinifex/handlers/ec2/tags/store.go @@ -96,10 +96,10 @@ func (s *TagsServiceImpl) mutateTags(ctx context.Context, accountID, resourceID return err } -// putTags replaces a resource's whole tag set. It still goes through the CAS -// loop so a concurrent merge is overwritten as a whole rather than interleaved -// with it, which is what a caller projecting an authoritative record wants. -func (s *TagsServiceImpl) putTags(ctx context.Context, accountID, resourceID string, tags map[string]string) error { +// PutResourceTags overwrites the stored tag set for a resource, projecting an +// authoritative record (an instance's tags) into the central store. It goes +// through the CAS loop so a concurrent merge is overwritten whole, not interleaved. +func (s *TagsServiceImpl) PutResourceTags(ctx context.Context, accountID, resourceID string, tags map[string]string) error { data, err := encodeTags(tags) if err != nil { return err diff --git a/spinifex/handlers/ec2/volume/service_impl.go b/spinifex/handlers/ec2/volume/service_impl.go index 5b453fd15..ed06daacd 100644 --- a/spinifex/handlers/ec2/volume/service_impl.go +++ b/spinifex/handlers/ec2/volume/service_impl.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "log/slog" + "slices" "strings" "sync" "time" @@ -415,12 +416,9 @@ func volumeAttachmentMatchesAny(attachments []*ec2.VolumeAttachment, fieldFn fun if len(attachments) == 0 { return false } - for _, a := range attachments { - if awsfilters.MatchesAny(values, fieldFn(a)) { - return true - } - } - return false + return slices.ContainsFunc(attachments, func(a *ec2.VolumeAttachment) bool { + return awsfilters.MatchesAny(values, fieldFn(a)) + }) } // DescribeVolumeStatus returns the status of one or more EBS volumes diff --git a/spinifex/handlers/ec2/vpc/GetSecurityGroupsForVpc_test.go b/spinifex/handlers/ec2/vpc/GetSecurityGroupsForVpc_test.go index c26154141..b55610ff7 100644 --- a/spinifex/handlers/ec2/vpc/GetSecurityGroupsForVpc_test.go +++ b/spinifex/handlers/ec2/vpc/GetSecurityGroupsForVpc_test.go @@ -114,7 +114,7 @@ func TestGetSecurityGroupsForVpc_ExcludesOtherAccount(t *testing.T) { VpcId: otherVpc.Vpc.VpcId, }, testAccountID) require.Error(t, err) - assert.Equal(t, awserrors.ErrorInvalidVpcIDNotFound, err.Error()) + assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInvalidVpcIDNotFound), err.Error()) } func TestGetSecurityGroupsForVpc_MissingVpcId(t *testing.T) { @@ -136,7 +136,7 @@ func TestGetSecurityGroupsForVpc_UnknownVpcIsNotFound(t *testing.T) { }, testAccountID) require.Error(t, err, "an unknown VPC must not be reported as a VPC with no groups") assert.Nil(t, out) - assert.Equal(t, awserrors.ErrorInvalidVpcIDNotFound, err.Error()) + assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInvalidVpcIDNotFound), err.Error()) } // A VPC whose only group is the default one still answers, so the not-found @@ -161,7 +161,7 @@ func TestGetSecurityGroupsForVpc_PagesEveryGroupOnce(t *testing.T) { vpcID := createTestVPC(t, svc, "10.0.0.0/16") expected := describedGroupIDsForVpc(t, svc, vpcID) - for _, name := range []string{"sg-a", "sg-b", "sg-c", "sg-d", "sg-e", "sg-f", "sg-g"} { + for _, name := range []string{"grp-a", "grp-b", "grp-c", "grp-d", "grp-e", "grp-f", "grp-g"} { expected = append(expected, createTestSG(t, svc, vpcID, name)) } diff --git a/spinifex/handlers/ec2/vpc/default_vpc.go b/spinifex/handlers/ec2/vpc/default_vpc.go index 78c4b0cdd..b8a0e89bf 100644 --- a/spinifex/handlers/ec2/vpc/default_vpc.go +++ b/spinifex/handlers/ec2/vpc/default_vpc.go @@ -270,7 +270,6 @@ func (s *VPCServiceImpl) buildDefaultVPC(ctx context.Context, accountID string, AZ: s.localAZ(), EnableDnsSupport: true, // AWS default EnableDnsHostnames: true, // AWS default for default VPC - Tags: map[string]string{"Name": "default"}, CreatedAt: time.Now(), }) if err != nil { @@ -292,7 +291,6 @@ func (s *VPCServiceImpl) buildDefaultVPC(ctx context.Context, accountID string, State: "available", IsDefault: true, MapPublicIpOnLaunch: !s.disableDefaultPublicIP, // AWS default subnets auto-assign public IPs (unless external mode has none) - Tags: map[string]string{"Name": "default"}, CreatedAt: time.Now(), }) if err != nil { diff --git a/spinifex/handlers/ec2/vpc/default_vpc_test.go b/spinifex/handlers/ec2/vpc/default_vpc_test.go index ebf70d29b..781e95d80 100644 --- a/spinifex/handlers/ec2/vpc/default_vpc_test.go +++ b/spinifex/handlers/ec2/vpc/default_vpc_test.go @@ -270,3 +270,22 @@ func TestEnsureDefaultVPC_UsesBootstrapIDs(t *testing.T) { assert.Equal(t, ids.IgwId, info.InternetGatewayId) } } + +// AWS leaves the default VPC and its default subnet untagged. +func TestEnsureDefaultVPC_LeavesVPCUntagged(t *testing.T) { + t.Parallel() + svcs, _, _ := newNodeServices(t, 1) + + info, err := svcs[0].EnsureDefaultVPC("000000000001", handlers_ec2_vpc.BootstrapIDs{}) + require.NoError(t, err) + + out, err := svcs[0].DescribeVpcs(t.Context(), &ec2.DescribeVpcsInput{VpcIds: []*string{aws.String(info.VpcId)}}, "000000000001") + require.NoError(t, err) + require.Len(t, out.Vpcs, 1) + assert.Empty(t, out.Vpcs[0].Tags) + + subnets, err := svcs[0].DescribeSubnets(t.Context(), &ec2.DescribeSubnetsInput{SubnetIds: []*string{aws.String(info.SubnetId)}}, "000000000001") + require.NoError(t, err) + require.Len(t, subnets.Subnets, 1) + assert.Empty(t, subnets.Subnets[0].Tags) +} diff --git a/spinifex/handlers/ec2/vpc/eni.go b/spinifex/handlers/ec2/vpc/eni.go index e2817d9b9..8c3ccf7ee 100644 --- a/spinifex/handlers/ec2/vpc/eni.go +++ b/spinifex/handlers/ec2/vpc/eni.go @@ -7,6 +7,7 @@ import ( "fmt" "log/slog" "net/netip" + "slices" "strings" "time" @@ -17,6 +18,7 @@ import ( "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags" "github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil" "github.com/mulgadc/spinifex/spinifex/network/topology" + "github.com/mulgadc/spinifex/spinifex/paging" "github.com/mulgadc/spinifex/spinifex/utils" "github.com/nats-io/nats.go/jetstream" ) @@ -149,7 +151,7 @@ func (s *VPCServiceImpl) CreateNetworkInterface(ctx context.Context, input *ec2. if err != nil { switch { case errors.Is(err, ErrIPInUse): - return nil, errors.New(awserrors.ErrorInvalidIPAddressInUse) + return nil, awserrors.Errorf(awserrors.ErrorInvalidIPAddressInUse, "The specified address is already in use.") case errors.Is(err, ErrIPOutOfRange): return nil, eniAddressError(*input.PrivateIpAddress, subnet.CidrBlock) default: @@ -166,7 +168,7 @@ func (s *VPCServiceImpl) CreateNetworkInterface(ctx context.Context, input *ec2. } // Generate a deterministic MAC address - macAddr := generateENIMac(eniId) + macAddr := utils.HashMAC(eniId) description := "" if input.Description != nil { @@ -216,8 +218,10 @@ func (s *VPCServiceImpl) CreateNetworkInterface(ctx context.Context, input *ec2. return nil, err } + groupNames := make(map[string]string) + s.fillSGNames(ctx, accountID, record.SecurityGroupIds, groupNames) return &ec2.CreateNetworkInterfaceOutput{ - NetworkInterface: s.eniRecordToEC2(&record, accountID), + NetworkInterface: s.eniRecordToEC2(&record, accountID, groupNames), }, nil } @@ -255,7 +259,7 @@ func (s *VPCServiceImpl) deleteNetworkInterface(ctx context.Context, eniId, acco if force { return &ec2.DeleteNetworkInterfaceOutput{}, nil } - return nil, errors.New(awserrors.ErrorInvalidNetworkInterfaceIDNotFound) + return nil, eniNotFoundError(eniId) } return nil, errors.New(awserrors.ErrorServerInternal) } @@ -350,7 +354,7 @@ func (s *VPCServiceImpl) DetachAndDeleteENI(ctx context.Context, accountID, eniI if force { return false, nil } - return false, errors.New(awserrors.ErrorInvalidNetworkInterfaceIDNotFound) + return false, eniNotFoundError(eniID) } return false, errors.New(awserrors.ErrorServerInternal) } @@ -405,18 +409,48 @@ func (s *VPCServiceImpl) DetachAndDeleteENI(ctx context.Context, accountID, eniI return false, fmt.Errorf("DetachAndDeleteENI: exhausted %d CAS attempts for %s: %w", maxAttempts, eniID, lastErr) } -// ModifyNetworkInterfaceAttribute modifies ENI attributes (security groups, -// description). SourceDestCheck=true is accepted as a no-op. +// ValidateModifyNetworkInterfaceAttributeAttributes checks the attribute fields +// of a ModifyNetworkInterfaceAttribute request, which AWS does before it looks +// the interface up. The gateway and the handler share it so their order agrees. +func ValidateModifyNetworkInterfaceAttributeAttributes(input *ec2.ModifyNetworkInterfaceAttributeInput) error { + var set []string + if input.SourceDestCheck != nil { + set = append(set, "sourceDestCheck") + } + if input.Description != nil { + set = append(set, "description") + } + if len(input.Groups) > 0 { + set = append(set, "securityGroups") + } + if len(set) == 0 { + return errors.New(awserrors.ErrorInvalidParameterValue) + } + if len(set) > 1 { + return multipleAttributesError(set) + } + // Disabling source/dest check is unsupported: OVN port security enforces it. + if input.SourceDestCheck != nil && input.SourceDestCheck.Value != nil && !*input.SourceDestCheck.Value { + return errors.New(awserrors.ErrorUnsupported) + } + return nil +} + +// multipleAttributesError is AWS's answer to a Modify*Attribute request that +// sets more than one attribute. AWS's name order is not stable; this one is. +func multipleAttributesError(names []string) error { + return awserrors.Errorf(awserrors.ErrorInvalidParameterCombination, + "Fields for multiple attribute types specified: %s", strings.Join(names, ", ")) +} + +// ModifyNetworkInterfaceAttribute modifies one ENI attribute (security groups +// or description). SourceDestCheck=true is accepted as a no-op. func (s *VPCServiceImpl) ModifyNetworkInterfaceAttribute(ctx context.Context, input *ec2.ModifyNetworkInterfaceAttributeInput, accountID string) (*ec2.ModifyNetworkInterfaceAttributeOutput, error) { if input.NetworkInterfaceId == nil || *input.NetworkInterfaceId == "" { return nil, errors.New(awserrors.ErrorMissingParameter) } - if len(input.Groups) == 0 && input.Description == nil && input.SourceDestCheck == nil { - return nil, errors.New(awserrors.ErrorInvalidParameterValue) - } - // Disabling source/dest check is unsupported: OVN port security enforces it. - if input.SourceDestCheck != nil && input.SourceDestCheck.Value != nil && !*input.SourceDestCheck.Value { - return nil, errors.New(awserrors.ErrorUnsupported) + if err := ValidateModifyNetworkInterfaceAttributeAttributes(input); err != nil { + return nil, err } eniId := *input.NetworkInterfaceId @@ -424,7 +458,7 @@ func (s *VPCServiceImpl) ModifyNetworkInterfaceAttribute(ctx context.Context, in entry, err := s.eniKV.Get(ctx, key) if err != nil { - return nil, errors.New(awserrors.ErrorInvalidNetworkInterfaceIDNotFound) + return nil, eniNotFoundError(eniId) } var record ENIRecord @@ -474,6 +508,13 @@ func (s *VPCServiceImpl) ModifyNetworkInterfaceAttribute(ctx context.Context, in return &ec2.ModifyNetworkInterfaceAttributeOutput{}, nil } +var describeNetworkInterfacesPaging = paging.EC2{ + MaxResults: 1000, + TooLarge: "Value ( %d ) for parameter MaxResults is invalid. Expecting a value smaller than or equal to 1000.", + TooSmall: "Value ( %d ) for parameter MaxResults is invalid. Expecting a value greater than or equal to 5.", + WithIDs: "The parameter NetworkInterfaceIds cannot be used with the parameter MaxResults", +} + var describeNetworkInterfacesValidFilters = map[string]bool{ "network-interface-id": true, "subnet-id": true, @@ -487,10 +528,17 @@ var describeNetworkInterfacesValidFilters = map[string]bool{ "attachment.attachment-id": true, "attachment.instance-id": true, "attachment.status": true, + "tag-key": true, + "tag-value": true, } // DescribeNetworkInterfaces lists ENIs with optional awsfilters. func (s *VPCServiceImpl) DescribeNetworkInterfaces(ctx context.Context, input *ec2.DescribeNetworkInterfacesInput, accountID string) (*ec2.DescribeNetworkInterfacesOutput, error) { + pageReq, err := describeNetworkInterfacesPaging.Parse(input.MaxResults, input.NextToken, len(input.NetworkInterfaceIds)) + if err != nil { + return nil, err + } + parsedFilters, err := awsfilters.ParseFilters(input.Filters, describeNetworkInterfacesValidFilters) if err != nil { slog.WarnContext(ctx, "DescribeNetworkInterfaces: invalid filter", "err", err) @@ -498,10 +546,14 @@ func (s *VPCServiceImpl) DescribeNetworkInterfaces(ctx context.Context, input *e } enis := make([]*ec2.NetworkInterface, 0) + groupNames := make(map[string]string) eniIDs := make(map[string]bool) for _, id := range input.NetworkInterfaceIds { if id != nil { + if err := eniIDMalformedError(*id); err != nil { + return nil, err + } eniIDs[*id] = true } } @@ -539,7 +591,8 @@ func (s *VPCServiceImpl) DescribeNetworkInterfaces(ctx context.Context, input *e continue } - enis = append(enis, s.eniRecordToEC2(&record, accountID)) + s.fillSGNames(ctx, accountID, record.SecurityGroupIds, groupNames) + enis = append(enis, s.eniRecordToEC2(&record, accountID, groupNames)) } // If specific ENI IDs were requested but not found, return error @@ -550,24 +603,27 @@ func (s *VPCServiceImpl) DescribeNetworkInterfaces(ctx context.Context, input *e found[*eni.NetworkInterfaceId] = true } } - for id := range eniIDs { - if !found[id] { - return nil, errors.New(awserrors.ErrorInvalidNetworkInterfaceIDNotFound) + for _, id := range input.NetworkInterfaceIds { + if id != nil && !found[*id] { + return nil, eniNotFoundError(*id) } } } + enis, nextToken := paging.EC2Page(enis, func(e *ec2.NetworkInterface) string { return *e.NetworkInterfaceId }, pageReq) + slog.InfoContext(ctx, "DescribeNetworkInterfaces completed", "count", len(enis), "accountID", accountID) return &ec2.DescribeNetworkInterfacesOutput{ NetworkInterfaces: enis, + NextToken: nextToken, }, nil } // eniMatchesFilters checks whether an ENI record matches all parsed awsfilters. func eniMatchesFilters(record *ENIRecord, filters map[string][]string) bool { for name, values := range filters { - if strings.HasPrefix(name, "tag:") { + if awsfilters.IsTagFilter(name) { continue } switch name { @@ -596,14 +652,9 @@ func eniMatchesFilters(record *ENIRecord, filters map[string][]string) bool { return false } case "group-id": - found := false - for _, sgId := range record.SecurityGroupIds { - if awsfilters.MatchesAny(values, sgId) { - found = true - break - } - } - if !found { + if !slices.ContainsFunc(record.SecurityGroupIds, func(sgId string) bool { + return awsfilters.MatchesAny(values, sgId) + }) { return false } case "mac-address": @@ -647,7 +698,7 @@ func (s *VPCServiceImpl) attachENI(ctx context.Context, accountID, eniId, instan key := utils.AccountKey(accountID, eniId) entry, err := s.eniKV.Get(ctx, key) if err != nil { - return "", errors.New(awserrors.ErrorInvalidNetworkInterfaceIDNotFound) + return "", eniNotFoundError(eniId) } var record ENIRecord @@ -689,7 +740,7 @@ func (s *VPCServiceImpl) DetachENI(ctx context.Context, accountID, eniId string) key := utils.AccountKey(accountID, eniId) entry, err := s.eniKV.Get(ctx, key) if err != nil { - return errors.New(awserrors.ErrorInvalidNetworkInterfaceIDNotFound) + return eniNotFoundError(eniId) } var record ENIRecord @@ -725,7 +776,7 @@ func (s *VPCServiceImpl) getENIRecord(ctx context.Context, accountID, eniId stri key := utils.AccountKey(accountID, eniId) entry, err := s.eniKV.Get(ctx, key) if err != nil { - return ENIRecord{}, errors.New(awserrors.ErrorInvalidNetworkInterfaceIDNotFound) + return ENIRecord{}, eniNotFoundError(eniId) } var record ENIRecord if err := json.Unmarshal(entry.Value(), &record); err != nil { @@ -745,7 +796,7 @@ func (s *VPCServiceImpl) updateENI(ctx context.Context, accountID, eniId string, key := utils.AccountKey(accountID, eniId) entry, err := s.eniKV.Get(ctx, key) if err != nil { - return errors.New(awserrors.ErrorInvalidNetworkInterfaceIDNotFound) + return eniNotFoundError(eniId) } var record ENIRecord if err := json.Unmarshal(entry.Value(), &record); err != nil { @@ -958,8 +1009,32 @@ func (s *VPCServiceImpl) updateENIPublicIP(ctx context.Context, accountID, eniId return nil } -// eniRecordToEC2 converts an ENI record to an EC2 NetworkInterface. -func (s *VPCServiceImpl) eniRecordToEC2(record *ENIRecord, accountID string) *ec2.NetworkInterface { +// fillSGNames records in names the name of each group in ids it does not +// already hold. A group that cannot be read is recorded as "", so it is listed +// without a name and not looked up again. +func (s *VPCServiceImpl) fillSGNames(ctx context.Context, accountID string, ids []string, names map[string]string) { + for _, id := range ids { + if _, ok := names[id]; ok { + continue + } + names[id] = "" + entry, err := s.sgKV.Get(ctx, utils.AccountKey(accountID, id)) + if err != nil { + slog.WarnContext(ctx, "fillSGNames: SG read failed", "groupId", id, "err", err) + continue + } + var rec SecurityGroupRecord + if err := json.Unmarshal(entry.Value(), &rec); err != nil { + slog.WarnContext(ctx, "fillSGNames: SG unmarshal failed", "groupId", id, "err", err) + continue + } + names[id] = rec.GroupName + } +} + +// eniRecordToEC2 converts an ENI record to an EC2 NetworkInterface, naming +// each security group from groupNames. +func (s *VPCServiceImpl) eniRecordToEC2(record *ENIRecord, accountID string, groupNames map[string]string) *ec2.NetworkInterface { // ENIs with spinifex:managed-by tag are system-managed (e.g. by ELBv2) requesterManaged := record.Tags["spinifex:managed-by"] != "" @@ -988,13 +1063,22 @@ func (s *VPCServiceImpl) eniRecordToEC2(record *ENIRecord, accountID string) *ec if len(record.SecurityGroupIds) > 0 { groups := make([]*ec2.GroupIdentifier, 0, len(record.SecurityGroupIds)) for _, sgId := range record.SecurityGroupIds { - groups = append(groups, &ec2.GroupIdentifier{ - GroupId: aws.String(sgId), - }) + group := &ec2.GroupIdentifier{GroupId: aws.String(sgId)} + if name := groupNames[sgId]; name != "" { + group.GroupName = aws.String(name) + } + groups = append(groups, group) } eni.Groups = groups } + if s.privateDNSName != nil && record.PrivateIpAddress != "" { + if name := s.privateDNSName(record.PrivateIpAddress); name != "" { + eni.PrivateDnsName = aws.String(name) + eni.PrivateIpAddresses[0].PrivateDnsName = aws.String(name) + } + } + if record.PublicIpAddress != "" { eni.Association = &ec2.NetworkInterfaceAssociation{ PublicIp: aws.String(record.PublicIpAddress), @@ -1016,11 +1100,6 @@ func (s *VPCServiceImpl) eniRecordToEC2(record *ENIRecord, accountID string) *ec return eni } -// generateENIMac creates a locally-administered unicast MAC address from an ENI ID. -func generateENIMac(eniId string) string { - return utils.HashMAC(eniId) -} - // portEventPayload is the wire shape for vpc.create-port / vpc.delete-port. // Mirrors network/subscribers.PortEvent — duplicated here to avoid a // subscribers → handlers import cycle. @@ -1111,7 +1190,7 @@ func (s *VPCServiceImpl) validateSGAttachment(ctx context.Context, accountID str for _, sgId := range sgIds { sgEntry, err := s.sgKV.Get(ctx, utils.AccountKey(accountID, sgId)) if err != nil { - return errors.New(awserrors.ErrorInvalidGroupNotFound) + return sgNotFoundError(sgId) } var sg SecurityGroupRecord if err := json.Unmarshal(sgEntry.Value(), &sg); err != nil { diff --git a/spinifex/handlers/ec2/vpc/eni_abandoned_test.go b/spinifex/handlers/ec2/vpc/eni_abandoned_test.go index daa897b33..7e38535dd 100644 --- a/spinifex/handlers/ec2/vpc/eni_abandoned_test.go +++ b/spinifex/handlers/ec2/vpc/eni_abandoned_test.go @@ -137,8 +137,7 @@ func TestAbandonedENIBlocksSecurityGroupDelete(t *testing.T) { GroupId: aws.String(sgID), }, testAccountID) require.Error(t, err) - assert.ErrorContains(t, err, awserrors.ErrorDependencyViolation) - assert.ErrorContains(t, err, eniID, "the refusal must name the ENI that blocked it") + requireAWSError(t, err, awserrors.ErrorDependencyViolation, "resource "+sgID+" has a dependent object") orphans, err := svc.ListAbandonedInstanceENIs(context.Background(), 15*time.Minute) require.NoError(t, err) diff --git a/spinifex/handlers/ec2/vpc/eni_mac_test.go b/spinifex/handlers/ec2/vpc/eni_mac_test.go deleted file mode 100644 index 3b93615e8..000000000 --- a/spinifex/handlers/ec2/vpc/eni_mac_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package handlers_ec2_vpc - -import ( - "net" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestGenerateENIMac_Deterministic(t *testing.T) { - t.Parallel() - mac1 := generateENIMac("eni-abc123") - mac2 := generateENIMac("eni-abc123") - assert.Equal(t, mac1, mac2, "same input must produce same MAC") -} - -func TestGenerateENIMac_DifferentInputs(t *testing.T) { - t.Parallel() - mac1 := generateENIMac("eni-aaa") - mac2 := generateENIMac("eni-bbb") - assert.NotEqual(t, mac1, mac2, "different inputs should produce different MACs") -} - -func TestGenerateENIMac_LocallyAdministered(t *testing.T) { - t.Parallel() - mac := generateENIMac("eni-test123") - hw, err := net.ParseMAC(mac) - require.NoError(t, err) - // IEEE 802 reserved bits on first octet: bit0=0 unicast, bit1=1 LAA. - assert.Equal(t, byte(0x02), hw[0]&0x03) -} - -func TestGenerateENIMac_EmptyString(t *testing.T) { - t.Parallel() - mac := generateENIMac("") - hw, err := net.ParseMAC(mac) - require.NoError(t, err) - assert.Equal(t, byte(0x02), hw[0]&0x03) - // New impl hashes the prefix-and-id; empty input no longer collapses to - // the degenerate 02:00:00:00:00:00 of the old 24-bit impl. - assert.NotEqual(t, "02:00:00:00:00:00", hw.String()) -} diff --git a/spinifex/handlers/ec2/vpc/eni_test.go b/spinifex/handlers/ec2/vpc/eni_test.go index edcd1e0af..788ef2147 100644 --- a/spinifex/handlers/ec2/vpc/eni_test.go +++ b/spinifex/handlers/ec2/vpc/eni_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "net" "sync" "testing" "time" @@ -50,6 +49,46 @@ func TestCreateNetworkInterface(t *testing.T) { assert.NotEmpty(t, *eni.MacAddress) } +func TestNetworkInterface_NamesGroupsAndPrivateAddress(t *testing.T) { + t.Parallel() + svc := setupTestVPCService(t) + vpcId := createTestVPC(t, svc, "10.0.0.0/16") + subnetId := createTestSubnet(t, svc, vpcId, "10.0.1.0/24") + sgId := createTestSG(t, svc, vpcId, "web") + + // Without a namer an interface carries no private name. + unnamed := createTestENI(t, svc, subnetId) + desc, err := svc.DescribeNetworkInterfaces(context.Background(), &ec2.DescribeNetworkInterfacesInput{ + NetworkInterfaceIds: []*string{aws.String(unnamed)}, + }, testAccountID) + require.NoError(t, err) + require.Len(t, desc.NetworkInterfaces, 1) + assert.Nil(t, desc.NetworkInterfaces[0].PrivateDnsName) + + svc.SetPrivateDNSNamer(func(ip string) string { return "name-of-" + ip }) + out, err := svc.CreateNetworkInterface(context.Background(), &ec2.CreateNetworkInterfaceInput{ + SubnetId: aws.String(subnetId), + Groups: []*string{aws.String(sgId)}, + }, testAccountID) + require.NoError(t, err) + + desc, err = svc.DescribeNetworkInterfaces(context.Background(), &ec2.DescribeNetworkInterfacesInput{ + NetworkInterfaceIds: []*string{out.NetworkInterface.NetworkInterfaceId}, + }, testAccountID) + require.NoError(t, err) + require.Len(t, desc.NetworkInterfaces, 1) + + for name, eni := range map[string]*ec2.NetworkInterface{"create": out.NetworkInterface, "describe": desc.NetworkInterfaces[0]} { + want := "name-of-" + aws.StringValue(eni.PrivateIpAddress) + assert.Equal(t, want, aws.StringValue(eni.PrivateDnsName), name) + require.Len(t, eni.PrivateIpAddresses, 1, name) + assert.Equal(t, want, aws.StringValue(eni.PrivateIpAddresses[0].PrivateDnsName), name) + require.Len(t, eni.Groups, 1, name) + assert.Equal(t, sgId, aws.StringValue(eni.Groups[0].GroupId), name) + assert.Equal(t, "web", aws.StringValue(eni.Groups[0].GroupName), name) + } +} + func TestCreateNetworkInterface_SequentialIPs(t *testing.T) { t.Parallel() svc := setupTestVPCService(t) @@ -178,7 +217,7 @@ func TestCreateNetworkInterface_RequestedPrivateIPRaceHasOneWinner(t *testing.T) switch { case err == nil: successes++ - case err.Error() == awserrors.ErrorInvalidIPAddressInUse: + case awserrors.IsErrorCode(err, awserrors.ErrorInvalidIPAddressInUse): conflicts++ default: t.Fatalf("unexpected error: %v", err) @@ -413,9 +452,30 @@ func TestDescribeNetworkInterfaces_NotFound(t *testing.T) { t.Parallel() svc := setupTestVPCService(t) _, err := svc.DescribeNetworkInterfaces(context.Background(), &ec2.DescribeNetworkInterfacesInput{ - NetworkInterfaceIds: []*string{aws.String("eni-nonexistent")}, + NetworkInterfaceIds: []*string{aws.String("eni-0000000000000dead")}, }, testAccountID) - assert.ErrorContains(t, err, "InvalidNetworkInterfaceID.NotFound") + requireAWSError(t, err, awserrors.ErrorInvalidNetworkInterfaceIDNotFound, "The networkInterface ID 'eni-0000000000000dead' does not exist") +} + +func TestDescribeNetworkInterfaces_MalformedID(t *testing.T) { + t.Parallel() + svc := setupTestVPCService(t) + describe := func(ids ...string) error { + _, err := svc.DescribeNetworkInterfaces(context.Background(), &ec2.DescribeNetworkInterfacesInput{ + NetworkInterfaceIds: aws.StringSlice(ids), + }, testAccountID) + return err + } + + for _, id := range []string{"eni-xyz", "eni-", "eni-ABC"} { + requireAWSError(t, describe(id), awserrors.ErrorInvalidNetworkInterfaceIdMalformed, fmt.Sprintf("Invalid id: %q", id)) + } + requireAWSError(t, describe("foo"), awserrors.ErrorInvalidNetworkInterfaceIdMalformed, `Invalid id: "foo" (expecting "eni-...")`) + // Unlike a group ID, any length of lowercase hex is merely unknown. + for _, id := range []string{"eni-12345", "eni-000000000000000000001"} { + requireAWSCode(t, describe(id), awserrors.ErrorInvalidNetworkInterfaceIDNotFound) + } + requireAWSCode(t, describe("eni-0aaaaaaa", "eni-xyz"), awserrors.ErrorInvalidNetworkInterfaceIdMalformed) } func TestAttachENI(t *testing.T) { @@ -629,20 +689,6 @@ func TestDetachAndDeleteENI_AbsentForceFalse_ReturnsNotFound(t *testing.T) { assert.ErrorContains(t, err, "InvalidNetworkInterfaceID.NotFound") } -func TestGenerateENIMac(t *testing.T) { - t.Parallel() - mac := generateENIMac("eni-test123") - hw, err := net.ParseMAC(mac) - require.NoError(t, err) - assert.Equal(t, byte(0x02), hw[0]&0x03) - - // Same input produces same MAC - assert.Equal(t, mac, generateENIMac("eni-test123")) - - // Different input produces different MAC - assert.NotEqual(t, mac, generateENIMac("eni-test456")) -} - // --- Filter tests --- func TestDescribeNetworkInterfaces_FilterByVpcId(t *testing.T) { @@ -830,8 +876,8 @@ func TestModifyNetworkInterfaceAttribute_SecurityGroups(t *testing.T) { vpcId := createTestVPC(t, svc, "10.0.0.0/16") subnetId := createTestSubnet(t, svc, vpcId, "10.0.1.0/24") eniId := createTestENI(t, svc, subnetId) - sg1 := createTestSG(t, svc, vpcId, "sg-one") - sg2 := createTestSG(t, svc, vpcId, "sg-two") + sg1 := createTestSG(t, svc, vpcId, "grp-one") + sg2 := createTestSG(t, svc, vpcId, "grp-two") _, err := svc.ModifyNetworkInterfaceAttribute(context.Background(), &ec2.ModifyNetworkInterfaceAttributeInput{ NetworkInterfaceId: aws.String(eniId), @@ -855,8 +901,8 @@ func TestModifyNetworkInterfaceAttribute_PublishesUpdatePortSGs(t *testing.T) { vpcId := createTestVPC(t, svc, "10.0.0.0/16") subnetId := createTestSubnet(t, svc, vpcId, "10.0.1.0/24") eniId := createTestENI(t, svc, subnetId) - sg1 := createTestSG(t, svc, vpcId, "sg-mod-1") - sg2 := createTestSG(t, svc, vpcId, "sg-mod-2") + sg1 := createTestSG(t, svc, vpcId, "grp-mod-1") + sg2 := createTestSG(t, svc, vpcId, "grp-mod-2") eventCh := make(chan *nats.Msg, 1) sub, err := nc.Subscribe("vpc.update-port-sgs", func(msg *nats.Msg) { @@ -913,7 +959,7 @@ func TestCreateNetworkInterface_PublishesEventCarriesSGs(t *testing.T) { svc, nc := setupTestVPCServiceWithNC(t) vpcId := createTestVPC(t, svc, "10.0.0.0/16") subnetId := createTestSubnet(t, svc, vpcId, "10.0.1.0/24") - sgA := createTestSG(t, svc, vpcId, "sg-evt-A") + sgA := createTestSG(t, svc, vpcId, "grp-evt-A") eventCh := make(chan *nats.Msg, 1) sub, err := nc.Subscribe("vpc.create-port", func(msg *nats.Msg) { @@ -1062,8 +1108,8 @@ func TestCreateNetworkInterface_WithSecurityGroups(t *testing.T) { svc := setupTestVPCService(t) vpcId := createTestVPC(t, svc, "10.0.0.0/16") subnetId := createTestSubnet(t, svc, vpcId, "10.0.1.0/24") - sgA := createTestSG(t, svc, vpcId, "sg-aaa") - sgB := createTestSG(t, svc, vpcId, "sg-bbb") + sgA := createTestSG(t, svc, vpcId, "grp-aaa") + sgB := createTestSG(t, svc, vpcId, "grp-bbb") out, err := svc.CreateNetworkInterface(context.Background(), &ec2.CreateNetworkInterfaceInput{ SubnetId: aws.String(subnetId), @@ -1222,8 +1268,8 @@ func TestDescribeNetworkInterfaces_FilterByGroupId(t *testing.T) { svc := setupTestVPCService(t) vpcId := createTestVPC(t, svc, "10.0.0.0/16") subnetId := createTestSubnet(t, svc, vpcId, "10.0.1.0/24") - sgA := createTestSG(t, svc, vpcId, "sg-aaa") - sgB := createTestSG(t, svc, vpcId, "sg-bbb") + sgA := createTestSG(t, svc, vpcId, "grp-aaa") + sgB := createTestSG(t, svc, vpcId, "grp-bbb") // Create ENI with security groups out, err := svc.CreateNetworkInterface(context.Background(), &ec2.CreateNetworkInterfaceInput{ @@ -1465,7 +1511,7 @@ func TestValidateSGAttachment_TooMany(t *testing.T) { vpcID := createTestVPC(t, svc, "10.0.0.0/16") sgs := make([]string, 6) for i := range sgs { - sgs[i] = createTestSG(t, svc, vpcID, fmt.Sprintf("sg-%d", i)) + sgs[i] = createTestSG(t, svc, vpcID, fmt.Sprintf("grp-%d", i)) } err := svc.validateSGAttachment(t.Context(), testAccountID, sgs, vpcID) @@ -1508,7 +1554,7 @@ func TestModifyNetworkInterfaceAttribute_VpcdError_Propagated(t *testing.T) { vpcId := createTestVPC(t, svc, "10.0.0.0/16") subnetId := createTestSubnet(t, svc, vpcId, "10.0.1.0/24") eniId := createTestENI(t, svc, subnetId) - sg1 := createTestSG(t, svc, vpcId, "sg-mod-fail-1") + sg1 := createTestSG(t, svc, vpcId, "grp-mod-fail-1") // Swap the stub's vpc.update-port-sgs reply to an error in-place so // there's exactly one responder (no race with a layered subscriber). @@ -1552,3 +1598,22 @@ func TestUpdateENIPublicIP_NotFound(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "eni-missing") } + +// AWS checks the attribute combination before it looks the interface up, and +// its order of names is unstable, so only the names are asserted. +func TestModifyNetworkInterfaceAttribute_MultipleAttributes(t *testing.T) { + t.Parallel() + svc := setupTestVPCService(t) + + _, err := svc.ModifyNetworkInterfaceAttribute(context.Background(), &ec2.ModifyNetworkInterfaceAttributeInput{ + NetworkInterfaceId: aws.String("eni-nonexistent"), + Groups: []*string{aws.String("sg-111")}, + Description: &ec2.AttributeValue{Value: aws.String("desc")}, + }, testAccountID) + code, msg, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok, "error %v carries no AWS code", err) + assert.Equal(t, awserrors.ErrorInvalidParameterCombination, code) + assert.Contains(t, msg, "Fields for multiple attribute types specified: ") + assert.Contains(t, msg, "description") + assert.Contains(t, msg, "securityGroups") +} diff --git a/spinifex/handlers/ec2/vpc/ids.go b/spinifex/handlers/ec2/vpc/ids.go new file mode 100644 index 000000000..5bb61db3d --- /dev/null +++ b/spinifex/handlers/ec2/vpc/ids.go @@ -0,0 +1,68 @@ +package handlers_ec2_vpc + +import ( + "strings" + + "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" +) + +// isLowerHex reports whether s is non-empty and holds only lowercase hex digits. +func isLowerHex(s string) bool { + if s == "" { + return false + } + for _, c := range s { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return false + } + } + return true +} + +// malformedIDError is AWS's Invalid id answer for an ID that is not of the +// form -, or whose suffix fails suffixOK. +func malformedIDError(code, prefix, id string, suffixOK func(string) bool) error { + suffix, ok := strings.CutPrefix(id, prefix+"-") + if !ok { + return awserrors.Errorf(code, "Invalid id: %q (expecting %q)", id, prefix+"-...") + } + if !suffixOK(suffix) { + return awserrors.Errorf(code, "Invalid id: %q", id) + } + return nil +} + +// sgIDMalformedError returns AWS's error for a group ID it calls malformed, or +// nil. AWS answers NotFound for up to 8 hex digits or exactly 17, the length +// of every Spinifex ID; other lengths are Malformed. +func sgIDMalformedError(id string) error { + return malformedIDError(awserrors.ErrorInvalidGroupIdMalformed, "sg", id, func(s string) bool { + return isLowerHex(s) && (len(s) <= 8 || len(s) == 17) + }) +} + +// eniIDMalformedError returns AWS's error for an interface ID it calls +// malformed, or nil. AWS answers NotFound for lowercase hex of any length. +func eniIDMalformedError(id string) error { + return malformedIDError(awserrors.ErrorInvalidNetworkInterfaceIdMalformed, "eni", id, isLowerHex) +} + +func vpcNotFoundError(id string) error { + return awserrors.IDNotFound(awserrors.ErrorInvalidVpcIDNotFound, "vpc", id) +} + +func eniNotFoundError(id string) error { + return awserrors.IDNotFound(awserrors.ErrorInvalidNetworkInterfaceIDNotFound, "networkInterface", id) +} + +// sgNotFoundError is AWS's security group not-found answer, which unlike the +// other resource types says no "ID". +func sgNotFoundError(id string) error { + return awserrors.Errorf(awserrors.ErrorInvalidGroupNotFound, "The security group '%s' does not exist", id) +} + +// subnetRangeError answers a subnet CIDR too large, too small or outside +// its VPC, which AWS does not tell apart. +func subnetRangeError(cidr string) error { + return awserrors.Errorf(awserrors.ErrorInvalidSubnetRange, "The CIDR '%s' is invalid.", cidr) +} diff --git a/spinifex/handlers/ec2/vpc/paging_test.go b/spinifex/handlers/ec2/vpc/paging_test.go new file mode 100644 index 000000000..dd39505db --- /dev/null +++ b/spinifex/handlers/ec2/vpc/paging_test.go @@ -0,0 +1,148 @@ +//test:in-package — reuses the in-package VPC service fixtures (setupTestVPCService, createTestVPC) + +package handlers_ec2_vpc + +import ( + "context" + "fmt" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDescribeSubnets_PagesThroughEverySubnetOnce(t *testing.T) { + t.Parallel() + svc := setupTestVPCService(t) + vpcID := createTestVPC(t, svc, "10.0.0.0/16") + want := map[string]bool{} + for i := range 7 { + want[createTestSubnet(t, svc, vpcID, fmt.Sprintf("10.0.%d.0/24", i))] = true + } + + got := map[string]bool{} + var token *string + pages := 0 + for { + out, err := svc.DescribeSubnets(context.Background(), &ec2.DescribeSubnetsInput{ + MaxResults: aws.Int64(5), + NextToken: token, + }, testAccountID) + require.NoError(t, err) + require.LessOrEqual(t, len(out.Subnets), 5) + for _, s := range out.Subnets { + assert.False(t, got[*s.SubnetId], "subnet %s returned twice", *s.SubnetId) + got[*s.SubnetId] = true + } + pages++ + if out.NextToken == nil { + break + } + token = out.NextToken + } + assert.Equal(t, want, got) + assert.Equal(t, 2, pages) +} + +func TestDescribeSecurityGroups_PagesAfterFiltering(t *testing.T) { + t.Parallel() + svc := setupTestVPCService(t) + vpcID := createTestVPC(t, svc, "10.0.0.0/16") + for i := range 6 { + createTestSG(t, svc, vpcID, fmt.Sprintf("paged-%d", i)) + } + createTestSG(t, svc, createTestVPC(t, svc, "10.1.0.0/16"), "other-vpc") + + filter := []*ec2.Filter{{Name: aws.String("group-name"), Values: []*string{aws.String("paged-*")}}} + first, err := svc.DescribeSecurityGroups(context.Background(), &ec2.DescribeSecurityGroupsInput{ + Filters: filter, MaxResults: aws.Int64(5), + }, testAccountID) + require.NoError(t, err) + require.Len(t, first.SecurityGroups, 5) + require.NotNil(t, first.NextToken) + + second, err := svc.DescribeSecurityGroups(context.Background(), &ec2.DescribeSecurityGroupsInput{ + Filters: filter, MaxResults: aws.Int64(5), NextToken: first.NextToken, + }, testAccountID) + require.NoError(t, err) + require.Len(t, second.SecurityGroups, 1) + assert.Nil(t, second.NextToken) +} + +func TestDescribeVpcs_ValidatesPagingButReturnsEverything(t *testing.T) { + t.Parallel() + svc := setupTestVPCService(t) + for i := range 6 { + createTestVPC(t, svc, fmt.Sprintf("10.%d.0.0/16", i)) + } + + out, err := svc.DescribeVpcs(context.Background(), &ec2.DescribeVpcsInput{MaxResults: aws.Int64(5)}, testAccountID) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(out.Vpcs), 6) + assert.Nil(t, out.NextToken) +} + +// TestNetworkDescribes_PagingErrors pins each operation to the code and message +// AWS returns, which differ in wording and casing between operations. +func TestNetworkDescribes_PagingErrors(t *testing.T) { + t.Parallel() + svc := setupTestVPCService(t) + ctx := context.Background() + ids := []*string{aws.String("x-0123456789abcdef0")} + + cases := []struct { + name string + call func() error + code string + message string + }{ + {"DescribeVpcs too large", func() error { + _, err := svc.DescribeVpcs(ctx, &ec2.DescribeVpcsInput{MaxResults: aws.Int64(2000)}, testAccountID) + return err + }, awserrors.ErrorInvalidParameterValue, "Value ( 2000 ) for parameter MaxResults is invalid. Expecting a value smaller than or equal to 1000."}, + {"DescribeVpcs ids", func() error { + _, err := svc.DescribeVpcs(ctx, &ec2.DescribeVpcsInput{MaxResults: aws.Int64(5), VpcIds: ids}, testAccountID) + return err + }, awserrors.ErrorInvalidParameterCombination, "The parameter VpcIds cannot be used with the parameter MaxResults"}, + {"DescribeSubnets too small", func() error { + _, err := svc.DescribeSubnets(ctx, &ec2.DescribeSubnetsInput{MaxResults: aws.Int64(4)}, testAccountID) + return err + }, awserrors.ErrorInvalidParameterValue, "Value ( 4 ) for parameter MaxResults is invalid. Expecting a value greater than or equal to 5."}, + {"DescribeSubnets bad token", func() error { + _, err := svc.DescribeSubnets(ctx, &ec2.DescribeSubnetsInput{NextToken: aws.String("garbage")}, testAccountID) + return err + }, awserrors.ErrorInvalidParameterValue, "Value ( garbage ) for parameter NextToken is invalid. The token is invalid."}, + {"DescribeSecurityGroups too large", func() error { + _, err := svc.DescribeSecurityGroups(ctx, &ec2.DescribeSecurityGroupsInput{MaxResults: aws.Int64(1001)}, testAccountID) + return err + }, awserrors.ErrorInvalidParameterValue, "Value ( 1001 ) for parameter maxResults is invalid. Expecting a value smaller than 1000."}, + {"DescribeSecurityGroups ids", func() error { + _, err := svc.DescribeSecurityGroups(ctx, &ec2.DescribeSecurityGroupsInput{MaxResults: aws.Int64(5), GroupIds: ids}, testAccountID) + return err + }, awserrors.ErrorInvalidParameterCombination, "The parameter securityGroupIdSet cannot be used with the parameter maxResults"}, + {"DescribeSecurityGroupRules bad token", func() error { + _, err := svc.DescribeSecurityGroupRules(ctx, &ec2.DescribeSecurityGroupRulesInput{NextToken: aws.String("garbage")}, testAccountID) + return err + }, awserrors.ErrorInvalidPaginationToken, "Next token 'garbage' is invalid"}, + {"DescribeSecurityGroupRules ids", func() error { + _, err := svc.DescribeSecurityGroupRules(ctx, &ec2.DescribeSecurityGroupRulesInput{MaxResults: aws.Int64(5), SecurityGroupRuleIds: ids}, testAccountID) + return err + }, awserrors.ErrorInvalidParameterCombination, "The parameter 'securityGroupRuleIds' may not be used in combination with 'maxResults'."}, + {"DescribeNetworkInterfaces ids", func() error { + _, err := svc.DescribeNetworkInterfaces(ctx, &ec2.DescribeNetworkInterfacesInput{MaxResults: aws.Int64(5), NetworkInterfaceIds: ids}, testAccountID) + return err + }, awserrors.ErrorInvalidParameterCombination, "The parameter NetworkInterfaceIds cannot be used with the parameter MaxResults"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + code, message, ok := awserrors.ResolveErrorDetail(tc.call()) + require.True(t, ok) + assert.Equal(t, tc.code, code) + assert.Equal(t, tc.message, message) + }) + } +} diff --git a/spinifex/handlers/ec2/vpc/security_group.go b/spinifex/handlers/ec2/vpc/security_group.go index c23147e3d..4fffb7d1c 100644 --- a/spinifex/handlers/ec2/vpc/security_group.go +++ b/spinifex/handlers/ec2/vpc/security_group.go @@ -19,6 +19,7 @@ import ( "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters" "github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil" + "github.com/mulgadc/spinifex/spinifex/paging" "github.com/mulgadc/spinifex/spinifex/utils" "github.com/nats-io/nats.go/jetstream" ) @@ -60,6 +61,16 @@ func normalizeIPProtocol(proto string) (string, error) { return "", fmt.Errorf("invalid IpProtocol %q: supported values are tcp, udp, icmp, -1 (or 6, 17, 1)", proto) } +// validateSGRulePorts rejects a tcp or udp rule whose FromPort exceeds its ToPort. +// Only create paths call it, so a rule stored before the check can still be +// revoked or re-described by its ports. +func validateSGRulePorts(proto string, fromPort, toPort int64) error { + if (proto == "tcp" || proto == "udp") && fromPort > toPort { + return awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "Invalid TCP/UDP port range(%d:%d)", fromPort, toPort) + } + return nil +} + // validateSGRule rejects CidrIp values that are non-canonical or IPv6, CidrIpv6 // values that are non-canonical or IPv4, IpProtocol values the ACL builder // cannot express, and SourceSG values not matching the sg-ID format. At least @@ -75,7 +86,7 @@ func validateSGRule(r SGRule) error { if r.CidrIp != "" { _, ipnet, err := net.ParseCIDR(r.CidrIp) if err != nil { - return fmt.Errorf("invalid CidrIp %q: %w", r.CidrIp, err) + return awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "CIDR block %s is malformed", r.CidrIp) } if ipnet.IP.To4() == nil { return fmt.Errorf("invalid CidrIp %q: IPv6 belongs in CidrIpv6", r.CidrIp) @@ -87,7 +98,7 @@ func validateSGRule(r SGRule) error { if r.CidrIpv6 != "" { _, ipnet, err := net.ParseCIDR(r.CidrIpv6) if err != nil { - return fmt.Errorf("invalid CidrIpv6 %q: %w", r.CidrIpv6, err) + return awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "CIDR block %s is malformed", r.CidrIpv6) } if ipnet.IP.To4() != nil { return fmt.Errorf("invalid CidrIpv6 %q: IPv4 belongs in CidrIp", r.CidrIpv6) @@ -174,15 +185,18 @@ func (s *VPCServiceImpl) CreateSecurityGroup(ctx context.Context, input *ec2.Cre vpcId := *input.VpcId groupName := *input.GroupName - // "default" is reserved for the per-VPC default SG that CreateVpc - // provisions internally. Matches AWS behavior. - if groupName == defaultSecurityGroupName { - return nil, errors.New(awserrors.ErrorInvalidGroupReserved) - } - if err := s.requireVPCExists(ctx, accountID, vpcId); err != nil { return nil, err } + // "default" is reserved for the per-VPC default SG that CreateVpc + // provisions internally. AWS checks the VPC first. + if groupName == defaultSecurityGroupName { + return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "Cannot use reserved security group name: %s", groupName) + } + if strings.HasPrefix(groupName, "sg-") { + return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, + "Value (%s) for parameter GroupName is invalid. Group names may not be in the format sg-*.", groupName) + } // Check for duplicate group name in the same VPC and enforce the per-VPC // SG quota in the same bucket walk. @@ -219,7 +233,7 @@ func (s *VPCServiceImpl) CreateSecurityGroup(ctx context.Context, input *ec2.Cre } sgsInVPC++ if existing.GroupName == groupName { - return nil, errors.New(awserrors.ErrorInvalidGroupDuplicate) + return nil, awserrors.Errorf(awserrors.ErrorInvalidGroupDuplicate, "The security group '%s' already exists for VPC '%s'", groupName, vpcId) } } if sgsInVPC >= maxSGsPerVPC { @@ -273,6 +287,7 @@ func (s *VPCServiceImpl) CreateSecurityGroup(ctx context.Context, input *ec2.Cre return &ec2.CreateSecurityGroupOutput{ GroupId: aws.String(groupId), + Tags: utils.MapToEC2Tags(record.Tags), }, nil } @@ -291,7 +306,7 @@ func (s *VPCServiceImpl) DeleteSecurityGroup(ctx context.Context, input *ec2.Del // success. Destroy orchestration tolerates it via awserrors.IsNotFound; // a transient read error stays a server error. if errors.Is(err, jetstream.ErrKeyNotFound) { - return nil, errors.New(awserrors.ErrorInvalidGroupNotFound) + return nil, sgNotFoundError(groupId) } return nil, errors.New(awserrors.ErrorServerInternal) } @@ -302,7 +317,7 @@ func (s *VPCServiceImpl) DeleteSecurityGroup(ctx context.Context, input *ec2.Del } if record.IsDefault { - return nil, errors.New(awserrors.ErrorCannotDelete) + return nil, awserrors.Errorf(awserrors.ErrorCannotDelete, "the specified group: %q name: %q cannot be deleted by a user", groupId, record.GroupName) } if err := s.checkSGDependencies(ctx, accountID, groupId); err != nil { @@ -337,14 +352,14 @@ func (s *VPCServiceImpl) validateSGRuleReferences(ctx context.Context, accountID } entry, err := s.sgKV.Get(ctx, utils.AccountKey(accountID, r.SourceSG)) if err != nil { - return errors.New(awserrors.ErrorInvalidGroupNotFound) + return sgNotFoundError(r.SourceSG) } var rec SecurityGroupRecord if err := json.Unmarshal(entry.Value(), &rec); err != nil { return errors.New(awserrors.ErrorServerInternal) } if rec.VpcId != ownerVpcId { - return errors.New(awserrors.ErrorInvalidGroupNotFound) + return sgNotFoundError(r.SourceSG) } } return nil @@ -381,8 +396,7 @@ func (s *VPCServiceImpl) checkSGDependencies(ctx context.Context, accountID, gro // afterwards otherwise means cross-referencing the whole ENI table. slog.WarnContext(ctx, "checkSGDependencies: SG still attached to ENI", "groupId", groupId, "eniId", eni.NetworkInterfaceId, "accountID", accountID) - return awserrors.Errorf(awserrors.ErrorDependencyViolation, - "the security group has a dependent network interface %s that must be detached first", eni.NetworkInterfaceId) + return sgDependentObjectError(groupId) } } @@ -421,16 +435,49 @@ func (s *VPCServiceImpl) checkSGDependencies(ctx context.Context, accountID, gro return nil } -// sgReferencedByError logs and returns the DependencyViolation naming the SG -// whose rule still references groupId, so the blocking id survives even when -// the message does not reach the client. +// sgReferencedByError logs the SG whose rule still references groupId, since +// AWS's message does not name it, and returns the DependencyViolation. func sgReferencedByError(ctx context.Context, accountID, groupId, referencingGroupId, direction string) error { slog.WarnContext(ctx, "checkSGDependencies: SG referenced by another SG's rule", "groupId", groupId, "referencingGroupId", referencingGroupId, "direction", direction, "accountID", accountID) - return awserrors.Errorf(awserrors.ErrorDependencyViolation, - "the security group is referenced by a %s rule on security group %s that must be revoked first", - direction, referencingGroupId) + return sgDependentObjectError(groupId) +} + +// sgDependentObjectError is AWS's answer to deleting a group that an +// interface or another group's rule still uses; it does not say which. +func sgDependentObjectError(groupId string) error { + return awserrors.Errorf(awserrors.ErrorDependencyViolation, "resource %s has a dependent object", groupId) +} + +// sgRuleDuplicateError is AWS's answer to adding a rule the group already +// has, which describes the rule by its peer, protocol and ports. +func sgRuleDuplicateError(rule SGRule) error { + peer := rule.CidrIp + if peer == "" { + peer = rule.CidrIpv6 + } + if peer == "" { + peer = rule.SourceSG + } + var desc string + switch rule.IpProtocol { + case allProtocols: + desc = fmt.Sprintf("peer: %s, ALL, ALLOW", peer) + case "icmp": + desc = fmt.Sprintf("peer: %s, ICMP, type: %s, code: %s, ALLOW", peer, icmpField(rule.FromPort), icmpField(rule.ToPort)) + default: + desc = fmt.Sprintf("peer: %s, %s, from port: %d, to port: %d, ALLOW", peer, strings.ToUpper(rule.IpProtocol), rule.FromPort, rule.ToPort) + } + return awserrors.Errorf(awserrors.ErrorInvalidPermissionDuplicate, "the specified rule %q already exists", desc) +} + +// icmpField renders an ICMP type or code as AWS does, with -1 as ALL. +func icmpField(v int64) string { + if v == -1 { + return "ALL" + } + return strconv.FormatInt(v, 10) } // describeSecurityGroupsValidFilters defines the set of filter names accepted by DescribeSecurityGroups. @@ -440,12 +487,27 @@ var describeSecurityGroupsValidFilters = map[string]bool{ "vpc-id": true, "description": true, "ip-permission.cidr": true, + "tag-key": true, + "tag-value": true, +} + +// AWS accepts 5 and 1000 despite the "smaller than" and "greater than" wording. +var describeSecurityGroupsPaging = paging.EC2{ + MaxResults: 1000, + TooLarge: "Value ( %d ) for parameter maxResults is invalid. Expecting a value smaller than 1000.", + TooSmall: "Value ( %d ) for parameter maxResults is invalid. Expecting a value greater than 5.", + WithIDs: "The parameter securityGroupIdSet cannot be used with the parameter maxResults", } // DescribeSecurityGroups lists security groups with optional awsfilters. func (s *VPCServiceImpl) DescribeSecurityGroups(ctx context.Context, input *ec2.DescribeSecurityGroupsInput, accountID string) (*ec2.DescribeSecurityGroupsOutput, error) { groups := []*ec2.SecurityGroup{} + pageReq, err := describeSecurityGroupsPaging.Parse(input.MaxResults, input.NextToken, len(input.GroupIds)) + if err != nil { + return nil, err + } + groupIDs := make(map[string]bool) for _, id := range input.GroupIds { if id != nil { @@ -458,6 +520,14 @@ func (s *VPCServiceImpl) DescribeSecurityGroups(ctx context.Context, input *ec2. slog.WarnContext(ctx, "DescribeSecurityGroups: invalid filter", "err", err) return nil, err } + for _, id := range input.GroupIds { + if id == nil { + continue + } + if err := sgIDMalformedError(*id); err != nil { + return nil, err + } + } prefix := accountID + "." keys, err := s.sgKV.Keys(ctx) @@ -504,24 +574,27 @@ func (s *VPCServiceImpl) DescribeSecurityGroups(ctx context.Context, input *ec2. found[*sg.GroupId] = true } } - for id := range groupIDs { - if !found[id] { - return nil, errors.New(awserrors.ErrorInvalidGroupNotFound) + for _, id := range input.GroupIds { + if id != nil && !found[*id] { + return nil, sgNotFoundError(*id) } } } + groups, nextToken := paging.EC2Page(groups, func(g *ec2.SecurityGroup) string { return *g.GroupId }, pageReq) + slog.InfoContext(ctx, "DescribeSecurityGroups completed", "count", len(groups), "accountID", accountID) return &ec2.DescribeSecurityGroupsOutput{ SecurityGroups: groups, + NextToken: nextToken, }, nil } // sgMatchesFilters checks whether a SecurityGroupRecord satisfies all parsed awsfilters. func sgMatchesFilters(record *SecurityGroupRecord, filters map[string][]string) bool { for name, values := range filters { - if strings.HasPrefix(name, "tag:") { + if awsfilters.IsTagFilter(name) { continue } @@ -556,12 +629,9 @@ func sgMatchesFilters(record *SecurityGroupRecord, filters map[string][]string) // sgIngressCIDRMatchesAny checks if any ingress rule's CIDR matches any of the filter values. func sgIngressCIDRMatchesAny(rules []SGRule, values []string) bool { - for _, rule := range rules { - if rule.CidrIp != "" && awsfilters.MatchesAny(values, rule.CidrIp) { - return true - } - } - return false + return slices.ContainsFunc(rules, func(rule SGRule) bool { + return rule.CidrIp != "" && awsfilters.MatchesAny(values, rule.CidrIp) + }) } // getSecurityGroupsForVpcValidFilters defines the set of filter names accepted @@ -733,27 +803,37 @@ var describeSecurityGroupRulesValidFilters = map[string]bool{ "group-id": true, "security-group-rule-id": true, "tag-key": true, + "tag-value": true, +} + +var describeSecurityGroupRulesPaging = paging.EC2{ + MaxResults: 1000, + TooLarge: "Value ( %d ) for parameter maxResults is invalid. Expecting a value less than or equal to 1000.", + TooSmall: "Value ( %d ) for parameter maxResults is invalid. Expecting a value greater than or equal to 5.", + WithIDs: "The parameter 'securityGroupRuleIds' may not be used in combination with 'maxResults'.", + PaginationTokenError: true, } // DescribeSecurityGroupRules returns a flat list of SecurityGroupRule objects // for the caller's account, optionally narrowed by SecurityGroupRuleIds or -// awsfilters. MaxResults and NextToken are accepted but ignored. +// filters, one page at a time. func (s *VPCServiceImpl) DescribeSecurityGroupRules(ctx context.Context, input *ec2.DescribeSecurityGroupRulesInput, accountID string) (*ec2.DescribeSecurityGroupRulesOutput, error) { - requested := make(map[string]bool) - if input != nil { - for _, id := range input.SecurityGroupRuleIds { - if id == nil || *id == "" { - return nil, errors.New(awserrors.ErrorInvalidSecurityGroupRuleIdMalformed) - } - requested[*id] = true - } + if input == nil { + input = &ec2.DescribeSecurityGroupRulesInput{} + } + pageReq, err := describeSecurityGroupRulesPaging.Parse(input.MaxResults, input.NextToken, len(input.SecurityGroupRuleIds)) + if err != nil { + return nil, err } - var filters []*ec2.Filter - if input != nil { - filters = input.Filters + requested := make(map[string]bool) + for _, id := range input.SecurityGroupRuleIds { + if id == nil || *id == "" { + return nil, errors.New(awserrors.ErrorInvalidSecurityGroupRuleIdMalformed) + } + requested[*id] = true } - parsedFilters, err := awsfilters.ParseFilters(filters, describeSecurityGroupRulesValidFilters) + parsedFilters, err := awsfilters.ParseFilters(input.Filters, describeSecurityGroupRulesValidFilters) if err != nil { slog.WarnContext(ctx, "DescribeSecurityGroupRules: invalid filter", "err", err) return nil, err @@ -805,11 +885,18 @@ func (s *VPCServiceImpl) DescribeSecurityGroupRules(ctx context.Context, input * } } - if len(requested) > 0 { - for id := range requested { - if !emitted[id] { - return nil, errors.New(awserrors.ErrorInvalidSecurityGroupRuleIdNotFound) - } + for _, id := range input.SecurityGroupRuleIds { + if !emitted[*id] { + return nil, sgRuleNotFoundError(*id) + } + } + + rules, nextToken := paging.EC2Page(rules, func(r *ec2.SecurityGroupRule) string { return *r.SecurityGroupRuleId }, pageReq) + // AWS lists an untagged rule with empty Tags here, but omits them from + // the Authorize* responses that share the rule shape. + for _, r := range rules { + if r.Tags == nil { + r.Tags = []*ec2.Tag{} } } @@ -817,6 +904,7 @@ func (s *VPCServiceImpl) DescribeSecurityGroupRules(ctx context.Context, input * return &ec2.DescribeSecurityGroupRulesOutput{ SecurityGroupRules: rules, + NextToken: nextToken, }, nil } @@ -824,7 +912,7 @@ func (s *VPCServiceImpl) DescribeSecurityGroupRules(ctx context.Context, input * // single rule. func sgRuleMatchesFilters(record *SecurityGroupRecord, rule SGRule, filters map[string][]string) bool { for name, values := range filters { - if strings.HasPrefix(name, "tag:") { + if awsfilters.IsTagFilter(name) { continue } switch name { @@ -836,10 +924,6 @@ func sgRuleMatchesFilters(record *SecurityGroupRecord, rule SGRule, filters map[ if !awsfilters.MatchesAny(values, rule.RuleId) { return false } - case "tag-key": - if !sgRuleMatchesTagKey(rule.Tags, values) { - return false - } default: // Unreachable: ParseFilters rejects names not in the valid set. // Logs an error if a future map entry lacks a matching case. @@ -918,17 +1002,6 @@ func findSGRuleByID(rules []SGRule, ruleID string) *SGRule { return nil } -// sgRuleMatchesTagKey reports whether the rule carries any of the named tag -// keys, whatever their values. -func sgRuleMatchesTagKey(tags map[string]string, keys []string) bool { - for key := range tags { - if awsfilters.MatchesAny(keys, key) { - return true - } - } - return false -} - // applySGRuleTags stamps the security-group-rule TagSpecification onto every // rule an authorize call creates. AWS tags the rules the call creates and no // others, so this runs on the new rules alone. @@ -944,8 +1017,8 @@ func applySGRuleTags(rules []SGRule, specs []*ec2.TagSpecification) []SGRule { } // sgRuleToSecurityGroupRule flattens a stored SGRule into the AWS API shape. -// accountID supplies GroupOwnerId and ReferencedGroupInfo.UserId; VpcId is -// derived from the parent record (same-VPC references are enforced on write). +// accountID supplies GroupOwnerId and ReferencedGroupInfo.UserId. AWS names +// no VpcId for a same-VPC reference, the only kind Spinifex allows. func sgRuleToSecurityGroupRule(record *SecurityGroupRecord, rule SGRule, isEgress bool, accountID string) *ec2.SecurityGroupRule { // An all-protocol rule has no ports to report. AWS answers -1 for both; // reporting the stored zeroes offers a caller two ports it never sent. @@ -976,7 +1049,6 @@ func sgRuleToSecurityGroupRule(record *SecurityGroupRecord, rule SGRule, isEgres out.ReferencedGroupInfo = &ec2.ReferencedSecurityGroup{ GroupId: aws.String(rule.SourceSG), UserId: aws.String(accountID), - VpcId: aws.String(record.VpcId), } } return out @@ -993,7 +1065,7 @@ func (s *VPCServiceImpl) AuthorizeSecurityGroupIngress(ctx context.Context, inpu entry, err := s.sgKV.Get(ctx, key) if err != nil { - return nil, errors.New(awserrors.ErrorInvalidGroupNotFound) + return nil, sgNotFoundError(groupId) } var record SecurityGroupRecord @@ -1008,6 +1080,9 @@ func (s *VPCServiceImpl) AuthorizeSecurityGroupIngress(ctx context.Context, inpu newRules, err := ipPermissionsToSGRules(input.IpPermissions, sgParseAuthorize) if err != nil { slog.WarnContext(ctx, "AuthorizeSecurityGroupIngress: invalid rule", "groupId", groupId, "err", err) + if _, ok := awserrors.ResolveErrorCode(err); ok { + return nil, err + } return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "%s", err) } newRules = applySGRuleTags(newRules, input.TagSpecifications) @@ -1020,7 +1095,7 @@ func (s *VPCServiceImpl) AuthorizeSecurityGroupIngress(ctx context.Context, inpu } for _, nr := range newRules { if _, ok := existing[sgRuleKey(nr)]; ok { - return nil, errors.New(awserrors.ErrorInvalidPermissionDuplicate) + return nil, sgRuleDuplicateError(nr) } } if len(record.IngressRules)+len(newRules) > maxRulesPerSGSide { @@ -1073,7 +1148,7 @@ func (s *VPCServiceImpl) AuthorizeSecurityGroupEgress(ctx context.Context, input entry, err := s.sgKV.Get(ctx, key) if err != nil { - return nil, errors.New(awserrors.ErrorInvalidGroupNotFound) + return nil, sgNotFoundError(groupId) } var record SecurityGroupRecord @@ -1088,6 +1163,9 @@ func (s *VPCServiceImpl) AuthorizeSecurityGroupEgress(ctx context.Context, input newRules, err := ipPermissionsToSGRules(input.IpPermissions, sgParseAuthorize) if err != nil { slog.WarnContext(ctx, "AuthorizeSecurityGroupEgress: invalid rule", "groupId", groupId, "err", err) + if _, ok := awserrors.ResolveErrorCode(err); ok { + return nil, err + } return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "%s", err) } newRules = applySGRuleTags(newRules, input.TagSpecifications) @@ -1100,7 +1178,7 @@ func (s *VPCServiceImpl) AuthorizeSecurityGroupEgress(ctx context.Context, input } for _, nr := range newRules { if _, ok := existing[sgRuleKey(nr)]; ok { - return nil, errors.New(awserrors.ErrorInvalidPermissionDuplicate) + return nil, sgRuleDuplicateError(nr) } } if len(record.EgressRules)+len(newRules) > maxRulesPerSGSide { @@ -1153,7 +1231,7 @@ func (s *VPCServiceImpl) RevokeSecurityGroupIngress(ctx context.Context, input * entry, err := s.sgKV.Get(ctx, key) if err != nil { - return nil, errors.New(awserrors.ErrorInvalidGroupNotFound) + return nil, sgNotFoundError(groupId) } var record SecurityGroupRecord @@ -1220,7 +1298,7 @@ func (s *VPCServiceImpl) RevokeSecurityGroupEgress(ctx context.Context, input *e entry, err := s.sgKV.Get(ctx, key) if err != nil { - return nil, errors.New(awserrors.ErrorInvalidGroupNotFound) + return nil, sgNotFoundError(groupId) } var record SecurityGroupRecord @@ -1341,7 +1419,7 @@ func (s *VPCServiceImpl) updateSGRuleDescriptions(ctx context.Context, accountID key := utils.AccountKey(accountID, req.groupId) entry, err := s.sgKV.Get(ctx, key) if err != nil { - return errors.New(awserrors.ErrorInvalidGroupNotFound) + return sgNotFoundError(req.groupId) } var record SecurityGroupRecord @@ -1417,7 +1495,7 @@ func applySGRuleDescriptions(rules []SGRule, descriptions []*ec2.SecurityGroupRu // absent from this record and so is not-found, never a silent no-op. i, ok := byID[id] if !ok { - return nil, errors.New(awserrors.ErrorInvalidSecurityGroupRuleIdNotFound) + return nil, sgRuleNotFoundError(id) } if d.Description != nil { out[i].Description = *d.Description @@ -1534,7 +1612,7 @@ func (s *VPCServiceImpl) ModifySecurityGroupRules(ctx context.Context, input *ec entry, err := s.sgKV.Get(ctx, key) if err != nil { - return nil, errors.New(awserrors.ErrorInvalidGroupNotFound) + return nil, sgNotFoundError(groupId) } var record SecurityGroupRecord @@ -1677,7 +1755,7 @@ func checkSGRuleModificationDuplicates(record *SecurityGroupRecord, mods []sgRul for i, r := range rules { if !replaced[i] && newKeys[sgRuleKey(r)] { - return errors.New(awserrors.ErrorInvalidPermissionDuplicate) + return sgRuleDuplicateError(r) } } return nil @@ -1708,6 +1786,9 @@ func sgRuleRequestToSGRule(req *ec2.SecurityGroupRuleRequest) (SGRule, error) { "Invalid value for portRange. Must specify both from and to ports with TCP/UDP.") } r.FromPort, r.ToPort = *req.FromPort, *req.ToPort + if err := validateSGRulePorts(proto, r.FromPort, r.ToPort); err != nil { + return SGRule{}, err + } case allProtocols: // Stored as 0/0, the form authorize stores for the no-ports case, so the // rule's sgRuleKey matches Terraform's and the default egress rule's. @@ -1782,8 +1863,11 @@ func sgRuleIDLookupError(id string) error { if SGRuleIDIsMalformed(id) { return awserrors.Errorf(awserrors.ErrorInvalidSecurityGroupRuleIdMalformed, "Invalid id: %q", id) } - return awserrors.Errorf(awserrors.ErrorInvalidSecurityGroupRuleIdNotFound, - "The security group rule ID '%s' does not exist", id) + return sgRuleNotFoundError(id) +} + +func sgRuleNotFoundError(id string) error { + return awserrors.IDNotFound(awserrors.ErrorInvalidSecurityGroupRuleIdNotFound, "security group rule", id) } // sgRecordToEC2 converts a SecurityGroupRecord to an EC2 SecurityGroup. @@ -1794,8 +1878,8 @@ func (s *VPCServiceImpl) sgRecordToEC2(record *SecurityGroupRecord, accountID st Description: aws.String(record.Description), VpcId: aws.String(record.VpcId), OwnerId: aws.String(accountID), - IpPermissions: sgRulesToIpPermissions(record.IngressRules), - IpPermissionsEgress: sgRulesToIpPermissions(record.EgressRules), + IpPermissions: sgRulesToIpPermissions(record.IngressRules, accountID), + IpPermissionsEgress: sgRulesToIpPermissions(record.EgressRules, accountID), } sg.Tags = utils.MapToEC2Tags(record.Tags) @@ -1838,6 +1922,11 @@ func ipPermissionsToSGRules(perms []*ec2.IpPermission, mode sgParseMode) ([]SGRu if perm.ToPort != nil { toPort = *perm.ToPort } + if mode == sgParseAuthorize { + if err := validateSGRulePorts(proto, fromPort, toPort); err != nil { + return nil, err + } + } appended := false for _, ipRange := range perm.IpRanges { @@ -1902,7 +1991,8 @@ func ipPermissionsToSGRules(perms []*ec2.IpPermission, mode sgParseMode) ([]SGRu } // sgRulesToIpPermissions converts SGRule slice to AWS IpPermission slice. -func sgRulesToIpPermissions(rules []SGRule) []*ec2.IpPermission { +// accountID owns every referenced group, since references stay in one VPC. +func sgRulesToIpPermissions(rules []SGRule, accountID string) []*ec2.IpPermission { // Group rules by protocol+port range type permKey struct { IpProtocol string @@ -1915,10 +2005,12 @@ func sgRulesToIpPermissions(rules []SGRule) []*ec2.IpPermission { key := permKey{IpProtocol: rule.IpProtocol, FromPort: rule.FromPort, ToPort: rule.ToPort} perm, exists := grouped[key] if !exists { - perm = &ec2.IpPermission{ - IpProtocol: aws.String(rule.IpProtocol), - FromPort: aws.Int64(rule.FromPort), - ToPort: aws.Int64(rule.ToPort), + perm = &ec2.IpPermission{IpProtocol: aws.String(rule.IpProtocol)} + // AWS omits both ports on an all-protocol permission here, unlike the + // -1/-1 that DescribeSecurityGroupRules reports for the same rule. + if rule.IpProtocol != allProtocols { + perm.FromPort = aws.Int64(rule.FromPort) + perm.ToPort = aws.Int64(rule.ToPort) } grouped[key] = perm } @@ -1938,7 +2030,7 @@ func sgRulesToIpPermissions(rules []SGRule) []*ec2.IpPermission { perm.Ipv6Ranges = append(perm.Ipv6Ranges, ipRange) } if rule.SourceSG != "" { - pair := &ec2.UserIdGroupPair{GroupId: aws.String(rule.SourceSG)} + pair := &ec2.UserIdGroupPair{GroupId: aws.String(rule.SourceSG), UserId: aws.String(accountID)} if rule.Description != "" { pair.Description = aws.String(rule.Description) } @@ -2007,7 +2099,11 @@ func removeSGRules(existing, toRemove []SGRule) []SGRule { } // sgRuleKey returns a string key for deduplication/matching of SG rules. +// An all-protocol rule has no ports, so its stored or requested ports are ignored. func sgRuleKey(r SGRule) string { + if r.IpProtocol == allProtocols { + r.FromPort, r.ToPort = 0, 0 + } return fmt.Sprintf("%s:%d:%d:%s:%s:%s", r.IpProtocol, r.FromPort, r.ToPort, r.CidrIp, r.CidrIpv6, r.SourceSG) } @@ -2036,7 +2132,7 @@ func resolveRuleIDsToRemove(existing []SGRule, ruleIDs []*string) ([]SGRule, err } r, ok := byID[*idp] if !ok { - return nil, errors.New(awserrors.ErrorInvalidSecurityGroupRuleIdNotFound) + return nil, sgRuleNotFoundError(*idp) } out = append(out, r) } diff --git a/spinifex/handlers/ec2/vpc/security_group_modify_test.go b/spinifex/handlers/ec2/vpc/security_group_modify_test.go index 5a3e353ab..c735a7988 100644 --- a/spinifex/handlers/ec2/vpc/security_group_modify_test.go +++ b/spinifex/handlers/ec2/vpc/security_group_modify_test.go @@ -7,6 +7,7 @@ package handlers_ec2_vpc import ( "context" "encoding/json" + "fmt" "sync" "testing" @@ -27,6 +28,14 @@ func requireAWSCode(t *testing.T, err error, code string) { assert.Equal(t, code, got, "error: %v", err) } +func requireAWSError(t *testing.T, err error, code, message string) { + t.Helper() + got, msg, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok, "error %v carries no AWS code", err) + assert.Equal(t, code, got) + assert.Equal(t, message, msg) +} + func modifyRules(svc *VPCServiceImpl, sgID string, updates ...*ec2.SecurityGroupRuleUpdate) error { _, err := svc.ModifySecurityGroupRules(context.Background(), &ec2.ModifySecurityGroupRulesInput{ GroupId: aws.String(sgID), @@ -631,3 +640,85 @@ func TestModifySecurityGroupRules_ConcurrentAuthorize(t *testing.T) { assert.Equal(t, modErr == nil, modified, "the modify succeeded iff its effect is stored") assert.Equal(t, authErr == nil, authorized, "the authorize succeeded iff its effect is stored") } + +func requireInvertedPortRange(t *testing.T, err error, from, to int64) { + t.Helper() + require.Error(t, err) + code, message, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok, "error %q carries no AWS code", err) + assert.Equal(t, awserrors.ErrorInvalidParameterValue, code) + assert.Equal(t, fmt.Sprintf("Invalid TCP/UDP port range(%d:%d)", from, to), message) +} + +func TestAuthorizeSecurityGroupRules_RejectsInvertedPortRange(t *testing.T) { + t.Parallel() + svc := setupTestVPCService(t) + vpcID := createTestVPC(t, svc, "10.0.0.0/16") + sgID := createTestSG(t, svc, vpcID, "inverted-ports") + before := storedSGRecord(t, svc, sgID) + + perm := func(proto string) []*ec2.IpPermission { + return []*ec2.IpPermission{{ + IpProtocol: aws.String(proto), FromPort: aws.Int64(100), ToPort: aws.Int64(50), + IpRanges: []*ec2.IpRange{{CidrIp: aws.String("10.0.0.0/24")}}, + }} + } + _, err := svc.AuthorizeSecurityGroupIngress(context.Background(), &ec2.AuthorizeSecurityGroupIngressInput{ + GroupId: aws.String(sgID), IpPermissions: perm("tcp"), + }, testAccountID) + requireInvertedPortRange(t, err, 100, 50) + + _, err = svc.AuthorizeSecurityGroupEgress(context.Background(), &ec2.AuthorizeSecurityGroupEgressInput{ + GroupId: aws.String(sgID), IpPermissions: perm("udp"), + }, testAccountID) + requireInvertedPortRange(t, err, 100, 50) + + assert.Equal(t, before, storedSGRecord(t, svc, sgID), "a rejected authorize may store nothing") +} + +func TestModifySecurityGroupRules_RejectsInvertedPortRange(t *testing.T) { + t.Parallel() + svc := setupTestVPCService(t) + vpcID := createTestVPC(t, svc, "10.0.0.0/16") + sgID := createTestSG(t, svc, vpcID, "mod-inverted-ports") + ruleID := authorizeIngressTCP(t, svc, sgID, 80, "10.0.0.0/24") + + req := tcpCIDRRequest(100, "10.0.0.0/24") + req.ToPort = aws.Int64(50) + requireInvertedPortRange(t, modifyRules(svc, sgID, ruleUpdate(ruleID, req)), 100, 50) + assert.Equal(t, int64(80), storedSGRule(t, svc, sgID, ruleID).FromPort) +} + +// TestRevokeSecurityGroupIngress_InvertedPortRangeStillRevocable covers a rule +// stored before the range check existed: revoking it by its ports must work. +func TestRevokeSecurityGroupIngress_InvertedPortRangeStillRevocable(t *testing.T) { + t.Parallel() + svc := setupTestVPCService(t) + vpcID := createTestVPC(t, svc, "10.0.0.0/16") + sgID := createTestSG(t, svc, vpcID, "stale-inverted-ports") + + key := utils.AccountKey(testAccountID, sgID) + var rec SecurityGroupRecord + require.NoError(t, json.Unmarshal(storedSGRecord(t, svc, sgID), &rec)) + rec.IngressRules = append(rec.IngressRules, SGRule{ + RuleId: "sgr-0123456789abcdef0", IpProtocol: "tcp", FromPort: 100, ToPort: 50, CidrIp: "10.0.0.0/24", + }) + data, err := json.Marshal(rec) + require.NoError(t, err) + _, err = svc.sgKV.Put(t.Context(), key, data) + require.NoError(t, err) + + _, err = svc.RevokeSecurityGroupIngress(context.Background(), &ec2.RevokeSecurityGroupIngressInput{ + GroupId: aws.String(sgID), + IpPermissions: []*ec2.IpPermission{{ + IpProtocol: aws.String("tcp"), FromPort: aws.Int64(100), ToPort: aws.Int64(50), + IpRanges: []*ec2.IpRange{{CidrIp: aws.String("10.0.0.0/24")}}, + }}, + }, testAccountID) + require.NoError(t, err) + + require.NoError(t, json.Unmarshal(storedSGRecord(t, svc, sgID), &rec)) + for _, r := range rec.IngressRules { + assert.NotEqual(t, "sgr-0123456789abcdef0", r.RuleId) + } +} diff --git a/spinifex/handlers/ec2/vpc/security_group_test.go b/spinifex/handlers/ec2/vpc/security_group_test.go index 86a8d8dcb..2b6ce671e 100644 --- a/spinifex/handlers/ec2/vpc/security_group_test.go +++ b/spinifex/handlers/ec2/vpc/security_group_test.go @@ -3,6 +3,7 @@ package handlers_ec2_vpc import ( "context" "encoding/json" + "fmt" "strings" "testing" "time" @@ -44,6 +45,7 @@ func TestCreateSecurityGroup_Success(t *testing.T) { }, testAccountID) require.NoError(t, err) assert.NotEmpty(t, *out.GroupId) + assert.Nil(t, out.Tags, "AWS omits Tags from an untagged CreateSecurityGroup") } func TestCreateSecurityGroup_MissingGroupName(t *testing.T) { @@ -93,8 +95,7 @@ func TestCreateSecurityGroup_DuplicateName(t *testing.T) { GroupName: aws.String("dup-sg"), VpcId: aws.String(vpcID), }, testAccountID) - assert.Error(t, err) - assert.Contains(t, err.Error(), "InvalidGroup.Duplicate") + requireAWSError(t, err, awserrors.ErrorInvalidGroupDuplicate, "The security group 'dup-sg' already exists for VPC '"+vpcID+"'") } // --- DeleteSecurityGroup --- @@ -132,8 +133,8 @@ func TestDescribeSecurityGroups_All(t *testing.T) { t.Parallel() svc := setupTestVPCService(t) vpcID := createTestVPC(t, svc, "10.0.0.0/16") - createTestSG(t, svc, vpcID, "sg-a") - createTestSG(t, svc, vpcID, "sg-b") + createTestSG(t, svc, vpcID, "grp-a") + createTestSG(t, svc, vpcID, "grp-b") desc, err := svc.DescribeSecurityGroups(context.Background(), &ec2.DescribeSecurityGroupsInput{}, testAccountID) require.NoError(t, err) @@ -178,10 +179,33 @@ func TestDescribeSecurityGroups_NotFound(t *testing.T) { svc := setupTestVPCService(t) _, err := svc.DescribeSecurityGroups(context.Background(), &ec2.DescribeSecurityGroupsInput{ - GroupIds: []*string{aws.String("sg-nonexistent")}, + GroupIds: []*string{aws.String("sg-0000000000000dead")}, }, testAccountID) - assert.Error(t, err) - assert.Contains(t, err.Error(), "InvalidGroup.NotFound") + requireAWSError(t, err, awserrors.ErrorInvalidGroupNotFound, "The security group 'sg-0000000000000dead' does not exist") +} + +// The shapes below are the answers AWS gave for each ID; a 17-digit ID is +// always treated as unknown here, though AWS decodes some of those as Malformed. +func TestDescribeSecurityGroups_MalformedID(t *testing.T) { + t.Parallel() + svc := setupTestVPCService(t) + describe := func(ids ...string) error { + _, err := svc.DescribeSecurityGroups(context.Background(), &ec2.DescribeSecurityGroupsInput{ + GroupIds: aws.StringSlice(ids), + }, testAccountID) + return err + } + + for _, id := range []string{"sg-xyz", "sg-", "sg-0AAAAAAAAAAAAAAA1", "sg-000000001", "sg-0000000000000001", "sg-000000000000000001"} { + requireAWSError(t, describe(id), awserrors.ErrorInvalidGroupIdMalformed, fmt.Sprintf("Invalid id: %q", id)) + } + requireAWSError(t, describe("foo"), awserrors.ErrorInvalidGroupIdMalformed, `Invalid id: "foo" (expecting "sg-...")`) + for _, id := range []string{"sg-a", "sg-0aaaaaaa", "sg-01234567890abcdef"} { + requireAWSCode(t, describe(id), awserrors.ErrorInvalidGroupNotFound) + } + + // A malformed ID wins over an unknown one, wherever it sits. + requireAWSCode(t, describe("sg-0aaaaaaa", "sg-xyz"), awserrors.ErrorInvalidGroupIdMalformed) } // --- AuthorizeSecurityGroupIngress --- @@ -405,6 +429,34 @@ func TestRevokeSecurityGroupEgress_Success(t *testing.T) { require.NoError(t, err) } +// TestRevokeSecurityGroupEgress_AllProtocolIgnoresPorts: DescribeSecurityGroups +// reports no ports on an all-protocol rule, so a caller revoking it may send +// -1/-1 (as the UI does) and must still match the stored rule. +func TestRevokeSecurityGroupEgress_AllProtocolIgnoresPorts(t *testing.T) { + t.Parallel() + svc := setupTestVPCService(t) + vpcID := createTestVPC(t, svc, "10.0.0.0/16") + sgID := createTestSG(t, svc, vpcID, "revoke-egress-ports-sg") + + _, err := svc.RevokeSecurityGroupEgress(context.Background(), &ec2.RevokeSecurityGroupEgressInput{ + GroupId: aws.String(sgID), + IpPermissions: []*ec2.IpPermission{{ + IpProtocol: aws.String("-1"), + FromPort: aws.Int64(-1), + ToPort: aws.Int64(-1), + IpRanges: []*ec2.IpRange{{CidrIp: aws.String("0.0.0.0/0")}}, + }}, + }, testAccountID) + require.NoError(t, err) + + desc, err := svc.DescribeSecurityGroups(context.Background(), &ec2.DescribeSecurityGroupsInput{ + GroupIds: []*string{aws.String(sgID)}, + }, testAccountID) + require.NoError(t, err) + require.Len(t, desc.SecurityGroups, 1) + assert.Empty(t, desc.SecurityGroups[0].IpPermissionsEgress) +} + func TestRevokeSecurityGroupEgress_NotFound(t *testing.T) { t.Parallel() svc := setupTestVPCService(t) @@ -583,29 +635,25 @@ func ingressRuleIDReferencing(t *testing.T, svc *VPCServiceImpl, sgID, srcSG str return "" } -// TestDeleteSecurityGroup_DependencyViolationNamesENI pins that the refusal -// identifies the attached ENI. A bare code forces the blocking id to be -// recovered by cross-referencing the whole ENI table after the fact. -func TestDeleteSecurityGroup_DependencyViolationNamesENI(t *testing.T) { +// TestDeleteSecurityGroup_DependencyViolationForENI pins AWS's refusal for a +// group still attached to an interface, which names the group, not the ENI. +func TestDeleteSecurityGroup_DependencyViolationForENI(t *testing.T) { t.Parallel() svc := setupTestVPCService(t) vpcID := createTestVPC(t, svc, "10.0.0.0/16") subnetID := createTestSubnet(t, svc, vpcID, "10.0.1.0/24") sgID := createTestSG(t, svc, vpcID, "attached-sg") - eniID := createAutoENI(t, svc, subnetID, "i-holder", []string{sgID}, time.Hour) + createAutoENI(t, svc, subnetID, "i-holder", []string{sgID}, time.Hour) _, err := svc.DeleteSecurityGroup(context.Background(), &ec2.DeleteSecurityGroupInput{ GroupId: aws.String(sgID), }, testAccountID) - require.Error(t, err) - code, _ := awserrors.ResolveErrorCode(err) - assert.Equal(t, awserrors.ErrorDependencyViolation, code) - assert.ErrorContains(t, err, eniID, "the refusal must name the blocking ENI") + requireAWSError(t, err, awserrors.ErrorDependencyViolation, "resource "+sgID+" has a dependent object") } -// TestDeleteSecurityGroup_DependencyViolationNamesReferencingSG pins the same -// for the SourceSG case: the group holding the rule is named, not just the code. -func TestDeleteSecurityGroup_DependencyViolationNamesReferencingSG(t *testing.T) { +// TestDeleteSecurityGroup_DependencyViolationForReferencingSG pins the same +// answer for a group another group's rule references. +func TestDeleteSecurityGroup_DependencyViolationForReferencingSG(t *testing.T) { t.Parallel() svc := setupTestVPCService(t) vpcID := createTestVPC(t, svc, "10.0.0.0/16") @@ -617,10 +665,7 @@ func TestDeleteSecurityGroup_DependencyViolationNamesReferencingSG(t *testing.T) _, err := svc.DeleteSecurityGroup(context.Background(), &ec2.DeleteSecurityGroupInput{ GroupId: aws.String(targetSG), }, testAccountID) - require.Error(t, err) - code, _ := awserrors.ResolveErrorCode(err) - assert.Equal(t, awserrors.ErrorDependencyViolation, code) - assert.ErrorContains(t, err, holderSG, "the refusal must name the referencing security group") + requireAWSError(t, err, awserrors.ErrorDependencyViolation, "resource "+targetSG+" has a dependent object") } // TestRevokeSecurityGroupIngress_ByRuleID_BreaksMutualReferenceTeardown @@ -879,7 +924,7 @@ func TestSGRulesToIpPermissions_IPv6RoundTrip(t *testing.T) { perms := sgRulesToIpPermissions([]SGRule{ {IpProtocol: "-1", CidrIp: "0.0.0.0/0"}, {IpProtocol: "-1", CidrIpv6: "::/0"}, - }) + }, testAccountID) require.Len(t, perms, 1) require.Len(t, perms[0].IpRanges, 1) require.Len(t, perms[0].Ipv6Ranges, 1) @@ -894,7 +939,7 @@ func TestSGRulesToIpPermissions_Conversion(t *testing.T) { {IpProtocol: "-1", SourceSG: "sg-other"}, } - perms := sgRulesToIpPermissions(rules) + perms := sgRulesToIpPermissions(rules, testAccountID) require.Len(t, perms, 2) // Order is non-deterministic (map-based), find each by protocol @@ -913,8 +958,11 @@ func TestSGRulesToIpPermissions_Conversion(t *testing.T) { assert.Len(t, tcpPerm.IpRanges, 1) require.NotNil(t, allPerm, "should have all-traffic permission") + assert.Nil(t, allPerm.FromPort, "AWS omits ports on an all-protocol permission") + assert.Nil(t, allPerm.ToPort) assert.Len(t, allPerm.UserIdGroupPairs, 1) assert.Equal(t, "sg-other", *allPerm.UserIdGroupPairs[0].GroupId) + assert.Equal(t, testAccountID, aws.StringValue(allPerm.UserIdGroupPairs[0].UserId)) } func TestSGRuleKey(t *testing.T) { @@ -1272,8 +1320,8 @@ func TestDescribeSecurityGroups_FilterByVpcId(t *testing.T) { svc := setupTestVPCService(t) vpc1 := createTestVPC(t, svc, "10.0.0.0/16") vpc2 := createTestVPC(t, svc, "172.16.0.0/16") - createTestSG(t, svc, vpc1, "sg-in-vpc1") - createTestSG(t, svc, vpc2, "sg-in-vpc2") + createTestSG(t, svc, vpc1, "grp-in-vpc1") + createTestSG(t, svc, vpc2, "grp-in-vpc2") out, err := svc.DescribeSecurityGroups(context.Background(), &ec2.DescribeSecurityGroupsInput{ Filters: []*ec2.Filter{ @@ -1325,13 +1373,13 @@ func TestDescribeSecurityGroups_FilterMultipleValues_OR(t *testing.T) { t.Parallel() svc := setupTestVPCService(t) vpcID := createTestVPC(t, svc, "10.0.0.0/16") - createTestSG(t, svc, vpcID, "sg-alpha") - createTestSG(t, svc, vpcID, "sg-beta") - createTestSG(t, svc, vpcID, "sg-gamma") + createTestSG(t, svc, vpcID, "grp-alpha") + createTestSG(t, svc, vpcID, "grp-beta") + createTestSG(t, svc, vpcID, "grp-gamma") out, err := svc.DescribeSecurityGroups(context.Background(), &ec2.DescribeSecurityGroupsInput{ Filters: []*ec2.Filter{ - {Name: aws.String("group-name"), Values: []*string{aws.String("sg-alpha"), aws.String("sg-gamma")}}, + {Name: aws.String("group-name"), Values: []*string{aws.String("grp-alpha"), aws.String("grp-gamma")}}, }, }, testAccountID) require.NoError(t, err) @@ -1403,6 +1451,9 @@ func TestDescribeSecurityGroups_FilterByTag(t *testing.T) { }, }, testAccountID) require.NoError(t, err) + require.Len(t, out.Tags, 1, "CreateSecurityGroup echoes the requested tags") + assert.Equal(t, "Env", aws.StringValue(out.Tags[0].Key)) + assert.Equal(t, "prod", aws.StringValue(out.Tags[0].Value)) createTestSG(t, svc, vpcID, "untagged-sg") @@ -1416,6 +1467,39 @@ func TestDescribeSecurityGroups_FilterByTag(t *testing.T) { assert.Equal(t, *out.GroupId, *desc.SecurityGroups[0].GroupId) } +func TestDescribeSecurityGroups_FilterByTagKeyAndTagValue(t *testing.T) { + t.Parallel() + svc := setupTestVPCService(t) + vpcID := createTestVPC(t, svc, "10.0.0.0/16") + + out, err := svc.CreateSecurityGroup(context.Background(), &ec2.CreateSecurityGroupInput{ + GroupName: aws.String("tagged-sg"), + Description: aws.String("tagged"), + VpcId: aws.String(vpcID), + TagSpecifications: []*ec2.TagSpecification{{ + ResourceType: aws.String("security-group"), + Tags: []*ec2.Tag{{Key: aws.String("Env"), Value: aws.String("prod")}}, + }}, + }, testAccountID) + require.NoError(t, err) + createTestSG(t, svc, vpcID, "untagged-sg") + + for _, tc := range []struct{ name, value string }{{"tag-key", "Env"}, {"tag-value", "prod"}} { + desc, err := svc.DescribeSecurityGroups(context.Background(), &ec2.DescribeSecurityGroupsInput{ + Filters: []*ec2.Filter{{Name: aws.String(tc.name), Values: []*string{aws.String(tc.value)}}}, + }, testAccountID) + require.NoError(t, err, tc.name) + require.Len(t, desc.SecurityGroups, 1, tc.name) + assert.Equal(t, *out.GroupId, *desc.SecurityGroups[0].GroupId, tc.name) + + desc, err = svc.DescribeSecurityGroups(context.Background(), &ec2.DescribeSecurityGroupsInput{ + Filters: []*ec2.Filter{{Name: aws.String(tc.name), Values: []*string{aws.String("zz-awsdiff-none")}}}, + }, testAccountID) + require.NoError(t, err, tc.name) + assert.Empty(t, desc.SecurityGroups, tc.name) + } +} + func TestDescribeSecurityGroups_FilterNoResults(t *testing.T) { t.Parallel() svc := setupTestVPCService(t) @@ -1488,8 +1572,31 @@ func TestCreateSecurityGroup_RejectsReservedDefaultName(t *testing.T) { Description: aws.String("user-supplied"), VpcId: aws.String(vpcID), }, testAccountID) - require.Error(t, err) - assert.Contains(t, err.Error(), "InvalidGroup.Reserved") + requireAWSError(t, err, awserrors.ErrorInvalidParameterValue, "Cannot use reserved security group name: default") +} + +func TestCreateSecurityGroup_RejectsSgPrefixedName(t *testing.T) { + t.Parallel() + svc := setupTestVPCService(t) + vpcID := createTestVPC(t, svc, "10.0.0.0/16") + + _, err := svc.CreateSecurityGroup(context.Background(), &ec2.CreateSecurityGroupInput{ + GroupName: aws.String("sg-foo"), + Description: aws.String("d"), + VpcId: aws.String(vpcID), + }, testAccountID) + code, msg, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok, "error %v carries no AWS code", err) + assert.Equal(t, awserrors.ErrorInvalidParameterValue, code) + assert.Equal(t, "Value (sg-foo) for parameter GroupName is invalid. Group names may not be in the format sg-*.", msg) + + // AWS reports a missing VPC ahead of the name. + _, err = svc.CreateSecurityGroup(context.Background(), &ec2.CreateSecurityGroupInput{ + GroupName: aws.String("sg-foo"), + Description: aws.String("d"), + VpcId: aws.String("vpc-nonexistent"), + }, testAccountID) + assert.ErrorContains(t, err, awserrors.ErrorInvalidVpcIDNotFound) } func TestDeleteSecurityGroup_RejectsDefault(t *testing.T) { @@ -1501,8 +1608,12 @@ func TestDeleteSecurityGroup_RejectsDefault(t *testing.T) { _, err := svc.DeleteSecurityGroup(context.Background(), &ec2.DeleteSecurityGroupInput{ GroupId: aws.String(sgID), }, testAccountID) - require.Error(t, err) - assert.Contains(t, err.Error(), "CannotDelete") + requireAWSError(t, err, awserrors.ErrorCannotDelete, `the specified group: "`+sgID+`" name: "default" cannot be deleted by a user`) + + _, err = svc.DeleteSecurityGroup(context.Background(), &ec2.DeleteSecurityGroupInput{ + GroupId: aws.String("sg-0000000000000dead"), + }, testAccountID) + requireAWSError(t, err, awserrors.ErrorInvalidGroupNotFound, "The security group 'sg-0000000000000dead' does not exist") } func TestDeleteSecurityGroup_RejectsAttachedToENI(t *testing.T) { @@ -1553,14 +1664,10 @@ func TestDeleteVpc_BlocksOnNonDefaultSG(t *testing.T) { t.Parallel() svc := setupTestVPCService(t) vpcID := createTestVPC(t, svc, "10.0.0.0/16") - sgID := createTestSG(t, svc, vpcID, "user-sg") + createTestSG(t, svc, vpcID, "user-sg") _, err := svc.DeleteVpc(context.Background(), &ec2.DeleteVpcInput{VpcId: aws.String(vpcID)}, testAccountID) - require.Error(t, err) - assert.Contains(t, err.Error(), "DependencyViolation") - // The message must name the blocking security group so the caller knows - // what to delete first, not just that something is blocking. - assert.Contains(t, err.Error(), sgID) + requireAWSError(t, err, awserrors.ErrorDependencyViolation, "The vpc '"+vpcID+"' has dependencies and cannot be deleted.") } func TestDeleteVpc_CascadesDefaultSG(t *testing.T) { @@ -2200,7 +2307,7 @@ func TestDescribeSecurityGroupRules_ReferencedGroupInfo(t *testing.T) { dstSG := createTestSG(t, svc, vpcID, "dest") proto := "tcp" - _, err := svc.AuthorizeSecurityGroupIngress(context.Background(), &ec2.AuthorizeSecurityGroupIngressInput{ + authOut, err := svc.AuthorizeSecurityGroupIngress(context.Background(), &ec2.AuthorizeSecurityGroupIngressInput{ GroupId: aws.String(dstSG), IpPermissions: []*ec2.IpPermission{{ IpProtocol: &proto, @@ -2210,6 +2317,8 @@ func TestDescribeSecurityGroupRules_ReferencedGroupInfo(t *testing.T) { }}, }, testAccountID) require.NoError(t, err) + require.Len(t, authOut.SecurityGroupRules, 1) + assert.Nil(t, authOut.SecurityGroupRules[0].Tags, "Authorize* omits Tags on an untagged rule") out, err := svc.DescribeSecurityGroupRules(context.Background(), &ec2.DescribeSecurityGroupRulesInput{ Filters: []*ec2.Filter{{Name: aws.String("group-id"), Values: []*string{aws.String(dstSG)}}}, @@ -2221,11 +2330,12 @@ func TestDescribeSecurityGroupRules_ReferencedGroupInfo(t *testing.T) { if r.ReferencedGroupInfo != nil { require.NotNil(t, r.ReferencedGroupInfo.GroupId) require.NotNil(t, r.ReferencedGroupInfo.UserId) - require.NotNil(t, r.ReferencedGroupInfo.VpcId) assert.Equal(t, srcSG, *r.ReferencedGroupInfo.GroupId) assert.Equal(t, testAccountID, *r.ReferencedGroupInfo.UserId) - assert.Equal(t, vpcID, *r.ReferencedGroupInfo.VpcId) + assert.Nil(t, r.ReferencedGroupInfo.VpcId, "AWS names no VpcId for a same-VPC reference") assert.Nil(t, r.CidrIpv4) + assert.NotNil(t, r.Tags, "DescribeSecurityGroupRules lists an untagged rule with empty Tags") + assert.Empty(t, r.Tags) found = true } } @@ -2285,8 +2395,43 @@ func TestAuthorizeSecurityGroupIngress_RejectsContentDuplicate(t *testing.T) { GroupId: aws.String(sgID), IpPermissions: []*ec2.IpPermission{perm}, }, testAccountID) - require.Error(t, err) - assert.Contains(t, err.Error(), "InvalidPermission.Duplicate") + requireAWSError(t, err, awserrors.ErrorInvalidPermissionDuplicate, + `the specified rule "peer: 10.0.0.0/24, TCP, from port: 22, to port: 22, ALLOW" already exists`) + + // The default egress rule, re-added, is described without ports + _, err = svc.AuthorizeSecurityGroupEgress(context.Background(), &ec2.AuthorizeSecurityGroupEgressInput{ + GroupId: aws.String(sgID), + IpPermissions: []*ec2.IpPermission{{ + IpProtocol: aws.String("-1"), + IpRanges: []*ec2.IpRange{{CidrIp: aws.String("0.0.0.0/0")}}, + }}, + }, testAccountID) + requireAWSError(t, err, awserrors.ErrorInvalidPermissionDuplicate, `the specified rule "peer: 0.0.0.0/0, ALL, ALLOW" already exists`) + + icmp := &ec2.IpPermission{ + IpProtocol: aws.String("icmp"), FromPort: aws.Int64(8), ToPort: aws.Int64(-1), + IpRanges: []*ec2.IpRange{{CidrIp: aws.String("10.0.0.0/24")}}, + } + for range 2 { + _, err = svc.AuthorizeSecurityGroupIngress(context.Background(), &ec2.AuthorizeSecurityGroupIngressInput{ + GroupId: aws.String(sgID), IpPermissions: []*ec2.IpPermission{icmp}, + }, testAccountID) + } + requireAWSError(t, err, awserrors.ErrorInvalidPermissionDuplicate, `the specified rule "peer: 10.0.0.0/24, ICMP, type: 8, code: ALL, ALLOW" already exists`) + + _, err = svc.AuthorizeSecurityGroupIngress(context.Background(), &ec2.AuthorizeSecurityGroupIngressInput{ + GroupId: aws.String(sgID), + IpPermissions: []*ec2.IpPermission{{ + IpProtocol: &proto, FromPort: aws.Int64(22), ToPort: aws.Int64(22), + IpRanges: []*ec2.IpRange{{CidrIp: aws.String("10.0.0.0/33")}}, + }}, + }, testAccountID) + requireAWSError(t, err, awserrors.ErrorInvalidParameterValue, "CIDR block 10.0.0.0/33 is malformed") + + _, err = svc.DescribeSecurityGroupRules(context.Background(), &ec2.DescribeSecurityGroupRulesInput{ + SecurityGroupRuleIds: []*string{aws.String("sgr-0000000000000dead")}, + }, testAccountID) + requireAWSError(t, err, awserrors.ErrorInvalidSecurityGroupRuleIdNotFound, "The security group rule ID 'sgr-0000000000000dead' does not exist") } func TestDescribeSecurityGroupRules_InvalidFilter(t *testing.T) { @@ -2441,6 +2586,13 @@ func TestDescribeSecurityGroupRules_FiltersByTag(t *testing.T) { require.NoError(t, err) require.Len(t, byKey.SecurityGroupRules, 1) assert.Equal(t, taggedID, aws.StringValue(byKey.SecurityGroupRules[0].SecurityGroupRuleId)) + + byTagValue, err := svc.DescribeSecurityGroupRules(context.Background(), &ec2.DescribeSecurityGroupRulesInput{ + Filters: []*ec2.Filter{{Name: aws.String("tag-value"), Values: []*string{aws.String("alb")}}}, + }, testAccountID) + require.NoError(t, err) + require.Len(t, byTagValue.SecurityGroupRules, 1) + assert.Equal(t, taggedID, aws.StringValue(byTagValue.SecurityGroupRules[0].SecurityGroupRuleId)) } func TestDescribeSecurityGroupRules_AllProtocolReportsMinusOnePorts(t *testing.T) { diff --git a/spinifex/handlers/ec2/vpc/service_impl.go b/spinifex/handlers/ec2/vpc/service_impl.go index 36246a24b..05f2bb0db 100644 --- a/spinifex/handlers/ec2/vpc/service_impl.go +++ b/spinifex/handlers/ec2/vpc/service_impl.go @@ -14,10 +14,12 @@ import ( "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/foundation/aws/arn" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters" "github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil" "github.com/mulgadc/spinifex/spinifex/foundation/state/migrate" + "github.com/mulgadc/spinifex/spinifex/paging" "github.com/mulgadc/spinifex/spinifex/utils" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" @@ -96,6 +98,10 @@ type VPCServiceImpl struct { // A nil value leaves both unchanged. centralTags CentralTagStore + // Optional: injected after construction to name interfaces' private + // addresses. Nil leaves them unnamed. + privateDNSName func(privateIP string) string + // disableDefaultPublicIP seeds default subnets with MapPublicIpOnLaunch=false // (non-pool external modes have no public IPs to assign). Zero value keeps // the AWS-faithful default of true. @@ -115,6 +121,13 @@ func (s *VPCServiceImpl) SetExternalIPAM(ipam *ExternalIPAM, eipKV jetstream.Key s.eipKV = eipKV } +// SetPrivateDNSNamer injects how a private address is named, so an interface +// reports the same PrivateDnsName as the instance using it. Nil, or a namer +// that answers "", leaves interfaces unnamed. +func (s *VPCServiceImpl) SetPrivateDNSNamer(namer func(privateIP string) string) { + s.privateDNSName = namer +} + // SetCentralTagStore injects the central tag store so create paths project // their record tags into it and delete paths clear them. Nil leaves both // unchanged. @@ -132,6 +145,15 @@ func (s *VPCServiceImpl) localAZ() string { return s.config.AZ } +// region returns the configured AWS region, falling back to the default when +// no config or region is wired. +func (s *VPCServiceImpl) region() string { + if s.config == nil || s.config.Region == "" { + return config.DefaultAWSRegion + } + return s.config.Region +} + // NewVPCServiceImplWithNATS creates a VPC service with NATS JetStream for persistence. func NewVPCServiceImplWithNATS(ctx context.Context, cfg *config.Config, natsConn *nats.Conn) (*VPCServiceImpl, error) { js, err := jetstream.New(natsConn) @@ -244,22 +266,27 @@ func (s *VPCServiceImpl) nextVNI(ctx context.Context) (int64, error) { return allocated, nil } +// invalidCIDRBlockError is AWS's answer to a CidrBlock that does not parse. +func invalidCIDRBlockError(cidr string) error { + return awserrors.Errorf(awserrors.ErrorInvalidParameterValue, + "Value (%s) for parameter cidrBlock is invalid. This is not a valid CIDR block.", cidr) +} + // CreateVpc creates a new VPC. func (s *VPCServiceImpl) CreateVpc(ctx context.Context, input *ec2.CreateVpcInput, accountID string) (*ec2.CreateVpcOutput, error) { if input.CidrBlock == nil || *input.CidrBlock == "" { return nil, errors.New(awserrors.ErrorMissingParameter) } - // Validate CIDR block _, ipNet, err := net.ParseCIDR(*input.CidrBlock) if err != nil { - return nil, errors.New(awserrors.ErrorInvalidVpcRange) + return nil, invalidCIDRBlockError(*input.CidrBlock) } // AWS allows /16 to /28 for VPC CIDR blocks ones, _ := ipNet.Mask.Size() if ones < 16 || ones > 28 { - return nil, errors.New(awserrors.ErrorInvalidVpcRange) + return nil, awserrors.Errorf(awserrors.ErrorInvalidVpcRange, "The CIDR '%s' is invalid.", *input.CidrBlock) } // Allocate VNI for overlay network @@ -312,16 +339,18 @@ func (s *VPCServiceImpl) CreateVpc(ctx context.Context, input *ec2.CreateVpcInpu return nil, errors.New(awserrors.ErrorServerInternal) } - return &ec2.CreateVpcOutput{ - Vpc: s.vpcRecordToEC2(&record, accountID), - }, nil + // AWS renders the empty IPv6 association list on CreateVpc only; + // DescribeVpcs omits it. + vpc := s.vpcRecordToEC2(&record, accountID) + vpc.Ipv6CidrBlockAssociationSet = []*ec2.VpcIpv6CidrBlockAssociation{} + return &ec2.CreateVpcOutput{Vpc: vpc}, nil } // requireVPCExists returns InvalidVpcID.NotFound if the VPC doesn't exist for // this account. func (s *VPCServiceImpl) requireVPCExists(ctx context.Context, accountID, vpcId string) error { if _, err := s.vpcKV.Get(ctx, utils.AccountKey(accountID, vpcId)); err != nil { - return errors.New(awserrors.ErrorInvalidVpcIDNotFound) + return vpcNotFoundError(vpcId) } return nil } @@ -340,7 +369,7 @@ func (s *VPCServiceImpl) DeleteVpc(ctx context.Context, input *ec2.DeleteVpcInpu // tolerates it on destroy); destroy orchestration tolerates it too. // A transient read error stays a server error. if errors.Is(err, jetstream.ErrKeyNotFound) { - return nil, errors.New(awserrors.ErrorInvalidVpcIDNotFound) + return nil, vpcNotFoundError(vpcID) } return nil, errors.New(awserrors.ErrorServerInternal) } @@ -376,8 +405,8 @@ func (s *VPCServiceImpl) DeleteVpc(ctx context.Context, input *ec2.DeleteVpcInpu return nil, errors.New(awserrors.ErrorServerInternal) } if subnet.VpcId == vpcID { - return nil, awserrors.Errorf(awserrors.ErrorDependencyViolation, - "the VPC has a dependent subnet %s that must be deleted first", subnet.SubnetId) + slog.WarnContext(ctx, "DeleteVpc: VPC has a dependent subnet", "vpcId", vpcID, "subnetId", subnet.SubnetId) + return nil, awserrors.HasDependencies("vpc", vpcID) } } @@ -413,8 +442,8 @@ func (s *VPCServiceImpl) DeleteVpc(ctx context.Context, input *ec2.DeleteVpcInpu continue } if !sg.IsDefault { - return nil, awserrors.Errorf(awserrors.ErrorDependencyViolation, - "the VPC has a dependent security group %s that must be deleted first", sg.GroupId) + slog.WarnContext(ctx, "DeleteVpc: VPC has a dependent security group", "vpcId", vpcID, "groupId", sg.GroupId) + return nil, awserrors.HasDependencies("vpc", vpcID) } defaultSGId = sg.GroupId } @@ -454,8 +483,8 @@ func (s *VPCServiceImpl) DeleteVpc(ctx context.Context, input *ec2.DeleteVpcInpu continue } if !rtb.IsMain { - return nil, awserrors.Errorf(awserrors.ErrorDependencyViolation, - "the VPC has a dependent route table %s that must be deleted first", rtb.RouteTableId) + slog.WarnContext(ctx, "DeleteVpc: VPC has a dependent route table", "vpcId", vpcID, "routeTableId", rtb.RouteTableId) + return nil, awserrors.HasDependencies("vpc", vpcID) } } @@ -548,8 +577,8 @@ func (s *VPCServiceImpl) rejectAttachedIGW(ctx context.Context, accountID, vpcID return errors.New(awserrors.ErrorServerInternal) } if igw.VpcId == vpcID { - return awserrors.Errorf(awserrors.ErrorDependencyViolation, - "the VPC has a dependent internet gateway %s that must be detached first", igw.InternetGatewayId) + slog.WarnContext(ctx, "DeleteVpc: VPC has an attached internet gateway", "vpcId", vpcID, "internetGatewayId", igw.InternetGatewayId) + return awserrors.HasDependencies("vpc", vpcID) } } @@ -569,6 +598,8 @@ var describeVpcsValidFilters = map[string]bool{ "isDefault": true, "owner-id": true, "cidr-block-association.cidr-block": true, + "tag-key": true, + "tag-value": true, } // SupportsDescribeVpcsFilter reports whether DescribeVpcs accepts a filter name. @@ -583,6 +614,11 @@ func SupportsDescribeVpcsFilter(name string) bool { func (s *VPCServiceImpl) DescribeVpcs(ctx context.Context, input *ec2.DescribeVpcsInput, accountID string) (*ec2.DescribeVpcsOutput, error) { var vpcs []*ec2.Vpc + // Validated but not paged: AWS's paging of DescribeVpcs is unobserved. + if _, err := describeVpcsPaging.Parse(input.MaxResults, input.NextToken, len(input.VpcIds)); err != nil { + return nil, err + } + vpcIDs := make(map[string]bool) for _, id := range input.VpcIds { if id != nil { @@ -641,9 +677,9 @@ func (s *VPCServiceImpl) DescribeVpcs(ctx context.Context, input *ec2.DescribeVp found[*vpc.VpcId] = true } } - for id := range vpcIDs { - if !found[id] { - return nil, errors.New(awserrors.ErrorInvalidVpcIDNotFound) + for _, id := range input.VpcIds { + if id != nil && !found[*id] { + return nil, vpcNotFoundError(*id) } } } @@ -666,26 +702,27 @@ func (s *VPCServiceImpl) CreateSubnet(ctx context.Context, input *ec2.CreateSubn vpcID := *input.VpcId + // AWS rejects an unparseable CIDR before it looks the VPC up. + _, subnetNet, err := net.ParseCIDR(*input.CidrBlock) + if err != nil { + return nil, invalidCIDRBlockError(*input.CidrBlock) + } + // Verify VPC exists and belongs to this account vpcEntry, err := s.vpcKV.Get(ctx, utils.AccountKey(accountID, vpcID)) if err != nil { - return nil, errors.New(awserrors.ErrorInvalidVpcIDNotFound) + return nil, vpcNotFoundError(vpcID) } var vpcRecord VPCRecord if err := json.Unmarshal(vpcEntry.Value(), &vpcRecord); err != nil { return nil, errors.New(awserrors.ErrorServerInternal) } - // Validate subnet CIDR - _, subnetNet, err := net.ParseCIDR(*input.CidrBlock) - if err != nil { - return nil, errors.New(awserrors.ErrorInvalidSubnetRange) - } // AWS allows /16 to /28 for subnet CIDR blocks ones, _ := subnetNet.Mask.Size() if ones < 16 || ones > 28 { - return nil, errors.New(awserrors.ErrorInvalidSubnetRange) + return nil, subnetRangeError(*input.CidrBlock) } // Verify subnet CIDR is within VPC CIDR @@ -695,7 +732,7 @@ func (s *VPCServiceImpl) CreateSubnet(ctx context.Context, input *ec2.CreateSubn } if !vpcNet.Contains(subnetNet.IP) { - return nil, errors.New(awserrors.ErrorInvalidSubnetRange) + return nil, subnetRangeError(*input.CidrBlock) } // Check for CIDR conflicts with existing subnets in this VPC (same account) @@ -728,7 +765,7 @@ func (s *VPCServiceImpl) CreateSubnet(ctx context.Context, input *ec2.CreateSubn continue } if existingNet.Contains(subnetNet.IP) || subnetNet.Contains(existingNet.IP) { - return nil, errors.New(awserrors.ErrorInvalidSubnetConflict) + return nil, awserrors.Errorf(awserrors.ErrorInvalidSubnetConflict, "The CIDR '%s' conflicts with another subnet", *input.CidrBlock) } } @@ -793,7 +830,7 @@ func (s *VPCServiceImpl) DeleteSubnet(ctx context.Context, input *ec2.DeleteSubn // destroy); destroy orchestration tolerates it too. A transient read // error stays a server error. if errors.Is(err, jetstream.ErrKeyNotFound) { - return nil, errors.New(awserrors.ErrorInvalidSubnetIDNotFound) + return nil, awserrors.Errorf(awserrors.ErrorInvalidSubnetIDNotFound, "The subnet ID '%s' does not exist", subnetID) } return nil, errors.New(awserrors.ErrorServerInternal) } @@ -858,7 +895,7 @@ func (s *VPCServiceImpl) checkSubnetResidents(ctx context.Context, accountID, su continue } if record.SubnetId == subnetID && eniIsLiveAttachment(&record) { - return errors.New(awserrors.ErrorDependencyViolation) + return awserrors.HasDependencies("subnet", subnetID) } } return nil @@ -955,6 +992,11 @@ func (s *VPCServiceImpl) clearRouteTableAssociationsForSubnet(ctx context.Contex func (s *VPCServiceImpl) DescribeSubnets(ctx context.Context, input *ec2.DescribeSubnetsInput, accountID string) (*ec2.DescribeSubnetsOutput, error) { var subnets []*ec2.Subnet + pageReq, err := describeSubnetsPaging.Parse(input.MaxResults, input.NextToken, len(input.SubnetIds)) + if err != nil { + return nil, err + } + subnetIDs := make(map[string]bool) for _, id := range input.SubnetIds { if id != nil { @@ -1021,17 +1063,20 @@ func (s *VPCServiceImpl) DescribeSubnets(ctx context.Context, input *ec2.Describ found[*subnet.SubnetId] = true } } - for id := range subnetIDs { - if !found[id] { - return nil, errors.New(awserrors.ErrorInvalidSubnetIDNotFound) + for _, id := range input.SubnetIds { + if id != nil && !found[*id] { + return nil, awserrors.Errorf(awserrors.ErrorInvalidSubnetIDNotFound, "The subnet ID '%s' does not exist", *id) } } } + subnets, nextToken := paging.EC2Page(subnets, func(s *ec2.Subnet) string { return *s.SubnetId }, pageReq) + slog.InfoContext(ctx, "DescribeSubnets completed", "count", len(subnets), "accountID", accountID) return &ec2.DescribeSubnetsOutput{ - Subnets: subnets, + Subnets: subnets, + NextToken: nextToken, }, nil } @@ -1149,7 +1194,7 @@ func (s *VPCServiceImpl) updateRecordTags(ctx context.Context, accountID, resour // vpcMatchesFilters checks whether a VPCRecord satisfies all parsed awsfilters. func vpcMatchesFilters(record *VPCRecord, accountID string, filters map[string][]string) bool { for name, values := range filters { - if strings.HasPrefix(name, "tag:") { + if awsfilters.IsTagFilter(name) { continue } @@ -1181,6 +1226,20 @@ func vpcMatchesFilters(record *VPCRecord, accountID string, filters map[string][ return awsfilters.MatchesTags(filters, record.Tags) } +var describeVpcsPaging = paging.EC2{ + MaxResults: 1000, + TooLarge: "Value ( %d ) for parameter MaxResults is invalid. Expecting a value smaller than or equal to 1000.", + TooSmall: "Value ( %d ) for parameter MaxResults is invalid. Expecting a value greater than or equal to 5.", + WithIDs: "The parameter VpcIds cannot be used with the parameter MaxResults", +} + +var describeSubnetsPaging = paging.EC2{ + MaxResults: 1000, + TooLarge: "Value ( %d ) for parameter MaxResults is invalid. Expecting a value smaller than or equal to 1000.", + TooSmall: "Value ( %d ) for parameter MaxResults is invalid. Expecting a value greater than or equal to 5.", + WithIDs: "The parameter SubnetIds cannot be used with the parameter MaxResults", +} + // describeSubnetsValidFilters defines the set of filter names accepted by DescribeSubnets. var describeSubnetsValidFilters = map[string]bool{ "subnet-id": true, @@ -1189,12 +1248,14 @@ var describeSubnetsValidFilters = map[string]bool{ "cidr-block": true, "state": true, "default-for-az": true, + "tag-key": true, + "tag-value": true, } // subnetMatchesFilters checks whether a SubnetRecord satisfies all parsed awsfilters. func subnetMatchesFilters(record *SubnetRecord, filters map[string][]string) bool { for name, values := range filters { - if strings.HasPrefix(name, "tag:") { + if awsfilters.IsTagFilter(name) { continue } @@ -1261,6 +1322,18 @@ func (s *VPCServiceImpl) subnetRecordToEC2(record *SubnetRecord, availableIPs in AvailableIpAddressCount: aws.Int64(int64(availableIPs)), OwnerId: aws.String(accountID), MapPublicIpOnLaunch: aws.Bool(record.MapPublicIpOnLaunch), + SubnetArn: aws.String(arn.FormatEC2(arn.EC2Subnet, s.region(), accountID, record.SubnetId)), + // Spinifex subnets are IPv4-only with no customer-owned pool, and + // instances are named ip- with no resource-name records. + AssignIpv6AddressOnCreation: aws.Bool(false), + EnableDns64: aws.Bool(false), + Ipv6Native: aws.Bool(false), + MapCustomerOwnedIpOnLaunch: aws.Bool(false), + PrivateDnsNameOptionsOnLaunch: &ec2.PrivateDnsNameOptionsOnLaunch{ + HostnameType: aws.String(ec2.HostnameTypeIpName), + EnableResourceNameDnsARecord: aws.Bool(false), + EnableResourceNameDnsAAAARecord: aws.Bool(false), + }, } subnet.Tags = utils.MapToEC2Tags(record.Tags) @@ -1309,16 +1382,29 @@ func (s *VPCServiceImpl) ModifyVpcAttribute(ctx context.Context, input *ec2.Modi if input.VpcId == nil || *input.VpcId == "" { return nil, errors.New(awserrors.ErrorMissingParameter) } - if input.EnableDnsHostnames == nil && input.EnableDnsSupport == nil && input.EnableNetworkAddressUsageMetrics == nil { + var set []string + if input.EnableDnsSupport != nil { + set = append(set, "enableDnsSupport") + } + if input.EnableDnsHostnames != nil { + set = append(set, "enableDnsHostnames") + } + if input.EnableNetworkAddressUsageMetrics != nil { + set = append(set, "enableNetworkAddressUsageMetrics") + } + if len(set) == 0 { return nil, errors.New(awserrors.ErrorInvalidParameterValue) } + if len(set) > 1 { + return nil, multipleAttributesError(set) + } vpcID := *input.VpcId key := utils.AccountKey(accountID, vpcID) entry, err := s.vpcKV.Get(ctx, key) if err != nil { - return nil, errors.New(awserrors.ErrorInvalidVpcIDNotFound) + return nil, vpcNotFoundError(vpcID) } var record VPCRecord @@ -1366,7 +1452,7 @@ func (s *VPCServiceImpl) DescribeVpcAttribute(ctx context.Context, input *ec2.De entry, err := s.vpcKV.Get(ctx, key) if err != nil { - return nil, errors.New(awserrors.ErrorInvalidVpcIDNotFound) + return nil, vpcNotFoundError(vpcID) } var record VPCRecord diff --git a/spinifex/handlers/ec2/vpc/service_impl_test.go b/spinifex/handlers/ec2/vpc/service_impl_test.go index e8630994d..e27cab431 100644 --- a/spinifex/handlers/ec2/vpc/service_impl_test.go +++ b/spinifex/handlers/ec2/vpc/service_impl_test.go @@ -103,7 +103,8 @@ func TestCreateVpc_InvalidCidr(t *testing.T) { _, err := svc.CreateVpc(context.Background(), &ec2.CreateVpcInput{ CidrBlock: aws.String("not-a-cidr"), }, testAccountID) - assert.ErrorContains(t, err, "InvalidVpcRange") + requireAWSError(t, err, awserrors.ErrorInvalidParameterValue, + "Value (not-a-cidr) for parameter cidrBlock is invalid. This is not a valid CIDR block.") } func TestCreateVpc_CidrTooLarge(t *testing.T) { @@ -112,7 +113,7 @@ func TestCreateVpc_CidrTooLarge(t *testing.T) { _, err := svc.CreateVpc(context.Background(), &ec2.CreateVpcInput{ CidrBlock: aws.String("10.0.0.0/8"), }, testAccountID) - assert.ErrorContains(t, err, "InvalidVpcRange") + requireAWSError(t, err, "InvalidVpc.Range", "The CIDR '10.0.0.0/8' is invalid.") } func TestCreateVpc_CidrTooSmall(t *testing.T) { @@ -121,7 +122,7 @@ func TestCreateVpc_CidrTooSmall(t *testing.T) { _, err := svc.CreateVpc(context.Background(), &ec2.CreateVpcInput{ CidrBlock: aws.String("10.0.0.0/29"), }, testAccountID) - assert.ErrorContains(t, err, "InvalidVpcRange") + requireAWSError(t, err, "InvalidVpc.Range", "The CIDR '10.0.0.0/29' is invalid.") } func TestCreateVpc_WithTags(t *testing.T) { @@ -212,16 +213,13 @@ func TestDeleteVpc_WithSubnets(t *testing.T) { t.Parallel() svc := setupTestVPCService(t) vpcID := createTestVPC(t, svc, "10.0.0.0/16") - subnetID := createTestSubnet(t, svc, vpcID, "10.0.1.0/24") + createTestSubnet(t, svc, vpcID, "10.0.1.0/24") // Should fail because VPC has subnets _, err := svc.DeleteVpc(context.Background(), &ec2.DeleteVpcInput{ VpcId: aws.String(vpcID), }, testAccountID) - assert.ErrorContains(t, err, "DependencyViolation") - // The message must name the blocking subnet so the caller knows what to - // delete first, not just that something is blocking. - assert.ErrorContains(t, err, subnetID) + requireAWSError(t, err, awserrors.ErrorDependencyViolation, "The vpc '"+vpcID+"' has dependencies and cannot be deleted.") } func TestDescribeVpcs_All(t *testing.T) { @@ -263,7 +261,10 @@ func TestDescribeVpcs_NotFound(t *testing.T) { _, err := svc.DescribeVpcs(context.Background(), &ec2.DescribeVpcsInput{ VpcIds: []*string{aws.String("vpc-nonexistent")}, }, testAccountID) - assert.ErrorContains(t, err, "InvalidVpcID.NotFound") + requireAWSError(t, err, awserrors.ErrorInvalidVpcIDNotFound, "The vpc ID 'vpc-nonexistent' does not exist") + + _, err = svc.DeleteVpc(context.Background(), &ec2.DeleteVpcInput{VpcId: aws.String("vpc-nonexistent")}, testAccountID) + requireAWSError(t, err, awserrors.ErrorInvalidVpcIDNotFound, "The vpc ID 'vpc-nonexistent' does not exist") } // --- Subnet Tests --- @@ -287,6 +288,44 @@ func TestCreateSubnet(t *testing.T) { assert.Equal(t, int64(251), *out.Subnet.AvailableIpAddressCount) } +// TestSubnetResponse_AWSDefaultFields checks the ARN and the fields AWS +// reports for a new subnet, on both the create and describe paths. +func TestSubnetResponse_AWSDefaultFields(t *testing.T) { + t.Parallel() + svc := setupTestVPCService(t) + svc.config = &config.Config{Region: "ap-southeast-2"} + vpcID := createTestVPC(t, svc, "10.0.0.0/16") + + out, err := svc.CreateSubnet(context.Background(), &ec2.CreateSubnetInput{ + VpcId: aws.String(vpcID), + CidrBlock: aws.String("10.0.1.0/24"), + }, testAccountID) + require.NoError(t, err) + desc, err := svc.DescribeSubnets(context.Background(), &ec2.DescribeSubnetsInput{ + SubnetIds: []*string{out.Subnet.SubnetId}, + }, testAccountID) + require.NoError(t, err) + require.Len(t, desc.Subnets, 1) + + for name, subnet := range map[string]*ec2.Subnet{"create": out.Subnet, "describe": desc.Subnets[0]} { + assert.Equal(t, "arn:aws:ec2:ap-southeast-2:"+testAccountID+":subnet/"+*out.Subnet.SubnetId, aws.StringValue(subnet.SubnetArn), name) + for field, v := range map[string]*bool{ + "AssignIpv6AddressOnCreation": subnet.AssignIpv6AddressOnCreation, + "EnableDns64": subnet.EnableDns64, + "Ipv6Native": subnet.Ipv6Native, + "MapCustomerOwnedIpOnLaunch": subnet.MapCustomerOwnedIpOnLaunch, + } { + require.NotNil(t, v, "%s %s", name, field) + assert.False(t, *v, "%s %s", name, field) + } + assert.Equal(t, &ec2.PrivateDnsNameOptionsOnLaunch{ + HostnameType: aws.String("ip-name"), + EnableResourceNameDnsARecord: aws.Bool(false), + EnableResourceNameDnsAAAARecord: aws.Bool(false), + }, subnet.PrivateDnsNameOptionsOnLaunch, name) + } +} + func TestCreateSubnet_MissingVpcId(t *testing.T) { t.Parallel() svc := setupTestVPCService(t) @@ -324,7 +363,15 @@ func TestCreateSubnet_InvalidCidr(t *testing.T) { VpcId: aws.String(vpcID), CidrBlock: aws.String("not-a-cidr"), }, testAccountID) - assert.ErrorContains(t, err, "InvalidSubnet.Range") + requireAWSError(t, err, awserrors.ErrorInvalidParameterValue, + "Value (not-a-cidr) for parameter cidrBlock is invalid. This is not a valid CIDR block.") + + // AWS rejects the CIDR before it looks the VPC up. + _, err = svc.CreateSubnet(context.Background(), &ec2.CreateSubnetInput{ + VpcId: aws.String("vpc-nonexistent"), + CidrBlock: aws.String("not-a-cidr"), + }, testAccountID) + requireAWSCode(t, err, awserrors.ErrorInvalidParameterValue) } func TestCreateSubnet_OutsideVpcCidr(t *testing.T) { @@ -335,7 +382,7 @@ func TestCreateSubnet_OutsideVpcCidr(t *testing.T) { VpcId: aws.String(vpcID), CidrBlock: aws.String("192.168.1.0/24"), }, testAccountID) - assert.ErrorContains(t, err, "InvalidSubnet.Range") + requireAWSError(t, err, awserrors.ErrorInvalidSubnetRange, "The CIDR '192.168.1.0/24' is invalid.") } func TestCreateSubnet_ConflictingCidr(t *testing.T) { @@ -349,7 +396,7 @@ func TestCreateSubnet_ConflictingCidr(t *testing.T) { VpcId: aws.String(vpcID), CidrBlock: aws.String("10.0.1.0/25"), }, testAccountID) - assert.ErrorContains(t, err, "InvalidSubnet.Conflict") + requireAWSError(t, err, awserrors.ErrorInvalidSubnetConflict, "The CIDR '10.0.1.0/25' conflicts with another subnet") } func TestCreateSubnet_WithTags(t *testing.T) { @@ -473,9 +520,24 @@ func TestDescribeSubnets_NotFound(t *testing.T) { t.Parallel() svc := setupTestVPCService(t) _, err := svc.DescribeSubnets(context.Background(), &ec2.DescribeSubnetsInput{ - SubnetIds: []*string{aws.String("subnet-nonexistent")}, + SubnetIds: []*string{aws.String("subnet-missing1"), aws.String("subnet-missing2")}, }, testAccountID) - assert.ErrorContains(t, err, "InvalidSubnetID.NotFound") + code, msg, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok, "error %v carries no AWS code", err) + assert.Equal(t, awserrors.ErrorInvalidSubnetIDNotFound, code) + assert.Equal(t, "The subnet ID 'subnet-missing1' does not exist", msg) +} + +func TestDeleteSubnet_NotFound(t *testing.T) { + t.Parallel() + svc := setupTestVPCService(t) + _, err := svc.DeleteSubnet(context.Background(), &ec2.DeleteSubnetInput{ + SubnetId: aws.String("subnet-missing1"), + }, testAccountID) + code, msg, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok, "error %v carries no AWS code", err) + assert.Equal(t, awserrors.ErrorInvalidSubnetIDNotFound, code) + assert.Equal(t, "The subnet ID 'subnet-missing1' does not exist", msg) } func TestCreateMultipleSubnetsInVpc(t *testing.T) { @@ -795,8 +857,7 @@ func TestDeleteVpc_RejectsAttachedInternetGateway(t *testing.T) { _, err = svc.DeleteVpc(context.Background(), &ec2.DeleteVpcInput{VpcId: aws.String(vpcID)}, testAccountID) require.Error(t, err) - assert.ErrorContains(t, err, "DependencyViolation") - assert.ErrorContains(t, err, "igw-pending", "the caller must be told which gateway to detach") + requireAWSError(t, err, awserrors.ErrorDependencyViolation, "The vpc '"+vpcID+"' has dependencies and cannot be deleted.") desc, err := svc.DescribeVpcs(context.Background(), &ec2.DescribeVpcsInput{VpcIds: []*string{aws.String(vpcID)}}, testAccountID) require.NoError(t, err) @@ -1035,7 +1096,7 @@ func TestCreateSubnet_CidrTooSmall(t *testing.T) { VpcId: aws.String(vpcID), CidrBlock: aws.String("10.0.0.0/29"), }, testAccountID) - assert.ErrorContains(t, err, "InvalidSubnet.Range") + requireAWSError(t, err, awserrors.ErrorInvalidSubnetRange, "The CIDR '10.0.0.0/29' is invalid.") } func TestVpcCidrBlockAssociation(t *testing.T) { @@ -1882,6 +1943,36 @@ func TestDescribeVpcs_FilterByTag(t *testing.T) { assert.Equal(t, "10.0.0.0/16", *desc.Vpcs[0].CidrBlock) } +func TestDescribeVpcs_FilterByTagKeyAndTagValue(t *testing.T) { + t.Parallel() + svc := setupTestVPCService(t) + + out, err := svc.CreateVpc(context.Background(), &ec2.CreateVpcInput{ + CidrBlock: aws.String("10.0.0.0/16"), + TagSpecifications: []*ec2.TagSpecification{{ + ResourceType: aws.String("vpc"), + Tags: []*ec2.Tag{{Key: aws.String("awsdiff"), Value: aws.String("yes")}}, + }}, + }, testAccountID) + require.NoError(t, err) + createTestVPC(t, svc, "172.16.0.0/16") + + for _, tc := range []struct{ name, value string }{{"tag-key", "awsdiff"}, {"tag-value", "yes"}} { + desc, err := svc.DescribeVpcs(context.Background(), &ec2.DescribeVpcsInput{ + Filters: []*ec2.Filter{{Name: aws.String(tc.name), Values: []*string{aws.String(tc.value)}}}, + }, testAccountID) + require.NoError(t, err, tc.name) + require.Len(t, desc.Vpcs, 1, tc.name) + assert.Equal(t, *out.Vpc.VpcId, *desc.Vpcs[0].VpcId, tc.name) + + desc, err = svc.DescribeVpcs(context.Background(), &ec2.DescribeVpcsInput{ + Filters: []*ec2.Filter{{Name: aws.String(tc.name), Values: []*string{aws.String("zz-awsdiff-none")}}}, + }, testAccountID) + require.NoError(t, err, tc.name) + assert.Empty(t, desc.Vpcs, tc.name) + } +} + func TestDescribeVpcs_FilterByVpcId(t *testing.T) { t.Parallel() svc := setupTestVPCService(t) @@ -2132,6 +2223,38 @@ func TestDescribeSubnets_FilterByTag(t *testing.T) { assert.Equal(t, *out.Subnet.SubnetId, *desc.Subnets[0].SubnetId) } +func TestDescribeSubnets_FilterByTagKeyAndTagValue(t *testing.T) { + t.Parallel() + svc := setupTestVPCService(t) + vpcID := createTestVPC(t, svc, "10.0.0.0/16") + + out, err := svc.CreateSubnet(context.Background(), &ec2.CreateSubnetInput{ + VpcId: aws.String(vpcID), + CidrBlock: aws.String("10.0.1.0/24"), + TagSpecifications: []*ec2.TagSpecification{{ + ResourceType: aws.String("subnet"), + Tags: []*ec2.Tag{{Key: aws.String("Env"), Value: aws.String("prod")}}, + }}, + }, testAccountID) + require.NoError(t, err) + createTestSubnet(t, svc, vpcID, "10.0.2.0/24") + + for _, tc := range []struct{ name, value string }{{"tag-key", "Env"}, {"tag-value", "prod"}} { + desc, err := svc.DescribeSubnets(context.Background(), &ec2.DescribeSubnetsInput{ + Filters: []*ec2.Filter{{Name: aws.String(tc.name), Values: []*string{aws.String(tc.value)}}}, + }, testAccountID) + require.NoError(t, err, tc.name) + require.Len(t, desc.Subnets, 1, tc.name) + assert.Equal(t, *out.Subnet.SubnetId, *desc.Subnets[0].SubnetId, tc.name) + + desc, err = svc.DescribeSubnets(context.Background(), &ec2.DescribeSubnetsInput{ + Filters: []*ec2.Filter{{Name: aws.String(tc.name), Values: []*string{aws.String("zz-awsdiff-none")}}}, + }, testAccountID) + require.NoError(t, err, tc.name) + assert.Empty(t, desc.Subnets, tc.name) + } +} + // --- SetExternalIPAM / GetSubnet --- func TestGetSubnet_Success(t *testing.T) { @@ -2322,6 +2445,35 @@ func TestApplyRecordTags_ENITagFilteredDescribe(t *testing.T) { assert.Equal(t, "primary", findTag(out.NetworkInterfaces[0].TagSet, "Name")) } +func TestDescribeNetworkInterfaces_FilterByTagKeyAndTagValue(t *testing.T) { + t.Parallel() + svc := setupTestVPCService(t) + vpcID := createTestVPC(t, svc, "10.5.0.0/16") + subnetID := createTestSubnet(t, svc, vpcID, "10.5.1.0/24") + eniID := createTestENI(t, svc, subnetID) + createTestENI(t, svc, subnetID) // untagged + + require.NoError(t, svc.ApplyRecordTags(&ec2.CreateTagsInput{ + Resources: []*string{aws.String(eniID)}, + Tags: []*ec2.Tag{{Key: aws.String("Name"), Value: aws.String("primary")}}, + }, testAccountID)) + + for _, tc := range []struct{ name, value string }{{"tag-key", "Name"}, {"tag-value", "primary"}} { + out, err := svc.DescribeNetworkInterfaces(context.Background(), &ec2.DescribeNetworkInterfacesInput{ + Filters: []*ec2.Filter{{Name: aws.String(tc.name), Values: []*string{aws.String(tc.value)}}}, + }, testAccountID) + require.NoError(t, err, tc.name) + require.Len(t, out.NetworkInterfaces, 1, tc.name) + assert.Equal(t, eniID, *out.NetworkInterfaces[0].NetworkInterfaceId, tc.name) + + out, err = svc.DescribeNetworkInterfaces(context.Background(), &ec2.DescribeNetworkInterfacesInput{ + Filters: []*ec2.Filter{{Name: aws.String(tc.name), Values: []*string{aws.String("zz-awsdiff-none")}}}, + }, testAccountID) + require.NoError(t, err, tc.name) + assert.Empty(t, out.NetworkInterfaces, tc.name) + } +} + func TestApplyRecordTags_UnknownResourceNoError(t *testing.T) { t.Parallel() svc := setupTestVPCService(t) @@ -2341,3 +2493,22 @@ func findTag(tags []*ec2.Tag, key string) string { } return "" } + +// AWS checks the attribute combination before it looks the VPC up, and its +// order of names is unstable, so only the names are asserted. +func TestVpc_ModifyVpcAttribute_MultipleAttributes(t *testing.T) { + t.Parallel() + svc := setupTestVPCService(t) + + _, err := svc.ModifyVpcAttribute(context.Background(), &ec2.ModifyVpcAttributeInput{ + VpcId: aws.String("vpc-nonexistent"), + EnableDnsSupport: &ec2.AttributeBooleanValue{Value: aws.Bool(true)}, + EnableDnsHostnames: &ec2.AttributeBooleanValue{Value: aws.Bool(true)}, + }, testAccountID) + code, msg, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok, "error %v carries no AWS code", err) + assert.Equal(t, awserrors.ErrorInvalidParameterCombination, code) + assert.Contains(t, msg, "Fields for multiple attribute types specified: ") + assert.Contains(t, msg, "enableDnsSupport") + assert.Contains(t, msg, "enableDnsHostnames") +} diff --git a/spinifex/handlers/ecs/tags.go b/spinifex/handlers/ecs/tags.go index 397db4f75..28dacc1f0 100644 --- a/spinifex/handlers/ecs/tags.go +++ b/spinifex/handlers/ecs/tags.go @@ -8,6 +8,7 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ecs" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/arn" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "github.com/nats-io/nats.go/jetstream" ) @@ -37,22 +38,22 @@ func ResourceARNSegment(resourceARN string) (string, error) { // (.../cluster/{name}, .../task-definition/{family}:{rev}); // service/task/container-instance ARNs embed the owning cluster. func parseResourceARN(resourceARN string) (kind ecsResourceKind, cluster, id, segment string, err error) { - // SplitN(..., 6) keeps the resource segment intact even though a - // task-definition id ("family:1") itself contains a colon. - parts := strings.SplitN(resourceARN, ":", 6) - if len(parts) != 6 || parts[0] != "arn" || parts[2] != "ecs" { + // Split keeps the resource segment intact even though a task-definition + // id ("family:1") itself contains a colon. + service, _, _, segment, ok := arn.Split(resourceARN) + if !ok || service != "ecs" { return 0, "", "", "", errors.New(awserrors.ErrorECSInvalidParameter) } - rtype, rest, ok := strings.Cut(parts[5], "/") + rtype, rest, ok := strings.Cut(segment, "/") if !ok || rtype == "" || rest == "" { return 0, "", "", "", errors.New(awserrors.ErrorECSInvalidParameter) } switch rtype { case "cluster": - return ecsResourceCluster, rest, rest, parts[5], nil + return ecsResourceCluster, rest, rest, segment, nil case "task-definition": - return ecsResourceTaskDefinition, "", rest, parts[5], nil + return ecsResourceTaskDefinition, "", rest, segment, nil case "service", "task", "container-instance": clusterName, name, ok := strings.Cut(rest, "/") if !ok || clusterName == "" || name == "" { @@ -60,11 +61,11 @@ func parseResourceARN(resourceARN string) (kind ecsResourceKind, cluster, id, se } switch rtype { case "service": - return ecsResourceService, clusterName, name, parts[5], nil + return ecsResourceService, clusterName, name, segment, nil case "task": - return ecsResourceTask, clusterName, name, parts[5], nil + return ecsResourceTask, clusterName, name, segment, nil default: - return ecsResourceContainerInstance, clusterName, name, parts[5], nil + return ecsResourceContainerInstance, clusterName, name, segment, nil } default: return 0, "", "", "", errors.New(awserrors.ErrorECSInvalidParameter) diff --git a/spinifex/handlers/eks/addon_catalog.go b/spinifex/handlers/eks/addon_catalog.go index 29980b02b..90146a64e 100644 --- a/spinifex/handlers/eks/addon_catalog.go +++ b/spinifex/handlers/eks/addon_catalog.go @@ -2,6 +2,7 @@ package handlers_eks import ( "fmt" + "maps" "slices" ) @@ -111,11 +112,7 @@ func (s AddonSpec) supportsVersion(version string) bool { // catalogSpecs returns the catalog entries sorted by name for stable output. func catalogSpecs() []AddonSpec { - names := make([]string, 0, len(addonCatalog)) - for n := range addonCatalog { - names = append(names, n) - } - slices.Sort(names) + names := slices.Sorted(maps.Keys(addonCatalog)) out := make([]AddonSpec, 0, len(names)) for _, n := range names { out = append(out, addonCatalog[n]) diff --git a/spinifex/handlers/eks/addon_installer.go b/spinifex/handlers/eks/addon_installer.go index 0063c3067..f0a323675 100644 --- a/spinifex/handlers/eks/addon_installer.go +++ b/spinifex/handlers/eks/addon_installer.go @@ -37,16 +37,15 @@ type StagedAddonManifest struct { // The VM-side delivery slice applies it via the K3s auto-deploy dir; until then // the add-on sits CREATING. type stagingInstaller struct { - nc *nats.Conn - clusterSize int + nc *nats.Conn } var _ AddonInstaller = (*stagingInstaller)(nil) // newStagingInstaller returns a stagingInstaller bound to the daemon NATS -// connection, using clusterSize as the per-account bucket's replica count. -func newStagingInstaller(nc *nats.Conn, clusterSize int) *stagingInstaller { - return &stagingInstaller{nc: nc, clusterSize: clusterSize} +// connection. +func newStagingInstaller(nc *nats.Conn) *stagingInstaller { + return &stagingInstaller{nc: nc} } func (i *stagingInstaller) acctKV(ctx context.Context, accountID string) (jetstream.KeyValue, error) { @@ -57,7 +56,7 @@ func (i *stagingInstaller) acctKV(ctx context.Context, accountID string) (jetstr if err != nil { return nil, fmt.Errorf("jetstream: %w", err) } - return GetOrCreateAccountBucket(ctx, js, accountID, max(i.clusterSize, 1)) + return GetOrCreateAccountBucket(ctx, js, accountID) } func (i *stagingInstaller) Install(ctx context.Context, accountID, cluster string, rec *AddonRecord) error { diff --git a/spinifex/handlers/eks/addon_status_reconciler_test.go b/spinifex/handlers/eks/addon_status_reconciler_test.go index d199879e2..c8abfe148 100644 --- a/spinifex/handlers/eks/addon_status_reconciler_test.go +++ b/spinifex/handlers/eks/addon_status_reconciler_test.go @@ -15,7 +15,7 @@ import ( func acctKVForTest(t *testing.T, svc *EKSServiceImpl) jetstream.KeyValue { t.Helper() js := testutil.NewJetStream(t, svc.deps.NATSConn) - kv, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID, 1) + kv, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID) require.NoError(t, err) return kv } diff --git a/spinifex/handlers/eks/addons.go b/spinifex/handlers/eks/addons.go index 4bf3be4fd..b60d35c27 100644 --- a/spinifex/handlers/eks/addons.go +++ b/spinifex/handlers/eks/addons.go @@ -331,7 +331,7 @@ func (s *EKSServiceImpl) addonInstaller() AddonInstaller { if s.deps.AddonInstaller != nil { return s.deps.AddonInstaller } - return newStagingInstaller(s.deps.NATSConn, s.deps.ClusterSize) + return newStagingInstaller(s.deps.NATSConn) } // markAddonFailed best-effort flips a record to CREATE_FAILED with the error reason. diff --git a/spinifex/handlers/eks/addons_test.go b/spinifex/handlers/eks/addons_test.go index 9ca5f9ba3..076158413 100644 --- a/spinifex/handlers/eks/addons_test.go +++ b/spinifex/handlers/eks/addons_test.go @@ -243,7 +243,7 @@ func TestStagingInstaller_StagesManifest(t *testing.T) { require.NoError(t, err) js := testutil.NewJetStream(t, svc.deps.NATSConn) - kv, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID, 1) + kv, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID) require.NoError(t, err) entry, err := kv.Get(t.Context(), AddonManifestKey("c1", albController)) require.NoError(t, err, "installer must stage a manifest for VM-side delivery") diff --git a/spinifex/handlers/eks/cluster_reconciler_state_test.go b/spinifex/handlers/eks/cluster_reconciler_state_test.go index ce85357e9..781bf5af7 100644 --- a/spinifex/handlers/eks/cluster_reconciler_state_test.go +++ b/spinifex/handlers/eks/cluster_reconciler_state_test.go @@ -21,9 +21,9 @@ func newStateReconcilerHarness(t *testing.T, opts ...ReconcilerOption) (*Cluster t.Helper() _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - leaderKV, err := InitLeaderBucket(t.Context(), js, 1) + leaderKV, err := InitLeaderBucket(t.Context(), js) require.NoError(t, err) - acctKV, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID, 1) + acctKV, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID) require.NoError(t, err) require.NoError(t, PutClusterMeta(t.Context(), acctKV, sampleClusterMeta("alpha"))) diff --git a/spinifex/handlers/eks/cluster_reconciler_test.go b/spinifex/handlers/eks/cluster_reconciler_test.go index 9faf17c20..29f12c8e9 100644 --- a/spinifex/handlers/eks/cluster_reconciler_test.go +++ b/spinifex/handlers/eks/cluster_reconciler_test.go @@ -58,9 +58,9 @@ func newReconcilerHarness(t *testing.T, healthURL string, opts ...ReconcilerOpti t.Helper() _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - leaderKV, err := InitLeaderBucket(t.Context(), js, 1) + leaderKV, err := InitLeaderBucket(t.Context(), js) require.NoError(t, err) - acctKV, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID, 1) + acctKV, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID) require.NoError(t, err) require.NoError(t, PutClusterMeta(t.Context(), acctKV, sampleClusterMeta("alpha"))) @@ -72,9 +72,9 @@ func newReconcilerHarness(t *testing.T, healthURL string, opts ...ReconcilerOpti func TestNewClusterReconciler_EmptyInputsRejected(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - leaderKV, err := InitLeaderBucket(t.Context(), js, 1) + leaderKV, err := InitLeaderBucket(t.Context(), js) require.NoError(t, err) - acctKV, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID, 1) + acctKV, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID) require.NoError(t, err) _, err = NewClusterReconciler(nil, acctKV, testAccountID, "alpha", "h", "") diff --git a/spinifex/handlers/eks/cluster_state_test.go b/spinifex/handlers/eks/cluster_state_test.go index 730af7e8e..f23da704c 100644 --- a/spinifex/handlers/eks/cluster_state_test.go +++ b/spinifex/handlers/eks/cluster_state_test.go @@ -15,7 +15,7 @@ func newClusterStateTestKV(t *testing.T) jetstream.KeyValue { t.Helper() _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - kv, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID, 1) + kv, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID) require.NoError(t, err) return kv } diff --git a/spinifex/handlers/eks/delete_cluster_test.go b/spinifex/handlers/eks/delete_cluster_test.go index 7dc136842..cd7b6a42b 100644 --- a/spinifex/handlers/eks/delete_cluster_test.go +++ b/spinifex/handlers/eks/delete_cluster_test.go @@ -60,7 +60,7 @@ func newEKSServiceFixture(t *testing.T) *eksServiceFixture { t.Helper() _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - kv, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID, 1) + kv, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID) require.NoError(t, err) nlb := newFakeNLBProvisioner() diff --git a/spinifex/handlers/eks/dns_reconcile_test.go b/spinifex/handlers/eks/dns_reconcile_test.go index bbc0b5805..05e3b6c32 100644 --- a/spinifex/handlers/eks/dns_reconcile_test.go +++ b/spinifex/handlers/eks/dns_reconcile_test.go @@ -62,7 +62,7 @@ func TestDesiredDNSChanges_MetadataReadFailureIsNotAuthoritative(t *testing.T) { require.NoError(t, PutClusterMeta(t.Context(), fixture.kv, active)) js := testutil.NewJetStream(t, fixture.svc.deps.NATSConn) - corruptKV, err := GetOrCreateAccountBucket(t.Context(), js, "444455556666", 1) + corruptKV, err := GetOrCreateAccountBucket(t.Context(), js, "444455556666") require.NoError(t, err) _, err = corruptKV.Put(t.Context(), ClusterMetaKey("unreadable"), []byte("{not json")) require.NoError(t, err) diff --git a/spinifex/handlers/eks/eks_billable_reaper.go b/spinifex/handlers/eks/eks_billable_reaper.go index 21528da82..4198d247a 100644 --- a/spinifex/handlers/eks/eks_billable_reaper.go +++ b/spinifex/handlers/eks/eks_billable_reaper.go @@ -69,7 +69,7 @@ func (r *EKSBillableReaper) Sweep(ctx context.Context) (int, error) { continue // ENI gone or untagged: cannot confirm orphan-hood, never reap } - acctKV, err := GetOrCreateAccountBucket(ctx, js, clusterAccount, max(r.svc.deps.ClusterSize, 1)) + acctKV, err := GetOrCreateAccountBucket(ctx, js, clusterAccount) if err != nil { slog.WarnContext(ctx, "eks-billable: account bucket lookup failed", "account", clusterAccount, "err", err) continue diff --git a/spinifex/handlers/eks/nats_bootstrap_test.go b/spinifex/handlers/eks/nats_bootstrap_test.go index 5efe2658b..2c32e39b9 100644 --- a/spinifex/handlers/eks/nats_bootstrap_test.go +++ b/spinifex/handlers/eks/nats_bootstrap_test.go @@ -39,7 +39,7 @@ func newBootstrapHarness(t *testing.T) *natsBootstrapHarness { t.Helper() _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - kv, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID, 1) + kv, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID) require.NoError(t, err) require.NoError(t, PutClusterMeta(t.Context(), kv, sampleClusterMeta(bootstrapTestCluster))) @@ -85,7 +85,7 @@ func (h *natsBootstrapHarness) waitSubsBound(base, want int) { func TestNewNATSBootstrap_RejectsBadInputs(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - kv, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID, 1) + kv, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID) require.NoError(t, err) _, err = NewNATSBootstrap(nil, kv, bootstrapTestMasterKey, testAccountID, "alpha") diff --git a/spinifex/handlers/eks/nodegroup.go b/spinifex/handlers/eks/nodegroup.go index 98a9d533c..0aa5844d9 100644 --- a/spinifex/handlers/eks/nodegroup.go +++ b/spinifex/handlers/eks/nodegroup.go @@ -195,7 +195,7 @@ func (s *EKSServiceImpl) nodegroupAcctKV(ctx context.Context, accountID string) if err != nil { return nil, fmt.Errorf("jetstream: %w", err) } - acctKV, err := GetOrCreateAccountBucket(ctx, js, accountID, max(s.deps.ClusterSize, 1)) + acctKV, err := GetOrCreateAccountBucket(ctx, js, accountID) if err != nil { return nil, fmt.Errorf("get account bucket: %w", err) } diff --git a/spinifex/handlers/eks/nodegroup_test.go b/spinifex/handlers/eks/nodegroup_test.go index 23c926560..54b4acf86 100644 --- a/spinifex/handlers/eks/nodegroup_test.go +++ b/spinifex/handlers/eks/nodegroup_test.go @@ -242,7 +242,7 @@ func TestReclaimOrphanedNodegroups_TerminatesStrandedWorkers(t *testing.T) { func TestNodegroupRecord_CRUDRoundTrip(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - kv, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID, 1) + kv, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID) require.NoError(t, err) rec := &NodegroupRecord{ diff --git a/spinifex/handlers/eks/recovery_directive_test.go b/spinifex/handlers/eks/recovery_directive_test.go index 78d64ded4..e868d4481 100644 --- a/spinifex/handlers/eks/recovery_directive_test.go +++ b/spinifex/handlers/eks/recovery_directive_test.go @@ -13,7 +13,7 @@ import ( func TestLoadRecoveryDirective_AbsentIsZeroNone(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - acctKV, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID, 1) + acctKV, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID) require.NoError(t, err) d, err := LoadRecoveryDirective(t.Context(), acctKV, "alpha", "i-missing") @@ -25,7 +25,7 @@ func TestLoadRecoveryDirective_AbsentIsZeroNone(t *testing.T) { func TestStoreRecoveryDirective_EpochIncrements(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - acctKV, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID, 1) + acctKV, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID) require.NoError(t, err) first, err := StoreRecoveryDirective(t.Context(), acctKV, "alpha", "i-0", RecoveryActionClusterReset, "", false) @@ -46,7 +46,7 @@ func TestStoreRecoveryDirective_EpochIncrements(t *testing.T) { func TestStoreRecoveryDirective_SnapshotRequiredRoundTrips(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - acctKV, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID, 1) + acctKV, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID) require.NoError(t, err) stored, err := StoreRecoveryDirective(t.Context(), acctKV, "alpha", "i-seed", RecoveryActionClusterReset, "etcd-frequent-20260709T010000Z.snap", true) diff --git a/spinifex/handlers/eks/security_groups.go b/spinifex/handlers/eks/security_groups.go index 1a28dbab8..86ec2da0d 100644 --- a/spinifex/handlers/eks/security_groups.go +++ b/spinifex/handlers/eks/security_groups.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log/slog" + "slices" "time" "github.com/aws/aws-sdk-go/aws" @@ -479,12 +480,9 @@ func EnsureControlPlaneHAIngress(ctx context.Context, sgp sgProvisioner, account // sgHasLBCClusterTag reports whether the SG carries the AWS LoadBalancer // Controller ownership tag for this cluster. func sgHasLBCClusterTag(g *ec2.SecurityGroup, clusterName string) bool { - for _, tag := range g.Tags { - if tag != nil && aws.StringValue(tag.Key) == lbcClusterOwnershipTagKey && aws.StringValue(tag.Value) == clusterName { - return true - } - } - return false + return slices.ContainsFunc(g.Tags, func(tag *ec2.Tag) bool { + return tag != nil && aws.StringValue(tag.Key) == lbcClusterOwnershipTagKey && aws.StringValue(tag.Value) == clusterName + }) } func lookupSGByName(ctx context.Context, sgp sgProvisioner, accountID, vpcID, name string) (string, error) { diff --git a/spinifex/handlers/eks/service_impl.go b/spinifex/handlers/eks/service_impl.go index 108ec2a96..024cba137 100644 --- a/spinifex/handlers/eks/service_impl.go +++ b/spinifex/handlers/eks/service_impl.go @@ -19,6 +19,7 @@ import ( "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/eks" "github.com/aws/aws-sdk-go/service/iam" + "github.com/mulgadc/bluebottle/pkg/auth" "github.com/mulgadc/spinifex/spinifex/admin" resourcearn "github.com/mulgadc/spinifex/spinifex/foundation/aws/arn" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" @@ -50,11 +51,6 @@ type EKSServiceDeps struct { Region string HolderID string - // ClusterSize is the daemon's node count, used as the JetStream replica - // count for the lazily-created per-account and leader KV buckets so they - // match the cluster's other R3 streams instead of staying stuck at R1. - ClusterSize int - // InternalSuffix is the AWS-parity internal DNS suffix (e.g. spinifex.internal) // used to compose the worker's ECR registry host. InternalSuffix string @@ -305,7 +301,7 @@ func NewEKSServiceImpl(deps EKSServiceDeps) (*EKSServiceImpl, error) { // The leader bucket outlives every request, so its open is bounded by the // service's own background context rather than a caller's. ctx, cancel := context.WithCancel(context.Background()) - leaderKV, err := InitLeaderBucket(ctx, js, max(deps.ClusterSize, 1)) + leaderKV, err := InitLeaderBucket(ctx, js) if err != nil { cancel() return nil, err @@ -509,7 +505,7 @@ func (s *EKSServiceImpl) CreateCluster(ctx context.Context, input *eks.CreateClu if err := s.requireOrchestrationDeps("CreateCluster"); err != nil { return nil, err } - if err := validateCreateClusterInput(input); err != nil { + if err := validateCreateClusterInput(input, accountID); err != nil { return nil, err } name := aws.StringValue(input.Name) @@ -519,7 +515,7 @@ func (s *EKSServiceImpl) CreateCluster(ctx context.Context, input *eks.CreateClu if err != nil { return nil, logCreateErr(name, accountID, "jetstream", err) } - acctKV, err := GetOrCreateAccountBucket(ctx, js, accountID, max(s.deps.ClusterSize, 1)) + acctKV, err := GetOrCreateAccountBucket(ctx, js, accountID) if err != nil { return nil, logCreateErr(name, accountID, "get account bucket", err) } @@ -1051,7 +1047,7 @@ func (s *EKSServiceImpl) DescribeCluster(ctx context.Context, input *eks.Describ if err != nil { return nil, fmt.Errorf("jetstream: %w", err) } - acctKV, err := GetOrCreateAccountBucket(ctx, js, accountID, max(s.deps.ClusterSize, 1)) + acctKV, err := GetOrCreateAccountBucket(ctx, js, accountID) if err != nil { return nil, fmt.Errorf("get account bucket: %w", err) } @@ -1070,7 +1066,7 @@ func (s *EKSServiceImpl) ListClusters(ctx context.Context, input *eks.ListCluste if err != nil { return nil, eksReadUnavailableOr(err, "jetstream") } - acctKV, err := GetOrCreateAccountBucket(ctx, js, accountID, max(s.deps.ClusterSize, 1)) + acctKV, err := GetOrCreateAccountBucket(ctx, js, accountID) if err != nil { return nil, eksReadUnavailableOr(err, "get account bucket") } @@ -1117,7 +1113,7 @@ func (s *EKSServiceImpl) DeleteCluster(ctx context.Context, input *eks.DeleteClu if err != nil { return nil, fmt.Errorf("jetstream: %w", err) } - acctKV, err := GetOrCreateAccountBucket(ctx, js, accountID, max(s.deps.ClusterSize, 1)) + acctKV, err := GetOrCreateAccountBucket(ctx, js, accountID) if err != nil { return nil, fmt.Errorf("get account bucket: %w", err) } @@ -1536,7 +1532,7 @@ func (s *EKSServiceImpl) acctKVForCluster(ctx context.Context, accountID, cluste if err != nil { return nil, fmt.Errorf("jetstream: %w", err) } - acctKV, err := GetOrCreateAccountBucket(ctx, js, accountID, max(s.deps.ClusterSize, 1)) + acctKV, err := GetOrCreateAccountBucket(ctx, js, accountID) if err != nil { return nil, fmt.Errorf("get account bucket: %w", err) } @@ -1801,12 +1797,9 @@ func (s *EKSServiceImpl) ListAccessPolicies(ctx context.Context, _ *eks.ListAcce // hasAssociatedPolicy reports whether the entry has the given policy ARN bound. func hasAssociatedPolicy(rec *AccessEntryRecord, policyARN string) bool { - for _, p := range rec.AssociatedPolicies { - if p.PolicyARN == policyARN { - return true - } - } - return false + return slices.ContainsFunc(rec.AssociatedPolicies, func(p AssociatedAccessPolicy) bool { + return p.PolicyARN == policyARN + }) } // accessPolicyName extracts the policy short name from its ARN @@ -2002,22 +1995,18 @@ func (s *EKSServiceImpl) accountBucket(ctx context.Context, accountID string) (j if err != nil { return nil, fmt.Errorf("jetstream: %w", err) } - return GetOrCreateAccountBucket(ctx, js, accountID, max(s.deps.ClusterSize, 1)) + return GetOrCreateAccountBucket(ctx, js, accountID) } // clusterNameFromARN extracts the cluster name from an EKS cluster ARN // (arn:aws:eks:::cluster/), reporting false for any other // ARN shape (e.g. nodegroup) the tag store does not back. func clusterNameFromARN(arn string) (string, bool) { - const prefix = "arn:aws:eks:" - if !strings.HasPrefix(arn, prefix) { + service, _, _, resource, ok := resourcearn.Split(arn) + if !ok || service != "eks" { return "", false } - parts := strings.SplitN(arn, ":", 6) - if len(parts) != 6 { - return "", false - } - resType, name, found := strings.Cut(parts[5], "/") + resType, name, found := strings.Cut(resource, "/") if !found || resType != "cluster" || name == "" { return "", false } @@ -2028,15 +2017,11 @@ func clusterNameFromARN(arn string) (string, bool) { // (arn:aws:eks:::nodegroup///), reporting false // for any other ARN shape. func nodegroupRefFromARN(arn string) (string, string, bool) { - const prefix = "arn:aws:eks:" - if !strings.HasPrefix(arn, prefix) { + service, _, _, resource, ok := resourcearn.Split(arn) + if !ok || service != "eks" { return "", "", false } - parts := strings.SplitN(arn, ":", 6) - if len(parts) != 6 { - return "", "", false - } - resType, rest, found := strings.Cut(parts[5], "/") + resType, rest, found := strings.Cut(resource, "/") if !found || resType != "nodegroup" { return "", "", false } @@ -2060,11 +2045,12 @@ func eksTagErr(err error) error { func notImpl() error { return errors.New(awserrors.ErrorNotImplemented) } -func validateCreateClusterInput(input *eks.CreateClusterInput) error { +func validateCreateClusterInput(input *eks.CreateClusterInput, accountID string) error { if input == nil || input.Name == nil || *input.Name == "" { return errors.New(awserrors.ErrorInvalidParameterValue) } - if input.RoleArn == nil || !strings.HasPrefix(*input.RoleArn, "arn:aws:iam:") { + // The cluster role must be a role in the caller's own account. + if roleAccount, _, err := auth.ParseRoleARN(aws.StringValue(input.RoleArn)); err != nil || roleAccount != accountID { return errors.New(awserrors.ErrorInvalidParameterValue) } if input.ResourcesVpcConfig == nil || len(input.ResourcesVpcConfig.SubnetIds) == 0 { @@ -2232,7 +2218,7 @@ func (s *EKSServiceImpl) spawnReconciler(accountID, clusterName string, _ *Clust slog.Error("spawnReconciler: jetstream", "err", err) return } - acctKV, err := GetOrCreateAccountBucket(s.bgCtx, js, accountID, max(s.deps.ClusterSize, 1)) + acctKV, err := GetOrCreateAccountBucket(s.bgCtx, js, accountID) if err != nil { slog.Error("spawnReconciler: account bucket", "err", err) return diff --git a/spinifex/handlers/eks/service_impl_test.go b/spinifex/handlers/eks/service_impl_test.go index cf333e008..956f193a4 100644 --- a/spinifex/handlers/eks/service_impl_test.go +++ b/spinifex/handlers/eks/service_impl_test.go @@ -90,7 +90,7 @@ func TestValidateCreateClusterInput_RejectsConfigMapAuthMode(t *testing.T) { in.AccessConfig = &eks.CreateAccessConfigRequest{ AuthenticationMode: aws.String(eks.AuthenticationModeConfigMap), } - err := validateCreateClusterInput(in) + err := validateCreateClusterInput(in, testAccountID) code, message, ok := awserrors.ResolveErrorDetail(err) require.True(t, ok) assert.Equal(t, awserrors.ErrorInvalidParameter, code) @@ -104,7 +104,7 @@ func TestValidateCreateClusterInput_RejectsJunkAuthMode(t *testing.T) { in.AccessConfig = &eks.CreateAccessConfigRequest{ AuthenticationMode: aws.String("NOT_A_REAL_MODE"), } - err := validateCreateClusterInput(in) + err := validateCreateClusterInput(in, testAccountID) code, message, ok := awserrors.ResolveErrorDetail(err) require.True(t, ok) assert.Equal(t, awserrors.ErrorInvalidParameter, code) @@ -119,7 +119,7 @@ func TestValidateCreateClusterInput_AcceptsAPIAndConfigMapAuthMode(t *testing.T) in.AccessConfig = &eks.CreateAccessConfigRequest{ AuthenticationMode: aws.String(eks.AuthenticationModeApiAndConfigMap), } - require.NoError(t, validateCreateClusterInput(in)) + require.NoError(t, validateCreateClusterInput(in, testAccountID)) } func TestValidateCreateClusterInput_AcceptsAPIAuthMode(t *testing.T) { @@ -127,17 +127,17 @@ func TestValidateCreateClusterInput_AcceptsAPIAuthMode(t *testing.T) { in.AccessConfig = &eks.CreateAccessConfigRequest{ AuthenticationMode: aws.String(eks.AuthenticationModeApi), } - require.NoError(t, validateCreateClusterInput(in)) + require.NoError(t, validateCreateClusterInput(in, testAccountID)) } // An unset accessConfig (or an unset authenticationMode within it) must stay // valid — it defaults to API, exactly as an explicit "API" does. func TestValidateCreateClusterInput_AcceptsUnsetAuthMode(t *testing.T) { in := createInput("alpha") - require.NoError(t, validateCreateClusterInput(in)) + require.NoError(t, validateCreateClusterInput(in, testAccountID)) in.AccessConfig = &eks.CreateAccessConfigRequest{} - require.NoError(t, validateCreateClusterInput(in)) + require.NoError(t, validateCreateClusterInput(in, testAccountID)) } // A create request with no subnets is malformed and must be rejected before any @@ -145,10 +145,10 @@ func TestValidateCreateClusterInput_AcceptsUnsetAuthMode(t *testing.T) { func TestValidateCreateClusterInput_RejectsMissingSubnetIds(t *testing.T) { in := createInput("alpha") in.ResourcesVpcConfig = &eks.VpcConfigRequest{} - require.EqualError(t, validateCreateClusterInput(in), awserrors.ErrorInvalidParameterValue) + require.EqualError(t, validateCreateClusterInput(in, testAccountID), awserrors.ErrorInvalidParameterValue) in.ResourcesVpcConfig = nil - require.EqualError(t, validateCreateClusterInput(in), awserrors.ErrorInvalidParameterValue) + require.EqualError(t, validateCreateClusterInput(in, testAccountID), awserrors.ErrorInvalidParameterValue) } // DescribeCluster on an absent cluster must reach the KV lookup (full deps @@ -223,7 +223,7 @@ func TestEKSServiceImpl_NodegroupMethodsShimMode(t *testing.T) { func seedTestCluster(t *testing.T, svc *EKSServiceImpl, cluster string) { t.Helper() js := testutil.NewJetStream(t, svc.deps.NATSConn) - kv, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID, 1) + kv, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID) require.NoError(t, err) require.NoError(t, PutClusterMeta(t.Context(), kv, &ClusterMeta{Name: cluster, Status: ClusterStatusActive})) } @@ -414,7 +414,7 @@ func TestAccessPolicy_DisassociateRemovesProjectedGroup(t *testing.T) { require.NoError(t, err) js := testutil.NewJetStream(t, svc.deps.NATSConn) - kv, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID, 1) + kv, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID) require.NoError(t, err) rec, err := GetAccessEntryRecord(t.Context(), kv, "c1", testPrincipalARN) require.NoError(t, err) @@ -461,7 +461,7 @@ func TestEKSServiceImpl_OIDCMethodsReturnNotImplemented(t *testing.T) { func TestEKSServiceImpl_ClusterTagRoundTrip(t *testing.T) { svc := setupTestService(t) js := testutil.NewJetStream(t, svc.deps.NATSConn) - kv, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID, 1) + kv, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID) require.NoError(t, err) const arn = "arn:aws:eks:us-east-1:111122223333:cluster/c1" @@ -503,7 +503,7 @@ func TestEKSServiceImpl_ClusterTagRoundTrip(t *testing.T) { func TestEKSServiceImpl_NodegroupTagRoundTrip(t *testing.T) { svc := setupTestService(t) js := testutil.NewJetStream(t, svc.deps.NATSConn) - kv, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID, 1) + kv, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID) require.NoError(t, err) const arn = "arn:aws:eks:us-east-1:111122223333:nodegroup/c1/ng1/abc123" @@ -580,3 +580,16 @@ func TestEKSServiceImpl_TagsUnsupportedARNNotImplemented(t *testing.T) { _, err = svc.ListTagsForResource(context.Background(), &eks.ListTagsForResourceInput{ResourceArn: aws.String(fpARN)}, testAccountID) require.EqualError(t, err, awserrors.ErrorNotImplemented) } + +// The cluster role must be a role in the caller's own account. +func TestValidateCreateClusterInput_RejectsRoleOutsideCallerAccount(t *testing.T) { + for _, roleARN := range []string{ + "arn:aws:iam::999999999999:role/eks-cluster", + "arn:aws:iam::" + testAccountID + ":user/eks-cluster", + "arn:aws:iam:", + } { + in := createInput("alpha") + in.RoleArn = aws.String(roleARN) + require.EqualError(t, validateCreateClusterInput(in, testAccountID), awserrors.ErrorInvalidParameterValue, roleARN) + } +} diff --git a/spinifex/handlers/eks/store.go b/spinifex/handlers/eks/store.go index 9efcdf64d..c8e2c1291 100644 --- a/spinifex/handlers/eks/store.go +++ b/spinifex/handlers/eks/store.go @@ -232,9 +232,9 @@ func AccountWatchBuckets(ctx context.Context, nc *nats.Conn) ([]*kvstore.Bucket, // creating it on first use at the given replica count (clamped to a minimum // of 1). Idempotent: subsequent calls with the same accountID return the // existing handle. -func GetOrCreateAccountBucket(ctx context.Context, js jetstream.JetStream, accountID string, replicas int) (jetstream.KeyValue, error) { +func GetOrCreateAccountBucket(ctx context.Context, js jetstream.JetStream, accountID string) (jetstream.KeyValue, error) { bucket := AccountBucketName(accountID) - kv, err := kvutil.GetOrCreateBucketWithReplicas(ctx, js, bucket, KVBucketEKSAccountHistory, replicas) + kv, err := kvutil.GetOrCreateBucket(ctx, js, bucket, KVBucketEKSAccountHistory) if err != nil { return nil, fmt.Errorf("failed to create EKS per-account KV bucket %s: %w", bucket, err) } @@ -245,13 +245,11 @@ func GetOrCreateAccountBucket(ctx context.Context, js jetstream.JetStream, accou } // InitLeaderBucket creates (or attaches to) the shared spinifex-eks-leader -// bucket used for per-cluster reconciler leader-lease CAS locks, at the given -// replica count (clamped to a minimum of 1). -func InitLeaderBucket(ctx context.Context, js jetstream.JetStream, replicas int) (jetstream.KeyValue, error) { +// bucket used for per-cluster reconciler leader-lease CAS locks. +func InitLeaderBucket(ctx context.Context, js jetstream.JetStream) (jetstream.KeyValue, error) { return kvlease.OpenBucket(ctx, js, kvlease.BucketConfig{ - Name: KVBucketEKSLeader, - TTL: KVBucketEKSLeaderTTL, - Replicas: max(replicas, 1), - Version: KVBucketEKSLeaderVersion, + Name: KVBucketEKSLeader, + TTL: KVBucketEKSLeaderTTL, + Version: KVBucketEKSLeaderVersion, }) } diff --git a/spinifex/handlers/eks/store_test.go b/spinifex/handlers/eks/store_test.go index 525663099..906bfc6ed 100644 --- a/spinifex/handlers/eks/store_test.go +++ b/spinifex/handlers/eks/store_test.go @@ -14,11 +14,11 @@ func TestGetOrCreateAccountBucket_Idempotent(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - kv1, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID, 1) + kv1, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID) require.NoError(t, err) require.NotNil(t, kv1) - kv2, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID, 1) + kv2, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID) require.NoError(t, err) require.NotNil(t, kv2) @@ -36,7 +36,7 @@ func TestGetOrCreateAccountBucket_ReplicasClamped(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - kv, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID, 0) + kv, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID) require.NoError(t, err) require.NotNil(t, kv) @@ -51,12 +51,12 @@ func TestInitLeaderBucket_Idempotent(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - kv1, err := InitLeaderBucket(t.Context(), js, 1) + kv1, err := InitLeaderBucket(t.Context(), js) require.NoError(t, err) require.NotNil(t, kv1) assert.Equal(t, KVBucketEKSLeader, kv1.Bucket()) - kv2, err := InitLeaderBucket(t.Context(), js, 1) + kv2, err := InitLeaderBucket(t.Context(), js) require.NoError(t, err) require.NotNil(t, kv2) assert.Equal(t, KVBucketEKSLeader, kv2.Bucket()) @@ -68,7 +68,7 @@ func TestInitLeaderBucket_ReplicasClamped(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - kv, err := InitLeaderBucket(t.Context(), js, -1) + kv, err := InitLeaderBucket(t.Context(), js) require.NoError(t, err) require.NotNil(t, kv) diff --git a/spinifex/handlers/eks/watch_source_test.go b/spinifex/handlers/eks/watch_source_test.go index 83f73c9cc..96662b2c7 100644 --- a/spinifex/handlers/eks/watch_source_test.go +++ b/spinifex/handlers/eks/watch_source_test.go @@ -18,9 +18,9 @@ func TestAccountWatchBuckets_ReturnsOnePerAccountBucket(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - _, err := handlers_eks.GetOrCreateAccountBucket(t.Context(), js, "111111111111", 1) + _, err := handlers_eks.GetOrCreateAccountBucket(t.Context(), js, "111111111111") require.NoError(t, err) - _, err = handlers_eks.GetOrCreateAccountBucket(t.Context(), js, "222222222222", 1) + _, err = handlers_eks.GetOrCreateAccountBucket(t.Context(), js, "222222222222") require.NoError(t, err) _, err = kvutil.GetOrCreateBucket(t.Context(), js, "something-else", 1) require.NoError(t, err) @@ -51,7 +51,7 @@ func TestAccountWatchBuckets_NoAccountsIsNotAnError(t *testing.T) { func TestAccountWatchBuckets_BucketsAreWatchable(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - _, err := handlers_eks.GetOrCreateAccountBucket(t.Context(), js, "111111111111", 1) + _, err := handlers_eks.GetOrCreateAccountBucket(t.Context(), js, "111111111111") require.NoError(t, err) buckets, err := handlers_eks.AccountWatchBuckets(t.Context(), nc) diff --git a/spinifex/handlers/elbv2/haproxy.go b/spinifex/handlers/elbv2/haproxy.go index 10b469ae9..f5c11dad1 100644 --- a/spinifex/handlers/elbv2/haproxy.go +++ b/spinifex/handlers/elbv2/haproxy.go @@ -218,7 +218,7 @@ func buildHAProxyConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgB if validHTTPStatusCode(fr.StatusCode) { hf.StatusCode = fr.StatusCode } - if validContentType(fr.ContentType) { + if contentTypeRegex.MatchString(fr.ContentType) { hf.ContentType = fr.ContentType } if validFixedResponseBody(fr.MessageBody) { @@ -529,11 +529,6 @@ func validHTTPStatusCode(s string) bool { return true } -// validContentType accepts a conservative type/subtype MIME string. -func validContentType(s string) bool { - return contentTypeRegex.MatchString(s) -} - // validFixedResponseBody rejects bytes that would terminate or inject into the HAProxy directive. func validFixedResponseBody(s string) bool { if s == "" { diff --git a/spinifex/handlers/iam/account_lifecycle_test.go b/spinifex/handlers/iam/account_lifecycle_test.go index 3cbe5912a..26b118d54 100644 --- a/spinifex/handlers/iam/account_lifecycle_test.go +++ b/spinifex/handlers/iam/account_lifecycle_test.go @@ -16,7 +16,7 @@ func newLifecycleService(t *testing.T) handlers_iam.IAMService { masterKey, err := handlers_iam.GenerateMasterKey() require.NoError(t, err) - svc, err := handlers_iam.NewIAMServiceImpl(t.Context(), nc, masterKey, 1) + svc, err := handlers_iam.NewIAMServiceImpl(t.Context(), nc, masterKey) require.NoError(t, err) return svc } diff --git a/spinifex/handlers/iam/account_test.go b/spinifex/handlers/iam/account_test.go index 5b4df9082..637eb1e5d 100644 --- a/spinifex/handlers/iam/account_test.go +++ b/spinifex/handlers/iam/account_test.go @@ -407,7 +407,7 @@ func TestCreateAccount_PublishesEvent(t *testing.T) { masterKey, err := GenerateMasterKey() require.NoError(t, err) - svc, err := NewIAMServiceImpl(t.Context(), nc, masterKey, 1) + svc, err := NewIAMServiceImpl(t.Context(), nc, masterKey) require.NoError(t, err) eventCh := make(chan *nats.Msg, 1) diff --git a/spinifex/handlers/iam/groups.go b/spinifex/handlers/iam/groups.go index 3945704d7..1e885b7dd 100644 --- a/spinifex/handlers/iam/groups.go +++ b/spinifex/handlers/iam/groups.go @@ -15,6 +15,7 @@ import ( "github.com/aws/aws-sdk-go/service/iam" "github.com/nats-io/nats.go/jetstream" + iamarn "github.com/mulgadc/bluebottle/pkg/auth" "github.com/mulgadc/spinifex/spinifex/foundation/aws/arn" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil" @@ -311,7 +312,7 @@ func (s *IAMServiceImpl) AttachGroupPolicy(accountID string, input *iam.AttachGr kvKey := accountID + "." + groupName // AWS-managed ARNs are stored opaquely (like roles); customer-managed ARNs must exist. - if !isAWSManagedPolicyARN(policyARN) { + if !iamarn.IsAWSManagedPolicyARN(policyARN) { if _, err := s.getPolicyByARN(ctx, accountID, policyARN); err != nil { return nil, err } @@ -377,25 +378,7 @@ func (s *IAMServiceImpl) ListAttachedGroupPolicies(accountID string, input *iam. return nil, err } - var attached []*iam.AttachedPolicy - for _, arn := range group.AttachedPolicies { - if isAWSManagedPolicyARN(arn) { - attached = append(attached, &iam.AttachedPolicy{ - PolicyArn: aws.String(arn), - PolicyName: aws.String(managedPolicyNameFromARN(arn)), - }) - continue - } - policy, err := s.getPolicyByARN(ctx, accountID, arn) - if err != nil { - slog.Warn("ListAttachedGroupPolicies: policy not found for ARN", "arn", arn, "err", err) - continue - } - attached = append(attached, &iam.AttachedPolicy{ - PolicyArn: aws.String(policy.ARN), - PolicyName: aws.String(policy.PolicyName), - }) - } + attached := s.attachedPolicies(ctx, accountID, group.AttachedPolicies, aws.StringValue(input.PathPrefix)) return &iam.ListAttachedGroupPoliciesOutput{ AttachedPolicies: attached, diff --git a/spinifex/handlers/iam/groups_test.go b/spinifex/handlers/iam/groups_test.go index 40b3836fb..c3e8f9128 100644 --- a/spinifex/handlers/iam/groups_test.go +++ b/spinifex/handlers/iam/groups_test.go @@ -83,7 +83,7 @@ func TestCreateGroup_InvalidName(t *testing.T) { GroupName: aws.String(longName), }) assert.Error(t, err) - assert.Contains(t, err.Error(), awserrors.ErrorIAMInvalidInput) + assert.Contains(t, err.Error(), awserrors.ErrorValidationError) } func TestCreateGroup_InvalidPath(t *testing.T) { diff --git a/spinifex/handlers/iam/instance_profiles.go b/spinifex/handlers/iam/instance_profiles.go index 36dfc5573..99e25cb11 100644 --- a/spinifex/handlers/iam/instance_profiles.go +++ b/spinifex/handlers/iam/instance_profiles.go @@ -40,6 +40,10 @@ func (s *IAMServiceImpl) CreateInstanceProfile(accountID string, input *iam.Crea } } + if err := validateTags(input.Tags, exactKeys); err != nil { + return nil, err + } + profileID, err := generateIAMID("AIPA") if err != nil { return nil, fmt.Errorf("generate instance profile ID: %w", err) @@ -261,25 +265,28 @@ func (s *IAMServiceImpl) ResolveInstanceProfile(accountID, nameOrARN string) (*I return s.getInstanceProfile(ctx, accountID, nameOrARN) } - profileAccountID, profileName, err := parseInstanceProfileARN(nameOrARN) - if err != nil { + var profile *InstanceProfile + _, _, err := auth.ResolveInstanceProfileARN(nameOrARN, func(profileAccountID, profileName string) (string, error) { + if profileAccountID != accountID { + return "", errors.New(awserrors.ErrorAccessDenied) + } + p, err := s.getInstanceProfile(ctx, accountID, profileName) + if err != nil { + return "", err + } + profile = p + return p.ARN, nil + }) + switch { + case errors.Is(err, auth.ErrInvalidInstanceProfileARN): + return nil, errors.New(awserrors.ErrorInvalidIamInstanceProfileArnMalformed) + case errors.Is(err, auth.ErrInstanceProfileARNMismatch): + // A non-canonical spelling names no stored profile. + return nil, errors.New(awserrors.ErrorIAMNoSuchEntity) + case err != nil: return nil, err } - if profileAccountID != accountID { - return nil, errors.New(awserrors.ErrorAccessDenied) - } - return s.getInstanceProfile(ctx, accountID, profileName) -} - -// parseInstanceProfileARN extracts accountID and profile name from an IAM -// instance-profile ARN, reporting every malformed shape as the one AWS error -// code this API returns. -func parseInstanceProfileARN(arnStr string) (accountID, name string, err error) { - accountID, name, err = auth.ParseInstanceProfileARN(arnStr) - if err != nil { - return "", "", errors.New(awserrors.ErrorInvalidIamInstanceProfileArnMalformed) - } - return accountID, name, nil + return profile, nil } // TagInstanceProfile upserts tags on an instance profile under CAS, like the @@ -287,13 +294,13 @@ func parseInstanceProfileARN(arnStr string) (accountID, name string, err error) // RoleName and silently undo a concurrent role attach. func (s *IAMServiceImpl) TagInstanceProfile(accountID string, input *iam.TagInstanceProfileInput) (*iam.TagInstanceProfileOutput, error) { ctx := context.Background() - if err := validateTags(input.Tags); err != nil { + if err := validateTags(input.Tags, exactKeys); err != nil { return nil, err } profileName := *input.InstanceProfileName err := s.updateInstanceProfileCAS(ctx, accountID, profileName, func(p *InstanceProfile) (bool, error) { - merged := mergeTags(p.Tags, input.Tags) + merged := mergeTags(p.Tags, input.Tags, exactKeys) if len(merged) > maxTagsPerResource { return false, errors.New(awserrors.ErrorIAMLimitExceeded) } @@ -311,10 +318,13 @@ func (s *IAMServiceImpl) TagInstanceProfile(accountID string, input *iam.TagInst // UntagInstanceProfile removes the named tag keys from an instance profile; // unknown keys are a no-op. func (s *IAMServiceImpl) UntagInstanceProfile(accountID string, input *iam.UntagInstanceProfileInput) (*iam.UntagInstanceProfileOutput, error) { + if err := validateTagKeys(input.TagKeys); err != nil { + return nil, err + } ctx := context.Background() profileName := *input.InstanceProfileName err := s.updateInstanceProfileCAS(ctx, accountID, profileName, func(p *InstanceProfile) (bool, error) { - kept := removeTagKeys(p.Tags, input.TagKeys) + kept := removeTagKeys(p.Tags, input.TagKeys, exactKeys) if len(kept) == len(p.Tags) { return false, nil } diff --git a/spinifex/handlers/iam/instance_profiles_test.go b/spinifex/handlers/iam/instance_profiles_test.go index 9a24e40e2..a12f17685 100644 --- a/spinifex/handlers/iam/instance_profiles_test.go +++ b/spinifex/handlers/iam/instance_profiles_test.go @@ -70,7 +70,7 @@ func TestCreateInstanceProfile_InvalidName(t *testing.T) { InstanceProfileName: aws.String(longName), }) assert.Error(t, err) - assert.Contains(t, err.Error(), awserrors.ErrorIAMInvalidInput) + assert.Contains(t, err.Error(), awserrors.ErrorValidationError) } func TestCreateInstanceProfile_InvalidPath(t *testing.T) { @@ -686,6 +686,29 @@ func TestResolveInstanceProfile_ARNNotFound(t *testing.T) { assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity) } +// The store is keyed by name alone, so an ARN that spells a different path +// must not resolve to the profile its trailing name matches. +func TestResolveInstanceProfile_NonCanonicalARNRejected(t *testing.T) { + t.Parallel() + svc := setupTestIAMService(t) + createTestInstanceProfile(t, svc, "flat-profile") + _, err := svc.CreateInstanceProfile(testAccountID, &iam.CreateInstanceProfileInput{ + InstanceProfileName: aws.String("pathed-profile"), + Path: aws.String("/team/"), + }) + require.NoError(t, err) + + for _, arn := range []string{ + "arn:aws:iam::" + testAccountID + ":instance-profile/decoy/flat-profile", + "arn:aws:iam::" + testAccountID + ":instance-profile/pathed-profile", + } { + _, err := svc.ResolveInstanceProfile(testAccountID, arn) + require.Error(t, err, arn) + // The EC2 gateway maps this to InvalidIamInstanceProfile.NotFound by exact match. + assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, err.Error(), arn) + } +} + func TestResolveInstanceProfile_MalformedARN(t *testing.T) { t.Parallel() svc := setupTestIAMService(t) diff --git a/spinifex/handlers/iam/managed_policies.go b/spinifex/handlers/iam/managed_policies.go index 0eeee068c..84ddc0bb8 100644 --- a/spinifex/handlers/iam/managed_policies.go +++ b/spinifex/handlers/iam/managed_policies.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "fmt" + + iamarn "github.com/mulgadc/bluebottle/pkg/auth" ) // awsManagedPolicyDocs maps well-known AWS-managed policy ARNs to the grant @@ -311,7 +313,7 @@ func builtinManagedPolicyDoc(arn string) (PolicyDocument, bool) { // failing the whole request. Customer-managed ARNs are fetched from KV and // fail closed (error) when unresolvable. func (s *IAMServiceImpl) resolveAttachedPolicy(ctx context.Context, accountID, arn string) (doc PolicyDocument, include bool, err error) { - if isAWSManagedPolicyARN(arn) { + if iamarn.IsAWSManagedPolicyARN(arn) { if d, ok := builtinManagedPolicyDoc(arn); ok { return d, true, nil } diff --git a/spinifex/handlers/iam/managed_policies_test.go b/spinifex/handlers/iam/managed_policies_test.go index 96c2d728b..eb2871d96 100644 --- a/spinifex/handlers/iam/managed_policies_test.go +++ b/spinifex/handlers/iam/managed_policies_test.go @@ -67,7 +67,7 @@ func TestNewIAMServiceImpl_PropagatesBuiltinManagedPolicyParseError(t *testing.T builtinManagedPolicyParseErr = parseErr t.Cleanup(func() { builtinManagedPolicyParseErr = previousErr }) - _, err := NewIAMServiceImpl(t.Context(), nil, make([]byte, 32), 1) + _, err := NewIAMServiceImpl(t.Context(), nil, make([]byte, 32)) require.ErrorIs(t, err, parseErr) assert.ErrorContains(t, err, "init builtin managed policies") } diff --git a/spinifex/handlers/iam/oidc_providers.go b/spinifex/handlers/iam/oidc_providers.go index 9ba46a041..8ba60ee9b 100644 --- a/spinifex/handlers/iam/oidc_providers.go +++ b/spinifex/handlers/iam/oidc_providers.go @@ -9,8 +9,10 @@ import ( "fmt" "log/slog" "net/url" + "slices" "strings" "time" + "unicode/utf8" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/iam" @@ -49,8 +51,8 @@ func OIDCProviderARN(accountID, issuerHostPath string) string { } // GetOrCreateIAMAccountBucket opens the per-account IAM bucket, creating it on first use. -func GetOrCreateIAMAccountBucket(ctx context.Context, js jetstream.JetStream, accountID string, replicas int) (jetstream.KeyValue, error) { - return kvutil.GetOrCreateBucketWithReplicas(ctx, js, IAMAccountBucketName(accountID), 1, max(replicas, 1)) +func GetOrCreateIAMAccountBucket(ctx context.Context, js jetstream.JetStream, accountID string) (jetstream.KeyValue, error) { + return kvutil.GetOrCreateBucket(ctx, js, IAMAccountBucketName(accountID), 1) } // OIDCProviderRecord is the stored shape of a registered OIDC identity provider. @@ -120,14 +122,43 @@ func awsStrings(in []*string) []string { return out } +// validateOIDCProviderLists checks every client ID and thumbprint against the +// model's lengths, before the Url as AWS orders them. AWS names each list once, +// however many of its entries break. +func validateOIDCProviderLists(clientIDs, thumbprints []*string) error { + outside := func(minLen, maxLen int) func(*string) bool { + return func(v *string) bool { + n := utf8.RuneCountInString(aws.StringValue(v)) + return v == nil || n < minLen || n > maxLen + } + } + var violations []string + if slices.ContainsFunc(clientIDs, outside(1, 255)) { + violations = append(violations, "Value at 'clientIDList' failed to satisfy constraint: Member must satisfy constraint: "+ + "[Member must have length less than or equal to 255, Member must have length greater than or equal to 1]") + } + if slices.ContainsFunc(thumbprints, outside(40, 40)) { + violations = append(violations, "Value at 'thumbprintList' failed to satisfy constraint: Member must satisfy constraint: "+ + "[Member must have length less than or equal to 40, Member must have length greater than or equal to 40]") + } + return validationError(violations) +} + func (s *IAMServiceImpl) CreateOpenIDConnectProvider(accountID string, input *iam.CreateOpenIDConnectProviderInput) (*iam.CreateOpenIDConnectProviderOutput, error) { ctx := context.Background() + if err := validateOIDCProviderLists(input.ClientIDList, input.ThumbprintList); err != nil { + return nil, err + } issuer := aws.StringValue(input.Url) if err := validateOIDCProviderURL(issuer); err != nil { slog.Debug("CreateOpenIDConnectProvider: invalid Url", "url", issuer, "err", err) return nil, awserrors.Errorf(awserrors.ErrorIAMInvalidInput, "The specified value for url is invalid: %v", err) } + if err := validateTags(input.Tags, exactKeys); err != nil { + return nil, err + } + record := OIDCProviderRecord{ Url: issuer, ClientIDList: awsStrings(input.ClientIDList), @@ -140,7 +171,7 @@ func (s *IAMServiceImpl) CreateOpenIDConnectProvider(accountID string, input *ia return nil, fmt.Errorf("marshal OIDC provider: %w", err) } - kv, err := GetOrCreateIAMAccountBucket(ctx, s.js, accountID, s.replicas) + kv, err := GetOrCreateIAMAccountBucket(ctx, s.js, accountID) if err != nil { return nil, fmt.Errorf("open IAM account bucket: %w", err) } @@ -261,7 +292,7 @@ func (s *IAMServiceImpl) DeleteOpenIDConnectProvider(accountID string, input *ia // read-modify-write Put like the other writers here (no CAS). func (s *IAMServiceImpl) TagOpenIDConnectProvider(accountID string, input *iam.TagOpenIDConnectProviderInput) (*iam.TagOpenIDConnectProviderOutput, error) { ctx := context.Background() - if err := validateTags(input.Tags); err != nil { + if err := validateTags(input.Tags, exactKeys); err != nil { return nil, err } @@ -271,7 +302,7 @@ func (s *IAMServiceImpl) TagOpenIDConnectProvider(accountID string, input *iam.T return nil, err } - merged := mergeTags(record.Tags, input.Tags) + merged := mergeTags(record.Tags, input.Tags, exactKeys) if len(merged) > maxTagsPerResource { return nil, errors.New(awserrors.ErrorIAMLimitExceeded) } @@ -288,6 +319,9 @@ func (s *IAMServiceImpl) TagOpenIDConnectProvider(accountID string, input *iam.T // UntagOpenIDConnectProvider removes the named tag keys from an OIDC provider; // unknown keys are a no-op. func (s *IAMServiceImpl) UntagOpenIDConnectProvider(accountID string, input *iam.UntagOpenIDConnectProviderInput) (*iam.UntagOpenIDConnectProviderOutput, error) { + if err := validateTagKeys(input.TagKeys); err != nil { + return nil, err + } ctx := context.Background() arn := aws.StringValue(input.OpenIDConnectProviderArn) record, err := s.getOIDCProvider(ctx, accountID, arn) @@ -295,7 +329,7 @@ func (s *IAMServiceImpl) UntagOpenIDConnectProvider(accountID string, input *iam return nil, err } - record.Tags = removeTagKeys(record.Tags, input.TagKeys) + record.Tags = removeTagKeys(record.Tags, input.TagKeys, exactKeys) if err := s.putOIDCProvider(ctx, accountID, record); err != nil { return nil, err diff --git a/spinifex/handlers/iam/oidc_providers_test.go b/spinifex/handlers/iam/oidc_providers_test.go index 7e5115556..a2c83fb20 100644 --- a/spinifex/handlers/iam/oidc_providers_test.go +++ b/spinifex/handlers/iam/oidc_providers_test.go @@ -214,3 +214,44 @@ func TestIssuerProviderARNRoundTrip(t *testing.T) { t.Fatal("round-trip key mismatch") } } + +// The messages are the ones AWS returned; the lists are checked before the Url. +func TestCreateOpenIDConnectProvider_ListEntryLengths(t *testing.T) { + t.Parallel() + const ( + clientIDMsg = "1 validation error detected: Value at 'clientIDList' failed to satisfy constraint: Member must satisfy constraint: [Member must have length less than or equal to 255, Member must have length greater than or equal to 1]" + thumbprintMsg = "1 validation error detected: Value at 'thumbprintList' failed to satisfy constraint: Member must satisfy constraint: [Member must have length less than or equal to 40, Member must have length greater than or equal to 40]" + ) + thumbprint := strings.Repeat("9", 40) + cases := []struct { + name string + clientIDs []string + thumbprints []string + wantMsg string + }{ + {"empty client ID", []string{"sts.amazonaws.com", ""}, []string{thumbprint}, clientIDMsg}, + {"256-character client ID", []string{strings.Repeat("c", 256)}, []string{thumbprint}, clientIDMsg}, + {"39-character thumbprint", []string{"sts.amazonaws.com"}, []string{strings.Repeat("9", 39)}, thumbprintMsg}, + {"41-character thumbprint", []string{"sts.amazonaws.com"}, []string{thumbprint, strings.Repeat("9", 41)}, thumbprintMsg}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + svc := setupTestIAMService(t) + _, err := svc.CreateOpenIDConnectProvider(testAccountID, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String("http://not-https.example.com"), ClientIDList: aws.StringSlice(tc.clientIDs), ThumbprintList: aws.StringSlice(tc.thumbprints), + }) + requireIAMError(t, err, awserrors.ErrorValidationError, tc.wantMsg) + }) + } + + svc := setupTestIAMService(t) + _, err := svc.CreateOpenIDConnectProvider(testAccountID, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String("https://oidc.example.com/id/BOUNDS"), + ClientIDList: aws.StringSlice([]string{"c", strings.Repeat("c", 255)}), + ThumbprintList: aws.StringSlice([]string{thumbprint}), + }) + if err != nil { + t.Fatalf("entries at the length bounds: %v", err) + } +} diff --git a/spinifex/handlers/iam/provision_test.go b/spinifex/handlers/iam/provision_test.go index 8279d8ae2..22887fa5b 100644 --- a/spinifex/handlers/iam/provision_test.go +++ b/spinifex/handlers/iam/provision_test.go @@ -20,7 +20,7 @@ func newIAMService(t *testing.T) *handlers_iam.IAMServiceImpl { masterKey, err := handlers_iam.GenerateMasterKey() require.NoError(t, err) - svc, err := handlers_iam.NewIAMServiceImpl(t.Context(), nc, masterKey, 1) + svc, err := handlers_iam.NewIAMServiceImpl(t.Context(), nc, masterKey) require.NoError(t, err) return svc } diff --git a/spinifex/handlers/iam/retry.go b/spinifex/handlers/iam/retry.go index 126e1f31e..e523603b7 100644 --- a/spinifex/handlers/iam/retry.go +++ b/spinifex/handlers/iam/retry.go @@ -3,10 +3,12 @@ package handlers_iam import ( "context" "fmt" - "github.com/mulgadc/spinifex/spinifex/foundation/telemetry" "log/slog" "time" + "github.com/mulgadc/spinifex/spinifex/clustersize" + telemetry "github.com/mulgadc/spinifex/spinifex/foundation/telemetry" + "github.com/nats-io/nats.go" ) @@ -16,7 +18,7 @@ import ( // leave the service nil for the process lifetime. Blocks up to maxWait, then // returns the last error. Callers that legitimately run without a master key // must guard the call themselves. -func NewIAMServiceWithRetry(ctx context.Context, natsConn *nats.Conn, masterKey []byte, clusterSize int) (*IAMServiceImpl, error) { +func NewIAMServiceWithRetry(ctx context.Context, natsConn *nats.Conn, masterKey []byte) (*IAMServiceImpl, error) { const maxWait = 5 * time.Minute retryDelay := 500 * time.Millisecond start := time.Now() @@ -24,20 +26,27 @@ func NewIAMServiceWithRetry(ctx context.Context, natsConn *nats.Conn, masterKey for { attempt++ - svc, err := NewIAMServiceImpl(ctx, natsConn, masterKey, clusterSize) + svc, err := NewIAMServiceImpl(ctx, natsConn, masterKey) if err == nil { if attempt > 1 { - slog.Info("IAM service initialized after retry", "attempts", attempt, "elapsed_ms", otelsetup.Millis(time.Since(start))) + slog.Info("IAM service initialized after retry", "attempts", attempt, "elapsed_ms", telemetry.Millis(time.Since(start))) } return svc, nil } + // An undeclared cluster size is a configuration fault, not a cluster that + // has yet to form, so retrying it turns a clear error into five minutes + // of waiting followed by a misleading one. + if clustersize.Permanent(err) { + return nil, err + } + elapsed := time.Since(start) if elapsed >= maxWait { return nil, fmt.Errorf("IAM service unavailable after %s (%d attempts): %w", elapsed.Round(time.Second), attempt, err) } - slog.Warn("IAM service not ready (waiting for JetStream cluster quorum)", "error", err, "attempt", attempt, "elapsed_ms", otelsetup.Millis(elapsed), "retry_in_ms", otelsetup.Millis(retryDelay)) + slog.Warn("IAM service not ready (waiting for JetStream cluster quorum)", "error", err, "attempt", attempt, "elapsed_ms", telemetry.Millis(elapsed), "retry_in_ms", telemetry.Millis(retryDelay)) time.Sleep(retryDelay) retryDelay = min(retryDelay*2, 10*time.Second) } diff --git a/spinifex/handlers/iam/retry_test.go b/spinifex/handlers/iam/retry_test.go new file mode 100644 index 000000000..464191d39 --- /dev/null +++ b/spinifex/handlers/iam/retry_test.go @@ -0,0 +1,38 @@ +package handlers_iam_test + +import ( + "testing" + "time" + + "github.com/mulgadc/spinifex/internal/testkit" + "github.com/mulgadc/spinifex/spinifex/clustersize" + handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" + "github.com/stretchr/testify/require" +) + +// TestNewIAMServiceWithRetry_StopsOnAConfigFault covers the difference between a +// cluster that has yet to form and one that was misconfigured. +// +// The retry loop exists for the first: on a cold multi-node boot the KV store +// needs a quorum that does not exist yet, so waiting is right. An undeclared +// cluster size is the second, and waiting five minutes for it produces a timeout +// that names quorum as the cause when the cause was a config the process never +// loaded. This test bounds that: it must come back fast, and it must come back +// saying what is actually wrong. +func TestNewIAMServiceWithRetry_StopsOnAConfigFault(t *testing.T) { + _, nc, _ := testutil.StartTestJetStream(t) + clustersize.RedeclareForTest(t, 0) + + masterKey, err := handlers_iam.GenerateMasterKey() + require.NoError(t, err) + + start := time.Now() + _, err = handlers_iam.NewIAMServiceWithRetry(t.Context(), nc, masterKey) + elapsed := time.Since(start) + + require.Error(t, err, "an undeclared cluster size must not look like a working IAM service") + require.ErrorIs(t, err, clustersize.ErrUndeclared, + "the error has to name the config fault, not the quorum the loop was waiting for") + require.Lessf(t, elapsed, 10*time.Second, + "returned after %s; a permanent fault must not consume the retry budget", elapsed) +} diff --git a/spinifex/handlers/iam/roles.go b/spinifex/handlers/iam/roles.go index f09b1d56e..c865a8fe5 100644 --- a/spinifex/handlers/iam/roles.go +++ b/spinifex/handlers/iam/roles.go @@ -27,7 +27,7 @@ const ( maxTrustPolicyDocumentSize = 2048 defaultMaxSessionDuration = int64(3600) - minMaxSessionDuration = int64(900) + minMaxSessionDuration = int64(3600) maxMaxSessionDuration = int64(43200) // Bound on optimistic-concurrency retries when a concurrent writer wins the @@ -55,6 +55,13 @@ func (s *IAMServiceImpl) CreateRole(accountID string, input *iam.CreateRoleInput } } + if err := validateDescription(input.Description); err != nil { + return nil, err + } + if err := validateMaxSessionDuration(input.MaxSessionDuration); err != nil { + return nil, err + } + // Carry the reason: a bare code cannot tell a caller which statement or key // was refused, and the gateway logs the returned error, so one message // serves both the client and the operator. @@ -66,9 +73,10 @@ func (s *IAMServiceImpl) CreateRole(accountID string, input *iam.CreateRoleInput maxSession := defaultMaxSessionDuration if input.MaxSessionDuration != nil { maxSession = *input.MaxSessionDuration - if maxSession < minMaxSessionDuration || maxSession > maxMaxSessionDuration { - return nil, errors.New(awserrors.ErrorValidationError) - } + } + + if err := validateTags(input.Tags, foldedKeys); err != nil { + return nil, err } roleID, err := generateIAMID("AROA") @@ -210,9 +218,29 @@ func (s *IAMServiceImpl) DeleteRole(accountID string, input *iam.DeleteRoleInput return &iam.DeleteRoleOutput{}, nil } +// validateMaxSessionDuration enforces the role session limit of one to twelve +// hours, in seconds, with the ValidationError AWS returns. +func validateMaxSessionDuration(d *int64) error { + switch { + case d == nil: + return nil + case *d < minMaxSessionDuration: + return validationError([]string{fmt.Sprintf("Value at 'maxSessionDuration' failed to satisfy constraint: Member must have value greater than or equal to %d", minMaxSessionDuration)}) + case *d > maxMaxSessionDuration: + return validationError([]string{fmt.Sprintf("Value at 'maxSessionDuration' failed to satisfy constraint: Member must have value less than or equal to %d", maxMaxSessionDuration)}) + } + return nil +} + func (s *IAMServiceImpl) UpdateRole(accountID string, input *iam.UpdateRoleInput) (*iam.UpdateRoleOutput, error) { ctx := context.Background() roleName := *input.RoleName + if err := validateDescription(input.Description); err != nil { + return nil, err + } + if err := validateMaxSessionDuration(input.MaxSessionDuration); err != nil { + return nil, err + } role, err := s.getRole(ctx, accountID, roleName) if err != nil { @@ -223,11 +251,7 @@ func (s *IAMServiceImpl) UpdateRole(accountID string, input *iam.UpdateRoleInput role.Description = *input.Description } if input.MaxSessionDuration != nil { - dur := *input.MaxSessionDuration - if dur < minMaxSessionDuration || dur > maxMaxSessionDuration { - return nil, errors.New(awserrors.ErrorValidationError) - } - role.MaxSessionDuration = dur + role.MaxSessionDuration = *input.MaxSessionDuration } data, err := json.Marshal(role) @@ -280,7 +304,7 @@ func (s *IAMServiceImpl) AttachRolePolicy(accountID string, input *iam.AttachRol // AmazonEKSWorkerNodePolicy, AmazonEKS_CNI_Policy, ...). Store them opaquely // so ListAttachedRolePolicies / DescribeNodegroup round-trip instead of // failing NoSuchEntity. Customer-managed ARNs must still exist. - if !isAWSManagedPolicyARN(policyARN) { + if !iamarn.IsAWSManagedPolicyARN(policyARN) { if _, err := s.getPolicyByARN(ctx, accountID, policyARN); err != nil { return nil, err } @@ -329,25 +353,7 @@ func (s *IAMServiceImpl) ListAttachedRolePolicies(accountID string, input *iam.L return nil, err } - var attached []*iam.AttachedPolicy - for _, arn := range role.AttachedPolicies { - if isAWSManagedPolicyARN(arn) { - attached = append(attached, &iam.AttachedPolicy{ - PolicyArn: aws.String(arn), - PolicyName: aws.String(managedPolicyNameFromARN(arn)), - }) - continue - } - policy, err := s.getPolicyByARN(ctx, accountID, arn) - if err != nil { - slog.Warn("ListAttachedRolePolicies: policy not found for ARN", "arn", arn, "err", err) - continue - } - attached = append(attached, &iam.AttachedPolicy{ - PolicyArn: aws.String(policy.ARN), - PolicyName: aws.String(policy.PolicyName), - }) - } + attached := s.attachedPolicies(ctx, accountID, role.AttachedPolicies, aws.StringValue(input.PathPrefix)) return &iam.ListAttachedRolePoliciesOutput{ AttachedPolicies: attached, @@ -465,13 +471,13 @@ func (s *IAMServiceImpl) ListRolePolicies(accountID string, input *iam.ListRoleP // TagRole upserts tags on a role under CAS, like the other role writers. func (s *IAMServiceImpl) TagRole(accountID string, input *iam.TagRoleInput) (*iam.TagRoleOutput, error) { ctx := context.Background() - if err := validateTags(input.Tags); err != nil { + if err := validateTags(input.Tags, foldedKeys); err != nil { return nil, err } roleName := *input.RoleName err := s.updateRoleCAS(ctx, accountID, roleName, func(role *Role) (bool, error) { - merged := mergeTags(role.Tags, input.Tags) + merged := mergeTags(role.Tags, input.Tags, foldedKeys) if len(merged) > maxTagsPerResource { return false, errors.New(awserrors.ErrorIAMLimitExceeded) } @@ -488,10 +494,13 @@ func (s *IAMServiceImpl) TagRole(accountID string, input *iam.TagRoleInput) (*ia // UntagRole removes the named tag keys from a role; unknown keys are a no-op. func (s *IAMServiceImpl) UntagRole(accountID string, input *iam.UntagRoleInput) (*iam.UntagRoleOutput, error) { + if err := validateTagKeys(input.TagKeys); err != nil { + return nil, err + } ctx := context.Background() roleName := *input.RoleName err := s.updateRoleCAS(ctx, accountID, roleName, func(role *Role) (bool, error) { - role.Tags = removeTagKeys(role.Tags, input.TagKeys) + role.Tags = removeTagKeys(role.Tags, input.TagKeys, foldedKeys) return true, nil }) if err != nil { @@ -547,14 +556,6 @@ func (s *IAMServiceImpl) GetRolePolicies(accountID, roleName string) ([]PolicyDo return docs, nil } -// isAWSManagedPolicyARN reports whether arn is an AWS-managed policy ARN -// (arn:aws:iam::aws:policy/...). These are not provisioned in Spinifex but are -// stored and round-tripped opaquely so stock EKS tooling that attaches them -// works without a backing policy document. -func isAWSManagedPolicyARN(arn string) bool { - return iamarn.IsAWSManagedPolicyARN(arn) -} - // managedPolicyNameFromARN returns the final path segment of an AWS-managed // policy ARN, e.g. .../service-role/AmazonEKS_CNI_Policy -> AmazonEKS_CNI_Policy. // Display-only, so an unparseable ARN falls back to itself rather than erroring. diff --git a/spinifex/handlers/iam/roles_test.go b/spinifex/handlers/iam/roles_test.go index e9c88850d..be69317ae 100644 --- a/spinifex/handlers/iam/roles_test.go +++ b/spinifex/handlers/iam/roles_test.go @@ -3,6 +3,7 @@ package handlers_iam import ( "encoding/json" "slices" + "strconv" "strings" "sync" "testing" @@ -102,7 +103,7 @@ func TestCreateRole_InvalidName(t *testing.T) { AssumeRolePolicyDocument: aws.String(validTrustPolicy()), }) assert.Error(t, err) - assert.Contains(t, err.Error(), awserrors.ErrorIAMInvalidInput) + assert.Contains(t, err.Error(), awserrors.ErrorValidationError) } func TestCreateRole_InvalidPath(t *testing.T) { @@ -320,30 +321,45 @@ func TestCreateRole_TrustPolicy_DenyEffectAccepted(t *testing.T) { require.NoError(t, err) } -func TestCreateRole_MaxSessionDuration_TooSmall(t *testing.T) { +// The bounds and messages are the ones AWS returned; both calls check the +// value before the trust policy or the role lookup. +func TestRole_MaxSessionDurationBounds(t *testing.T) { t.Parallel() - svc := setupTestIAMService(t) - - _, err := svc.CreateRole(testAccountID, &iam.CreateRoleInput{ - RoleName: aws.String("short-session"), - AssumeRolePolicyDocument: aws.String(validTrustPolicy()), - MaxSessionDuration: aws.Int64(minMaxSessionDuration - 1), - }) - assert.Error(t, err) - assert.Contains(t, err.Error(), awserrors.ErrorValidationError) -} - -func TestCreateRole_MaxSessionDuration_TooLarge(t *testing.T) { - t.Parallel() - svc := setupTestIAMService(t) - - _, err := svc.CreateRole(testAccountID, &iam.CreateRoleInput{ - RoleName: aws.String("long-session"), - AssumeRolePolicyDocument: aws.String(validTrustPolicy()), - MaxSessionDuration: aws.Int64(maxMaxSessionDuration + 1), - }) - assert.Error(t, err) - assert.Contains(t, err.Error(), awserrors.ErrorValidationError) + const ( + tooShort = "1 validation error detected: Value at 'maxSessionDuration' failed to satisfy constraint: Member must have value greater than or equal to 3600" + tooLong = "1 validation error detected: Value at 'maxSessionDuration' failed to satisfy constraint: Member must have value less than or equal to 43200" + ) + cases := []struct { + seconds int64 + wantMsg string + }{ + {900, tooShort}, + {3599, tooShort}, + {3600, ""}, + {43200, ""}, + {43201, tooLong}, + } + for _, tc := range cases { + t.Run(strconv.FormatInt(tc.seconds, 10), func(t *testing.T) { + t.Parallel() + svc := setupTestIAMService(t) + if tc.wantMsg != "" { + _, err := svc.CreateRole(testAccountID, &iam.CreateRoleInput{ + RoleName: aws.String("r"), AssumeRolePolicyDocument: aws.String("{"), MaxSessionDuration: aws.Int64(tc.seconds), + }) + requireIAMError(t, err, awserrors.ErrorValidationError, tc.wantMsg) + _, err = svc.UpdateRole(testAccountID, &iam.UpdateRoleInput{RoleName: aws.String("missing"), MaxSessionDuration: aws.Int64(tc.seconds)}) + requireIAMError(t, err, awserrors.ErrorValidationError, tc.wantMsg) + return + } + _, err := svc.CreateRole(testAccountID, &iam.CreateRoleInput{ + RoleName: aws.String("r"), AssumeRolePolicyDocument: aws.String(validTrustPolicy()), MaxSessionDuration: aws.Int64(tc.seconds), + }) + require.NoError(t, err) + _, err = svc.UpdateRole(testAccountID, &iam.UpdateRoleInput{RoleName: aws.String("r"), MaxSessionDuration: aws.Int64(tc.seconds)}) + require.NoError(t, err) + }) + } } func TestCreateRole_Duplicate(t *testing.T) { @@ -541,19 +557,6 @@ func TestUpdateRole_NotFound(t *testing.T) { assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity) } -func TestUpdateRole_InvalidMaxSessionDuration(t *testing.T) { - t.Parallel() - svc := setupTestIAMService(t) - createTestRole(t, svc, "session-role") - - _, err := svc.UpdateRole(testAccountID, &iam.UpdateRoleInput{ - RoleName: aws.String("session-role"), - MaxSessionDuration: aws.Int64(60), - }) - assert.Error(t, err) - assert.Contains(t, err.Error(), awserrors.ErrorValidationError) -} - func TestUpdateAssumeRolePolicy(t *testing.T) { t.Parallel() svc := setupTestIAMService(t) @@ -1597,3 +1600,35 @@ func TestUpdateAssumeRolePolicy_NotActionRejected(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), awserrors.ErrorIAMMalformedPolicyDocument) } + +// The message is the one AWS returned for a 1001-character description, which +// it checks before the role lookup and the trust policy. +func TestRoleAndPolicy_DescriptionLength(t *testing.T) { + t.Parallel() + svc := setupTestIAMService(t) + const msg = "1 validation error detected: Value at 'description' failed to satisfy constraint: Member must have length less than or equal to 1000" + tooLong := aws.String(strings.Repeat("é", maxDescriptionLength+1)) + + _, err := svc.CreateRole(testAccountID, &iam.CreateRoleInput{ + RoleName: aws.String("r"), AssumeRolePolicyDocument: aws.String("{"), Description: tooLong, + }) + requireIAMError(t, err, awserrors.ErrorValidationError, msg) + _, err = svc.UpdateRole(testAccountID, &iam.UpdateRoleInput{RoleName: aws.String("missing"), Description: tooLong}) + requireIAMError(t, err, awserrors.ErrorValidationError, msg) + _, err = svc.CreatePolicy(testAccountID, &iam.CreatePolicyInput{ + PolicyName: aws.String("p"), PolicyDocument: aws.String("{"), Description: tooLong, + }) + requireIAMError(t, err, awserrors.ErrorValidationError, msg) + + atMax := aws.String(strings.Repeat("é", maxDescriptionLength)) + _, err = svc.CreateRole(testAccountID, &iam.CreateRoleInput{ + RoleName: aws.String("r"), AssumeRolePolicyDocument: aws.String(validTrustPolicy()), Description: atMax, + }) + require.NoError(t, err) + _, err = svc.UpdateRole(testAccountID, &iam.UpdateRoleInput{RoleName: aws.String("r"), Description: atMax}) + require.NoError(t, err) + _, err = svc.CreatePolicy(testAccountID, &iam.CreatePolicyInput{ + PolicyName: aws.String("p"), PolicyDocument: aws.String(validPolicyDocument()), Description: atMax, + }) + require.NoError(t, err) +} diff --git a/spinifex/handlers/iam/service_impl.go b/spinifex/handlers/iam/service_impl.go index a6d2fe4f0..4bbeac4e9 100644 --- a/spinifex/handlers/iam/service_impl.go +++ b/spinifex/handlers/iam/service_impl.go @@ -10,10 +10,12 @@ import ( "log/slog" "maps" "net/netip" + "regexp" "slices" "strconv" "strings" "time" + "unicode/utf8" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/iam" @@ -54,6 +56,7 @@ const ( maxTagsPerResource = 50 maxTagKeyLength = 128 maxTagValueLength = 256 + maxDescriptionLength = 1000 // LongLivedAccessKeyIDPrefix is the AWS-defined prefix for long-lived IAM access keys. // The access-keys bucket rejects writes with any other prefix to prevent silent privilege escalation. @@ -106,16 +109,13 @@ type IAMServiceImpl struct { instanceProfilesBucket jetstream.KeyValue groupsBucket jetstream.KeyValue key *masterkey.Key - // replicas is the JetStream replication factor for lazily-created per-account buckets. - replicas int } var _ IAMService = (*IAMServiceImpl)(nil) // NewIAMServiceImpl creates a new IAM service backed by NATS JetStream KV. -// clusterSize sets the replication factor; pass 1 for single-node or test setups. // The context bounds bucket creation and the schema migrations only. -func NewIAMServiceImpl(ctx context.Context, natsConn *nats.Conn, masterKey []byte, clusterSize int) (*IAMServiceImpl, error) { +func NewIAMServiceImpl(ctx context.Context, natsConn *nats.Conn, masterKey []byte) (*IAMServiceImpl, error) { if builtinManagedPolicyParseErr != nil { return nil, fmt.Errorf("init builtin managed policies: %w", builtinManagedPolicyParseErr) } @@ -123,14 +123,12 @@ func NewIAMServiceImpl(ctx context.Context, natsConn *nats.Conn, masterKey []byt return nil, fmt.Errorf("master key must be 32 bytes, got %d", len(masterKey)) } - replicas := max(clusterSize, 1) - js, err := jetstream.New(natsConn) if err != nil { return nil, fmt.Errorf("get JetStream context: %w", err) } - usersBucket, err := kvutil.GetOrCreateBucketWithReplicas(ctx, js, KVBucketUsers, 10, replicas) + usersBucket, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketUsers, 10) if err != nil { return nil, fmt.Errorf("init users bucket: %w", err) } @@ -138,7 +136,7 @@ func NewIAMServiceImpl(ctx context.Context, natsConn *nats.Conn, masterKey []byt return nil, fmt.Errorf("migrate %s: %w", KVBucketUsers, err) } - accessKeysBucket, err := kvutil.GetOrCreateBucketWithReplicas(ctx, js, KVBucketAccessKeys, 5, replicas) + accessKeysBucket, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketAccessKeys, 5) if err != nil { return nil, fmt.Errorf("init access keys bucket: %w", err) } @@ -146,7 +144,7 @@ func NewIAMServiceImpl(ctx context.Context, natsConn *nats.Conn, masterKey []byt return nil, fmt.Errorf("migrate %s: %w", KVBucketAccessKeys, err) } - policiesBucket, err := kvutil.GetOrCreateBucketWithReplicas(ctx, js, KVBucketPolicies, 10, replicas) + policiesBucket, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketPolicies, 10) if err != nil { return nil, fmt.Errorf("init policies bucket: %w", err) } @@ -154,7 +152,7 @@ func NewIAMServiceImpl(ctx context.Context, natsConn *nats.Conn, masterKey []byt return nil, fmt.Errorf("migrate %s: %w", KVBucketPolicies, err) } - accountsBucket, err := kvutil.GetOrCreateBucketWithReplicas(ctx, js, KVBucketAccounts, 5, replicas) + accountsBucket, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketAccounts, 5) if err != nil { return nil, fmt.Errorf("init accounts bucket: %w", err) } @@ -162,7 +160,7 @@ func NewIAMServiceImpl(ctx context.Context, natsConn *nats.Conn, masterKey []byt return nil, fmt.Errorf("migrate %s: %w", KVBucketAccounts, err) } - accountCounterBucket, err := kvutil.GetOrCreateBucketWithReplicas(ctx, js, KVBucketAccountCounter, 5, replicas) + accountCounterBucket, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketAccountCounter, 5) if err != nil { return nil, fmt.Errorf("init account counter bucket: %w", err) } @@ -170,7 +168,7 @@ func NewIAMServiceImpl(ctx context.Context, natsConn *nats.Conn, masterKey []byt return nil, fmt.Errorf("migrate %s: %w", KVBucketAccountCounter, err) } - rolesBucket, err := kvutil.GetOrCreateBucketWithReplicas(ctx, js, KVBucketRoles, 10, replicas) + rolesBucket, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketRoles, 10) if err != nil { return nil, fmt.Errorf("init roles bucket: %w", err) } @@ -178,7 +176,7 @@ func NewIAMServiceImpl(ctx context.Context, natsConn *nats.Conn, masterKey []byt return nil, fmt.Errorf("migrate %s: %w", KVBucketRoles, err) } - instanceProfilesBucket, err := kvutil.GetOrCreateBucketWithReplicas(ctx, js, KVBucketInstanceProfiles, 10, replicas) + instanceProfilesBucket, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketInstanceProfiles, 10) if err != nil { return nil, fmt.Errorf("init instance profiles bucket: %w", err) } @@ -186,7 +184,7 @@ func NewIAMServiceImpl(ctx context.Context, natsConn *nats.Conn, masterKey []byt return nil, fmt.Errorf("migrate %s: %w", KVBucketInstanceProfiles, err) } - groupsBucket, err := kvutil.GetOrCreateBucketWithReplicas(ctx, js, KVBucketGroups, 10, replicas) + groupsBucket, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketGroups, 10) if err != nil { return nil, fmt.Errorf("init groups bucket: %w", err) } @@ -206,8 +204,7 @@ func NewIAMServiceImpl(ctx context.Context, natsConn *nats.Conn, masterKey []byt "accounts_bucket", KVBucketAccounts, "roles_bucket", KVBucketRoles, "instance_profiles_bucket", KVBucketInstanceProfiles, - "groups_bucket", KVBucketGroups, - "replicas", replicas) + "groups_bucket", KVBucketGroups) return &IAMServiceImpl{ js: js, @@ -221,18 +218,15 @@ func NewIAMServiceImpl(ctx context.Context, natsConn *nats.Conn, masterKey []byt instanceProfilesBucket: instanceProfilesBucket, groupsBucket: groupsBucket, key: key, - replicas: replicas, }, nil } -// copyTags converts SDK IAM tags into the stored Tag slice, skipping entries -// with a nil key or value. Returns a non-nil (possibly empty) slice. +// copyTags converts SDK IAM tags, already checked by validateTags, into the +// stored Tag slice. Returns a non-nil (possibly empty) slice. func copyTags(tags []*iam.Tag) []Tag { out := make([]Tag, 0, len(tags)) for _, tag := range tags { - if tag.Key != nil && tag.Value != nil { - out = append(out, Tag{Key: *tag.Key, Value: *tag.Value}) - } + out = append(out, Tag{Key: *tag.Key, Value: *tag.Value}) } return out } @@ -249,29 +243,57 @@ func tagsToSDK(tags []Tag) []*iam.Tag { return out } -// validateTags enforces the AWS IAM tag limits on request input: at most 50 -// tags, key length 1-128, value length 0-256, no duplicate keys. -func validateTags(tags []*iam.Tag) error { - if len(tags) > maxTagsPerResource { - return errors.New(awserrors.ErrorIAMLimitExceeded) +// Tag key and value patterns from the IAM model, as AWS enforces them. +var ( + tagKeyPattern = regexp.MustCompile(`^[\p{L}\p{Z}\p{N}_.:/=+\-@]+$`) + tagValuePattern = regexp.MustCompile(`^[\p{L}\p{Z}\p{N}_.:/=+\-@]*$`) +) + +// keyCase is how a resource type compares tag keys: AWS ignores case on users +// and roles, and matches exactly on policies, instance profiles and OIDC +// providers. +type keyCase bool + +const ( + exactKeys keyCase = false + foldedKeys keyCase = true +) + +func (c keyCase) norm(key string) string { + if c == foldedKeys { + return strings.ToLower(key) + } + return key +} + +// validateTags enforces the AWS IAM tag limits on request input: the model's +// list length and its null, length (in characters) and pattern constraints on +// every key and value, then no reserved aws: prefix and no duplicate keys as +// keys compares them. +func validateTags(tags []*iam.Tag, keys keyCase) error { + var violations []string + if len(tags) > maxTagsPerResource { + violations = append(violations, fmt.Sprintf("Value at 'tags' failed to satisfy constraint: Member must have length less than or equal to %d", maxTagsPerResource)) } - seen := make(map[string]struct{}, len(tags)) for i, tag := range tags { field := fmt.Sprintf("tags.%d.member", i+1) - if tag == nil || tag.Key == nil { - return awserrors.Errorf(awserrors.ErrorIAMInvalidInput, - "1 validation error detected: Value null at '%s.key' failed to satisfy constraint: Member must not be null", field) + if tag == nil { + tag = &iam.Tag{} } - key := *tag.Key - if len(key) < 1 { - return lengthViolation(key, field+".key", "greater than or equal to 1") - } - if len(key) > maxTagKeyLength { - return lengthViolation(key, field+".key", fmt.Sprintf("less than or equal to %d", maxTagKeyLength)) - } - if tag.Value != nil && len(*tag.Value) > maxTagValueLength { - return lengthViolation(*tag.Value, field+".value", fmt.Sprintf("less than or equal to %d", maxTagValueLength)) + violations = append(violations, tagMemberViolations(field+".key", tag.Key, 1, maxTagKeyLength, tagKeyPattern)...) + violations = append(violations, tagMemberViolations(field+".value", tag.Value, 0, maxTagValueLength, tagValuePattern)...) + } + if err := validationError(violations); err != nil { + return err + } + for _, tag := range tags { + if strings.HasPrefix(strings.ToLower(*tag.Key), "aws:") { + return awserrors.Errorf(awserrors.ErrorIAMInvalidInput, "Tag keys beginning with aws: are reserved for system use.") } + } + seen := make(map[string]struct{}, len(tags)) + for _, tag := range tags { + key := keys.norm(*tag.Key) if _, dup := seen[key]; dup { return awserrors.Errorf(awserrors.ErrorIAMInvalidInput, "Duplicate tag keys found. Please note that Tag keys are case insensitive.") @@ -281,16 +303,73 @@ func validateTags(tags []*iam.Tag) error { return nil } +// validateTagKeys enforces the model constraints on an untag request's key +// list. AWS reports every bad key as one violation of the list. +func validateTagKeys(keys []*string) error { + var violations []string + if len(keys) > maxTagsPerResource { + violations = append(violations, fmt.Sprintf("Value at 'tagKeys' failed to satisfy constraint: Member must have length less than or equal to %d", maxTagsPerResource)) + } + for _, key := range keys { + if key == nil || len(tagMemberViolations("", key, 1, maxTagKeyLength, tagKeyPattern)) > 0 { + violations = append(violations, fmt.Sprintf("Value at 'tagKeys' failed to satisfy constraint: Member must satisfy constraint: "+ + "[Member must have length less than or equal to %d, Member must have length greater than or equal to 1, "+ + "Member must satisfy regular expression pattern: %s, Member must not be null]", maxTagKeyLength, modelPattern(tagKeyPattern))) + break + } + } + return validationError(violations) +} + +// validationError renders model-constraint violations as the single +// ValidationError AWS returns for them, or nil when there are none. +func validationError(violations []string) error { + if len(violations) == 0 { + return nil + } + noun := "errors" + if len(violations) == 1 { + noun = "error" + } + return awserrors.Errorf(awserrors.ErrorValidationError, + "%d validation %s detected: %s", len(violations), noun, strings.Join(violations, "; ")) +} + +// tagMemberViolations checks one tag key or value. AWS leaves the value out of +// tag violation messages, so these name only the member. +func tagMemberViolations(field string, value *string, minLen, maxLen int, pattern *regexp.Regexp) []string { + prefix := "Value at '" + field + "' failed to satisfy constraint: Member must " + if value == nil { + return []string{prefix + "not be null"} + } + var out []string + if n := utf8.RuneCountInString(*value); n < minLen { + out = append(out, fmt.Sprintf("%shave length greater than or equal to %d", prefix, minLen)) + } else if n > maxLen { + out = append(out, fmt.Sprintf("%shave length less than or equal to %d", prefix, maxLen)) + } + if !pattern.MatchString(*value) { + out = append(out, prefix+"satisfy regular expression pattern: "+modelPattern(pattern)) + } + return out +} + +// modelPattern renders a compiled tag pattern as the model writes it. +func modelPattern(pattern *regexp.Regexp) string { + return strings.TrimSuffix(strings.TrimPrefix(pattern.String(), "^"), "$") +} + // mergeTags upserts add into existing by key, matching AWS semantics: a -// repeated key overwrites in place, new keys append in input order. -func mergeTags(existing []Tag, add []*iam.Tag) []Tag { +// repeated key, as keys compares them, is overwritten in place with the new +// key's case; new keys append in input order. +func mergeTags(existing []Tag, add []*iam.Tag, keys keyCase) []Tag { out := slices.Clone(existing) for _, tag := range add { if tag.Key == nil { continue } next := Tag{Key: *tag.Key, Value: aws.StringValue(tag.Value)} - idx := slices.IndexFunc(out, func(t Tag) bool { return t.Key == next.Key }) + idx := slices.IndexFunc(out, func(t Tag) bool { return keys.norm(t.Key) == keys.norm(next.Key) }) if idx >= 0 { out[idx] = next } else { @@ -300,18 +379,18 @@ func mergeTags(existing []Tag, add []*iam.Tag) []Tag { return out } -// removeTagKeys drops the named keys from existing; unknown keys are -// silently ignored, matching AWS. -func removeTagKeys(existing []Tag, keys []*string) []Tag { +// removeTagKeys drops the named keys from existing, as match compares them; +// unknown keys are silently ignored, matching AWS. +func removeTagKeys(existing []Tag, keys []*string, match keyCase) []Tag { drop := make(map[string]struct{}, len(keys)) for _, k := range keys { if k != nil { - drop[*k] = struct{}{} + drop[match.norm(*k)] = struct{}{} } } out := make([]Tag, 0, len(existing)) for _, t := range existing { - if _, gone := drop[t.Key]; !gone { + if _, gone := drop[match.norm(t.Key)]; !gone { out = append(out, t) } } @@ -341,6 +420,10 @@ func (s *IAMServiceImpl) CreateUser(accountID string, input *iam.CreateUserInput } } + if err := validateTags(input.Tags, foldedKeys); err != nil { + return nil, err + } + userID, err := generateIAMID("AIDA") if err != nil { return nil, fmt.Errorf("generate user ID: %w", err) @@ -1192,19 +1275,23 @@ func (s *IAMServiceImpl) CreatePolicy(accountID string, input *iam.CreatePolicyI kvKey := accountID + "." + policyName + if err := validateDescription(input.Description); err != nil { + return nil, err + } + path := "/" + if input.Path != nil { + path = *input.Path + if err := validatePath(path); err != nil { + return nil, err + } + } + if _, err := ValidatePolicyDocument(*input.PolicyDocument); err != nil { return nil, awserrors.Errorf(awserrors.ErrorIAMMalformedPolicyDocument, "policy %q: %w", policyName, err) } - if err := validateTags(input.Tags); err != nil { - return nil, err - } - - path := aws.StringValue(input.Path) - if path == "" { - path = "/" - } else if err := validatePath(path); err != nil { + if err := validateTags(input.Tags, exactKeys); err != nil { return nil, err } @@ -1264,6 +1351,12 @@ func (s *IAMServiceImpl) GetPolicy(accountID string, input *iam.GetPolicyInput) func (s *IAMServiceImpl) ListPolicies(accountID string, input *iam.ListPoliciesInput) (*iam.ListPoliciesOutput, error) { ctx := context.Background() + if err := checkEnum("scope", input.Scope, policyScopeValues); err != nil { + return nil, err + } + if err := checkEnum("policyUsageFilter", input.PolicyUsageFilter, policyUsageValues); err != nil { + return nil, err + } keys, err := kvutil.Keys(ctx, s.policiesBucket) if err != nil { if errors.Is(err, jetstream.ErrNoKeysFound) { @@ -1350,7 +1443,7 @@ func policyToSDK(p *Policy, attachmentCount int64) *iam.Policy { // policyInScope applies ListPolicies' Scope: Local is customer-managed, AWS is // arn:aws:iam::aws:policy/..., and All (or unset) keeps both. func policyInScope(policyARN, scope string) bool { - awsManaged := isAWSManagedPolicyARN(policyARN) + awsManaged := iamarn.IsAWSManagedPolicyARN(policyARN) switch scope { case iam.PolicyScopeTypeLocal: return !awsManaged @@ -1412,6 +1505,9 @@ func (s *IAMServiceImpl) ListEntitiesForPolicy(accountID string, input *iam.List IsTruncated: aws.Bool(false), } + if err := checkEnum("policyUsageFilter", input.PolicyUsageFilter, policyUsageValues); err != nil { + return nil, err + } // Permissions boundaries are rejected outright at policy-attachment time, // so no entity ever uses a policy that way. The correct answer is empty. if aws.StringValue(input.PolicyUsageFilter) == iam.PolicyUsageTypePermissionsBoundary { @@ -1479,7 +1575,7 @@ func (s *IAMServiceImpl) AttachUserPolicy(accountID string, input *iam.AttachUse // in Spinifex, so store them opaquely rather than failing NoSuchEntity — the // grant document is modeled in builtinManagedPolicyDoc and resolved at // evaluation time. Customer-managed ARNs must still exist. - if !isAWSManagedPolicyARN(policyARN) { + if !iamarn.IsAWSManagedPolicyARN(policyARN) { if _, err := s.getPolicyByARN(ctx, accountID, policyARN); err != nil { return nil, err } @@ -1538,27 +1634,28 @@ func (s *IAMServiceImpl) DetachUserPolicy(accountID string, input *iam.DetachUse return &iam.DetachUserPolicyOutput{}, nil } -func (s *IAMServiceImpl) ListAttachedUserPolicies(accountID string, input *iam.ListAttachedUserPoliciesInput) (*iam.ListAttachedUserPoliciesOutput, error) { - ctx := context.Background() - user, err := s.getUser(ctx, accountID, *input.UserName) - if err != nil { - return nil, err - } - +// attachedPolicies resolves an identity's attached policy ARNs for the +// ListAttached*Policies calls, keeping those whose path starts with pathPrefix. +func (s *IAMServiceImpl) attachedPolicies(ctx context.Context, accountID string, arns []string, pathPrefix string) []*iam.AttachedPolicy { var attached []*iam.AttachedPolicy - for _, arn := range user.AttachedPolicies { + for _, arn := range arns { // AWS-managed ARNs have no KV entry; report them from the ARN itself so // attach/list round-trips instead of silently dropping them. - if isAWSManagedPolicyARN(arn) { - attached = append(attached, &iam.AttachedPolicy{ - PolicyArn: aws.String(arn), - PolicyName: aws.String(managedPolicyNameFromARN(arn)), - }) + if iamarn.IsAWSManagedPolicyARN(arn) { + if strings.HasPrefix(policyPathFromARN(arn), pathPrefix) { + attached = append(attached, &iam.AttachedPolicy{ + PolicyArn: aws.String(arn), + PolicyName: aws.String(managedPolicyNameFromARN(arn)), + }) + } continue } policy, err := s.getPolicyByARN(ctx, accountID, arn) if err != nil { - slog.Warn("ListAttachedUserPolicies: policy not found for ARN", "arn", arn, "err", err) + slog.Warn("attached policy not found for ARN", "arn", arn, "err", err) + continue + } + if !strings.HasPrefix(policy.Path, pathPrefix) { continue } attached = append(attached, &iam.AttachedPolicy{ @@ -1566,6 +1663,24 @@ func (s *IAMServiceImpl) ListAttachedUserPolicies(accountID string, input *iam.L PolicyName: aws.String(policy.PolicyName), }) } + return attached +} + +// policyPathFromARN returns the path of a policy ARN, e.g. +// arn:aws:iam::aws:policy/service-role/Name -> /service-role/. +func policyPathFromARN(arn string) string { + _, resource, _ := strings.Cut(arn, ":policy") + return resource[:strings.LastIndex(resource, "/")+1] +} + +func (s *IAMServiceImpl) ListAttachedUserPolicies(accountID string, input *iam.ListAttachedUserPoliciesInput) (*iam.ListAttachedUserPoliciesOutput, error) { + ctx := context.Background() + user, err := s.getUser(ctx, accountID, *input.UserName) + if err != nil { + return nil, err + } + + attached := s.attachedPolicies(ctx, accountID, user.AttachedPolicies, aws.StringValue(input.PathPrefix)) return &iam.ListAttachedUserPoliciesOutput{ AttachedPolicies: attached, @@ -1703,7 +1818,7 @@ func (s *IAMServiceImpl) ListUserPolicies(accountID string, input *iam.ListUserP // user writers (no CAS). func (s *IAMServiceImpl) TagUser(accountID string, input *iam.TagUserInput) (*iam.TagUserOutput, error) { ctx := context.Background() - if err := validateTags(input.Tags); err != nil { + if err := validateTags(input.Tags, foldedKeys); err != nil { return nil, err } @@ -1713,7 +1828,7 @@ func (s *IAMServiceImpl) TagUser(accountID string, input *iam.TagUserInput) (*ia return nil, err } - merged := mergeTags(user.Tags, input.Tags) + merged := mergeTags(user.Tags, input.Tags, foldedKeys) if len(merged) > maxTagsPerResource { return nil, errors.New(awserrors.ErrorIAMLimitExceeded) } @@ -1733,6 +1848,9 @@ func (s *IAMServiceImpl) TagUser(accountID string, input *iam.TagUserInput) (*ia // UntagUser removes the named tag keys from a user; unknown keys are a no-op. func (s *IAMServiceImpl) UntagUser(accountID string, input *iam.UntagUserInput) (*iam.UntagUserOutput, error) { + if err := validateTagKeys(input.TagKeys); err != nil { + return nil, err + } ctx := context.Background() userName := *input.UserName user, err := s.getUser(ctx, accountID, userName) @@ -1740,7 +1858,7 @@ func (s *IAMServiceImpl) UntagUser(accountID string, input *iam.UntagUserInput) return nil, err } - user.Tags = removeTagKeys(user.Tags, input.TagKeys) + user.Tags = removeTagKeys(user.Tags, input.TagKeys, foldedKeys) data, err := json.Marshal(user) if err != nil { @@ -1771,12 +1889,12 @@ func (s *IAMServiceImpl) ListUserTags(accountID string, input *iam.ListUserTagsI // record cannot undo a concurrent change of default version. func (s *IAMServiceImpl) TagPolicy(accountID string, input *iam.TagPolicyInput) (*iam.TagPolicyOutput, error) { ctx := context.Background() - if err := validateTags(input.Tags); err != nil { + if err := validateTags(input.Tags, exactKeys); err != nil { return nil, err } err := s.updatePolicyCAS(ctx, accountID, *input.PolicyArn, func(policy *Policy) (bool, error) { - merged := mergeTags(policy.Tags, input.Tags) + merged := mergeTags(policy.Tags, input.Tags, exactKeys) if len(merged) > maxTagsPerResource { return false, errors.New(awserrors.ErrorIAMLimitExceeded) } @@ -1793,9 +1911,12 @@ func (s *IAMServiceImpl) TagPolicy(accountID string, input *iam.TagPolicyInput) // UntagPolicy removes the named tag keys from a policy; unknown keys are a no-op. func (s *IAMServiceImpl) UntagPolicy(accountID string, input *iam.UntagPolicyInput) (*iam.UntagPolicyOutput, error) { + if err := validateTagKeys(input.TagKeys); err != nil { + return nil, err + } ctx := context.Background() err := s.updatePolicyCAS(ctx, accountID, *input.PolicyArn, func(policy *Policy) (bool, error) { - policy.Tags = removeTagKeys(policy.Tags, input.TagKeys) + policy.Tags = removeTagKeys(policy.Tags, input.TagKeys, exactKeys) return true, nil }) if err != nil { @@ -1895,34 +2016,36 @@ func (s *IAMServiceImpl) GetUserPolicies(accountID, userName string) ([]PolicyDo // --------------------------------------------------------------------------- func (s *IAMServiceImpl) getPolicyByARN(ctx context.Context, accountID, policyARN string) (*Policy, error) { - _, policyName, err := iamarn.ParsePolicyARN(policyARN) - if err != nil { + var policy *Policy + _, _, err := iamarn.ResolvePolicyARN(policyARN, func(_, policyName string) (string, error) { + kvKey := accountID + "." + policyName + entry, err := s.policiesBucket.Get(ctx, kvKey) + if err != nil { + if errors.Is(err, jetstream.ErrKeyNotFound) { + return "", errors.New(awserrors.ErrorIAMNoSuchEntity) + } + return "", fmt.Errorf("get policy: %w", err) + } + var p Policy + if err := json.Unmarshal(entry.Value(), &p); err != nil { + return "", fmt.Errorf("unmarshal policy: %w", err) + } + policy = &p + return p.ARN, nil + }) + switch { + case errors.Is(err, iamarn.ErrInvalidPolicyARN): // NoSuchEntity is the response contract, so log the parse reason — // otherwise a malformed ARN is indistinguishable from a missing policy. slog.Debug("getPolicyByARN: unparseable policy ARN", "accountID", accountID, "policyArn", policyARN, "err", err) return nil, errors.New(awserrors.ErrorIAMNoSuchEntity) - } - - kvKey := accountID + "." + policyName - entry, err := s.policiesBucket.Get(ctx, kvKey) - if err != nil { - if errors.Is(err, jetstream.ErrKeyNotFound) { - return nil, errors.New(awserrors.ErrorIAMNoSuchEntity) - } - return nil, fmt.Errorf("get policy: %w", err) - } - - var policy Policy - if err := json.Unmarshal(entry.Value(), &policy); err != nil { - return nil, fmt.Errorf("unmarshal policy: %w", err) - } - - if policy.ARN != policyARN { + case errors.Is(err, iamarn.ErrPolicyARNMismatch): return nil, errors.New(awserrors.ErrorIAMNoSuchEntity) + case err != nil: + return nil, err } - - return &policy, nil + return policy, nil } // entityRef identifies one principal record found to have a given policy @@ -2093,10 +2216,10 @@ func isIAMNameChar(c byte) bool { // message AWS gives, which names the failing parameter. func validateIAMName(field, name string, maxLen int) error { if len(name) == 0 { - return lengthViolation(name, field, "greater than or equal to 1") + return lengthViolation(field, "greater than or equal to 1") } if len(name) > maxLen { - return lengthViolation(name, field, fmt.Sprintf("less than or equal to %d", maxLen)) + return lengthViolation(field, fmt.Sprintf("less than or equal to %d", maxLen)) } for i := range len(name) { if !isIAMNameChar(name[i]) { @@ -2107,9 +2230,33 @@ func validateIAMName(field, name string, maxLen int) error { return nil } -func lengthViolation(value, field, bound string) error { - return awserrors.Errorf(awserrors.ErrorIAMInvalidInput, - "1 validation error detected: Value '%s' at '%s' failed to satisfy constraint: Member must have length %s", value, field, bound) +// validateDescription enforces the 1000-character limit on a role or policy +// description. +func validateDescription(description *string) error { + if description != nil && utf8.RuneCountInString(*description) > maxDescriptionLength { + return lengthViolation("description", fmt.Sprintf("less than or equal to %d", maxDescriptionLength)) + } + return nil +} + +// lengthViolation is the ValidationError AWS returns for a member outside its +// length bounds; IAM leaves the value out of the message. +func lengthViolation(field, bound string) error { + return validationError([]string{fmt.Sprintf("Value at '%s' failed to satisfy constraint: Member must have length %s", field, bound)}) +} + +// Enum values in the order AWS lists them in a violation, not the model's. +var ( + policyScopeValues = []string{iam.PolicyScopeTypeAll, iam.PolicyScopeTypeLocal, iam.PolicyScopeTypeAws} + policyUsageValues = []string{iam.PolicyUsageTypePermissionsBoundary, iam.PolicyUsageTypePermissionsPolicy} +) + +// checkEnum refuses an optional member set to a value outside allowed. +func checkEnum(field string, value *string, allowed []string) error { + if value == nil || slices.Contains(allowed, *value) { + return nil + } + return enumViolation(field, allowed) } func enumViolation(field string, allowed []string) error { @@ -2134,7 +2281,7 @@ func validatePath(path string) error { "The specified value for path is invalid. It must begin and end with / and contain only alphanumeric characters and/or / characters.") } if len(path) > 512 { - return lengthViolation(path, "path", "less than or equal to 512") + return lengthViolation("path", "less than or equal to 512") } return nil } @@ -2162,11 +2309,14 @@ func ValidatePolicyDocument(docJSON string) (*PolicyDocument, error) { if stmt.Effect != PolicyEffectAllow && stmt.Effect != PolicyEffectDeny { return nil, fmt.Errorf("statement %d: Effect must be Allow or Deny, got %q", i, stmt.Effect) } - if len(stmt.Action) == 0 { - return nil, fmt.Errorf("statement %d: Action is required", i) + if err := validateSelectorPair(i, "Action", stmt.Action, stmt.NotAction); err != nil { + return nil, err } - if len(stmt.Resource) == 0 { - return nil, fmt.Errorf("statement %d: Resource is required", i) + if err := validateSelectorPair(i, "Resource", stmt.Resource, stmt.NotResource); err != nil { + return nil, err + } + if err := validateSelectorEntries(i, stmt); err != nil { + return nil, err } if err := validateStatementRestrictions(i, stmt); err != nil { return nil, err @@ -2176,6 +2326,40 @@ func ValidatePolicyDocument(docJSON string) (*PolicyDocument, error) { return &doc, nil } +// validateSelectorPair requires exactly one of a selector and its Not form, as +// AWS does: a statement naming both, or neither, is malformed. +func validateSelectorPair(i int, name string, positive, negative StringOrArr) error { + switch { + case len(positive) > 0 && len(negative) > 0: + return fmt.Errorf("statement %d: %s and Not%s cannot both be specified", i, name, name) + case len(positive) == 0 && len(negative) == 0: + return fmt.Errorf("statement %d: %s is required (or Not%s)", i, name, name) + } + return nil +} + +// validateSelectorEntries rejects entries AWS rejects as malformed. A bad entry +// in Action only grants less, but the same entry in NotAction excludes nothing +// and so grants everything. +func validateSelectorEntries(i int, stmt Statement) error { + for field, actions := range map[string]StringOrArr{"Action": stmt.Action, "NotAction": stmt.NotAction} { + for _, action := range actions { + vendor, name, found := strings.Cut(action, ":") + if action != "*" && (!found || vendor == "" || name == "") { + return fmt.Errorf("statement %d: %s %q: Actions/Conditions must be prefaced by a vendor, e.g. iam, sdb, ec2", i, field, action) + } + } + } + for field, resources := range map[string]StringOrArr{"Resource": stmt.Resource, "NotResource": stmt.NotResource} { + for _, resource := range resources { + if resource != "*" && !strings.HasPrefix(resource, "arn:") { + return fmt.Errorf("statement %d: %s %q must be in ARN format or \"*\"", i, field, resource) + } + } + } + return nil +} + // validateStatementRestrictions rejects the clauses the evaluator cannot enforce, // so an identity policy is never accepted with an inert restriction on it. // Conditions inside the supported allowlist are accepted and enforced. @@ -2183,17 +2367,16 @@ func validateStatementRestrictions(i int, stmt Statement) error { if isRawJSONNonEmpty(stmt.Principal) { return fmt.Errorf("statement %d: Principal is not valid on an identity policy; use a resource or trust policy instead", i) } - if len(stmt.NotAction) > 0 { - return fmt.Errorf("statement %d: NotAction blocks are not supported in this release; use Action with an explicit list instead", i) - } - if len(stmt.NotResource) > 0 { - return fmt.Errorf("statement %d: NotResource blocks are not supported in this release; use Resource with an explicit list instead", i) - } for _, resource := range stmt.Resource { if err := validatePolicyVariables(i, "Resource", resource); err != nil { return err } } + for _, resource := range stmt.NotResource { + if err := validatePolicyVariables(i, "NotResource", resource); err != nil { + return err + } + } for op, keys := range stmt.Condition { for key, values := range keys { if !iampolicy.SupportedCondition(op, key) { @@ -2228,26 +2411,43 @@ func validateConditionValues(i int, op, key string, values ConditionValue) error if len(values) == 0 { return fmt.Errorf("statement %d: Condition operator %q on key %q has no value", i, op, key) } + // An IfExists form takes its base operator's values. + base, _ := iampolicy.BaseOperator(op) for _, v := range values { - // Only the string operators expand variables; a reference in a Bool or - // IpAddress value is already rejected as unparseable below. - if op == iampolicy.OpStringEquals || op == iampolicy.OpStringLike { + // Only the string and ARN operators expand variables; a reference in a + // Bool, Null, IpAddress or date value is already rejected as unparseable below. + switch base { + case iampolicy.OpIPAddress, iampolicy.OpNotIPAddress, iampolicy.OpBool, iampolicy.OpNull, + iampolicy.OpDateEquals, iampolicy.OpDateNotEquals, iampolicy.OpDateLessThan, + iampolicy.OpDateLessThanEquals, iampolicy.OpDateGreaterThan, iampolicy.OpDateGreaterThanEquals: + default: if err := validatePolicyVariables(i, fmt.Sprintf("Condition %s on key %q", op, key), v); err != nil { return err } } - switch op { - case iampolicy.OpIPAddress: + switch base { + case iampolicy.OpArnEquals, iampolicy.OpArnLike, iampolicy.OpArnNotEquals, iampolicy.OpArnNotLike: + if _, ok := iampolicy.SplitARN(v); !ok { + return fmt.Errorf("statement %d: Condition %s on key %q: %q is not an ARN of six colon-separated components", + i, op, key, v) + } + case iampolicy.OpIPAddress, iampolicy.OpNotIPAddress: if _, prefixErr := netip.ParsePrefix(v); prefixErr != nil { if _, addrErr := netip.ParseAddr(v); addrErr != nil { return fmt.Errorf("statement %d: Condition %s on key %q: %q is not a valid IP address or CIDR block", i, op, key, v) } } - case iampolicy.OpBool: + case iampolicy.OpBool, iampolicy.OpNull: if !strings.EqualFold(v, "true") && !strings.EqualFold(v, "false") { return fmt.Errorf("statement %d: Condition %s on key %q: %q is not true or false", i, op, key, v) } + case iampolicy.OpDateEquals, iampolicy.OpDateNotEquals, iampolicy.OpDateLessThan, + iampolicy.OpDateLessThanEquals, iampolicy.OpDateGreaterThan, iampolicy.OpDateGreaterThanEquals: + if _, err := iampolicy.ParseDate(v); err != nil { + return fmt.Errorf("statement %d: Condition %s on key %q: %q is not an ISO 8601 date or epoch seconds", + i, op, key, v) + } } } return nil diff --git a/spinifex/handlers/iam/service_impl_test.go b/spinifex/handlers/iam/service_impl_test.go index eda08a1cb..dcf5c5ae7 100644 --- a/spinifex/handlers/iam/service_impl_test.go +++ b/spinifex/handlers/iam/service_impl_test.go @@ -9,6 +9,7 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/iam" + "github.com/mulgadc/bluebottle/pkg/iampolicy" "github.com/mulgadc/spinifex/spinifex/admin" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "github.com/mulgadc/spinifex/internal/testkit" @@ -29,7 +30,7 @@ func setupTestIAMService(t *testing.T) *IAMServiceImpl { masterKey, err := GenerateMasterKey() require.NoError(t, err) - svc, err := NewIAMServiceImpl(t.Context(), nc, masterKey, 1) + svc, err := NewIAMServiceImpl(t.Context(), nc, masterKey) require.NoError(t, err) return svc } @@ -386,7 +387,7 @@ func TestCreateUser_InvalidName_TooLong(t *testing.T) { UserName: aws.String(longName), }) assert.Error(t, err) - assert.Contains(t, err.Error(), awserrors.ErrorIAMInvalidInput) + assert.Contains(t, err.Error(), awserrors.ErrorValidationError) } func TestCreateUser_InvalidName_BadChars(t *testing.T) { @@ -422,7 +423,7 @@ func TestCreatePolicy_InvalidName(t *testing.T) { PolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"}]}`), }) assert.Error(t, err) - assert.Contains(t, err.Error(), awserrors.ErrorIAMInvalidInput) + assert.Contains(t, err.Error(), awserrors.ErrorValidationError) } func TestCreatePolicy_InvalidPath(t *testing.T) { @@ -438,6 +439,25 @@ func TestCreatePolicy_InvalidPath(t *testing.T) { assert.Contains(t, err.Error(), awserrors.ErrorValidationError) } +// AWS refuses an empty Path, as every other Create* does, and checks it before +// the policy document. +func TestCreatePolicy_EmptyPathRefusedBeforeDocument(t *testing.T) { + t.Parallel() + svc := setupTestIAMService(t) + + _, err := svc.CreatePolicy(testAccountID, &iam.CreatePolicyInput{ + PolicyName: aws.String("EmptyPath"), PolicyDocument: aws.String("{"), Path: aws.String(""), + }) + requireIAMError(t, err, awserrors.ErrorValidationError, + "The specified value for path is invalid. It must begin and end with / and contain only alphanumeric characters and/or / characters.") + + out, err := svc.CreatePolicy(testAccountID, &iam.CreatePolicyInput{ + PolicyName: aws.String("NoPath"), PolicyDocument: aws.String(validPolicyDocument()), + }) + require.NoError(t, err) + assert.Equal(t, "/", aws.StringValue(out.Policy.Path)) +} + func TestValidatePolicyDocument_TooLarge(t *testing.T) { t.Parallel() largeDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"` + strings.Repeat("a", maxPolicyDocumentSize) + `"}]}` @@ -1264,7 +1284,7 @@ func TestCreatePolicy_InvalidTag(t *testing.T) { Tags: []*iam.Tag{{Key: aws.String(""), Value: aws.String("x")}}, }) assert.Error(t, err) - assert.Contains(t, err.Error(), awserrors.ErrorIAMInvalidInput) + assert.Contains(t, err.Error(), awserrors.ErrorValidationError) // The rejected policy must not have been stored. _, err = svc.GetPolicy(testAccountID, &iam.GetPolicyInput{ @@ -1700,6 +1720,37 @@ func TestDeletePolicy_AttachedConflict_Group(t *testing.T) { // ListEntitiesForPolicy Tests // ============================================================================ +// The messages are the ones AWS returned for the same values. +func TestListPolicyCalls_EnumViolations(t *testing.T) { + t.Parallel() + svc := setupTestIAMService(t) + const ( + scopeMsg = "1 validation error detected: Value at 'scope' failed to satisfy constraint: Member must satisfy enum value set: [All, Local, AWS]" + usageMsg = "1 validation error detected: Value at 'policyUsageFilter' failed to satisfy constraint: Member must satisfy enum value set: [PermissionsBoundary, PermissionsPolicy]" + ) + _, err := svc.ListPolicies(testAccountID, &iam.ListPoliciesInput{Scope: aws.String("Bogus")}) + requireIAMError(t, err, awserrors.ErrorValidationError, scopeMsg) + _, err = svc.ListPolicies(testAccountID, &iam.ListPoliciesInput{PolicyUsageFilter: aws.String("Bogus")}) + requireIAMError(t, err, awserrors.ErrorValidationError, usageMsg) + _, err = svc.ListEntitiesForPolicy(testAccountID, &iam.ListEntitiesForPolicyInput{ + PolicyArn: aws.String("arn:aws:iam::aws:policy/ReadOnlyAccess"), PolicyUsageFilter: aws.String("Bogus"), + }) + requireIAMError(t, err, awserrors.ErrorValidationError, usageMsg) + + for _, scope := range iam.PolicyScopeType_Values() { + _, err = svc.ListPolicies(testAccountID, &iam.ListPoliciesInput{Scope: aws.String(scope)}) + require.NoError(t, err, scope) + } + for _, usage := range iam.PolicyUsageType_Values() { + _, err = svc.ListPolicies(testAccountID, &iam.ListPoliciesInput{PolicyUsageFilter: aws.String(usage)}) + require.NoError(t, err, usage) + _, err = svc.ListEntitiesForPolicy(testAccountID, &iam.ListEntitiesForPolicyInput{ + PolicyArn: aws.String("arn:aws:iam::aws:policy/ReadOnlyAccess"), PolicyUsageFilter: aws.String(usage), + }) + require.NoError(t, err, usage) + } +} + func TestListEntitiesForPolicy_Empty(t *testing.T) { t.Parallel() svc := setupTestIAMService(t) @@ -2002,6 +2053,78 @@ func TestListAttachedUserPolicies(t *testing.T) { assert.True(t, names["ListPolicy2"]) } +func TestListAttachedPolicies_FilterByPathPrefix(t *testing.T) { + t.Parallel() + svc := setupTestIAMService(t) + root := createTestPolicy(t, svc, "RootPolicy") + team, err := svc.CreatePolicy(testAccountID, &iam.CreatePolicyInput{ + PolicyName: aws.String("TeamPolicy"), Path: aws.String("/team/a/"), PolicyDocument: aws.String(validPolicyDocument()), + }) + require.NoError(t, err) + createTestUser(t, svc, "u") + createTestRole(t, svc, "r") + createTestGroup(t, svc, "g") + for _, arn := range []*string{root.Arn, team.Policy.Arn} { + _, err = svc.AttachUserPolicy(testAccountID, &iam.AttachUserPolicyInput{UserName: aws.String("u"), PolicyArn: arn}) + require.NoError(t, err) + _, err = svc.AttachRolePolicy(testAccountID, &iam.AttachRolePolicyInput{RoleName: aws.String("r"), PolicyArn: arn}) + require.NoError(t, err) + _, err = svc.AttachGroupPolicy(testAccountID, &iam.AttachGroupPolicyInput{GroupName: aws.String("g"), PolicyArn: arn}) + require.NoError(t, err) + } + + list := map[string]func(prefix *string) ([]*iam.AttachedPolicy, error){ + "user": func(prefix *string) ([]*iam.AttachedPolicy, error) { + out, err := svc.ListAttachedUserPolicies(testAccountID, &iam.ListAttachedUserPoliciesInput{UserName: aws.String("u"), PathPrefix: prefix}) + if err != nil { + return nil, err + } + return out.AttachedPolicies, nil + }, + "role": func(prefix *string) ([]*iam.AttachedPolicy, error) { + out, err := svc.ListAttachedRolePolicies(testAccountID, &iam.ListAttachedRolePoliciesInput{RoleName: aws.String("r"), PathPrefix: prefix}) + if err != nil { + return nil, err + } + return out.AttachedPolicies, nil + }, + "group": func(prefix *string) ([]*iam.AttachedPolicy, error) { + out, err := svc.ListAttachedGroupPolicies(testAccountID, &iam.ListAttachedGroupPoliciesInput{GroupName: aws.String("g"), PathPrefix: prefix}) + if err != nil { + return nil, err + } + return out.AttachedPolicies, nil + }, + } + cases := []struct { + prefix *string + want []string + }{ + {nil, []string{"RootPolicy", "TeamPolicy"}}, + {aws.String("/"), []string{"RootPolicy", "TeamPolicy"}}, + {aws.String("/team/"), []string{"TeamPolicy"}}, + {aws.String("/other/"), nil}, + } + for identity, fn := range list { + for _, tc := range cases { + got, err := fn(tc.prefix) + require.NoError(t, err) + var names []string + for _, p := range got { + names = append(names, aws.StringValue(p.PolicyName)) + } + assert.ElementsMatch(t, tc.want, names, "%s with PathPrefix %v", identity, aws.StringValue(tc.prefix)) + } + } +} + +func TestPolicyPathFromARN(t *testing.T) { + t.Parallel() + assert.Equal(t, "/", policyPathFromARN("arn:aws:iam::aws:policy/ReadOnlyAccess")) + assert.Equal(t, "/service-role/", policyPathFromARN("arn:aws:iam::aws:policy/service-role/AmazonEC2RoleforSSM")) + assert.Equal(t, "/team/a/", policyPathFromARN("arn:aws:iam::000000000000:policy/team/a/TeamPolicy")) +} + func TestListAttachedUserPolicies_Empty(t *testing.T) { t.Parallel() svc := setupTestIAMService(t) @@ -2162,6 +2285,42 @@ func TestValidatePolicyDocument_SupportedCondition(t *testing.T) { doc.Statement[0].Condition["IpAddress"]["aws:SourceIp"]) } +// Null takes a JSON boolean or its string form, and IfExists forms reach the +// write path on the keys their base operator does. +func TestValidatePolicyDocument_NullAndIfExists(t *testing.T) { + t.Parallel() + for _, cond := range []string{ + `{"Null":{"aws:username":true}}`, + `{"Null":{"s3:prefix":"false"}}`, + `{"StringEqualsIfExists":{"aws:username":"alice"}}`, + `{"NotIpAddressIfExists":{"aws:SourceIp":"10.0.0.0/8"}}`, + `{"BoolIfExists":{"aws:SecureTransport":"true"}}`, + } { + _, err := ValidatePolicyDocument(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Action":"s3:*","Resource":"*","Condition":` + cond + `}]}`) + assert.NoError(t, err, cond) + } +} + +// Date values take either W3C ISO 8601 or epoch seconds, the latter as a JSON +// number too, on both date-valued keys. +func TestValidatePolicyDocument_DateOperators(t *testing.T) { + t.Parallel() + for _, cond := range []string{ + `{"DateGreaterThan":{"aws:CurrentTime":"2026-10-01T12:00:00Z"}}`, + `{"DateLessThan":{"aws:CurrentTime":"2027-01-01"}}`, + `{"DateEquals":{"aws:EpochTime":"2026-10-01"}}`, + `{"DateNotEquals":{"aws:EpochTime":1790856000}}`, + `{"DateLessThanEquals":{"aws:EpochTime":["1790856000","2026-10-01T22:00+10:00"]}}`, + `{"DateGreaterThanEqualsIfExists":{"aws:CurrentTime":"2026-10-01T12:00:00.5Z"}}`, + `{"Null":{"aws:CurrentTime":"false"}}`, + } { + _, err := ValidatePolicyDocument(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Action":"s3:*","Resource":"*","Condition":` + cond + `}]}`) + assert.NoError(t, err, cond) + } +} + func TestValidatePolicyDocument_UnsupportedConditionOperator(t *testing.T) { t.Parallel() _, err := ValidatePolicyDocument(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:*","Resource":"*", @@ -2196,12 +2355,62 @@ func TestValidatePolicyDocument_RejectsPrincipal(t *testing.T) { assert.Contains(t, err.Error(), "Principal is not valid on an identity policy") } -func TestValidatePolicyDocument_RejectsNotAction(t *testing.T) { +func TestValidatePolicyDocument_Selectors(t *testing.T) { t.Parallel() - _, err := ValidatePolicyDocument(`{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"*","Resource":"*", - "NotAction":"sts:AssumeRole"}]}`) - assert.Error(t, err) - assert.Contains(t, err.Error(), "NotAction blocks are not supported") + tests := []struct { + name string + stmt string + wantErr string + }{ + {"NotAction", `"NotAction":"iam:*","Resource":"*"`, ""}, + {"NotResource", `"Action":"s3:*","NotResource":["arn:aws:s3:::secret","arn:aws:s3:::secret/*"]`, ""}, + {"NotAction and NotResource", `"NotAction":"iam:*","NotResource":"arn:aws:s3:::secret/*"`, ""}, + {"NotResource with a variable", `"Action":"s3:*","NotResource":"arn:aws:s3:::home/${aws:username}/*"`, ""}, + {"Action and NotAction", `"Action":"*","NotAction":"sts:AssumeRole","Resource":"*"`, "Action and NotAction cannot both be specified"}, + {"Resource and NotResource", `"Action":"*","Resource":"*","NotResource":"arn:aws:s3:::public/*"`, "Resource and NotResource cannot both be specified"}, + {"NotResource with an unknown variable", `"Action":"s3:*","NotResource":"arn:aws:s3:::${aws:PrincipalOrgID}/*"`, "NotResource"}, + {"empty NotAction entry", `"NotAction":"","Resource":"*"`, "must be prefaced by a vendor"}, + {"NotAction without a vendor", `"NotAction":["iam"],"Resource":"*"`, "must be prefaced by a vendor"}, + {"NotAction with an empty name", `"NotAction":"iam:","Resource":"*"`, "must be prefaced by a vendor"}, + {"Action without a vendor", `"Action":"RunInstances","Resource":"*"`, "must be prefaced by a vendor"}, + {"empty NotResource entry", `"Action":"s3:*","NotResource":[""]`, "must be in ARN format"}, + {"NotResource not an ARN", `"Action":"s3:*","NotResource":"secret-bucket"`, "must be in ARN format"}, + {"Resource not an ARN", `"Action":"s3:*","Resource":"secret-bucket"`, "must be in ARN format"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + for _, effect := range []string{"Allow", "Deny"} { + _, err := ValidatePolicyDocument(`{"Version":"2012-10-17","Statement":[{"Effect":"` + effect + `",` + tc.stmt + `}]}`) + if tc.wantErr == "" { + assert.NoError(t, err, effect) + } else { + require.Error(t, err, effect) + assert.Contains(t, err.Error(), tc.wantErr, effect) + } + } + }) + } +} + +// NotAction reaches CreatePolicy as an accepted identity policy, and both +// selectors together come back as MalformedPolicyDocument. +func TestCreatePolicy_NotAction(t *testing.T) { + t.Parallel() + svc := setupTestIAMService(t) + + _, err := svc.CreatePolicy(testAccountID, &iam.CreatePolicyInput{ + PolicyName: aws.String("PowerUser"), + PolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotAction":"iam:*","Resource":"*"}]}`), + }) + require.NoError(t, err) + + _, err = svc.CreatePolicy(testAccountID, &iam.CreatePolicyInput{ + PolicyName: aws.String("Both"), + PolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","NotAction":"iam:*","Resource":"*"}]}`), + }) + require.Error(t, err) + assert.Contains(t, err.Error(), awserrors.ErrorIAMMalformedPolicyDocument) } // An allowlisted operator over a malformed value is still an inert grant: the @@ -2220,6 +2429,20 @@ func TestValidatePolicyDocument_RejectsMalformedConditionValues(t *testing.T) { {"numeric bool", `{"Bool":{"aws:SecureTransport":1}}`, "not true or false"}, {"yes", `{"Bool":{"aws:SecureTransport":"yes"}}`, "not true or false"}, {"empty array", `{"IpAddress":{"aws:SourceIp":[]}}`, "has no value"}, + {"NotIpAddress hostname", `{"NotIpAddress":{"aws:SourceIp":"office.example.com"}}`, "not a valid IP address or CIDR block"}, + {"NotIpAddress empty array", `{"NotIpAddress":{"aws:SourceIp":[]}}`, "has no value"}, + {"Null yes", `{"Null":{"aws:username":"yes"}}`, "not true or false"}, + {"Null variable", `{"Null":{"aws:username":"${aws:username}"}}`, "not true or false"}, + {"IpAddressIfExists hostname", `{"IpAddressIfExists":{"aws:SourceIp":"office.example.com"}}`, "not a valid IP address or CIDR block"}, + {"BoolIfExists yes", `{"BoolIfExists":{"aws:SecureTransport":"yes"}}`, "not true or false"}, + {"StringEqualsIfExists unknown variable", `{"StringEqualsIfExists":{"aws:username":"${aws:bogus}"}}`, `references policy variable "aws:bogus"`}, + {"NullIfExists", `{"NullIfExists":{"aws:username":"true"}}`, "is not supported in this release"}, + {"DateGreaterThan prose", `{"DateGreaterThan":{"aws:CurrentTime":"next week"}}`, "not an ISO 8601 date or epoch seconds"}, + {"DateLessThan no time zone", `{"DateLessThan":{"aws:CurrentTime":"2026-10-01T12:00:00"}}`, "not an ISO 8601 date or epoch seconds"}, + {"DateEquals fractional epoch", `{"DateEquals":{"aws:EpochTime":"1790856000.5"}}`, "not an ISO 8601 date or epoch seconds"}, + {"DateEquals variable", `{"DateEquals":{"aws:CurrentTime":"${aws:CurrentTime}"}}`, "not an ISO 8601 date or epoch seconds"}, + {"DateLessThanIfExists empty", `{"DateLessThanIfExists":{"aws:EpochTime":""}}`, "not an ISO 8601 date or epoch seconds"}, + {"DateGreaterThan on a string key", `{"DateGreaterThan":{"aws:username":"2026"}}`, "is not supported in this release"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -2232,6 +2455,38 @@ func TestValidatePolicyDocument_RejectsMalformedConditionValues(t *testing.T) { } } +// No supported key carries an Arn operator yet, so the write path cannot reach +// this arm; it is exercised directly so the key that unlocks it lands validated. +func TestValidateConditionValues_ArnOperators(t *testing.T) { + t.Parallel() + const key = "aws:SourceArn" + tests := []struct { + name string + value string + wantErr string + }{ + {"exact ARN", "arn:aws:ecs:us-east-1:111122223333:task/c/abc", ""}, + {"wildcard components", "arn:aws:ecs:*:111122223333:*", ""}, + {"resource with colons", "arn:aws:logs:us-east-1:111122223333:log-group:app:*", ""}, + {"variable", "arn:aws:iam::${aws:PrincipalAccount}:role/*", ""}, + {"too few components", "arn:aws:ecs:*", "not an ARN of six colon-separated components"}, + {"bare wildcard", "*", "not an ARN of six colon-separated components"}, + {"unknown variable", "arn:aws:iam::${aws:bogus}:role/*", `references policy variable "aws:bogus"`}, + } + for _, op := range []string{ + iampolicy.OpArnEquals, iampolicy.OpArnLike, iampolicy.OpArnNotEquals, iampolicy.OpArnNotLike, + } { + for _, tt := range tests { + err := validateConditionValues(0, op, key, ConditionValue{tt.value}) + if tt.wantErr == "" { + assert.NoError(t, err, "%s %s", op, tt.name) + continue + } + assert.ErrorContains(t, err, tt.wantErr, "%s %s", op, tt.name) + } + } +} + // A reference the evaluator cannot resolve makes an Allow select nothing and a // Deny select everything it named, so the policy does not do what it reads as. // Rejecting it on write is the only place an operator can be told. @@ -2250,6 +2505,15 @@ func TestValidatePolicyDocument_RejectsUnresolvableVariables(t *testing.T) { {"MFA in a StringLike value", `"Action":"s3:*","Resource":"*","Condition":{"StringLike":{"s3:prefix":"${aws:MultiFactorAuthPresent}/*"}}`, `references policy variable "aws:MultiFactorAuthPresent"`}, + {"MFA in a StringNotEquals value", + `"Action":"s3:*","Resource":"*","Condition":{"StringNotEquals":{"aws:username":"${aws:MultiFactorAuthPresent}"}}`, + `references policy variable "aws:MultiFactorAuthPresent"`}, + {"MFA in a StringNotLike value", + `"Action":"s3:*","Resource":"*","Condition":{"StringNotLike":{"s3:prefix":"${aws:MultiFactorAuthPresent}/*"}}`, + `references policy variable "aws:MultiFactorAuthPresent"`}, + {"unterminated in a StringEqualsIgnoreCase value", + `"Action":"s3:*","Resource":"*","Condition":{"StringEqualsIgnoreCase":{"aws:username":"${aws:username"}}`, + "unterminated policy variable reference"}, {"unknown key", `"Action":"s3:*","Resource":"arn:aws:s3:::home/${aws:bogus}/*"`, `references policy variable "aws:bogus"`}, {"condition key that is not substitutable", @@ -2322,14 +2586,6 @@ func TestValidatePolicyDocument_RejectsUnsupportedAlongsideSupported(t *testing. assert.Contains(t, err.Error(), "aws:PrincipalOrgID") } -func TestValidatePolicyDocument_RejectsNotResource(t *testing.T) { - t.Parallel() - _, err := ValidatePolicyDocument(`{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"*","Resource":"*", - "NotResource":"arn:aws:s3:::public/*"}]}`) - assert.Error(t, err) - assert.Contains(t, err.Error(), "NotResource blocks are not supported") -} - // ============================================================================ // Sensitive Data Not Logged Tests // ============================================================================ @@ -2381,7 +2637,7 @@ func TestSensitiveDataNotLogged_MasterKey(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) masterKey, err := GenerateMasterKey() require.NoError(t, err) - _, err = NewIAMServiceImpl(t.Context(), nc, masterKey, 1) + _, err = NewIAMServiceImpl(t.Context(), nc, masterKey) require.NoError(t, err) logOutput := buf.String() @@ -2460,8 +2716,8 @@ func TestInputValidation_PathLength(t *testing.T) { func TestInputValidation_PolicyDocumentSize(t *testing.T) { t.Parallel() // 6144 bytes — should pass - filler6144 := strings.Repeat("a", 6144-len(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"`)-len(`"}]}`)) - doc6144 := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"` + filler6144 + `"}]}` + filler6144 := strings.Repeat("a", 6144-len(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"arn:aws:s3:::`)-len(`"}]}`)) + doc6144 := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"arn:aws:s3:::` + filler6144 + `"}]}` assert.Len(t, doc6144, 6144) _, err := ValidatePolicyDocument(doc6144) assert.NoError(t, err, "6144-byte policy document should be valid") @@ -2584,7 +2840,7 @@ func TestIsIAMNameChar(t *testing.T) { func TestValidateIAMName(t *testing.T) { t.Parallel() const charset = "It must contain only alphanumeric characters and/or the following: +=,.@_-" - invalid, validation := awserrors.ErrorIAMInvalidInput, awserrors.ErrorValidationError + validation := awserrors.ErrorValidationError tests := []struct { name string field string @@ -2598,10 +2854,10 @@ func TestValidateIAMName(t *testing.T) { {"valid single char", "userName", "a", 64, "", ""}, {"valid at max length", "userName", strings.Repeat("a", 64), 64, "", ""}, {"valid policy at max length", "policyName", strings.Repeat("x", 128), 128, "", ""}, - {"empty", "userName", "", 64, invalid, - "1 validation error detected: Value '' at 'userName' failed to satisfy constraint: Member must have length greater than or equal to 1"}, - {"too long", "policyName", strings.Repeat("x", 129), 128, invalid, - "1 validation error detected: Value '" + strings.Repeat("x", 129) + "' at 'policyName' failed to satisfy constraint: Member must have length less than or equal to 128"}, + {"empty", "userName", "", 64, validation, + "1 validation error detected: Value at 'userName' failed to satisfy constraint: Member must have length greater than or equal to 1"}, + {"too long", "policyName", strings.Repeat("x", 129), 128, validation, + "1 validation error detected: Value at 'policyName' failed to satisfy constraint: Member must have length less than or equal to 128"}, {"space", "userName", "bad name!", 64, validation, "The specified value for userName is invalid. " + charset}, {"slash", "roleName", "alice/bob", 64, validation, "The specified value for roleName is invalid. " + charset}, {"colon", "groupName", "alice:bob", 128, validation, "The specified value for groupName is invalid. " + charset}, @@ -2632,7 +2888,7 @@ func TestValidatePath(t *testing.T) { t.Parallel() const shape = "The specified value for path is invalid. It must begin and end with / and contain only alphanumeric characters and/or / characters." tooLong := "/" + strings.Repeat("a", 511) + "/" - invalid, validation := awserrors.ErrorIAMInvalidInput, awserrors.ErrorValidationError + validation := awserrors.ErrorValidationError tests := []struct { name string input string @@ -2646,8 +2902,8 @@ func TestValidatePath(t *testing.T) { {"empty string", "", validation, shape}, {"just text", "noslash", validation, shape}, {"max length 512", "/" + strings.Repeat("a", 510) + "/", "", ""}, - {"over max length 513", tooLong, invalid, - "1 validation error detected: Value '" + tooLong + "' at 'path' failed to satisfy constraint: Member must have length less than or equal to 512"}, + {"over max length 513", tooLong, validation, + "1 validation error detected: Value at 'path' failed to satisfy constraint: Member must have length less than or equal to 512"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/spinifex/handlers/iam/tags_test.go b/spinifex/handlers/iam/tags_test.go index 925e619c8..cadbc7427 100644 --- a/spinifex/handlers/iam/tags_test.go +++ b/spinifex/handlers/iam/tags_test.go @@ -30,22 +30,34 @@ func TestValidateTags(t *testing.T) { }{ {"nil slice", nil, ""}, {"valid tags", []*iam.Tag{sdkTag("env", "prod"), sdkTag("team", "")}, ""}, + {"every allowed symbol", []*iam.Tag{sdkTag("ok_.:/=+-@ key", "ok_.:/=+-@ value")}, ""}, + {"non-ASCII letters", []*iam.Tag{sdkTag("ключ", "значение")}, ""}, {"max key length", []*iam.Tag{sdkTag(strings.Repeat("k", maxTagKeyLength), "v")}, ""}, {"max value length", []*iam.Tag{sdkTag("k", strings.Repeat("v", maxTagValueLength))}, ""}, - {"nil value allowed", []*iam.Tag{{Key: aws.String("k")}}, ""}, - {"over 50 tags", tooMany, awserrors.ErrorIAMLimitExceeded}, - {"nil tag entry", []*iam.Tag{nil}, awserrors.ErrorIAMInvalidInput}, - {"nil key", []*iam.Tag{{Value: aws.String("v")}}, awserrors.ErrorIAMInvalidInput}, - {"empty key", []*iam.Tag{sdkTag("", "v")}, awserrors.ErrorIAMInvalidInput}, - {"over-length key", []*iam.Tag{sdkTag(strings.Repeat("k", maxTagKeyLength+1), "v")}, awserrors.ErrorIAMInvalidInput}, - {"over-length value", []*iam.Tag{sdkTag("k", strings.Repeat("v", maxTagValueLength+1))}, awserrors.ErrorIAMInvalidInput}, + {"max key length in multibyte characters", []*iam.Tag{sdkTag(strings.Repeat("é", maxTagKeyLength), "v")}, ""}, + {"max value length in multibyte characters", []*iam.Tag{sdkTag("k", strings.Repeat("é", maxTagValueLength))}, ""}, + {"over 50 tags", tooMany, awserrors.ErrorValidationError}, + {"reserved aws: prefix", []*iam.Tag{sdkTag("aws:thing", "v")}, awserrors.ErrorIAMInvalidInput}, + {"reserved prefix in any case", []*iam.Tag{sdkTag("AWS:thing", "v")}, awserrors.ErrorIAMInvalidInput}, + {"aws prefix without colon", []*iam.Tag{sdkTag("awsthing", "v")}, ""}, + {"constraint break wins over reserved prefix", []*iam.Tag{sdkTag("aws:thing", "v"), sdkTag("bad#", "v")}, awserrors.ErrorValidationError}, + {"nil tag entry", []*iam.Tag{nil}, awserrors.ErrorValidationError}, + {"nil key", []*iam.Tag{{Value: aws.String("v")}}, awserrors.ErrorValidationError}, + {"nil value", []*iam.Tag{{Key: aws.String("k")}}, awserrors.ErrorValidationError}, + {"empty key", []*iam.Tag{sdkTag("", "v")}, awserrors.ErrorValidationError}, + {"over-length key", []*iam.Tag{sdkTag(strings.Repeat("é", maxTagKeyLength+1), "v")}, awserrors.ErrorValidationError}, + {"over-length value", []*iam.Tag{sdkTag("k", strings.Repeat("é", maxTagValueLength+1))}, awserrors.ErrorValidationError}, + {"key outside pattern", []*iam.Tag{sdkTag("bad#key", "v")}, awserrors.ErrorValidationError}, + {"value outside pattern", []*iam.Tag{sdkTag("k", "bad\nvalue")}, awserrors.ErrorValidationError}, {"duplicate keys", []*iam.Tag{sdkTag("k", "1"), sdkTag("k", "2")}, awserrors.ErrorIAMInvalidInput}, + {"duplicate keys differing in case", []*iam.Tag{sdkTag("Dup", "1"), sdkTag("dup", "2")}, awserrors.ErrorIAMInvalidInput}, + {"constraint break wins over duplicate", []*iam.Tag{sdkTag("k", "1"), sdkTag("K", "2"), sdkTag("bad#", "v")}, awserrors.ErrorValidationError}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - err := validateTags(tc.tags) + err := validateTags(tc.tags, foldedKeys) if tc.wantErr == "" { require.NoError(t, err) return @@ -58,28 +70,104 @@ func TestValidateTags(t *testing.T) { } } -func TestValidateTags_NamesFailingMember(t *testing.T) { +// The messages are the ones AWS returned to TagUser for the same input. +func TestValidateTags_Messages(t *testing.T) { t.Parallel() cases := []struct { - name string - tags []*iam.Tag - wantMsg string + name string + tags []*iam.Tag + wantCode string + wantMsg string }{ - {"nil key", []*iam.Tag{sdkTag("a", "1"), {Value: aws.String("v")}}, - "1 validation error detected: Value null at 'tags.2.member.key' failed to satisfy constraint: Member must not be null"}, - {"over-length value", []*iam.Tag{sdkTag("a", "1"), sdkTag("b", "2"), sdkTag("k", strings.Repeat("v", maxTagValueLength+1))}, - "1 validation error detected: Value '" + strings.Repeat("v", maxTagValueLength+1) + "' at 'tags.3.member.value' failed to satisfy constraint: Member must have length less than or equal to 256"}, - {"duplicate keys", []*iam.Tag{sdkTag("k", "1"), sdkTag("k", "2")}, + {"nil value", []*iam.Tag{{Key: aws.String("novalue")}}, awserrors.ErrorValidationError, + "1 validation error detected: Value at 'tags.1.member.value' failed to satisfy constraint: Member must not be null"}, + {"key outside pattern", []*iam.Tag{sdkTag("bad#key", "v")}, awserrors.ErrorValidationError, + `1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must satisfy regular expression pattern: [\p{L}\p{Z}\p{N}_.:/=+\-@]+`}, + {"value outside pattern", []*iam.Tag{sdkTag("k", "bad#value")}, awserrors.ErrorValidationError, + `1 validation error detected: Value at 'tags.1.member.value' failed to satisfy constraint: Member must satisfy regular expression pattern: [\p{L}\p{Z}\p{N}_.:/=+\-@]*`}, + {"over-length key", []*iam.Tag{sdkTag(strings.Repeat("é", maxTagKeyLength+1), "v")}, awserrors.ErrorValidationError, + "1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must have length less than or equal to 128"}, + {"over-length value", []*iam.Tag{sdkTag("k", strings.Repeat("é", maxTagValueLength+1))}, awserrors.ErrorValidationError, + "1 validation error detected: Value at 'tags.1.member.value' failed to satisfy constraint: Member must have length less than or equal to 256"}, + {"empty key breaks length and pattern", []*iam.Tag{sdkTag("", "v")}, awserrors.ErrorValidationError, + "2 validation errors detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must have length greater than or equal to 1; " + + `Value at 'tags.1.member.key' failed to satisfy constraint: Member must satisfy regular expression pattern: [\p{L}\p{Z}\p{N}_.:/=+\-@]+`}, + {"names the failing member", []*iam.Tag{sdkTag("a", "1"), {Value: aws.String("v")}}, awserrors.ErrorValidationError, + "1 validation error detected: Value at 'tags.2.member.key' failed to satisfy constraint: Member must not be null"}, + {"duplicate keys differing in case", []*iam.Tag{sdkTag("Dup", "1"), sdkTag("dup", "2")}, awserrors.ErrorIAMInvalidInput, "Duplicate tag keys found. Please note that Tag keys are case insensitive."}, + {"reserved aws: prefix", []*iam.Tag{sdkTag("aws:thing", "v")}, awserrors.ErrorIAMInvalidInput, + "Tag keys beginning with aws: are reserved for system use."}, + {"over 50 tags with a bad key", append(tagsNamed(maxTagsPerResource), sdkTag("bad#key", "v")), awserrors.ErrorValidationError, + "2 validation errors detected: Value at 'tags' failed to satisfy constraint: Member must have length less than or equal to 50; " + + `Value at 'tags.51.member.key' failed to satisfy constraint: Member must satisfy regular expression pattern: [\p{L}\p{Z}\p{N}_.:/=+\-@]+`}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - requireIAMError(t, validateTags(tc.tags), awserrors.ErrorIAMInvalidInput, tc.wantMsg) + requireIAMError(t, validateTags(tc.tags, foldedKeys), tc.wantCode, tc.wantMsg) }) } } +func TestValidateTagKeys(t *testing.T) { + t.Parallel() + keySet := `Value at 'tagKeys' failed to satisfy constraint: Member must satisfy constraint: [Member must have length less than or equal to 128, ` + + `Member must have length greater than or equal to 1, Member must satisfy regular expression pattern: [\p{L}\p{Z}\p{N}_.:/=+\-@]+, Member must not be null]` + tooMany := make([]*string, maxTagsPerResource+1) + for i := range tooMany { + tooMany[i] = aws.String(fmt.Sprintf("k%d", i)) + } + cases := []struct { + name string + keys []*string + wantMsg string + }{ + {"valid keys", []*string{aws.String("env"), aws.String(strings.Repeat("é", maxTagKeyLength))}, ""}, + {"empty key", []*string{aws.String("")}, "1 validation error detected: " + keySet}, + {"key outside pattern", []*string{aws.String("bad#key")}, "1 validation error detected: " + keySet}, + {"over-length key", []*string{aws.String(strings.Repeat("k", maxTagKeyLength+1))}, "1 validation error detected: " + keySet}, + {"several bad keys are one violation", []*string{aws.String("bad#"), aws.String("k"), aws.String("bad*")}, "1 validation error detected: " + keySet}, + {"nil key", []*string{nil}, "1 validation error detected: " + keySet}, + {"over 50 keys", tooMany, + "1 validation error detected: Value at 'tagKeys' failed to satisfy constraint: Member must have length less than or equal to 50"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := validateTagKeys(tc.keys) + if tc.wantMsg == "" { + require.NoError(t, err) + return + } + requireIAMError(t, err, awserrors.ErrorValidationError, tc.wantMsg) + }) + } +} + +// AWS checks the keys before looking the resource up. +func TestUntag_InvalidKeyRefusedBeforeLookup(t *testing.T) { + t.Parallel() + for _, ops := range allTagOps() { + t.Run(ops.resource, func(t *testing.T) { + t.Parallel() + svc := setupTestIAMService(t) + err := ops.untag(svc, ops.missingID, []*string{aws.String("bad#key")}) + code, ok := awserrors.ResolveErrorCode(err) + require.True(t, ok, "error must carry a registered code: %v", err) + assert.Equal(t, awserrors.ErrorValidationError, code) + }) + } +} + +func tagsNamed(n int) []*iam.Tag { + tags := make([]*iam.Tag, n) + for i := range tags { + tags[i] = sdkTag(fmt.Sprintf("k%d", i), "v") + } + return tags +} + func TestMergeTags(t *testing.T) { t.Parallel() cases := []struct { @@ -95,6 +183,12 @@ func TestMergeTags(t *testing.T) { []*iam.Tag{sdkTag("a", "9"), sdkTag("c", "3")}, []Tag{{Key: "a", Value: "9"}, {Key: "b", Value: "2"}, {Key: "c", Value: "3"}}, }, + { + "upsert ignores case and takes the new key", + []Tag{{Key: "Env", Value: "base"}, {Key: "b", Value: "2"}}, + []*iam.Tag{sdkTag("env", "lower")}, + []Tag{{Key: "env", Value: "lower"}, {Key: "b", Value: "2"}}, + }, { "nil key skipped", []Tag{{Key: "a", Value: "1"}}, @@ -112,16 +206,28 @@ func TestMergeTags(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got := mergeTags(tc.existing, tc.add) + got := mergeTags(tc.existing, tc.add, foldedKeys) assert.Equal(t, tc.want, got) }) } } +func TestMergeAndRemoveTags_ExactKeys(t *testing.T) { + t.Parallel() + existing := []Tag{{Key: "Env", Value: "base"}} + merged := mergeTags(existing, []*iam.Tag{sdkTag("env", "lower")}, exactKeys) + assert.Equal(t, []Tag{{Key: "Env", Value: "base"}, {Key: "env", Value: "lower"}}, merged) + assert.Equal(t, merged, removeTagKeys(merged, []*string{aws.String("ENV")}, exactKeys)) + assert.Equal(t, []Tag{{Key: "env", Value: "lower"}}, removeTagKeys(merged, []*string{aws.String("Env")}, exactKeys)) + require.NoError(t, validateTags([]*iam.Tag{sdkTag("Dup", "1"), sdkTag("dup", "2")}, exactKeys)) + requireIAMError(t, validateTags([]*iam.Tag{sdkTag("dup", "1"), sdkTag("dup", "2")}, exactKeys), awserrors.ErrorIAMInvalidInput, + "Duplicate tag keys found. Please note that Tag keys are case insensitive.") +} + func TestMergeTags_DoesNotMutateExisting(t *testing.T) { t.Parallel() existing := []Tag{{Key: "a", Value: "1"}} - _ = mergeTags(existing, []*iam.Tag{sdkTag("a", "9")}) + _ = mergeTags(existing, []*iam.Tag{sdkTag("a", "9")}, foldedKeys) assert.Equal(t, []Tag{{Key: "a", Value: "1"}}, existing) } @@ -135,6 +241,7 @@ func TestRemoveTagKeys(t *testing.T) { want []Tag }{ {"remove one", []*string{aws.String("b")}, []Tag{{Key: "a", Value: "1"}, {Key: "c", Value: "3"}}}, + {"key case ignored", []*string{aws.String("B")}, []Tag{{Key: "a", Value: "1"}, {Key: "c", Value: "3"}}}, {"unknown key ignored", []*string{aws.String("zzz")}, existing}, {"nil key ignored", []*string{nil, aws.String("a")}, []Tag{{Key: "b", Value: "2"}, {Key: "c", Value: "3"}}}, {"remove all", []*string{aws.String("a"), aws.String("b"), aws.String("c")}, []Tag{}}, @@ -143,7 +250,7 @@ func TestRemoveTagKeys(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got := removeTagKeys(existing, tc.keys) + got := removeTagKeys(existing, tc.keys, foldedKeys) assert.Equal(t, tc.want, got) }) } @@ -152,21 +259,29 @@ func TestRemoveTagKeys(t *testing.T) { // tagOps abstracts the per-resource tag/untag/list triple so the round-trip // behaviour is asserted identically for every taggable resource. type tagOps struct { - resource string - create func(t *testing.T, svc *IAMServiceImpl) string - missingID string - tag func(svc *IAMServiceImpl, id string, tags []*iam.Tag) error - untag func(svc *IAMServiceImpl, id string, keys []*string) error - list func(svc *IAMServiceImpl, id string) ([]*iam.Tag, error) + resource string + keys keyCase + create func(t *testing.T, svc *IAMServiceImpl) string + // createTagged creates the resource with tags and returns the ID it would have. + createTagged func(svc *IAMServiceImpl, tags []*iam.Tag) (string, error) + missingID string + tag func(svc *IAMServiceImpl, id string, tags []*iam.Tag) error + untag func(svc *IAMServiceImpl, id string, keys []*string) error + list func(svc *IAMServiceImpl, id string) ([]*iam.Tag, error) } func allTagOps() []tagOps { return []tagOps{ { resource: "user", + keys: foldedKeys, create: func(t *testing.T, svc *IAMServiceImpl) string { return *createTestUser(t, svc, "tagme").UserName }, + createTagged: func(svc *IAMServiceImpl, tags []*iam.Tag) (string, error) { + _, err := svc.CreateUser(testAccountID, &iam.CreateUserInput{UserName: aws.String("tagged"), Tags: tags}) + return "tagged", err + }, missingID: "no-such-user", tag: func(svc *IAMServiceImpl, id string, tags []*iam.Tag) error { _, err := svc.TagUser(testAccountID, &iam.TagUserInput{UserName: aws.String(id), Tags: tags}) @@ -186,9 +301,16 @@ func allTagOps() []tagOps { }, { resource: "role", + keys: foldedKeys, create: func(t *testing.T, svc *IAMServiceImpl) string { return *createTestRole(t, svc, "tagme").RoleName }, + createTagged: func(svc *IAMServiceImpl, tags []*iam.Tag) (string, error) { + _, err := svc.CreateRole(testAccountID, &iam.CreateRoleInput{ + RoleName: aws.String("tagged"), AssumeRolePolicyDocument: aws.String(validTrustPolicy()), Tags: tags, + }) + return "tagged", err + }, missingID: "no-such-role", tag: func(svc *IAMServiceImpl, id string, tags []*iam.Tag) error { _, err := svc.TagRole(testAccountID, &iam.TagRoleInput{RoleName: aws.String(id), Tags: tags}) @@ -208,6 +330,7 @@ func allTagOps() []tagOps { }, { resource: "policy", + keys: exactKeys, create: func(t *testing.T, svc *IAMServiceImpl) string { out, err := svc.CreatePolicy(testAccountID, &iam.CreatePolicyInput{ PolicyName: aws.String("tagme"), @@ -216,6 +339,12 @@ func allTagOps() []tagOps { require.NoError(t, err) return *out.Policy.Arn }, + createTagged: func(svc *IAMServiceImpl, tags []*iam.Tag) (string, error) { + _, err := svc.CreatePolicy(testAccountID, &iam.CreatePolicyInput{ + PolicyName: aws.String("tagged"), PolicyDocument: aws.String(validPolicyDocument()), Tags: tags, + }) + return "arn:aws:iam::" + testAccountID + ":policy/tagged", err + }, missingID: "arn:aws:iam::" + testAccountID + ":policy/no-such-policy", tag: func(svc *IAMServiceImpl, id string, tags []*iam.Tag) error { _, err := svc.TagPolicy(testAccountID, &iam.TagPolicyInput{PolicyArn: aws.String(id), Tags: tags}) @@ -235,9 +364,16 @@ func allTagOps() []tagOps { }, { resource: "instance profile", + keys: exactKeys, create: func(t *testing.T, svc *IAMServiceImpl) string { return *createTestInstanceProfile(t, svc, "tagme").InstanceProfileName }, + createTagged: func(svc *IAMServiceImpl, tags []*iam.Tag) (string, error) { + _, err := svc.CreateInstanceProfile(testAccountID, &iam.CreateInstanceProfileInput{ + InstanceProfileName: aws.String("tagged"), Tags: tags, + }) + return "tagged", err + }, missingID: "no-such-profile", tag: func(svc *IAMServiceImpl, id string, tags []*iam.Tag) error { _, err := svc.TagInstanceProfile(testAccountID, &iam.TagInstanceProfileInput{InstanceProfileName: aws.String(id), Tags: tags}) @@ -257,6 +393,7 @@ func allTagOps() []tagOps { }, { resource: "OIDC provider", + keys: exactKeys, create: func(t *testing.T, svc *IAMServiceImpl) string { out, err := svc.CreateOpenIDConnectProvider(testAccountID, &iam.CreateOpenIDConnectProviderInput{ Url: aws.String("https://oidc.example.com/id/TAGME"), @@ -264,6 +401,12 @@ func allTagOps() []tagOps { require.NoError(t, err) return *out.OpenIDConnectProviderArn }, + createTagged: func(svc *IAMServiceImpl, tags []*iam.Tag) (string, error) { + _, err := svc.CreateOpenIDConnectProvider(testAccountID, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String("https://oidc.example.com/id/TAGGED"), Tags: tags, + }) + return "arn:aws:iam::" + testAccountID + ":oidc-provider/oidc.example.com/id/TAGGED", err + }, missingID: "arn:aws:iam::" + testAccountID + ":oidc-provider/oidc.example.com/id/MISSING", tag: func(svc *IAMServiceImpl, id string, tags []*iam.Tag) error { _, err := svc.TagOpenIDConnectProvider(testAccountID, &iam.TagOpenIDConnectProviderInput{OpenIDConnectProviderArn: aws.String(id), Tags: tags}) @@ -292,6 +435,91 @@ func tagsAsMap(tags []*iam.Tag) map[string]string { return m } +func TestCreate_InvalidTagsRefusedAndNotStored(t *testing.T) { + t.Parallel() + cases := []struct { + name string + tags []*iam.Tag + wantCode string + }{ + {"nil value", []*iam.Tag{{Key: aws.String("k")}}, awserrors.ErrorValidationError}, + {"key outside pattern", []*iam.Tag{sdkTag("bad#key", "v")}, awserrors.ErrorValidationError}, + {"duplicate keys", []*iam.Tag{sdkTag("dup", "1"), sdkTag("dup", "2")}, awserrors.ErrorIAMInvalidInput}, + {"reserved aws: prefix", []*iam.Tag{sdkTag("aws:thing", "v")}, awserrors.ErrorIAMInvalidInput}, + } + for _, ops := range allTagOps() { + for _, tc := range cases { + t.Run(ops.resource+"/"+tc.name, func(t *testing.T) { + t.Parallel() + svc := setupTestIAMService(t) + id, err := ops.createTagged(svc, tc.tags) + require.Error(t, err) + code, ok := awserrors.ResolveErrorCode(err) + require.True(t, ok, "error must carry a registered code: %v", err) + assert.Equal(t, tc.wantCode, code) + + _, err = ops.list(svc, id) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, err.Error()) + }) + } + } +} + +func TestCreate_ValidTagsStored(t *testing.T) { + t.Parallel() + for _, ops := range allTagOps() { + t.Run(ops.resource, func(t *testing.T) { + t.Parallel() + svc := setupTestIAMService(t) + id, err := ops.createTagged(svc, []*iam.Tag{sdkTag("env", "prod"), sdkTag("team", "")}) + require.NoError(t, err) + got, err := ops.list(svc, id) + require.NoError(t, err) + assert.Equal(t, map[string]string{"env": "prod", "team": ""}, tagsAsMap(got)) + }) + } +} + +// AWS compares tag keys ignoring case on users and roles only: elsewhere Dup +// and dup are two tags, and neither tagging nor untagging in another case +// touches the stored key. +func TestResourceTagging_KeyCase(t *testing.T) { + t.Parallel() + for _, ops := range allTagOps() { + t.Run(ops.resource, func(t *testing.T) { + t.Parallel() + svc := setupTestIAMService(t) + id := ops.create(t, svc) + + err := ops.tag(svc, id, []*iam.Tag{sdkTag("Dup", "1"), sdkTag("dup", "2")}) + if ops.keys == foldedKeys { + requireIAMError(t, err, awserrors.ErrorIAMInvalidInput, "Duplicate tag keys found. Please note that Tag keys are case insensitive.") + } else { + require.NoError(t, err) + } + + require.NoError(t, ops.tag(svc, id, []*iam.Tag{sdkTag("Case", "upper")})) + require.NoError(t, ops.tag(svc, id, []*iam.Tag{sdkTag("case", "lower")})) + got, err := ops.list(svc, id) + require.NoError(t, err) + want := map[string]string{"Dup": "1", "dup": "2", "Case": "upper", "case": "lower"} + if ops.keys == foldedKeys { + want = map[string]string{"case": "lower"} + } + assert.Equal(t, want, tagsAsMap(got)) + + require.NoError(t, ops.untag(svc, id, []*string{aws.String("CASE")})) + got, err = ops.list(svc, id) + require.NoError(t, err) + if ops.keys == foldedKeys { + want = map[string]string{} + } + assert.Equal(t, want, tagsAsMap(got)) + }) + } +} + func TestResourceTagging_RoundTrip(t *testing.T) { t.Parallel() for _, ops := range allTagOps() { diff --git a/spinifex/handlers/ochrevector/backend.go b/spinifex/handlers/ochrevector/backend.go index 0bbd16033..9f18e89c4 100644 --- a/spinifex/handlers/ochrevector/backend.go +++ b/spinifex/handlers/ochrevector/backend.go @@ -27,11 +27,11 @@ type VectorRow struct { // stored metadata, source provenance, and cosine similarity Score in [0,1] // (D8; 1 - cosine_distance). type QueryResult struct { - Chunk string - Metadata map[string]any - SourceKey string - SourceOffset int - Score float32 + Chunk string `json:"Chunk"` + Metadata map[string]any `json:"Metadata"` + SourceKey string `json:"SourceKey"` + SourceOffset int `json:"SourceOffset"` + Score float32 `json:"Score"` } // defaultQueryK and maxQueryK bound Query's k (D8/D10): k<=0 defaults to diff --git a/spinifex/handlers/ochrevector/filter.go b/spinifex/handlers/ochrevector/filter.go index 4cfe1e0d0..2a7a5ee50 100644 --- a/spinifex/handlers/ochrevector/filter.go +++ b/spinifex/handlers/ochrevector/filter.go @@ -46,10 +46,10 @@ var filterKeyPattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]{0,63}$`) // orAll) over Children. Construct via the Equals/GreaterThan/.../AndAll // helpers rather than the struct literal directly. type Filter struct { - Op FilterOp - Key string - Value any - Children []*Filter + Op FilterOp `json:"Op"` + Key string `json:"Key"` + Value any `json:"Value"` + Children []*Filter `json:"Children"` } // Equals builds an `equals` leaf filter: metadata[key] as text equals value. diff --git a/spinifex/handlers/quota/bedrock.go b/spinifex/handlers/quota/bedrock.go index 9fafaaf9c..2f1ada5ca 100644 --- a/spinifex/handlers/quota/bedrock.go +++ b/spinifex/handlers/quota/bedrock.go @@ -167,11 +167,11 @@ type bedrockRPMSnapshot struct { // RunBedrockRPMSync periodically snapshots every account this gateway has // rate-limited into kv, until ctx is cancelled. A no-op when the RPM // dimension is disabled, so a default-off deployment starts no ticker. -func (s *Service) RunBedrockRPMSync(ctx context.Context, js jetstream.JetStream, replicas int) { +func (s *Service) RunBedrockRPMSync(ctx context.Context, js jetstream.JetStream) { if s == nil || !s.limits.RequestsPerMinuteEnabled { return } - kv, err := kvutil.GetOrCreateBucketWithReplicas(ctx, js, KVBucketBedrockRPMUsage, 1, replicas) + kv, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketBedrockRPMUsage, 1) if err != nil { slog.Warn("bedrock rpm sync: open usage bucket failed, sync disabled", "err", err) return diff --git a/spinifex/handlers/quota/bedrock_test.go b/spinifex/handlers/quota/bedrock_test.go index 7411d8e4d..6af343c2e 100644 --- a/spinifex/handlers/quota/bedrock_test.go +++ b/spinifex/handlers/quota/bedrock_test.go @@ -195,7 +195,7 @@ func TestRunBedrockRPMSync_DisabledNoop(t *testing.T) { s := New(Limits{}, nil) done := make(chan struct{}) go func() { - s.RunBedrockRPMSync(context.Background(), nil, 1) + s.RunBedrockRPMSync(context.Background(), nil) close(done) }() select { diff --git a/spinifex/handlers/quota/records_test.go b/spinifex/handlers/quota/records_test.go index dd5ae9047..c1f42717a 100644 --- a/spinifex/handlers/quota/records_test.go +++ b/spinifex/handlers/quota/records_test.go @@ -21,9 +21,8 @@ func recordStore(t *testing.T) *kvstore.Store[vm.InstanceRecord] { t.Helper() _, nc, _ := testutil.StartTestJetStream(t) return kvstore.New[vm.InstanceRecord](testutil.NewJetStream(t, nc), kvstore.Config{ - Name: "spinifex-instance-state", - History: 1, - Replicas: 1, + Name: "spinifex-instance-state", + History: 1, }) } diff --git a/spinifex/handlers/rds/backup.go b/spinifex/handlers/rds/backup.go index 355b70b2a..8475c35c7 100644 --- a/spinifex/handlers/rds/backup.go +++ b/spinifex/handlers/rds/backup.go @@ -395,7 +395,10 @@ func AutomatedBackupStamp(at time.Time) string { // listable through DescribeDBSnapshots --snapshot-type automated. func (s *Service) DescribeDBInstanceAutomatedBackups(ctx context.Context, input *rds.DescribeDBInstanceAutomatedBackupsInput, accountID string) (*rds.DescribeDBInstanceAutomatedBackupsOutput, error) { - wanted, err := validateDescribeAutomatedBackupsRequest(input) + if input == nil { + input = &rds.DescribeDBInstanceAutomatedBackupsInput{} + } + wanted, filters, err := validateDescribeAutomatedBackupsRequest(input) if err != nil { return nil, err } @@ -434,31 +437,68 @@ func (s *Service) DescribeDBInstanceAutomatedBackups(ctx context.Context, if err != nil { return nil, err } - backups = append(backups, s.projectAutomatedBackup(&rec, len(stamps))) + backup := s.projectAutomatedBackup(&rec, len(stamps)) + if !matchesFilters(filters, func(name string) (string, bool) { + switch name { + case filterDBInstanceID: + return aws.StringValue(backup.DBInstanceIdentifier), true + case filterStatus: + return aws.StringValue(backup.Status), true + } + return "", false + }) { + continue + } + backups = append(backups, backup) } - return &rds.DescribeDBInstanceAutomatedBackupsOutput{DBInstanceAutomatedBackups: backups}, nil + backups, next, err := Page(backups, automatedBackupPageKey, input.MaxRecords, input.Marker) + if err != nil { + return nil, err + } + return &rds.DescribeDBInstanceAutomatedBackupsOutput{DBInstanceAutomatedBackups: backups, Marker: next}, nil } -// A filter this phase cannot honour is rejected rather than dropped, since a +// A scope this phase cannot honour is rejected rather than dropped, since a // silently unfiltered list reads as a complete answer. Returns the DB instance to -// report on, empty for every one. -func validateDescribeAutomatedBackupsRequest(input *rds.DescribeDBInstanceAutomatedBackupsInput) (string, error) { +// report on, empty for every one, and the Filters. +func validateDescribeAutomatedBackupsRequest(input *rds.DescribeDBInstanceAutomatedBackupsInput) (string, []Filter, error) { if input == nil { - return "", nil + return "", nil, nil } - if len(input.Filters) > 0 { - return "", unimplemented("Filters", "DescribeDBInstanceAutomatedBackups filters on DBInstanceIdentifier only") + filters, err := ReadFilters(input.Filters, filterDBInstanceID, filterDbiResourceID, filterStatus) + if err != nil { + return "", nil, err + } + for _, filter := range filters { + switch filter.Name { + case filterDbiResourceID: + return "", nil, unimplemented("Filter dbi-resource-id", automatedBackupNoResourceID) + case filterStatus: + for _, value := range filter.Values { + if !slices.Contains(awsAutomatedBackupStatuses, value) { + return "", nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, + "The parameter Filter: status is not a valid Automated Backup status. Allowed values are creating, active, retained, pending, replicating, deleting.") + } + } + } } if aws.StringValue(input.DbiResourceId) != "" { - return "", unimplemented("DbiResourceId", "a DB instance has no resource ID distinct from its identifier here") + return "", nil, unimplemented("DbiResourceId", automatedBackupNoResourceID) } if aws.StringValue(input.DBInstanceAutomatedBackupsArn) != "" { - return "", unimplemented("DBInstanceAutomatedBackupsArn", + return "", nil, unimplemented("DBInstanceAutomatedBackupsArn", "cross-region automated backup replication is not offered, so no replicated backup has an ARN") } - return aws.StringValue(input.DBInstanceIdentifier), nil + return aws.StringValue(input.DBInstanceIdentifier), filters, nil } +// Why DbiResourceId and the dbi-resource-id filter are refused: an instance has +// one, but its automated backups are addressed by its identifier only. +const automatedBackupNoResourceID = "automated backups are addressed by DBInstanceIdentifier only" + +// AWS's whole status vocabulary; the ones never reported here match nothing. +var awsAutomatedBackupStatuses = []string{"creating", "active", "retained", "pending", "replicating", "deleting"} + // RestoreWindow and LatestRestorableTime are deliberately absent: this phase // backs discrete daily snapshots, and reporting a restore window would tell a // client it can recover to any instant inside it. diff --git a/spinifex/handlers/rds/backup_test.go b/spinifex/handlers/rds/backup_test.go index 4d0535b40..32b1c697f 100644 --- a/spinifex/handlers/rds/backup_test.go +++ b/spinifex/handlers/rds/backup_test.go @@ -766,14 +766,37 @@ func TestDescribeDBInstanceAutomatedBackups_RejectsAnUnknownInstance(t *testing. assert.Contains(t, err.Error(), awserrors.ErrorDBInstanceNotFound) } -// A filter this phase cannot honour is rejected, because a silently -// unfiltered list reads as a complete answer. +func TestDescribeDBInstanceAutomatedBackups_FiltersByInstanceAndStatus(t *testing.T) { + t.Parallel() + h := newSnapshotHarness(t, false) + seedInstance(t, h.svc, backupReadyRecord()) + + for name, tc := range map[string]struct { + filter *rds.Filter + want int + }{ + "own instance": {&rds.Filter{Name: aws.String("db-instance-id"), Values: aws.StringSlice([]string{testDBID})}, 1}, + "other instance": {&rds.Filter{Name: aws.String("db-instance-id"), Values: aws.StringSlice([]string{"other-db"})}, 0}, + "own status": {&rds.Filter{Name: aws.String("status"), Values: aws.StringSlice([]string{"retained", "creating"})}, 1}, + "other status": {&rds.Filter{Name: aws.String("status"), Values: aws.StringSlice([]string{"active"})}, 0}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + out, err := h.svc.DescribeDBInstanceAutomatedBackups(t.Context(), + &rds.DescribeDBInstanceAutomatedBackupsInput{Filters: []*rds.Filter{tc.filter}}, testAccountID) + require.NoError(t, err) + assert.Len(t, out.DBInstanceAutomatedBackups, tc.want) + }) + } +} + +// A scope this phase cannot honour is rejected, because a silently unfiltered +// list reads as a complete answer; so are names and statuses AWS refuses. func TestDescribeDBInstanceAutomatedBackups_RejectsUnimplementedFilters(t *testing.T) { cases := map[string]*rds.DescribeDBInstanceAutomatedBackupsInput{ - "Filters": {Filters: []*rds.Filter{{ - Name: aws.String("db-instance-id"), - Values: aws.StringSlice([]string{testDBID}), - }}}, + "dbi-resource-id filter": {Filters: filter("dbi-resource-id", "db-ABCDEF")}, + "unknown filter": {Filters: filter("engine", "postgres")}, + "bad status": {Filters: filter("status", "available")}, "DbiResourceId": {DbiResourceId: aws.String("db-ABCDEF")}, "DBInstanceAutomatedBackupsArn": {DBInstanceAutomatedBackupsArn: aws.String("arn:aws:rds:::auto-backup:x")}, } diff --git a/spinifex/handlers/rds/describe.go b/spinifex/handlers/rds/describe.go index 8dfaf3754..0563c8b29 100644 --- a/spinifex/handlers/rds/describe.go +++ b/spinifex/handlers/rds/describe.go @@ -4,6 +4,8 @@ import ( "context" "errors" "slices" + "strings" + "unicode/utf8" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/rds" @@ -11,42 +13,36 @@ import ( "github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore" ) -// The prefix AWS gives a DB instance's immutable resource ID, and the two filter -// names DescribeDBInstances accepts against it. -const ( - dbiResourceIDPrefix = "db" - - filterDbiResourceID = "dbi-resource-id" - filterDBInstanceID = "db-instance-id" -) +// The prefix AWS gives a DB instance's immutable resource ID. +const dbiResourceIDPrefix = "db" // Named DB instances that do not exist are an error, matching AWS: a client // polling a create would otherwise read an empty list as "gone" rather than // "not ready". func (s *Service) DescribeDBInstances(ctx context.Context, input *rds.DescribeDBInstancesInput, accountID string) (*rds.DescribeDBInstancesOutput, error) { + if input == nil { + input = &rds.DescribeDBInstancesInput{} + } kv, err := s.bucket(ctx, accountID) if err != nil { return nil, err } - var matches func(*DBInstanceRecord) bool - if input != nil { - matches, err = dbInstanceFilterMatcher(input.Filters) + matches, err := dbInstanceFilterMatcher(input.Filters) + if err != nil { + return nil, err + } + if id := aws.StringValue(input.DBInstanceIdentifier); id != "" { + rec, _, err := s.getDBInstance(ctx, kv, id) if err != nil { return nil, err } - if id := aws.StringValue(input.DBInstanceIdentifier); id != "" { - rec, _, err := s.getDBInstance(ctx, kv, id) - if err != nil { - return nil, err - } - // A named instance the filters exclude is reported as absent rather than - // returned anyway, so the two halves of one request cannot disagree. - if matches != nil && !matches(rec) { - return nil, errors.New(awserrors.ErrorDBInstanceNotFound) - } - return &rds.DescribeDBInstancesOutput{DBInstances: []*rds.DBInstance{s.projectDBInstance(rec)}}, nil + // A named instance the filters exclude is reported as absent rather than + // returned anyway, so the two halves of one request cannot disagree. + if matches != nil && !matches(rec) { + return nil, errors.New(awserrors.ErrorDBInstanceNotFound) } + return &rds.DescribeDBInstancesOutput{DBInstances: []*rds.DBInstance{s.projectDBInstance(rec)}}, nil } ids, err := ListDBInstanceIDs(ctx, kv) @@ -72,49 +68,65 @@ func (s *Service) DescribeDBInstances(ctx context.Context, input *rds.DescribeDB } instances = append(instances, s.projectDBInstance(&rec)) } - return &rds.DescribeDBInstancesOutput{DBInstances: instances}, nil + instances, next, err := Page(instances, dbInstancePageKey, input.MaxRecords, input.Marker) + if err != nil { + return nil, err + } + return &rds.DescribeDBInstancesOutput{DBInstances: instances, Marker: next}, nil } -// The filter names AWS documents for this action and clients actually send. -// Ignoring them is not a safe default here: the Terraform provider keys its +// Ignoring a filter is not a safe default here: the Terraform provider keys its // state off DbiResourceId and reads an instance back by filtering on it, so an // unapplied filter answers with every instance in the account, which the -// provider rejects as an ambiguous match. An unrecognised name is refused for -// the same reason — a filter silently dropped returns rows the caller asked to -// exclude. +// provider rejects as an ambiguous match. // // Returns nil when there is nothing to filter on, so the common path allocates // no closure. -func dbInstanceFilterMatcher(filters []*rds.Filter) (func(*DBInstanceRecord) bool, error) { - if len(filters) == 0 { - return nil, nil +func dbInstanceFilterMatcher(entries []*rds.Filter) (func(*DBInstanceRecord) bool, error) { + filters, err := ReadFilters(entries, filterDBClusterID, filterDBInstanceID, filterDbiResourceID, filterDomain, filterEngine) + if err != nil || len(filters) == 0 { + return nil, err } for _, filter := range filters { - switch name := aws.StringValue(filter.Name); name { - case filterDbiResourceID, filterDBInstanceID: - default: - return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, - "unrecognized filter name: %s", name) + if filter.Name != filterDomain { + continue + } + for _, value := range filter.Values { + if !validDomainID(value) { + return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, + "The parameter Filter: domain is not a valid Domain identifier. Domain identifier must begin with d- prefix, must contain only ASCII characters, and must not be empty") + } } } - // AWS's own semantics: a record must match every filter, and matches a filter - // by carrying any one of its values. + // No instance belongs to a cluster or joins a directory domain, so those two + // filters match nothing. return func(rec *DBInstanceRecord) bool { - for _, filter := range filters { - var value string - if aws.StringValue(filter.Name) == filterDbiResourceID { - value = rec.DbiResourceID - } else { - value = rec.DBInstanceIdentifier + return matchesFilters(filters, func(name string) (string, bool) { + switch name { + case filterDbiResourceID: + return rec.DbiResourceID, true + case filterDBInstanceID: + return rec.DBInstanceIdentifier, true + case filterEngine: + return rec.Engine, true } - if !slices.Contains(aws.StringValueSlice(filter.Values), value) { - return false - } - } - return true + return "", false + }) }, nil } +func validDomainID(value string) bool { + if !strings.HasPrefix(value, "d-") { + return false + } + for i := 0; i < len(value); i++ { + if value[i] >= utf8.RuneSelf { + return false + } + } + return true +} + // Returns the record plus its revision, for callers that follow with a CAS. func (s *Service) getDBInstance(ctx context.Context, kv *kvstore.Bucket, id string) (*DBInstanceRecord, uint64, error) { var rec DBInstanceRecord diff --git a/spinifex/handlers/rds/describe_test.go b/spinifex/handlers/rds/describe_test.go index b7fb27b38..24543259e 100644 --- a/spinifex/handlers/rds/describe_test.go +++ b/spinifex/handlers/rds/describe_test.go @@ -203,18 +203,61 @@ func TestDescribeDBInstances_EchoesOmittedInertSettingsAsDisabled(t *testing.T) assert.Zero(t, aws.Int64Value(out.DBInstances[0].MonitoringInterval)) } -// A filter nobody implemented is refused rather than dropped: dropping it -// returns exactly the rows the caller asked to exclude. -func TestDescribeDBInstances_RejectsAnUnrecognizedFilter(t *testing.T) { +// AWS refuses a name it does not document for this call, and an ill-formed +// domain identifier, rather than dropping the filter. +func TestDescribeDBInstances_RejectsUnrecognizedFiltersAndBadDomains(t *testing.T) { t.Parallel() h := newCreateHarness(t, "") seedCreated(t, h, testDBInstanceID) - _, err := h.svc.DescribeDBInstances(t.Context(), &rds.DescribeDBInstancesInput{ - Filters: []*rds.Filter{{Name: aws.String("engine"), Values: aws.StringSlice([]string{"postgres"})}}, - }, testAccountID) - require.Error(t, err) - assert.Contains(t, err.Error(), awserrors.ErrorInvalidParameterValue) + for name, tc := range map[string]struct { + filter *rds.Filter + msg string + }{ + "unknown name": {&rds.Filter{Name: aws.String("snapshot-type"), Values: aws.StringSlice([]string{"manual"})}, + "Unrecognized filter name: snapshot-type"}, + "domain without prefix": {&rds.Filter{Name: aws.String(filterDomain), Values: aws.StringSlice([]string{"nope"})}, + "is not a valid Domain identifier"}, + "non-ASCII domain": {&rds.Filter{Name: aws.String(filterDomain), Values: aws.StringSlice([]string{"d-é"})}, + "is not a valid Domain identifier"}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, err := h.svc.DescribeDBInstances(t.Context(), &rds.DescribeDBInstancesInput{ + Filters: []*rds.Filter{tc.filter}, + }, testAccountID) + require.Error(t, err) + assert.Contains(t, err.Error(), awserrors.ErrorInvalidParameterValue) + assert.Contains(t, err.Error(), tc.msg) + }) + } +} + +// engine narrows like the identifiers do; no instance is in a cluster or a +// directory domain, so a well-formed value for either matches nothing. +func TestDescribeDBInstances_FiltersOnEngineClusterAndDomain(t *testing.T) { + t.Parallel() + h := newCreateHarness(t, "") + rec := seedCreated(t, h, testDBInstanceID) + + for name, tc := range map[string]struct { + filter *rds.Filter + want int + }{ + "own engine": {&rds.Filter{Name: aws.String(filterEngine), Values: aws.StringSlice([]string{"mariadb", rec.Engine})}, 1}, + "other engine": {&rds.Filter{Name: aws.String(filterEngine), Values: aws.StringSlice([]string{"mariadb"})}, 0}, + "cluster": {&rds.Filter{Name: aws.String(filterDBClusterID), Values: aws.StringSlice([]string{"any-cluster"})}, 0}, + "domain": {&rds.Filter{Name: aws.String(filterDomain), Values: aws.StringSlice([]string{"d-1234567890"})}, 0}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + out, err := h.svc.DescribeDBInstances(t.Context(), &rds.DescribeDBInstancesInput{ + Filters: []*rds.Filter{tc.filter}, + }, testAccountID) + require.NoError(t, err) + assert.Len(t, out.DBInstances, tc.want) + }) + } } // A named instance and a filter that excludes it disagree; reporting the diff --git a/spinifex/handlers/rds/events.go b/spinifex/handlers/rds/events.go index 27890caa1..92bec9c3b 100644 --- a/spinifex/handlers/rds/events.go +++ b/spinifex/handlers/rds/events.go @@ -33,9 +33,8 @@ const ( // once, so the CAS is retried rather than dropped on first contention. eventWriteAttempts = 8 - // AWS's defaults for the read: a one-hour window and a 100-record page. + // AWS's default window for the read. defaultEventDuration = time.Hour - maxEventRecords = 100 ) // AWS's SourceType values. Only the ones some phase actually writes are @@ -158,6 +157,11 @@ func trimEvents(events []Event) []Event { // to a resource, so an unfiltered call reports the whole account's recent // history, including resources that have since been deleted. func (s *Service) DescribeEvents(ctx context.Context, input *rds.DescribeEventsInput, accountID string) (*rds.DescribeEventsOutput, error) { + // AWS recognises these names. They are not applied, as recognised names are + // not on the other calls documented as not supporting Filters. + if _, err := ReadFilters(input.Filters, filterEventCategory, filterDBInstanceID); err != nil { + return nil, err + } window, err := eventWindow(input) if err != nil { return nil, err @@ -209,16 +213,14 @@ func (s *Service) DescribeEvents(ctx context.Context, input *rds.DescribeEventsI // Oldest first across resources, as AWS returns them, so a client that has // already read up to a timestamp can resume from it. slices.SortFunc(events, func(a, b Event) int { - if cmp := a.Date.Compare(b.Date); cmp != 0 { - return cmp - } - return strings.Compare(a.SourceIdentifier, b.SourceIdentifier) + return strings.Compare(eventPageKey(a), eventPageKey(b)) }) - if limit := eventRecordLimit(input); len(events) > limit { - events = events[:limit] + events, marker, err := Page(events, eventPageKey, input.MaxRecords, input.Marker) + if err != nil { + return nil, err } - out := &rds.DescribeEventsOutput{Events: make([]*rds.Event, 0, len(events))} + out := &rds.DescribeEventsOutput{Events: make([]*rds.Event, 0, len(events)), Marker: marker} for _, event := range events { out.Events = append(out.Events, s.projectEvent(accountID, event)) } @@ -267,12 +269,11 @@ func eventWindow(input *rds.DescribeEventsInput) (timeWindow, error) { return window, nil } -func eventRecordLimit(input *rds.DescribeEventsInput) int { - requested := aws.Int64Value(input.MaxRecords) - if requested <= 0 || requested > maxEventRecords { - return maxEventRecords - } - return int(requested) +// Fixed-width nanoseconds lead, so the key sorts by date, and the remaining +// fields break a tie the same way on every read. +func eventPageKey(event Event) string { + return pageKey(fmt.Sprintf("%020d", event.Date.UnixNano()), + event.SourceIdentifier, event.SourceType, event.Message) } // A fully qualified read is one Get. Anything broader has to enumerate, because @@ -305,10 +306,7 @@ func matchesCategories(event Event, wanted []string) bool { if len(wanted) == 0 { return true } - for _, category := range wanted { - if slices.Contains(event.Categories, category) { - return true - } - } - return false + return slices.ContainsFunc(wanted, func(category string) bool { + return slices.Contains(event.Categories, category) + }) } diff --git a/spinifex/handlers/rds/events_test.go b/spinifex/handlers/rds/events_test.go index 5c2db1bb7..0fa65d841 100644 --- a/spinifex/handlers/rds/events_test.go +++ b/spinifex/handlers/rds/events_test.go @@ -102,6 +102,28 @@ func TestDescribeEvents_ReadsAcrossResourcesAndScopesToOneWhenAsked(t *testing.T assert.Equal(t, []string{"snapshot event"}, messagesOf(scoped)) } +// A truncated read carries a Marker, so a client can tell the list was cut and +// resume from it. +func TestDescribeEvents_PagesOldestFirst(t *testing.T) { + t.Parallel() + svc := newTestService(t) + now := time.Now().UTC() + seedEvent(t, svc, EventSourceTypeDBInstance, testDBID, now.Add(-3*time.Minute), "first") + seedEvent(t, svc, EventSourceTypeDBInstance, testDBID, now.Add(-2*time.Minute), "second") + seedEvent(t, svc, EventSourceTypeDBInstance, testDBID, now.Add(-time.Minute), "third") + + first, err := svc.DescribeEvents(t.Context(), &rds.DescribeEventsInput{MaxRecords: aws.Int64(2)}, testAccountID) + require.NoError(t, err) + assert.Equal(t, []string{"first", "second"}, messagesOf(first.Events)) + require.NotNil(t, first.Marker) + + rest, err := svc.DescribeEvents(t.Context(), + &rds.DescribeEventsInput{MaxRecords: aws.Int64(2), Marker: first.Marker}, testAccountID) + require.NoError(t, err) + assert.Equal(t, []string{"third"}, messagesOf(rest.Events)) + assert.Nil(t, rest.Marker) +} + func TestDescribeEvents_RejectsMalformedRequests(t *testing.T) { cases := []struct { name string diff --git a/spinifex/handlers/rds/filters.go b/spinifex/handlers/rds/filters.go new file mode 100644 index 000000000..82c8ce6f7 --- /dev/null +++ b/spinifex/handlers/rds/filters.go @@ -0,0 +1,68 @@ +package handlers_rds + +import ( + "slices" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/rds" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" +) + +// Filter names, as AWS spells them. Each Describe call recognises its own subset. +const ( + filterDBClusterID = "db-cluster-id" + filterDBInstanceID = "db-instance-id" + filterDBParameterGroupFamily = "db-parameter-group-family" + filterDBSnapshotID = "db-snapshot-id" + filterDataType = "data-type" + filterDbiResourceID = "dbi-resource-id" + filterDomain = "domain" + filterEngine = "engine" + filterEventCategory = "event-category" + filterParameterName = "parameter-name" + filterResourceID = "resource-id" + filterSnapshotType = "snapshot-type" + filterStatus = "status" +) + +// Filter is one validated Filters entry. +type Filter struct { + Name string + Values []string +} + +// ReadFilters validates Filters against the names one Describe call recognises, +// in AWS's order: an unknown name, compared case-sensitively, before an absent +// value list. The query parser reads an empty list as absent, so both answer +// the same. +func ReadFilters(filters []*rds.Filter, recognised ...string) ([]Filter, error) { + out := make([]Filter, 0, len(filters)) + for _, entry := range filters { + name := "null" + if entry != nil && entry.Name != nil { + name = *entry.Name + } + if entry == nil || entry.Name == nil || !slices.Contains(recognised, name) { + return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "Unrecognized filter name: %s", name) + } + if len(entry.Values) == 0 { + return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterCombination, + "The values list cannot be null for the filter %s.", name) + } + out = append(out, Filter{Name: name, Values: aws.StringValueSlice(entry.Values)}) + } + return out, nil +} + +// matchesFilters is AWS's rule: a row must match every filter, and matches one +// by carrying any of its values. field returns the row's value for a filter +// name; a name the row has no field for matches nothing. +func matchesFilters(filters []Filter, field func(name string) (string, bool)) bool { + for _, filter := range filters { + value, ok := field(filter.Name) + if !ok || !slices.Contains(filter.Values, value) { + return false + } + } + return true +} diff --git a/spinifex/handlers/rds/filters_test.go b/spinifex/handlers/rds/filters_test.go new file mode 100644 index 000000000..bd8c77f7d --- /dev/null +++ b/spinifex/handlers/rds/filters_test.go @@ -0,0 +1,155 @@ +//test:in-package — reuses the in-package create harness and event seeding, and +// the filter-name constants are unexported + +package handlers_rds + +import ( + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/rds" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func filter(name string, values ...string) []*rds.Filter { + return []*rds.Filter{{Name: aws.String(name), Values: aws.StringSlice(values)}} +} + +func TestReadFilters_AnswersAsAWSDoes(t *testing.T) { + cases := map[string]struct { + filters []*rds.Filter + code string + msg string + }{ + "unknown name": {filter("bogus", "x"), awserrors.ErrorInvalidParameterValue, "Unrecognized filter name: bogus"}, + "name case": {filter("Engine", "x"), awserrors.ErrorInvalidParameterValue, "Unrecognized filter name: Engine"}, + "missing name": {[]*rds.Filter{{Values: aws.StringSlice([]string{"x"})}}, awserrors.ErrorInvalidParameterValue, "Unrecognized filter name: null"}, + "nil entry": {[]*rds.Filter{nil}, awserrors.ErrorInvalidParameterValue, "Unrecognized filter name: null"}, + "unknown first": {[]*rds.Filter{{Name: aws.String("bogus")}}, awserrors.ErrorInvalidParameterValue, "Unrecognized filter name: bogus"}, + "no values": {[]*rds.Filter{{Name: aws.String("engine")}}, awserrors.ErrorInvalidParameterCombination, + "The values list cannot be null for the filter engine."}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + _, err := ReadFilters(tc.filters, filterEngine) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.code) + assert.Contains(t, err.Error(), tc.msg) + }) + } + + got, err := ReadFilters(append(filter(filterEngine, "postgres"), filter(filterEngine, "mariadb")...), filterEngine) + require.NoError(t, err) + assert.Equal(t, []Filter{{filterEngine, []string{"postgres"}}, {filterEngine, []string{"mariadb"}}}, got) +} + +// AWS applies parameter-name, matching names in any case, and accepts data-type +// without narrowing by it. +func TestDescribeDBParameters_FiltersOnParameterName(t *testing.T) { + t.Parallel() + h := newCreateHarness(t, testBaseDomain) + _, err := h.svc.CreateDBParameterGroup(t.Context(), parameterGroupInput(testParameterGroup), testAccountID) + require.NoError(t, err) + describe := func(filters []*rds.Filter) (*rds.DescribeDBParametersOutput, error) { + return h.svc.DescribeDBParameters(t.Context(), &rds.DescribeDBParametersInput{ + DBParameterGroupName: aws.String(testParameterGroup), + Filters: filters, + MaxRecords: aws.Int64(100), + }, testAccountID) + } + + byName, err := describe(filter(filterParameterName, "nope", "WORK_MEM")) + require.NoError(t, err) + require.Len(t, byName.Parameters, 1) + assert.Equal(t, "work_mem", aws.StringValue(byName.Parameters[0].ParameterName)) + + all, err := describe(nil) + require.NoError(t, err) + none, err := describe(filter(filterParameterName, "nope")) + require.NoError(t, err) + assert.NotNil(t, none.Parameters, "AWS answers an empty list, not an absent one") + assert.Empty(t, none.Parameters) + + byType, err := describe(filter(filterDataType, "nope")) + require.NoError(t, err) + assert.Len(t, byType.Parameters, len(all.Parameters)) + + _, err = describe(filter("source", "user")) + require.Error(t, err) + assert.Contains(t, err.Error(), "Unrecognized filter name: source") +} + +// On these calls AWS refuses a name it does not recognise but narrows by none +// of the names it does. +func TestRecognisedFiltersThatDoNotNarrow(t *testing.T) { + t.Parallel() + h := newCreateHarness(t, "") + input := validCreateInput() + input.Tags = awsTags("env", "prod") + _, err := h.svc.CreateDBInstance(t.Context(), input, testAccountID) + require.NoError(t, err) + h.svc.RecordEvent(t.Context(), testAccountID, EventSourceTypeDBInstance, testDBInstanceID, + "DB instance stopped.", EventCategoryAvailability) + arn := FormatARN(ResourceKindDBInstance, testRegion, testAccountID, testDBInstanceID) + + groups := func(filters []*rds.Filter) (int, error) { + out, err := h.svc.DescribeDBParameterGroups(t.Context(), &rds.DescribeDBParameterGroupsInput{Filters: filters}, testAccountID) + if err != nil { + return 0, err + } + return len(out.DBParameterGroups), nil + } + tags := func(filters []*rds.Filter) (int, error) { + out, err := h.svc.ListTagsForResource(t.Context(), &rds.ListTagsForResourceInput{ResourceName: aws.String(arn), Filters: filters}, testAccountID) + if err != nil { + return 0, err + } + return len(out.TagList), nil + } + events := func(filters []*rds.Filter) (int, error) { + out, err := h.svc.DescribeEvents(t.Context(), &rds.DescribeEventsInput{Filters: filters}, testAccountID) + if err != nil { + return 0, err + } + return len(out.Events), nil + } + + for name, tc := range map[string]struct { + call func([]*rds.Filter) (int, error) + recognised [][]*rds.Filter + unknown string + }{ + "DescribeDBParameterGroups": {groups, + [][]*rds.Filter{filter(filterDBParameterGroupFamily, "mysql8.0"), filter(filterEngine, "mysql")}, "db-parameter-group-name"}, + "ListTagsForResource": {tags, [][]*rds.Filter{filter(filterResourceID, "nope")}, "tag-key"}, + "DescribeEvents": {events, + [][]*rds.Filter{filter(filterEventCategory, "backup"), filter(filterDBInstanceID, "other-db")}, "source-id"}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + want, err := tc.call(nil) + require.NoError(t, err) + require.NotZero(t, want) + for _, filters := range tc.recognised { + got, err := tc.call(filters) + require.NoError(t, err) + assert.Equal(t, want, got, aws.StringValue(filters[0].Name)) + } + _, err = tc.call(filter(tc.unknown, "x")) + require.Error(t, err) + assert.Contains(t, err.Error(), "Unrecognized filter name: "+tc.unknown) + }) + } +} + +// AWS ignores Filters here entirely, even an unknown or malformed entry. +func TestDescribeDBSubnetGroups_IgnoresFilters(t *testing.T) { + t.Parallel() + h := newCreateHarness(t, "") + _, err := h.svc.DescribeDBSubnetGroups(t.Context(), &rds.DescribeDBSubnetGroupsInput{ + Filters: []*rds.Filter{{Name: aws.String("bogus")}, {Values: aws.StringSlice([]string{"x"})}}, + }, testAccountID) + require.NoError(t, err) +} diff --git a/spinifex/handlers/rds/paging.go b/spinifex/handlers/rds/paging.go new file mode 100644 index 000000000..8cf4a26f7 --- /dev/null +++ b/spinifex/handlers/rds/paging.go @@ -0,0 +1,74 @@ +package handlers_rds + +import ( + "encoding/base64" + "slices" + "strings" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/rds" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" +) + +// AWS's page size when a Describe call names none. +const defaultMaxRecords = 100 + +// Joins the fields of a compound page key. It sorts below every printable byte, +// so a key compares field by field exactly as the fields do. +const pageKeySeparator = "\x00" + +// One page of items, already in ascending key order. The Marker is base64 of the +// next item's key, as AWS issues it; a resume starts at the first key not below +// it, so that item's deletion skips nothing. MaxRecords outside 1-100 is 100. +func Page[T any](items []T, key func(T) string, maxRecords *int64, marker *string) ([]T, *string, error) { + start := 0 + if encoded := aws.StringValue(marker); encoded != "" { + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, + "Marker %q was not issued by this service", encoded) + } + start, _ = slices.BinarySearchFunc(items, string(decoded), func(item T, target string) int { + return strings.Compare(key(item), target) + }) + } + + limit := defaultMaxRecords + if requested := aws.Int64Value(maxRecords); requested > 0 && requested < defaultMaxRecords { + limit = int(requested) + } + rest := items[start:] + if len(rest) <= limit { + return rest, nil, nil + } + return rest[:limit], aws.String(base64.StdEncoding.EncodeToString([]byte(key(rest[limit])))), nil +} + +func pageKey(fields ...string) string { + return strings.Join(fields, pageKeySeparator) +} + +// The key each paged listing is already sorted by. +func parameterPageKey(p *rds.Parameter) string { return aws.StringValue(p.ParameterName) } + +func dbInstancePageKey(i *rds.DBInstance) string { return aws.StringValue(i.DBInstanceIdentifier) } + +func dbSnapshotPageKey(s *rds.DBSnapshot) string { return aws.StringValue(s.DBSnapshotIdentifier) } + +func automatedBackupPageKey(b *rds.DBInstanceAutomatedBackup) string { + return aws.StringValue(b.DBInstanceIdentifier) +} + +func subnetGroupPageKey(g *rds.DBSubnetGroup) string { return aws.StringValue(g.DBSubnetGroupName) } + +func parameterGroupPageKey(g *rds.DBParameterGroup) string { + return aws.StringValue(g.DBParameterGroupName) +} + +func EngineVersionPageKey(v *rds.DBEngineVersion) string { + return pageKey(aws.StringValue(v.Engine), aws.StringValue(v.EngineVersion)) +} + +func OrderableOptionPageKey(o *rds.OrderableDBInstanceOption) string { + return pageKey(aws.StringValue(o.Engine), aws.StringValue(o.EngineVersion), aws.StringValue(o.DBInstanceClass)) +} diff --git a/spinifex/handlers/rds/paging_test.go b/spinifex/handlers/rds/paging_test.go new file mode 100644 index 000000000..38dcc26e7 --- /dev/null +++ b/spinifex/handlers/rds/paging_test.go @@ -0,0 +1,107 @@ +package handlers_rds + +import ( + "encoding/base64" + "fmt" + "slices" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/rds" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func identity(s string) string { return s } + +func pageNames(n int) []string { + names := make([]string, 0, n) + for i := range n { + names = append(names, fmt.Sprintf("item-%03d", i)) + } + return names +} + +// Follows every Marker to the end, returning the pages as served. +func walkPages(t *testing.T, items []string, maxRecords int64) [][]string { + t.Helper() + var pages [][]string + var marker *string + for { + page, next, err := Page(items, identity, aws.Int64(maxRecords), marker) + require.NoError(t, err) + pages = append(pages, page) + if next == nil { + return pages + } + marker = next + } +} + +func TestPage_WalksEveryItemOnceInOrder(t *testing.T) { + t.Parallel() + items := pageNames(53) + pages := walkPages(t, items, 20) + + require.Len(t, pages, 3) + assert.Len(t, pages[0], 20) + assert.Len(t, pages[1], 20) + assert.Len(t, pages[2], 13) + assert.Equal(t, items, slices.Concat(pages...)) +} + +// AWS's marker is base64 of the next row's name, and the last page carries none. +func TestPage_MarkerNamesTheNextItem(t *testing.T) { + t.Parallel() + items := pageNames(25) + _, next, err := Page(items, identity, aws.Int64(20), nil) + require.NoError(t, err) + assert.Equal(t, base64.StdEncoding.EncodeToString([]byte("item-020")), aws.StringValue(next)) + + last, next, err := Page(items, identity, aws.Int64(20), next) + require.NoError(t, err) + assert.Equal(t, items[20:], last) + assert.Nil(t, next) +} + +func TestPage_ResumesPastAnItemDeletedBetweenPages(t *testing.T) { + t.Parallel() + items := pageNames(30) + _, marker, err := Page(items, identity, aws.Int64(10), nil) + require.NoError(t, err) + + remaining := slices.Delete(slices.Clone(items), 10, 11) + page, _, err := Page(remaining, identity, aws.Int64(10), marker) + require.NoError(t, err) + assert.Equal(t, items[11:21], page) +} + +func TestPage_ClampsAnUnsetOrOutOfRangeMaxRecordsToTheDefault(t *testing.T) { + t.Parallel() + items := pageNames(defaultMaxRecords + 5) + for _, maxRecords := range []*int64{nil, aws.Int64(0), aws.Int64(-1), aws.Int64(500)} { + page, next, err := Page(items, identity, maxRecords, nil) + require.NoError(t, err) + assert.Len(t, page, defaultMaxRecords) + assert.NotNil(t, next) + } +} + +func TestPage_RejectsAMarkerItDidNotIssue(t *testing.T) { + t.Parallel() + _, _, err := Page(pageNames(5), identity, nil, aws.String("not base64!")) + require.Error(t, err) + assert.Contains(t, err.Error(), awserrors.ErrorInvalidParameterValue) +} + +// The compound keys the catalog listings page by must sort as the listings do, +// which a separator above '-' or '.' would break for engine names sharing a prefix. +func TestPageKey_SortsFieldByField(t *testing.T) { + t.Parallel() + keys := []string{ + OrderableOptionPageKey(&rds.OrderableDBInstanceOption{Engine: aws.String("aurora"), DBInstanceClass: aws.String("db.t3.micro")}), + OrderableOptionPageKey(&rds.OrderableDBInstanceOption{Engine: aws.String("aurora-mysql"), DBInstanceClass: aws.String("db.m5.large")}), + } + assert.True(t, slices.IsSorted(keys)) +} diff --git a/spinifex/handlers/rds/parametergroup.go b/spinifex/handlers/rds/parametergroup.go index cc185e3e5..e0880a7ff 100644 --- a/spinifex/handlers/rds/parametergroup.go +++ b/spinifex/handlers/rds/parametergroup.go @@ -84,20 +84,25 @@ func (s *Service) CreateDBParameterGroup(ctx context.Context, input *rds.CreateD // or not anything has materialised it yet — a client that lists groups before // creating its first instance must still see the one it can name. func (s *Service) DescribeDBParameterGroups(ctx context.Context, input *rds.DescribeDBParameterGroupsInput, accountID string) (*rds.DescribeDBParameterGroupsOutput, error) { + if input == nil { + input = &rds.DescribeDBParameterGroupsInput{} + } + // AWS recognises both names and narrows by neither. + if _, err := ReadFilters(input.Filters, filterDBParameterGroupFamily, filterEngine); err != nil { + return nil, err + } kv, err := s.bucket(ctx, accountID) if err != nil { return nil, err } - if input != nil { - if name := aws.StringValue(input.DBParameterGroupName); name != "" { - rec, _, err := getDBParameterGroup(ctx, kv, accountID, name) - if err != nil { - return nil, err - } - return &rds.DescribeDBParameterGroupsOutput{ - DBParameterGroups: []*rds.DBParameterGroup{s.projectParameterGroupRecord(rec)}, - }, nil + if name := aws.StringValue(input.DBParameterGroupName); name != "" { + rec, _, err := getDBParameterGroup(ctx, kv, accountID, name) + if err != nil { + return nil, err } + return &rds.DescribeDBParameterGroupsOutput{ + DBParameterGroups: []*rds.DBParameterGroup{s.projectParameterGroupRecord(rec)}, + }, nil } names, err := ListDBParameterGroupNames(ctx, kv) @@ -125,7 +130,11 @@ func (s *Service) DescribeDBParameterGroups(ctx context.Context, input *rds.Desc } groups = append(groups, s.projectParameterGroupRecord(rec)) } - return &rds.DescribeDBParameterGroupsOutput{DBParameterGroups: groups}, nil + groups, next, err := Page(groups, parameterGroupPageKey, input.MaxRecords, input.Marker) + if err != nil { + return nil, err + } + return &rds.DescribeDBParameterGroupsOutput{DBParameterGroups: groups, Marker: next}, nil } // Stores validated overrides, one KV key per parameter, so a modify touching one @@ -243,6 +252,11 @@ func (s *Service) DescribeDBParameters(ctx context.Context, input *rds.DescribeD return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "Source %q is not one of %s or %s", source, ParameterSourceUser, ParameterSourceEngineDefault) } + // AWS narrows by parameter-name, matching names in any case, and not by data-type. + filters, err := ReadFilters(input.Filters, filterParameterName, filterDataType) + if err != nil { + return nil, err + } kv, err := s.bucket(ctx, accountID) if err != nil { @@ -267,7 +281,8 @@ func (s *Service) DescribeDBParameters(ctx context.Context, input *rds.DescribeD return nil, err } - out := &rds.DescribeDBParametersOutput{} + // Non-nil, so a filter that matches nothing answers with an empty list, as AWS does. + out := &rds.DescribeDBParametersOutput{Parameters: []*rds.Parameter{}} for _, param := range engine.CatalogParameterNames() { spec, _ := engine.LookupParameter(param) override, isOverride := overrides[param] @@ -278,11 +293,29 @@ func (s *Service) DescribeDBParameters(ctx context.Context, input *rds.DescribeD if source != "" && source != parameterSource(isOverride) { continue } + if !parameterNameMatches(filters, param) { + continue + } out.Parameters = append(out.Parameters, projectParameter(spec, value, override.ApplyMethod, isOverride)) } + out.Parameters, out.Marker, err = Page(out.Parameters, parameterPageKey, input.MaxRecords, input.Marker) + if err != nil { + return nil, err + } return out, nil } +func parameterNameMatches(filters []Filter, param string) bool { + for _, filter := range filters { + if filter.Name == filterParameterName && !slices.ContainsFunc(filter.Values, func(v string) bool { + return strings.EqualFold(v, param) + }) { + return false + } + } + return true +} + // Refused for a default group, and while any instance still references it — // including one that is only deleting, so a destroy that races the teardown // fails cleanly rather than leaving a live engine's configuration unreadable. diff --git a/spinifex/handlers/rds/parametergroup_test.go b/spinifex/handlers/rds/parametergroup_test.go index 60ea45daa..68dcaac26 100644 --- a/spinifex/handlers/rds/parametergroup_test.go +++ b/spinifex/handlers/rds/parametergroup_test.go @@ -1,6 +1,7 @@ package handlers_rds import ( + "encoding/base64" "strings" "testing" @@ -599,6 +600,56 @@ func TestDescribeDBParameters_FiltersOnSource(t *testing.T) { assert.Len(t, defaults.Parameters, len(enginePostgres.CatalogParameterNames())-1) } +// AWS's observed first page of a fresh postgres18 group at MaxRecords 20: twenty +// parameters by name, and a Marker that is base64 of the twenty-first's name. +func TestDescribeDBParameters_PagesByName(t *testing.T) { + t.Parallel() + h := newCreateHarness(t, testBaseDomain) + catalog := enginePostgres.CatalogParameterNames() + + var listed []string + var marker *string + for page := 0; ; page++ { + out, err := h.svc.DescribeDBParameters(t.Context(), &rds.DescribeDBParametersInput{ + DBParameterGroupName: aws.String(testDefaultPG), + MaxRecords: aws.Int64(20), + Marker: marker, + }, testAccountID) + require.NoError(t, err) + if page == 0 { + require.Len(t, out.Parameters, 20) + assert.Equal(t, base64.StdEncoding.EncodeToString([]byte(catalog[20])), aws.StringValue(out.Marker)) + } + for _, p := range out.Parameters { + listed = append(listed, aws.StringValue(p.ParameterName)) + } + if out.Marker == nil { + break + } + marker = out.Marker + } + assert.Equal(t, catalog, listed) +} + +func TestDescribeDBParameterGroups_PagesByName(t *testing.T) { + t.Parallel() + h := newCreateHarness(t, testBaseDomain) + _, err := h.svc.CreateDBParameterGroup(t.Context(), parameterGroupInput("alpha"), testAccountID) + require.NoError(t, err) + + first, err := h.svc.DescribeDBParameterGroups(t.Context(), + &rds.DescribeDBParameterGroupsInput{MaxRecords: aws.Int64(2)}, testAccountID) + require.NoError(t, err) + assert.Equal(t, []string{"alpha", testDefaultMariaDB}, parameterGroupNames(first)) + require.NotNil(t, first.Marker) + + rest, err := h.svc.DescribeDBParameterGroups(t.Context(), + &rds.DescribeDBParameterGroupsInput{MaxRecords: aws.Int64(2), Marker: first.Marker}, testAccountID) + require.NoError(t, err) + assert.Equal(t, []string{testDefaultPG}, parameterGroupNames(rest)) + assert.Nil(t, rest.Marker) +} + func TestDeleteDBParameterGroup_RemovesTheGroupAndItsValues(t *testing.T) { t.Parallel() h := newCreateHarness(t, testBaseDomain) diff --git a/spinifex/handlers/rds/reconciler.go b/spinifex/handlers/rds/reconciler.go index c1da52ca9..7d2cccce5 100644 --- a/spinifex/handlers/rds/reconciler.go +++ b/spinifex/handlers/rds/reconciler.go @@ -154,7 +154,7 @@ func (r *Reconciler) watchBuckets(ctx context.Context) ([]*kvstore.Bucket, error // One pass, and when the loop should run again with nothing having changed. func (r *Reconciler) reconcilePass(ctx context.Context) (time.Duration, error) { - if !r.isLeader() { + if !r.lease.Held() { // Leadership changes without anything being written, so a follower has // to keep asking. The pass itself is a lease check and nothing more. return leaseRefresh, nil @@ -172,11 +172,7 @@ func (r *Reconciler) reconcilePass(ctx context.Context) (time.Duration, error) { // cluster-singular and held continuously rather than claimed per sweep, so // holding it is the whole answer and there is nothing for the caller to release. func (r *Reconciler) AcquireClusterLease() (func(), bool) { - return func() {}, r.isLeader() -} - -func (r *Reconciler) isLeader() bool { - return r.lease.Held() + return func() {}, r.lease.Held() } func (r *Reconciler) leaderBucket(ctx context.Context) (jetstream.KeyValue, error) { diff --git a/spinifex/handlers/rds/reconciler_test.go b/spinifex/handlers/rds/reconciler_test.go index 229f45e91..468adb276 100644 --- a/spinifex/handlers/rds/reconciler_test.go +++ b/spinifex/handlers/rds/reconciler_test.go @@ -396,7 +396,7 @@ func TestReconciler_RunHoldsLeadershipIndependentlyOfTheReconcileLoop(t *testing done := make(chan struct{}) go func() { defer close(done); h.rec.Run(ctx) }() - require.Eventually(t, h.rec.isLeader, 2*time.Second, 10*time.Millisecond, + require.Eventually(t, h.rec.lease.Held, 2*time.Second, 10*time.Millisecond, "Run must elect without waiting for a reconcile tick") cancel() diff --git a/spinifex/handlers/rds/snapshot.go b/spinifex/handlers/rds/snapshot.go index 2a2a09281..a12bad91a 100644 --- a/spinifex/handlers/rds/snapshot.go +++ b/spinifex/handlers/rds/snapshot.go @@ -222,7 +222,7 @@ func (s *Service) releaseQuiesce(ctx context.Context, accountID, dbInstanceIdent // empty list, matching AWS: a client polling a create would otherwise read // "gone" for "not ready". func (s *Service) DescribeDBSnapshots(ctx context.Context, input *rds.DescribeDBSnapshotsInput, accountID string) (*rds.DescribeDBSnapshotsOutput, error) { - snapshotType, err := validateDescribeSnapshotsRequest(input) + snapshotType, filters, err := validateDescribeSnapshotsRequest(input) if err != nil { return nil, err } @@ -236,7 +236,7 @@ func (s *Service) DescribeDBSnapshots(ctx context.Context, input *rds.DescribeDB if err != nil { return nil, err } - if !snapshotMatches(rec, aws.StringValue(input.DBInstanceIdentifier), snapshotType) { + if !snapshotMatches(rec, aws.StringValue(input.DBInstanceIdentifier), snapshotType, filters) { return &rds.DescribeDBSnapshotsOutput{DBSnapshots: []*rds.DBSnapshot{}}, nil } return &rds.DescribeDBSnapshotsOutput{DBSnapshots: []*rds.DBSnapshot{s.projectDBSnapshot(rec)}}, nil @@ -257,20 +257,39 @@ func (s *Service) DescribeDBSnapshots(ctx context.Context, input *rds.DescribeDB } // A snapshot deleted between the key listing and this read is simply gone, // which is what a describe one tick later would report too. - if !found || !snapshotMatches(&rec, aws.StringValue(input.DBInstanceIdentifier), snapshotType) { + if !found || !snapshotMatches(&rec, aws.StringValue(input.DBInstanceIdentifier), snapshotType, filters) { continue } snapshots = append(snapshots, s.projectDBSnapshot(&rec)) } - return &rds.DescribeDBSnapshotsOutput{DBSnapshots: snapshots}, nil + snapshots, next, err := Page(snapshots, dbSnapshotPageKey, input.MaxRecords, input.Marker) + if err != nil { + return nil, err + } + return &rds.DescribeDBSnapshotsOutput{DBSnapshots: snapshots, Marker: next}, nil } -// An empty filter matches everything, as AWS does. -func snapshotMatches(rec *DBSnapshotRecord, dbInstanceIdentifier, snapshotType string) bool { +// An empty parameter matches everything, as AWS does. +func snapshotMatches(rec *DBSnapshotRecord, dbInstanceIdentifier, snapshotType string, filters []Filter) bool { if dbInstanceIdentifier != "" && rec.DBInstanceIdentifier != dbInstanceIdentifier { return false } - return snapshotType == "" || rec.SnapshotType == snapshotType + if snapshotType != "" && rec.SnapshotType != snapshotType { + return false + } + return matchesFilters(filters, func(name string) (string, bool) { + switch name { + case filterDBInstanceID: + return rec.DBInstanceIdentifier, true + case filterDBSnapshotID: + return rec.DBSnapshotIdentifier, true + case filterSnapshotType: + return rec.SnapshotType, true + case filterEngine: + return rec.Engine, true + } + return "", false + }) } // Removes the snapshot and, when it was the last thing holding a data volume @@ -481,31 +500,53 @@ func validateCreateSnapshotRequest(input *rds.CreateDBSnapshotInput) (*validated }, nil } -// Returns the snapshot type to filter on, empty for "any". A filter this -// phase cannot honour is rejected rather than dropped, because a silently -// unfiltered list reads as a complete answer. -func validateDescribeSnapshotsRequest(input *rds.DescribeDBSnapshotsInput) (string, error) { +// Returns the snapshot type to filter on, empty for "any", and the Filters. A +// scope this phase cannot honour is rejected rather than dropped, because a +// silently unfiltered list reads as a complete answer. +func validateDescribeSnapshotsRequest(input *rds.DescribeDBSnapshotsInput) (string, []Filter, error) { if input == nil { - return "", nil + return "", nil, nil } - if len(input.Filters) > 0 { - return "", unimplemented("Filters", "DescribeDBSnapshots filters on DBSnapshotIdentifier, DBInstanceIdentifier and SnapshotType only") + filters, err := ReadFilters(input.Filters, + filterDBInstanceID, filterDBSnapshotID, filterDbiResourceID, filterSnapshotType, filterEngine) + if err != nil { + return "", nil, err + } + for _, filter := range filters { + switch filter.Name { + case filterDbiResourceID: + return "", nil, unimplemented("Filter dbi-resource-id", snapshotNoResourceID) + case filterSnapshotType: + // AWS's whole vocabulary is accepted; the types never offered here match nothing. + for _, value := range filter.Values { + if !slices.Contains(awsSnapshotTypes, value) { + return "", nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, + "Invalid snapshot type. If specified, must be one of: public, shared, manual, awsbackup, automated.") + } + } + } } if aws.StringValue(input.DbiResourceId) != "" { - return "", unimplemented("DbiResourceId", "a DB instance has no resource ID distinct from its identifier here") + return "", nil, unimplemented("DbiResourceId", snapshotNoResourceID) } if aws.BoolValue(input.IncludeShared) || aws.BoolValue(input.IncludePublic) { - return "", unimplemented("IncludeShared/IncludePublic", "cross-account snapshot sharing is not offered") + return "", nil, unimplemented("IncludeShared/IncludePublic", "cross-account snapshot sharing is not offered") } switch snapshotType := aws.StringValue(input.SnapshotType); snapshotType { case "", SnapshotTypeManual, SnapshotTypeAutomated: - return snapshotType, nil + return snapshotType, filters, nil default: - return "", awserrors.Errorf(awserrors.ErrorInvalidParameterValue, + return "", nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "SnapshotType %q is not offered; use %q or %q", snapshotType, SnapshotTypeManual, SnapshotTypeAutomated) } } +// Why DbiResourceId and the dbi-resource-id filter are refused: an instance has +// one, but a snapshot does not record it. +const snapshotNoResourceID = "a snapshot does not record its source instance's resource ID" + +var awsSnapshotTypes = []string{"public", "shared", SnapshotTypeManual, "awsbackup", SnapshotTypeAutomated} + // A name the caller is minting: a customer snapshot, or a final snapshot at // delete. The rds: namespace belongs to automated backups, so it is refused // wherever a name is created — and accepted by validateDBSnapshotReference, which diff --git a/spinifex/handlers/rds/snapshot_test.go b/spinifex/handlers/rds/snapshot_test.go index 2fb38165f..627eb892e 100644 --- a/spinifex/handlers/rds/snapshot_test.go +++ b/spinifex/handlers/rds/snapshot_test.go @@ -341,6 +341,34 @@ func TestDescribeDBSnapshots_FiltersByInstanceAndType(t *testing.T) { }, testAccountID) require.NoError(t, err) assert.Empty(t, byType.DBSnapshots, "nothing in this phase is automated") + + for name, tc := range map[string]struct { + filters []*rds.Filter + want []string + }{ + "instance": {[]*rds.Filter{{Name: aws.String("db-instance-id"), Values: aws.StringSlice([]string{"reports-db"})}}, + []string{"reports-db-daily"}}, + "snapshot": {[]*rds.Filter{{Name: aws.String("db-snapshot-id"), Values: aws.StringSlice([]string{testSnapshotID, "nope"})}}, + []string{testSnapshotID}}, + "type": {[]*rds.Filter{{Name: aws.String("snapshot-type"), Values: aws.StringSlice([]string{"shared", SnapshotTypeManual})}}, + []string{testSnapshotID, "reports-db-daily"}}, + "engine": {[]*rds.Filter{{Name: aws.String("engine"), Values: aws.StringSlice([]string{"mariadb"})}}, nil}, + "every filter must match": {[]*rds.Filter{ + {Name: aws.String("snapshot-type"), Values: aws.StringSlice([]string{SnapshotTypeManual})}, + {Name: aws.String("db-instance-id"), Values: aws.StringSlice([]string{testDBID})}, + }, []string{testSnapshotID}}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + out, err := h.svc.DescribeDBSnapshots(t.Context(), &rds.DescribeDBSnapshotsInput{Filters: tc.filters}, testAccountID) + require.NoError(t, err) + var got []string + for _, snap := range out.DBSnapshots { + got = append(got, aws.StringValue(snap.DBSnapshotIdentifier)) + } + assert.ElementsMatch(t, tc.want, got) + }) + } } // A client polling a create would otherwise read "gone" for "not ready". @@ -361,10 +389,12 @@ func TestDescribeDBSnapshots_RejectsUnhonouredScoping(t *testing.T) { h := newSnapshotHarness(t, false) cases := map[string]*rds.DescribeDBSnapshotsInput{ - "Filters": {Filters: []*rds.Filter{{Name: aws.String("engine")}}}, - "DbiResourceId": {DbiResourceId: aws.String("db-ABC123")}, - "IncludeShared": {IncludeShared: aws.Bool(true)}, - "SnapshotType": {SnapshotType: aws.String("shared")}, + "dbi-resource-id filter": {Filters: filter("dbi-resource-id", "db-ABC123")}, + "unknown filter": {Filters: filter("status", "available")}, + "bad snapshot-type": {Filters: filter("snapshot-type", "bogus")}, + "DbiResourceId": {DbiResourceId: aws.String("db-ABC123")}, + "IncludeShared": {IncludeShared: aws.Bool(true)}, + "SnapshotType": {SnapshotType: aws.String("shared")}, } for name, input := range cases { t.Run(name, func(t *testing.T) { diff --git a/spinifex/handlers/rds/subnetgroup.go b/spinifex/handlers/rds/subnetgroup.go index 514acbba9..d7e599429 100644 --- a/spinifex/handlers/rds/subnetgroup.go +++ b/spinifex/handlers/rds/subnetgroup.go @@ -89,20 +89,22 @@ func (s *Service) CreateDBSubnetGroup(ctx context.Context, input *rds.CreateDBSu } // A named group that does not exist is an error, matching AWS; an unnamed -// request lists the account's groups. +// request lists the account's groups. Filters is not read: AWS ignores it here, +// even an unknown or malformed entry. func (s *Service) DescribeDBSubnetGroups(ctx context.Context, input *rds.DescribeDBSubnetGroupsInput, accountID string) (*rds.DescribeDBSubnetGroupsOutput, error) { + if input == nil { + input = &rds.DescribeDBSubnetGroupsInput{} + } kv, err := s.bucket(ctx, accountID) if err != nil { return nil, err } - if input != nil { - if name := aws.StringValue(input.DBSubnetGroupName); name != "" { - rec, _, err := getDBSubnetGroup(ctx, kv, name) - if err != nil { - return nil, err - } - return &rds.DescribeDBSubnetGroupsOutput{DBSubnetGroups: []*rds.DBSubnetGroup{s.projectSubnetGroup(rec)}}, nil + if name := aws.StringValue(input.DBSubnetGroupName); name != "" { + rec, _, err := getDBSubnetGroup(ctx, kv, name) + if err != nil { + return nil, err } + return &rds.DescribeDBSubnetGroupsOutput{DBSubnetGroups: []*rds.DBSubnetGroup{s.projectSubnetGroup(rec)}}, nil } names, err := listNames(ctx, kv, DBSubnetGroupsPrefix()) @@ -123,7 +125,11 @@ func (s *Service) DescribeDBSubnetGroups(ctx context.Context, input *rds.Describ } groups = append(groups, s.projectSubnetGroup(&rec)) } - return &rds.DescribeDBSubnetGroupsOutput{DBSubnetGroups: groups}, nil + groups, next, err := Page(groups, subnetGroupPageKey, input.MaxRecords, input.Marker) + if err != nil { + return nil, err + } + return &rds.DescribeDBSubnetGroupsOutput{DBSubnetGroups: groups, Marker: next}, nil } // Refused while any instance still names the group, including one that is only @@ -280,11 +286,15 @@ func (s *Service) projectSubnetGroup(rec *DBSubnetGroupRecord) *rds.DBSubnetGrou DBSubnetGroupArn: aws.String(FormatARN(ResourceKindDBSubnetGroup, s.region, rec.AccountID, rec.Name)), SubnetGroupStatus: aws.String(subnetGroupStatusComplete), VpcId: aws.String(rec.VpcID), + SupportedNetworkTypes: aws.StringSlice([]string{networkTypeIPv4}), } for _, subnet := range rec.Subnets { + // Empty rather than nil: AWS reports SubnetOutpost as {} on every subnet, + // and the XML marshaller omits a nil pointer entirely. member := &rds.Subnet{ SubnetIdentifier: aws.String(subnet.SubnetID), SubnetStatus: aws.String("Active"), + SubnetOutpost: &rds.Outpost{}, } if subnet.AvailabilityZone != "" { member.SubnetAvailabilityZone = &rds.AvailabilityZone{Name: aws.String(subnet.AvailabilityZone)} diff --git a/spinifex/handlers/rds/subnetgroup_test.go b/spinifex/handlers/rds/subnetgroup_test.go index e516500c2..ea8249f25 100644 --- a/spinifex/handlers/rds/subnetgroup_test.go +++ b/spinifex/handlers/rds/subnetgroup_test.go @@ -1,12 +1,17 @@ package handlers_rds import ( + "bytes" + "encoding/json" + "encoding/xml" "testing" "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/private/protocol/xml/xmlutil" "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/rds" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" + "github.com/mulgadc/spinifex/spinifex/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -180,6 +185,33 @@ func TestDescribeDBSubnetGroups_ListsAndNames(t *testing.T) { assert.Equal(t, "zeta", aws.StringValue(named.DBSubnetGroups[0].DBSubnetGroupName)) } +// AWS reports SupportedNetworkTypes on the group and SubnetOutpost as {} on each +// subnet. Read back through the NATS JSON hop and the gateway's XML, as a client +// sees it, since an empty Outpost that either hop drops reads as absent. +func TestDescribeDBSubnetGroups_ReportsNetworkTypesAndAnEmptyOutpost(t *testing.T) { + t.Parallel() + h := newCreateHarness(t, testBaseDomain) + _, err := h.svc.CreateDBSubnetGroup(t.Context(), subnetGroupInput(testSubnetGroup, "subnet-alpha"), testAccountID) + require.NoError(t, err) + described, err := h.svc.DescribeDBSubnetGroups(t.Context(), &rds.DescribeDBSubnetGroupsInput{}, testAccountID) + require.NoError(t, err) + + wire, err := json.Marshal(described) + require.NoError(t, err) + var relayed rds.DescribeDBSubnetGroupsOutput + require.NoError(t, json.Unmarshal(wire, &relayed)) + body, err := utils.MarshalToXML(utils.GenerateIAMXMLPayload("DescribeDBSubnetGroups", &relayed)) + require.NoError(t, err) + var parsed rds.DescribeDBSubnetGroupsOutput + require.NoError(t, xmlutil.UnmarshalXML(&parsed, xml.NewDecoder(bytes.NewReader(body)), "DescribeDBSubnetGroupsResult")) + + require.Len(t, parsed.DBSubnetGroups, 1) + group := parsed.DBSubnetGroups[0] + assert.Equal(t, []string{"IPV4"}, aws.StringValueSlice(group.SupportedNetworkTypes)) + require.Len(t, group.Subnets, 1) + assert.Equal(t, &rds.Outpost{}, group.Subnets[0].SubnetOutpost) +} + // A client polling a create would read an empty list as "gone" rather than // "not there", so a named group that does not exist is an error. func TestDescribeDBSubnetGroups_RejectsAnUnknownName(t *testing.T) { diff --git a/spinifex/handlers/rds/tags.go b/spinifex/handlers/rds/tags.go index 61441d82d..dbfddeb49 100644 --- a/spinifex/handlers/rds/tags.go +++ b/spinifex/handlers/rds/tags.go @@ -75,6 +75,10 @@ func (s *Service) ListTagsForResource(ctx context.Context, input *rds.ListTagsFo if input == nil { return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "empty request") } + // AWS recognises the name and does not narrow by it. + if _, err := ReadFilters(input.Filters, filterResourceID); err != nil { + return nil, err + } resource, parsed, err := s.resolveTaggable(aws.StringValue(input.ResourceName), accountID) if err != nil { return nil, err diff --git a/spinifex/handlers/rds/window.go b/spinifex/handlers/rds/window.go index 4425fda1c..2bc5b2a6d 100644 --- a/spinifex/handlers/rds/window.go +++ b/spinifex/handlers/rds/window.go @@ -3,6 +3,8 @@ package handlers_rds import ( "fmt" "hash/fnv" + "math" + "slices" "strconv" "strings" "time" @@ -242,12 +244,9 @@ func (w weeklyWindow) segments() [][2]time.Duration { } func withinSegments(segments [][2]time.Duration, offset time.Duration) bool { - for _, segment := range segments { - if offset >= segment[0] && offset < segment[1] { - return true - } - } - return false + return slices.ContainsFunc(segments, func(segment [2]time.Duration) bool { + return offset >= segment[0] && offset < segment[1] + }) } func sinceMidnight(t time.Time) time.Duration { @@ -292,7 +291,10 @@ func assignDailySlot(block dailyWindow, identifier string, shift int64) dailyWin if slots < 1 { return block } - slot := int64(windowHash(identifier) % uint64(slots)) //nolint:gosec // the modulus bounds it to the slot count + var slot int64 + if h := windowHash(identifier) % uint64(slots); h <= math.MaxInt64 { + slot = int64(h) + } start := (block.start + time.Duration((slot+shift)%slots)*windowSlot) % oneDay return dailyWindow{start: start, end: (start + windowSlot) % oneDay} } diff --git a/spinifex/handlers/sts/assume_role.go b/spinifex/handlers/sts/assume_role.go index f8b8cf5b5..5361b7124 100644 --- a/spinifex/handlers/sts/assume_role.go +++ b/spinifex/handlers/sts/assume_role.go @@ -13,18 +13,17 @@ import ( "log/slog" "regexp" "slices" - "strconv" "strings" "time" - "unicode/utf8" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/iam" "github.com/aws/aws-sdk-go/service/sts" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/arn" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" - handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" "github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore" + handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" "github.com/mulgadc/spinifex/spinifex/utils" ) @@ -75,10 +74,6 @@ const ( // ASCII range. `:` and `/` are excluded so the assumed-role ARN stays unambiguously parseable. var roleSessionNameRegex = regexp.MustCompile(`^[A-Za-z0-9_+=,.@-]{2,64}$`) -// roleSessionNameCharsetRegex is roleSessionNameRegex without the length bound, so the -// charset and length constraints are reported separately, as AWS does. -var roleSessionNameCharsetRegex = regexp.MustCompile(`^[A-Za-z0-9_+=,.@-]*$`) - // AssumeRole mints temporary credentials after evaluating the target role's // trust policy against the caller. func (s *STSServiceImpl) AssumeRole(callerAccountID, callerARN, callerIdentity string, input *sts.AssumeRoleInput) (*sts.AssumeRoleOutput, error) { @@ -94,28 +89,9 @@ func (s *STSServiceImpl) AssumeRole(callerAccountID, callerARN, callerIdentity s // AWS reports every failed constraint at once, durationSeconds first, and before // resolving the role, so a bad value against a missing role is not AccessDenied. - var violations []constraintViolation - if d := input.DurationSeconds; d != nil { - value := strconv.FormatInt(*d, 10) - if *d < minDurationSeconds { - violations = append(violations, constraintViolation{"durationSeconds", value, - fmt.Sprintf("Member must have value greater than or equal to %d", minDurationSeconds)}) - } else if *d > maxDurationSeconds { - violations = append(violations, constraintViolation{"durationSeconds", value, - fmt.Sprintf("Member must have value less than or equal to %d", maxDurationSeconds)}) - } - } - if !roleSessionNameCharsetRegex.MatchString(sessionName) { - violations = append(violations, constraintViolation{"roleSessionName", sessionName, - `Member must satisfy regular expression pattern: [\w+=,.@-]*`}) - } - if n := utf8.RuneCountInString(sessionName); n < minRoleSessionNameLength { - violations = append(violations, constraintViolation{"roleSessionName", sessionName, - fmt.Sprintf("Member must have length greater than or equal to %d", minRoleSessionNameLength)}) - } else if n > maxRoleSessionNameLength { - violations = append(violations, constraintViolation{"roleSessionName", sessionName, - fmt.Sprintf("Member must have length less than or equal to %d", maxRoleSessionNameLength)}) - } + violations := durationViolations(input.DurationSeconds, maxDurationSeconds) + violations = append(violations, roleSessionNameConstraint.check("roleSessionName", sessionName)...) + violations = append(violations, mfaViolations(input.SerialNumber, input.TokenCode)...) if err := validationError(violations); err != nil { return nil, err } @@ -128,7 +104,7 @@ func (s *STSServiceImpl) AssumeRole(callerAccountID, callerARN, callerIdentity s return nil, awserrors.Errorf(awserrors.ErrorValidationError, "Session tags are not supported in this release; omit Tags and TransitiveTagKeys") } - if aws.StringValue(input.SerialNumber) != "" || aws.StringValue(input.TokenCode) != "" { + if input.SerialNumber != nil || input.TokenCode != nil { return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "MFA is not supported in this release; omit SerialNumber and TokenCode") } @@ -353,12 +329,9 @@ func assumeRoleConditionsHold(raw json.RawMessage, sourceAccount string) bool { } func matchTrustAction(actions []string) bool { - for _, a := range actions { - if a == stsActionAssumeRole || a == stsActionWildcard || a == globalWildcard { - return true - } - } - return false + return slices.ContainsFunc(actions, func(a string) bool { + return a == stsActionAssumeRole || a == stsActionWildcard || a == globalWildcard + }) } // serviceSourcesForCaller returns the service principals an authenticated HTTPS @@ -466,7 +439,7 @@ func matchAWSPrincipalEntry(clause, callerARN string) bool { return true } if utils.IsAccountID(clause) { - clause = fmt.Sprintf("arn:aws:iam::%s:root", clause) + clause = arn.FormatIAMRoot(clause) } if clause == callerARN { return true @@ -513,11 +486,11 @@ type principalARN struct { } func parsePrincipalARN(arnStr string) (principalARN, bool) { - parts := strings.SplitN(arnStr, ":", 6) - if len(parts) != 6 || parts[0] != "arn" || parts[1] != "aws" || parts[3] != "" { + service, region, account, resource, ok := arn.Split(arnStr) + if !ok || region != "" { return principalARN{}, false } - return principalARN{service: parts[2], account: parts[4], resource: parts[5]}, true + return principalARN{service: service, account: account, resource: resource}, true } func lastPathSegment(s string) string { diff --git a/spinifex/handlers/sts/assume_role_test.go b/spinifex/handlers/sts/assume_role_test.go index 6207171d8..21b50fdcf 100644 --- a/spinifex/handlers/sts/assume_role_test.go +++ b/spinifex/handlers/sts/assume_role_test.go @@ -497,8 +497,58 @@ func TestAssumeRole_RejectsMFA(t *testing.T) { requireMFANotSupported(t, err) }) } + + for _, tc := range mfaConstraintCases { + t.Run(tc.name, func(t *testing.T) { + input := basicAssumeRoleInput(*role.Arn, "sess") + input.SerialNumber, input.TokenCode = tc.serialNumber, tc.tokenCode + _, err := svc.AssumeRole(testCallerAccountID, testCallerARN(), testCallerUserName, input) + requireAWSError(t, err, awserrors.ErrorValidationError, tc.wantMessage) + }) + } } +// mfaConstraintCases break the model's serialNumber and tokenCode constraints, which +// are checked before MFA is refused as unsupported. +var mfaConstraintCases = func() []struct { + name string + serialNumber, tokenCode *string + wantMessage string +} { + const ( + serialPattern = `Member must satisfy regular expression pattern: [\w+=/:,.@-]*` + serialShort = "Member must have length greater than or equal to 9" + serialLong = "Member must have length less than or equal to 256" + tokenPattern = `Member must satisfy regular expression pattern: [\d]*` + tokenShort = "Member must have length greater than or equal to 6" + tokenLong = "Member must have length less than or equal to 6" + ) + violation := func(field, value, constraint string) string { + return fmt.Sprintf("Value '%s' at '%s' failed to satisfy constraint: %s", value, field, constraint) + } + one := func(field, value, constraint string) string { + return "1 validation error detected: " + violation(field, value, constraint) + } + long := strings.Repeat("a", 257) + return []struct { + name string + serialNumber, tokenCode *string + wantMessage string + }{ + {"serial_empty", aws.String(""), nil, one("serialNumber", "", serialShort)}, + {"serial_short", aws.String("GAHT1234"), nil, one("serialNumber", "GAHT1234", serialShort)}, + {"serial_long", aws.String(long), nil, one("serialNumber", long, serialLong)}, + {"serial_charset", aws.String("GAHT 12345"), nil, one("serialNumber", "GAHT 12345", serialPattern)}, + {"token_empty", nil, aws.String(""), one("tokenCode", "", tokenShort)}, + {"token_long", nil, aws.String("1234567"), one("tokenCode", "1234567", tokenLong)}, + {"token_charset", nil, aws.String("12345a"), one("tokenCode", "12345a", tokenPattern)}, + {"token_charset_and_short", nil, aws.String("abc"), "2 validation errors detected: " + + violation("tokenCode", "abc", tokenPattern) + "; " + violation("tokenCode", "abc", tokenShort)}, + {"both", aws.String(""), aws.String(""), "2 validation errors detected: " + + violation("serialNumber", "", serialShort) + "; " + violation("tokenCode", "", tokenShort)}, + } +}() + func TestAssumeRole_RejectsInvalidSessionName(t *testing.T) { svc, _ := newTestSetup(t) role := createRoleInAccount(t, svc, testCallerAccountID, "name", trustPolicyAllowingUser(testCallerARN())) diff --git a/spinifex/handlers/sts/assume_role_with_web_identity_test.go b/spinifex/handlers/sts/assume_role_with_web_identity_test.go index 79a75e0e3..a9617ce76 100644 --- a/spinifex/handlers/sts/assume_role_with_web_identity_test.go +++ b/spinifex/handlers/sts/assume_role_with_web_identity_test.go @@ -74,7 +74,7 @@ func newWebIdentityFixture(t *testing.T, svc *STSServiceImpl, accountID string) federatedARN := handlers_iam.OIDCProviderARN(accountID, issuerHostPath) // Publish JWKS to the per-cluster EKS bucket. - kv, err := handlers_eks.GetOrCreateAccountBucket(t.Context(), svc.js, accountID, 1) + kv, err := handlers_eks.GetOrCreateAccountBucket(t.Context(), svc.js, accountID) require.NoError(t, err) raw, err := json.Marshal(jwks) require.NoError(t, err) @@ -364,7 +364,7 @@ func TestAssumeRoleWithWebIdentity_ProviderNotRegistered(t *testing.T) { federatedARN := handlers_iam.OIDCProviderARN(testCallerAccountID, issuerHostPath) // Publish JWKS — but skip the IAM provider registration. - kv, err := handlers_eks.GetOrCreateAccountBucket(t.Context(), svc.js, testCallerAccountID, 1) + kv, err := handlers_eks.GetOrCreateAccountBucket(t.Context(), svc.js, testCallerAccountID) require.NoError(t, err) jwk := signingJWK(t, &priv.PublicKey) jwk.Alg, jwk.Use, jwk.Kid = "ES256", "sig", kid diff --git a/spinifex/handlers/sts/get_session_token.go b/spinifex/handlers/sts/get_session_token.go index e96ae9423..26c120712 100644 --- a/spinifex/handlers/sts/get_session_token.go +++ b/spinifex/handlers/sts/get_session_token.go @@ -29,6 +29,13 @@ func (s *STSServiceImpl) GetSessionToken(callerAccountID, callerUserName, caller input = &sts.GetSessionTokenInput{} } + // Model constraints are checked before the caller, as AWS does for AssumeRole. + violations := durationViolations(input.DurationSeconds, getSessionTokenMaxDuration) + violations = append(violations, mfaViolations(input.SerialNumber, input.TokenCode)...) + if err := validationError(violations); err != nil { + return nil, err + } + if callerPrincipalType != principalTypeUser { slog.Warn("GetSessionToken denied: caller is not a long-lived user principal", "account_id", callerAccountID, "principal_type", callerPrincipalType) @@ -50,14 +57,14 @@ func (s *STSServiceImpl) GetSessionToken(callerAccountID, callerUserName, caller } // MFA is out of scope: reject rather than silently ignore, so callers don't believe MFA was enforced. - if aws.StringValue(input.SerialNumber) != "" || aws.StringValue(input.TokenCode) != "" { + if input.SerialNumber != nil || input.TokenCode != nil { return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "MFA is not supported in this release; omit SerialNumber and TokenCode") } duration := getSessionTokenDefaultDuration if input.DurationSeconds != nil { - duration = clampGetSessionTokenDuration(*input.DurationSeconds) + duration = *input.DurationSeconds } // Resolved here rather than threaded from the gateway: the gateway's value is @@ -100,18 +107,6 @@ func (s *STSServiceImpl) GetSessionToken(callerAccountID, callerUserName, caller }, nil } -// clampGetSessionTokenDuration coerces a duration into [900, 129600]. -// A nil DurationSeconds is handled by the caller; AWS clamps explicit values rather than rejecting them. -func clampGetSessionTokenDuration(requested int64) int64 { - if requested < minDurationSeconds { - return minDurationSeconds - } - if requested > getSessionTokenMaxDuration { - return getSessionTokenMaxDuration - } - return requested -} - // userEnvelope is the session envelope for a GetSessionToken user session: // PrincipalType "user", SessionName = the IAM user name, no assumed-role fields. func userEnvelope(accountID, userName, userID string) sessionEnvelope { diff --git a/spinifex/handlers/sts/get_session_token_test.go b/spinifex/handlers/sts/get_session_token_test.go index a7bc8dc1f..5c4c96a0d 100644 --- a/spinifex/handlers/sts/get_session_token_test.go +++ b/spinifex/handlers/sts/get_session_token_test.go @@ -4,6 +4,7 @@ import ( "crypto/hmac" "crypto/sha256" "encoding/base64" + "fmt" "strings" "testing" "time" @@ -79,29 +80,47 @@ func TestGetSessionToken_NilInput_DefaultsToTwelveHours(t *testing.T) { assertStoredDuration(t, svc, out, getSessionTokenDefaultDuration) } -func TestGetSessionToken_DurationClamp(t *testing.T) { +func TestGetSessionToken_DurationBounds(t *testing.T) { svc, _ := newTestSetup(t) seedUser(t, svc, testCallerAccountID, testCallerUserName) - cases := []struct { - name string - requested int64 - want int64 - }{ - {"below_minimum_clamps_up", minDurationSeconds - 1, minDurationSeconds}, - {"at_minimum", minDurationSeconds, minDurationSeconds}, - {"in_range", 7200, 7200}, - {"at_maximum", getSessionTokenMaxDuration, getSessionTokenMaxDuration}, - {"above_maximum_clamps_down", getSessionTokenMaxDuration + 1, getSessionTokenMaxDuration}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { + for _, seconds := range []int64{minDurationSeconds, 7200, getSessionTokenMaxDuration} { + t.Run(fmt.Sprintf("accepts_%d", seconds), func(t *testing.T) { out, err := svc.GetSessionToken(testCallerAccountID, testCallerUserName, principalTypeUser, testCallerAccessKeyID, - &sts.GetSessionTokenInput{DurationSeconds: aws.Int64(tc.requested)}) + &sts.GetSessionTokenInput{DurationSeconds: aws.Int64(seconds)}) require.NoError(t, err) - assertStoredDuration(t, svc, out, tc.want) + assertStoredDuration(t, svc, out, seconds) }) } + + refused := []struct { + seconds int64 + constraint string + }{ + {minDurationSeconds - 1, "Member must have value greater than or equal to 900"}, + {getSessionTokenMaxDuration + 1, "Member must have value less than or equal to 129600"}, + } + for _, tc := range refused { + t.Run(fmt.Sprintf("refuses_%d", tc.seconds), func(t *testing.T) { + out, err := svc.GetSessionToken(testCallerAccountID, testCallerUserName, principalTypeUser, testCallerAccessKeyID, + &sts.GetSessionTokenInput{DurationSeconds: aws.Int64(tc.seconds)}) + requireAWSError(t, err, awserrors.ErrorValidationError, fmt.Sprintf( + "1 validation error detected: Value '%d' at 'durationSeconds' failed to satisfy constraint: %s", tc.seconds, tc.constraint)) + assert.Nil(t, out) + }) + } +} + +// A session caller is refused AccessDenied, but only once its input is valid. +func TestGetSessionToken_ConstraintsCheckedBeforeCaller(t *testing.T) { + svc, _ := newTestSetup(t) + + _, err := svc.GetSessionToken(testCallerAccountID, testCallerUserName, principalTypeUser, "ASIAEXAMPLEAAAAAAAAA", + &sts.GetSessionTokenInput{DurationSeconds: aws.Int64(899), TokenCode: aws.String("")}) + requireAWSError(t, err, awserrors.ErrorValidationError, + "2 validation errors detected: Value '899' at 'durationSeconds' failed to satisfy constraint: "+ + "Member must have value greater than or equal to 900; "+ + "Value '' at 'tokenCode' failed to satisfy constraint: Member must have length greater than or equal to 6") } func TestGetSessionToken_RejectsNonUserAndSessionCallers(t *testing.T) { @@ -149,6 +168,15 @@ func TestGetSessionToken_RejectsMFAParameters(t *testing.T) { assert.Nil(t, out) }) } + + for _, tc := range mfaConstraintCases { + t.Run(tc.name, func(t *testing.T) { + out, err := svc.GetSessionToken(testCallerAccountID, testCallerUserName, principalTypeUser, testCallerAccessKeyID, + &sts.GetSessionTokenInput{SerialNumber: tc.serialNumber, TokenCode: tc.tokenCode}) + requireAWSError(t, err, awserrors.ErrorValidationError, tc.wantMessage) + assert.Nil(t, out) + }) + } } func TestGetSessionToken_RejectsMissingUserName(t *testing.T) { diff --git a/spinifex/handlers/sts/oidc_jwks_test.go b/spinifex/handlers/sts/oidc_jwks_test.go index 827e58ee0..6549eabb6 100644 --- a/spinifex/handlers/sts/oidc_jwks_test.go +++ b/spinifex/handlers/sts/oidc_jwks_test.go @@ -80,7 +80,7 @@ func TestFetchClusterJWKS_RoundTrip(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - kv, err := handlers_eks.GetOrCreateAccountBucket(t.Context(), js, "123456789012", 1) + kv, err := handlers_eks.GetOrCreateAccountBucket(t.Context(), js, "123456789012") require.NoError(t, err) want := &JWKS{Keys: []JWK{ @@ -104,7 +104,7 @@ func TestFetchClusterJWKS_EmptyKeysIsError(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - kv, err := handlers_eks.GetOrCreateAccountBucket(t.Context(), js, "123456789012", 1) + kv, err := handlers_eks.GetOrCreateAccountBucket(t.Context(), js, "123456789012") require.NoError(t, err) _, err = kv.Put(t.Context(), handlers_eks.OIDCJWKSKey("empty-cluster"), []byte(`{"keys":[]}`)) diff --git a/spinifex/handlers/sts/service_impl.go b/spinifex/handlers/sts/service_impl.go index 7520d471d..5edcb91ee 100644 --- a/spinifex/handlers/sts/service_impl.go +++ b/spinifex/handlers/sts/service_impl.go @@ -37,10 +37,9 @@ type STSServiceImpl struct { var _ STSService = (*STSServiceImpl)(nil) // NewSTSServiceImpl constructs an STSServiceImpl. masterKey must be the 32-byte -// key shared with IAMServiceImpl. clusterSize sets the JetStream replication factor; -// pass 1 for single-node or test setups. The context bounds bucket creation and -// the schema migration only. -func NewSTSServiceImpl(ctx context.Context, natsConn *nats.Conn, iamSvc handlers_iam.IAMService, masterKey []byte, clusterSize int) (*STSServiceImpl, error) { +// key shared with IAMServiceImpl. The context bounds bucket creation and the +// schema migration only. +func NewSTSServiceImpl(ctx context.Context, natsConn *nats.Conn, iamSvc handlers_iam.IAMService, masterKey []byte) (*STSServiceImpl, error) { if natsConn == nil { return nil, errors.New("nil NATS connection") } @@ -51,21 +50,18 @@ func NewSTSServiceImpl(ctx context.Context, natsConn *nats.Conn, iamSvc handlers return nil, fmt.Errorf("master key must be %d bytes, got %d", masterKeySize, len(masterKey)) } - replicas := max(clusterSize, 1) - js, err := jetstream.New(natsConn) if err != nil { return nil, fmt.Errorf("get JetStream context: %w", err) } - sessions, err := initSessionCredentialsStore(ctx, js, replicas) + sessions, err := initSessionCredentialsStore(ctx, js) if err != nil { return nil, fmt.Errorf("init session credentials bucket: %w", err) } slog.Info("STS service initialized", - "sessions_bucket", KVBucketSessionCredentials, - "replicas", replicas) + "sessions_bucket", KVBucketSessionCredentials) return &STSServiceImpl{ natsConn: natsConn, diff --git a/spinifex/handlers/sts/service_impl_test.go b/spinifex/handlers/sts/service_impl_test.go index a8124f4da..7914033a0 100644 --- a/spinifex/handlers/sts/service_impl_test.go +++ b/spinifex/handlers/sts/service_impl_test.go @@ -24,17 +24,17 @@ func newTestSetup(t *testing.T) (*STSServiceImpl, *nats.Conn) { masterKey, err := handlers_iam.GenerateMasterKey() require.NoError(t, err) - iamSvc, err := handlers_iam.NewIAMServiceImpl(t.Context(), nc, masterKey, 1) + iamSvc, err := handlers_iam.NewIAMServiceImpl(t.Context(), nc, masterKey) require.NoError(t, err) - stsSvc, err := NewSTSServiceImpl(t.Context(), nc, iamSvc, masterKey, 1) + stsSvc, err := NewSTSServiceImpl(t.Context(), nc, iamSvc, masterKey) require.NoError(t, err) return stsSvc, nc } func TestNewSTSServiceImpl_RejectsNilNATSConn(t *testing.T) { masterKey := bytes.Repeat([]byte{0x01}, masterKeySize) - _, err := NewSTSServiceImpl(t.Context(), nil, nopIAMService{}, masterKey, 1) + _, err := NewSTSServiceImpl(t.Context(), nil, nopIAMService{}, masterKey) require.Error(t, err) assert.Contains(t, err.Error(), "NATS") } @@ -42,7 +42,7 @@ func TestNewSTSServiceImpl_RejectsNilNATSConn(t *testing.T) { func TestNewSTSServiceImpl_RejectsNilIAMService(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) masterKey := bytes.Repeat([]byte{0x01}, masterKeySize) - _, err := NewSTSServiceImpl(t.Context(), nc, nil, masterKey, 1) + _, err := NewSTSServiceImpl(t.Context(), nc, nil, masterKey) require.Error(t, err) assert.Contains(t, err.Error(), "IAM") } @@ -51,7 +51,7 @@ func TestNewSTSServiceImpl_RejectsWrongMasterKeySize(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) for _, size := range []int{0, 1, 16, 31, 33, 64} { t.Run("size", func(t *testing.T) { - _, err := NewSTSServiceImpl(t.Context(), nc, nopIAMService{}, bytes.Repeat([]byte{0xaa}, size), 1) + _, err := NewSTSServiceImpl(t.Context(), nc, nopIAMService{}, bytes.Repeat([]byte{0xaa}, size)) require.Error(t, err) assert.Contains(t, err.Error(), "master key") }) @@ -68,10 +68,10 @@ func TestNewSTSServiceImpl_NormalisesNegativeClusterSize(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) masterKey, err := handlers_iam.GenerateMasterKey() require.NoError(t, err) - iamSvc, err := handlers_iam.NewIAMServiceImpl(t.Context(), nc, masterKey, 1) + iamSvc, err := handlers_iam.NewIAMServiceImpl(t.Context(), nc, masterKey) require.NoError(t, err) - svc, err := NewSTSServiceImpl(t.Context(), nc, iamSvc, masterKey, 0) + svc, err := NewSTSServiceImpl(t.Context(), nc, iamSvc, masterKey) require.NoError(t, err) require.NotNil(t, svc) } diff --git a/spinifex/handlers/sts/session_credentials.go b/spinifex/handlers/sts/session_credentials.go index a68fd3e16..2e94ef0de 100644 --- a/spinifex/handlers/sts/session_credentials.go +++ b/spinifex/handlers/sts/session_credentials.go @@ -65,12 +65,11 @@ type SessionCredential struct { // sessionCredentialsConfig describes the session-credentials bucket. History is // fixed at 1: credentials are write-once at mint and delete-once at expiry. -func sessionCredentialsConfig(replicas int) kvstore.Config { +func sessionCredentialsConfig() kvstore.Config { return kvstore.Config{ - Name: KVBucketSessionCredentials, - History: 1, - Replicas: replicas, - Missing: "session credentials KV bucket not initialized", + Name: KVBucketSessionCredentials, + History: 1, + Missing: "session credentials KV bucket not initialized", OnOpen: func(ctx context.Context, kv jetstream.KeyValue) error { return migrate.DefaultRegistry.RunKV(ctx, KVBucketSessionCredentials, kv, KVBucketSessionCredentialsVersion) }, @@ -78,9 +77,8 @@ func sessionCredentialsConfig(replicas int) kvstore.Config { } // initSessionCredentialsStore opens (or creates) the session-credentials bucket. -func initSessionCredentialsStore(ctx context.Context, js jetstream.JetStream, replicas int) (*kvstore.Store[SessionCredential], error) { - // kvstore clamps replicas to a minimum of 1, so a zero clusterSize still creates. - store := kvstore.New[SessionCredential](js, sessionCredentialsConfig(replicas)) +func initSessionCredentialsStore(ctx context.Context, js jetstream.JetStream) (*kvstore.Store[SessionCredential], error) { + store := kvstore.New[SessionCredential](js, sessionCredentialsConfig()) // Opened eagerly: a bucket that cannot be created must fail service // construction, not the first AssumeRole that needs to mint into it. if _, err := store.KV(ctx); err != nil { diff --git a/spinifex/handlers/sts/session_credentials_test.go b/spinifex/handlers/sts/session_credentials_test.go index 1ce66839d..64af3a1a9 100644 --- a/spinifex/handlers/sts/session_credentials_test.go +++ b/spinifex/handlers/sts/session_credentials_test.go @@ -17,7 +17,7 @@ import ( func setupBucket(t *testing.T) *kvstore.Store[SessionCredential] { t.Helper() _, nc, _ := testutil.StartTestJetStream(t) - store, err := initSessionCredentialsStore(t.Context(), testutil.NewJetStream(t, nc), 1) + store, err := initSessionCredentialsStore(t.Context(), testutil.NewJetStream(t, nc)) require.NoError(t, err) return store } @@ -52,7 +52,7 @@ func TestInitSessionCredentialsBucket_StampsVersion(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - store, err := initSessionCredentialsStore(t.Context(), js, 1) + store, err := initSessionCredentialsStore(t.Context(), js) require.NoError(t, err) kv, err := store.KV(t.Context()) require.NoError(t, err) @@ -66,11 +66,11 @@ func TestInitSessionCredentialsBucket_Idempotent(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - store1, err := initSessionCredentialsStore(t.Context(), js, 1) + store1, err := initSessionCredentialsStore(t.Context(), js) require.NoError(t, err) // Reopen — must return the same bucket without error. - store2, err := initSessionCredentialsStore(t.Context(), js, 1) + store2, err := initSessionCredentialsStore(t.Context(), js) require.NoError(t, err) assert.Equal(t, store1.Name(), store2.Name()) } diff --git a/spinifex/handlers/sts/validation.go b/spinifex/handlers/sts/validation.go index 1eb9842cd..51f8b26dd 100644 --- a/spinifex/handlers/sts/validation.go +++ b/spinifex/handlers/sts/validation.go @@ -2,7 +2,10 @@ package handlers_sts import ( "fmt" + "regexp" + "strconv" "strings" + "unicode/utf8" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" ) @@ -14,6 +17,70 @@ type constraintViolation struct { constraint string } +// stringConstraint is a model string shape: a pattern, as AWS prints it without +// anchors, and a length range counted in characters. +type stringConstraint struct { + pattern *regexp.Regexp + patternText string + minLen, maxLen int +} + +var ( + // roleSessionNameConstraint is roleSessionNameRegex without the length bound, so + // the charset and length constraints are reported separately, as AWS does. + roleSessionNameConstraint = stringConstraint{ + regexp.MustCompile(`^[A-Za-z0-9_+=,.@-]*$`), `[\w+=,.@-]*`, minRoleSessionNameLength, maxRoleSessionNameLength, + } + serialNumberConstraint = stringConstraint{regexp.MustCompile(`^[A-Za-z0-9_+=/:,.@-]*$`), `[\w+=/:,.@-]*`, 9, 256} + tokenCodeConstraint = stringConstraint{regexp.MustCompile(`^[0-9]*$`), `[\d]*`, 6, 6} +) + +// check reports value's pattern violation, then its length violation. +func (c stringConstraint) check(field, value string) []constraintViolation { + var violations []constraintViolation + if !c.pattern.MatchString(value) { + violations = append(violations, constraintViolation{field, value, + "Member must satisfy regular expression pattern: " + c.patternText}) + } + if n := utf8.RuneCountInString(value); n < c.minLen { + violations = append(violations, constraintViolation{field, value, + fmt.Sprintf("Member must have length greater than or equal to %d", c.minLen)}) + } else if n > c.maxLen { + violations = append(violations, constraintViolation{field, value, + fmt.Sprintf("Member must have length less than or equal to %d", c.maxLen)}) + } + return violations +} + +// durationViolations range-checks a present durationSeconds against [900, maxSeconds]. +func durationViolations(d *int64, maxSeconds int64) []constraintViolation { + if d == nil { + return nil + } + value := strconv.FormatInt(*d, 10) + if *d < minDurationSeconds { + return []constraintViolation{{"durationSeconds", value, + fmt.Sprintf("Member must have value greater than or equal to %d", minDurationSeconds)}} + } + if *d > maxSeconds { + return []constraintViolation{{"durationSeconds", value, + fmt.Sprintf("Member must have value less than or equal to %d", maxSeconds)}} + } + return nil +} + +// mfaViolations checks the MFA members when present, an empty value included. +func mfaViolations(serialNumber, tokenCode *string) []constraintViolation { + var violations []constraintViolation + if serialNumber != nil { + violations = append(violations, serialNumberConstraint.check("serialNumber", *serialNumber)...) + } + if tokenCode != nil { + violations = append(violations, tokenCodeConstraint.check("tokenCode", *tokenCode)...) + } + return violations +} + // validationError renders violations as the single ValidationError AWS returns for // failed model constraints, in the order given, or nil when there are none. func validationError(violations []constraintViolation) error { diff --git a/spinifex/handlers/systemvpc/systemvpc.go b/spinifex/handlers/systemvpc/systemvpc.go index 728637df5..7185af777 100644 --- a/spinifex/handlers/systemvpc/systemvpc.go +++ b/spinifex/handlers/systemvpc/systemvpc.go @@ -19,6 +19,7 @@ import ( "hash/fnv" "log/slog" "net/netip" + "slices" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ec2" @@ -431,12 +432,9 @@ func ensureRouteTable(ctx context.Context, rtp RouteTableProvisioner, spec Spec, // associatedWith reports whether subnetID already holds an explicit association // on rt, as of the describe ensureRouteTable adopted it from. func associatedWith(rt *ec2.RouteTable, subnetID string) bool { - for _, assoc := range rt.Associations { - if aws.StringValue(assoc.SubnetId) == subnetID && !aws.BoolValue(assoc.Main) { - return true - } - } - return false + return slices.ContainsFunc(rt.Associations, func(assoc *ec2.RouteTableAssociation) bool { + return aws.StringValue(assoc.SubnetId) == subnetID && !aws.BoolValue(assoc.Main) + }) } // describeOrCreateRouteTable returns the role-tagged route table, adopting an diff --git a/spinifex/ingress/aws/query/parser_fuzz_test.go b/spinifex/ingress/aws/query/parser_fuzz_test.go new file mode 100644 index 000000000..7e7f01026 --- /dev/null +++ b/spinifex/ingress/aws/query/parser_fuzz_test.go @@ -0,0 +1,151 @@ +package query + +import ( + "fmt" + "net/url" + "reflect" + "testing" + + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/aws/aws-sdk-go/service/elbv2" + "github.com/aws/aws-sdk-go/service/rds" +) + +// Input shapes covering each list serialisation: EC2 Prefix.N, ELBv2 +// Prefix.member.N, RDS locationNameList, and deep nesting under a struct. +var fuzzTargets = []func() any{ + func() any { return &ec2.DescribeInstancesInput{} }, + func() any { return &ec2.RunInstancesInput{} }, + func() any { return &ec2.CreateLaunchTemplateInput{} }, + func() any { return &elbv2.RegisterTargetsInput{} }, + func() any { return &elbv2.CreateListenerInput{} }, + func() any { return &rds.CreateDBInstanceInput{} }, +} + +// queryParams builds the params map the way the gateway does from a request +// body: url.ParseQuery, keeping the last value of a repeated key. +func queryParams(body string) (map[string]string, bool) { + values, err := url.ParseQuery(body) + if err != nil { + return nil, false + } + params := make(map[string]string, len(values)) + for key, vs := range values { + params[key] = vs[len(vs)-1] + } + return params, true +} + +// countSliceElems tallies materialised slice elements by nesting depth, +// failing on any slice past MaxSliceLen. +func countSliceElems(t *testing.T, v reflect.Value, depth int, counts map[int]int) { + switch v.Kind() { + case reflect.Pointer, reflect.Interface: + if !v.IsNil() { + countSliceElems(t, v.Elem(), depth, counts) + } + case reflect.Struct: + for i := range v.NumField() { + if v.Type().Field(i).IsExported() { + countSliceElems(t, v.Field(i), depth, counts) + } + } + case reflect.Slice: + if v.Type().Elem().Kind() == reflect.Uint8 { + return + } + if v.Len() > MaxSliceLen { + t.Fatalf("slice of %d entries exceeds MaxSliceLen %d", v.Len(), MaxSliceLen) + } + counts[depth] += v.Len() + for i := range v.Len() { + countSliceElems(t, v.Index(i), depth+1, counts) + } + } +} + +// FuzzQueryParamsToStruct pins the properties the parser owes untrusted input: +// it never panics, and on success every list level holds no more entries than +// the request had parameters, so allocation is bounded by the input. +func FuzzQueryParamsToStruct(f *testing.F) { + for _, seed := range []string{ + "Filter.1.Name=instance-type&Filter.1.Value.1=t2.micro&Filter.3.Name=x&Filter.3.Value.1=y", + "Filter.2.Name=skipped&Filter.2.Value.1=x", + "Filter.999999.Name=ignored", + "Filter.01.Name=a&Filter.+1.Value.1=b&Filter.-1.Name=c", + "Filter.1.Value.item.1=a&Filter.1.Value.2=b", + "InstanceId.1=i-1&InstanceId.2=i-2&MaxResults=5&DryRun=true", + "MaxResults=99999999999999999999&DryRun=maybe", + "ImageId=ami-1&MinCount=1&MaxCount=1&UserData=aGVsbG8=&BlockDeviceMapping.1.DeviceName=/dev/sda1&BlockDeviceMapping.1.Ebs.VolumeSize=20", + "TagSpecification.1.ResourceType=instance&TagSpecification.1.Tag.1.Key=k&TagSpecification.1.Tag.1.Value=v", + "LaunchTemplateData.NetworkInterface.1.PrivateIpAddresses.1.PrivateIpAddress=10.0.0.1&LaunchTemplateData.InstanceMarketOptions.SpotOptions.ValidUntil=2026-07-10T12:00:00Z", + "TargetGroupArn=arn&Targets.member.1.Id=i-1&Targets.member.1.Port=80&Targets.member.2.Id=i-2", + "DefaultActions.member.1.Type=forward&DefaultActions.member.1.ForwardConfig.TargetGroups.member.1.Weight=1", + "DBInstanceIdentifier=db&Tags.Tag.1.Key=k&Tags.Tag.1.Value=v&Tags.member.2.Key=x", + } { + f.Add(seed) + } + + f.Fuzz(func(t *testing.T, body string) { + params, ok := queryParams(body) + if !ok { + return + } + for _, target := range fuzzTargets { + out := target() + if err := QueryParamsToStruct(params, out); err != nil { + continue + } + counts := map[int]int{} + countSliceElems(t, reflect.ValueOf(out), 0, counts) + for depth, n := range counts { + if n > len(params) { + t.Fatalf("%T: %d list entries at depth %d from %d params: %q", out, n, depth, len(params), body) + } + } + } + }) +} + +// FuzzQueryParamsToStruct_EntryPastGapIsIgnored pins AWS's gap rule on +// arbitrary input: adding an entry one past the first gap in any list leaves +// the parsed request unchanged. +func FuzzQueryParamsToStruct_EntryPastGapIsIgnored(f *testing.F) { + for _, seed := range []string{ + "Filter.1.Name=a&Filter.1.Value.1=b&Filter.3.Name=c", + "Filter.2.Name=a", + "Filter.1.Name=a&Filter.1.Value.2=b", + "InstanceId.1=i-1&InstanceId.3=i-3", + "Filter.01.Name=a&Filter.1.Value.1=b", + "Filter.1.Value.item.1=a", + } { + f.Add(seed) + } + + f.Fuzz(func(t *testing.T, body string) { + params, ok := queryParams(body) + if !ok { + return + } + want := &ec2.DescribeInstancesInput{} + if err := QueryParamsToStruct(params, want); err != nil { + return + } + + pastGap := len(want.Filters) + 2 + params[fmt.Sprintf("Filter.%d.Name", pastGap)] = "past-gap" + params[fmt.Sprintf("Filter.%d.Value.1", pastGap)] = "past-gap" + params[fmt.Sprintf("InstanceId.%d", len(want.InstanceIds)+2)] = "past-gap" + for i, filter := range want.Filters { + params[fmt.Sprintf("Filter.%d.Value.%d", i+1, len(filter.Values)+2)] = "past-gap" + } + + got := &ec2.DescribeInstancesInput{} + if err := QueryParamsToStruct(params, got); err != nil { + t.Fatalf("entry past the gap turned success into %v: %q", err, body) + } + if !reflect.DeepEqual(want, got) { + t.Fatalf("entry past the gap changed the parse of %q:\nwant %s\ngot %s", body, want.String(), got.String()) + } + }) +} diff --git a/spinifex/lbagent/healthcheck.go b/spinifex/lbagent/healthcheck.go index 14424755e..4bd752283 100644 --- a/spinifex/lbagent/healthcheck.go +++ b/spinifex/lbagent/healthcheck.go @@ -76,7 +76,7 @@ func probeHTTP(t HealthTarget) bool { } url := fmt.Sprintf("%s://%s%s", scheme, t.Address, path) - resp, err := client.Get(url) //nolint:noctx // short fixed-timeout probe + resp, err := client.Get(url) if err != nil { slog.Warn("HTTP probe failed", "url", url, "err", err) return false diff --git a/spinifex/migrate/version_readers.go b/spinifex/migrate/version_readers.go index 1dc388144..748b6ad4e 100644 --- a/spinifex/migrate/version_readers.go +++ b/spinifex/migrate/version_readers.go @@ -42,9 +42,7 @@ func (r *TOMLVersionReader) ReadVersion(path string) (int, error) { } // Handle "1.0" style — take the integer part before the dot. - if idx := strings.Index(raw, "."); idx >= 0 { - raw = raw[:idx] - } + raw, _, _ = strings.Cut(raw, ".") v, err := strconv.Atoi(raw) if err != nil { diff --git a/spinifex/network/external/exonet/allocator.go b/spinifex/network/external/exonet/allocator.go new file mode 100644 index 000000000..ad4f240d3 --- /dev/null +++ b/spinifex/network/external/exonet/allocator.go @@ -0,0 +1,282 @@ +// Package exonet implements external.Allocator against Exoscale Elastic IPs. +// +// A manual Exoscale EIP is delivered to the instance addressed to itself, and +// the instance may use it as a source, so the address Spinifex hands out is the +// address on the wire. That makes this the OCI allocator without its hardest +// half: no private-IP object, no VNIC and no public-to-private mapping. What +// remains is the part every cloud shares — the address only exists once the +// provider has been asked for it, so allocation is an API round trip. +// +// One node, one Exoscale instance: Allocate creates the EIP and attaches it to +// this node straight away, and the routed EIP path takes it from there exactly +// as it does on bare metal. +package exonet + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/netip" + "strings" + + "github.com/mulgadc/spinifex/spinifex/cloud/exoscale" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" + "github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore" + "github.com/mulgadc/spinifex/spinifex/network/external" +) + +// MarkerPrefix starts the description of every EIP this package creates. +// Exoscale EIPs carry no labels, so the description is the only place to say +// who made one, and Reconcile never touches an EIP without our marker. +const MarkerPrefix = "spinifex:" + +// purposeEIP duplicates handlers/ec2/vpc.PurposeEIP so the allocator can tell +// an Elastic IP from an auto-assigned address without importing handlers. +const purposeEIP = "eip" + +// Binding is one allocated EIP and the AWS identity that claimed it, keyed in +// the record by the address. +type Binding struct { + EIPID string `json:"eip_id"` + Purpose string `json:"purpose,omitempty"` + AllocationID string `json:"allocation_id,omitempty"` + ENIID string `json:"eni_id,omitempty"` + InstanceID string `json:"instance_id,omitempty"` + // NodeInstanceID is the Exoscale instance the EIP is attached to. With one + // node it is always ours; recording it now means a second node adds a + // check rather than a migration. + NodeInstanceID string `json:"node_instance_id"` +} + +// Record is the per-pool binding set. +type Record struct { + Bindings map[string]Binding `json:"bindings"` +} + +// Store persists bindings. JetStream KV in store.go; an interface so the +// allocator's tests need no NATS. +type Store interface { + Mutate(ctx context.Context, poolName string, fn func(*Record) (bool, error)) error + Get(ctx context.Context, poolName string) (Record, error) +} + +// Config wires one pool to one Exoscale instance. +type Config struct { + Pool external.ExternalPoolConfig + // InstanceID is this node's Exoscale instance, which every EIP attaches to. + InstanceID string +} + +// PoolAllocator implements external.Allocator against Exoscale. +type PoolAllocator struct { + client exoscale.Client + store Store + cfg Config +} + +var _ external.Allocator = (*PoolAllocator)(nil) + +// New constructs an allocator bound to a single pool. +func New(client exoscale.Client, store Store, cfg Config) (*PoolAllocator, error) { + if client == nil { + return nil, errors.New("exonet: nil Exoscale client") + } + if store == nil { + return nil, errors.New("exonet: nil store") + } + if cfg.InstanceID == "" { + return nil, fmt.Errorf("exonet: pool %q missing instance id", cfg.Pool.Name) + } + return &PoolAllocator{client: client, store: store, cfg: cfg}, nil +} + +// Pool returns the pool this allocator manages. +func (a *PoolAllocator) Pool() external.ExternalPoolConfig { return a.cfg.Pool } + +// Allocate creates a marked EIP, attaches it to this node, and records it. +// +// The EIP is created before the record is written. A crash in between leaves a +// marked EIP with no binding, which Reconcile finds and deletes; the reverse +// order could leave a binding naming an address that never existed. +func (a *PoolAllocator) Allocate(ctx context.Context, req external.AllocateRequest) (netip.Addr, error) { + if req.PoolName != "" && req.PoolName != a.cfg.Pool.Name { + return netip.Addr{}, fmt.Errorf("exonet: pool mismatch (got %q, bound to %q)", req.PoolName, a.cfg.Pool.Name) + } + + eip, err := a.client.CreateElasticIP(ctx, a.marker(req)) + if err != nil { + return netip.Addr{}, capacityErr(req.Purpose, fmt.Errorf("exonet: create elastic IP: %w", err), err) + } + + if err := a.client.AttachElasticIP(ctx, a.cfg.InstanceID, eip.ID); err != nil { + a.undo(ctx, eip, false) + return netip.Addr{}, fmt.Errorf("exonet: attach elastic IP %s to %s: %w", eip.ID, a.cfg.InstanceID, err) + } + + key := eip.Address.String() + binding := Binding{ + EIPID: eip.ID, + Purpose: req.Purpose, + AllocationID: req.AllocationID, + ENIID: req.ENIID, + InstanceID: req.InstanceID, + NodeInstanceID: a.cfg.InstanceID, + } + err = a.store.Mutate(ctx, a.cfg.Pool.Name, func(rec *Record) (bool, error) { + if rec.Bindings == nil { + rec.Bindings = map[string]Binding{} + } + rec.Bindings[key] = binding + return true, nil + }) + if err != nil { + a.undo(ctx, eip, true) + return netip.Addr{}, fmt.Errorf("exonet: record binding for %s: %w", key, err) + } + + slog.InfoContext(ctx, "exonet allocated external IP", + "pool", a.cfg.Pool.Name, "public_ip", key, "eip_id", eip.ID, "purpose", req.Purpose) + return eip.Address, nil +} + +// Release detaches and deletes the EIP and drops the binding. ownerENIID scopes +// the release as StaticPoolAllocator.Release does, so a teardown sweep naming a +// stale owner is a no-op rather than a double-free. +// +// The record is dropped first: if the Exoscale calls then fail, the EIP is an +// unbound marked address that Reconcile collects, not a record that lies. +func (a *PoolAllocator) Release(ctx context.Context, poolName string, ip netip.Addr, ownerENIID string) error { + if poolName != "" && poolName != a.cfg.Pool.Name { + return fmt.Errorf("exonet: pool mismatch (got %q, bound to %q)", poolName, a.cfg.Pool.Name) + } + if !ip.IsValid() { + return errors.New("exonet: invalid ip") + } + key := ip.String() + + var released Binding + var found bool + err := a.store.Mutate(ctx, a.cfg.Pool.Name, func(rec *Record) (bool, error) { + released, found = Binding{}, false + + b, ok := rec.Bindings[key] + if !ok { + if ownerENIID != "" { + return false, nil + } + return false, fmt.Errorf("exonet: %s not allocated in pool %s", key, a.cfg.Pool.Name) + } + if ownerENIID != "" && b.ENIID != "" && b.ENIID != ownerENIID { + slog.InfoContext(ctx, "exonet release skip — address reassigned to a different ENI (stale release)", + "pool", a.cfg.Pool.Name, "public_ip", key, + "stale_owner_eni", ownerENIID, "current_owner_eni", b.ENIID) + return false, nil + } + // Only ReleaseAddress, which names no interface, may free an Elastic IP. + // Instance teardown must leave it allocated, as AWS does. + if ownerENIID != "" && (b.Purpose == purposeEIP || b.AllocationID != "") { + slog.InfoContext(ctx, "exonet release skip — address belongs to an Elastic IP allocation", + "pool", a.cfg.Pool.Name, "public_ip", key, + "allocation_id", b.AllocationID, "owner_eni", ownerENIID) + return false, nil + } + delete(rec.Bindings, key) + released, found = b, true + return true, nil + }) + if err != nil || !found { + return err + } + + if err := a.detachAndDelete(ctx, released.NodeInstanceID, released.EIPID); err != nil { + return err + } + slog.InfoContext(ctx, "exonet released external IP", + "pool", a.cfg.Pool.Name, "public_ip", key, "eip_id", released.EIPID) + return nil +} + +// detachAndDelete returns an EIP to Exoscale. Not-found on either step is +// success: the address is already in the state we wanted. A failed detach is +// not final, because the delete may still succeed on an EIP nobody holds. +func (a *PoolAllocator) detachAndDelete(ctx context.Context, instanceID, eipID string) error { + var detachErr error + if instanceID != "" { + if err := a.client.DetachElasticIP(ctx, instanceID, eipID); err != nil && !errors.Is(err, exoscale.ErrNotFound) { + detachErr = fmt.Errorf("exonet: detach elastic IP %s: %w", eipID, err) + } + } + if err := a.client.DeleteElasticIP(ctx, eipID); err != nil && !errors.Is(err, exoscale.ErrNotFound) { + return errors.Join(detachErr, fmt.Errorf("exonet: delete elastic IP %s: %w", eipID, err)) + } + if detachErr != nil { + slog.WarnContext(ctx, "exonet: detach failed but the delete succeeded", "eip_id", eipID, "error", detachErr) + } + return nil +} + +// undo rolls back a partial Allocate. Failures are logged, not returned: the +// caller is already failing, and the EIP keeps its marker for Reconcile. +func (a *PoolAllocator) undo(ctx context.Context, eip exoscale.ElasticIP, attached bool) { + instanceID := "" + if attached { + instanceID = a.cfg.InstanceID + } + if err := a.detachAndDelete(ctx, instanceID, eip.ID); err != nil { + slog.WarnContext(ctx, "exonet: could not roll back elastic IP; reconcile will collect it", + "eip_id", eip.ID, "public_ip", eip.Address.String(), "error", err) + } +} + +// markerFor is the description prefix for EIPs attached to instanceID. The +// instance is in it so two Spinifex nodes in one organisation never judge each +// other's addresses. +func markerFor(instanceID string) string { return MarkerPrefix + instanceID + ":" } + +// marker is this allocation's description: our prefix plus the AWS identity, +// so the Exoscale portal shows what claimed the address. +func (a *PoolAllocator) marker(req external.AllocateRequest) string { + id := "unclaimed" + switch { + case req.AllocationID != "": + id = req.AllocationID + case req.ENIID != "": + id = req.ENIID + case req.InstanceID != "": + id = req.InstanceID + } + return markerFor(a.cfg.InstanceID) + id +} + +// ours reports whether an EIP carries this node's marker. +func (a *PoolAllocator) ours(e exoscale.ElasticIP) bool { + return strings.HasPrefix(e.Description, markerFor(a.cfg.InstanceID)) +} + +// capacityErr maps the organisation quota onto the AWS code for the call. An +// Elastic IP allocation that hits a limit is AddressLimitExceeded in AWS; an +// auto-assigned address has no such limit there, so it reports capacity. +func capacityErr(purpose string, wrapped, raw error) error { + if !errors.Is(raw, exoscale.ErrQuotaExceeded) { + return wrapped + } + code := awserrors.ErrorInsufficientAddressCapacity + if purpose == purposeEIP { + code = awserrors.ErrorAddressLimitExceeded + } + return fmt.Errorf("%w: %w", errors.New(code), wrapped) +} + +// BindingFor returns the binding behind an allocated address. +func (a *PoolAllocator) BindingFor(ctx context.Context, ip netip.Addr) (Binding, bool, error) { + rec, err := a.store.Get(ctx, a.cfg.Pool.Name) + if errors.Is(err, kvstore.ErrNotFound) { + return Binding{}, false, nil + } + if err != nil { + return Binding{}, false, err + } + b, ok := rec.Bindings[ip.String()] + return b, ok, nil +} diff --git a/spinifex/network/external/exonet/allocator_test.go b/spinifex/network/external/exonet/allocator_test.go new file mode 100644 index 000000000..0f4871d09 --- /dev/null +++ b/spinifex/network/external/exonet/allocator_test.go @@ -0,0 +1,250 @@ +package exonet + +import ( + "context" + "errors" + "fmt" + "maps" + "net/netip" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mulgadc/spinifex/spinifex/cloud/exoscale" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" + "github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore" + "github.com/mulgadc/spinifex/spinifex/network/external" +) + +const ( + nodeID = "11111111-1111-4111-8111-111111111111" + otherID = "22222222-2222-4222-8222-222222222222" + pool = "exo" +) + +// memStore mirrors KVStore, including not-found for a pool never written. +type memStore struct { + mu sync.Mutex + records map[string]Record + mutateErr error +} + +func newMemStore() *memStore { return &memStore{records: map[string]Record{}} } + +func (m *memStore) Mutate(_ context.Context, poolName string, fn func(*Record) (bool, error)) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.mutateErr != nil { + return m.mutateErr + } + rec := Record{Bindings: map[string]Binding{}} + maps.Copy(rec.Bindings, m.records[poolName].Bindings) + changed, err := fn(&rec) + if err != nil { + return err + } + if changed { + m.records[poolName] = rec + } + return nil +} + +func (m *memStore) Get(_ context.Context, poolName string) (Record, error) { + m.mu.Lock() + defer m.mu.Unlock() + r, ok := m.records[poolName] + if !ok { + return Record{}, fmt.Errorf("%w: %s", kvstore.ErrNotFound, poolName) + } + rec := Record{Bindings: map[string]Binding{}} + maps.Copy(rec.Bindings, r.Bindings) + return rec, nil +} + +func newTest(t *testing.T, quota int) (*PoolAllocator, *exoscale.Fake, *memStore) { + t.Helper() + fake := exoscale.NewFake("de-fra-1", quota) + store := newMemStore() + a, err := New(fake, store, Config{Pool: external.ExternalPoolConfig{Name: pool, Source: external.SourceExoscale}, InstanceID: nodeID}) + require.NoError(t, err) + return a, fake, store +} + +func binding(t *testing.T, s *memStore, ip netip.Addr) (Binding, bool) { + t.Helper() + rec, err := s.Get(context.Background(), pool) + if errors.Is(err, kvstore.ErrNotFound) { + return Binding{}, false + } + require.NoError(t, err) + b, ok := rec.Bindings[ip.String()] + return b, ok +} + +func TestAllocate_CreatesAttachesAndRecords(t *testing.T) { + a, fake, store := newTest(t, 0) + ip, err := a.Allocate(context.Background(), external.AllocateRequest{PoolName: pool, Purpose: "eip", AllocationID: "eipalloc-1"}) + require.NoError(t, err) + + b, ok := binding(t, store, ip) + require.True(t, ok) + assert.Equal(t, nodeID, b.NodeInstanceID) + assert.Equal(t, "eipalloc-1", b.AllocationID) + + inst, attached := fake.AttachedTo(b.EIPID) + assert.True(t, attached) + assert.Equal(t, nodeID, inst) + + eips, err := fake.ListElasticIPs(context.Background()) + require.NoError(t, err) + require.Len(t, eips, 1) + assert.Equal(t, "spinifex:"+nodeID+":eipalloc-1", eips[0].Description) +} + +func TestAllocate_QuotaMapsToTheAWSCodeForThePurpose(t *testing.T) { + cases := []struct{ purpose, want string }{ + {"eip", awserrors.ErrorAddressLimitExceeded}, + {"eni-public", awserrors.ErrorInsufficientAddressCapacity}, + } + for _, tc := range cases { + t.Run(tc.purpose, func(t *testing.T) { + a, fake, _ := newTest(t, 1) + fake.Seed(exoscale.ElasticIP{ID: "operator", Address: netip.MustParseAddr("89.145.162.53"), Description: "mulga1"}) + _, err := a.Allocate(context.Background(), external.AllocateRequest{Purpose: tc.purpose}) + require.Error(t, err) + code, ok := awserrors.ResolveErrorCode(err) + require.True(t, ok, "no AWS code in %v", err) + assert.Equal(t, tc.want, code) + }) + } +} + +func TestAllocate_AttachFailureDeletesTheNewEIP(t *testing.T) { + a, fake, store := newTest(t, 0) + fake.Fail["attach"] = errors.New("boom") + _, err := a.Allocate(context.Background(), external.AllocateRequest{Purpose: "eni-public", ENIID: "eni-1"}) + require.Error(t, err) + assert.Empty(t, fake.IDs()) + _, err = store.Get(context.Background(), pool) + assert.ErrorIs(t, err, kvstore.ErrNotFound) +} + +func TestAllocate_RecordFailureDetachesAndDeletes(t *testing.T) { + a, fake, store := newTest(t, 0) + store.mutateErr = errors.New("kv down") + _, err := a.Allocate(context.Background(), external.AllocateRequest{Purpose: "eni-public", ENIID: "eni-1"}) + require.Error(t, err) + assert.Empty(t, fake.IDs()) + assert.Contains(t, fake.Calls, "detach") +} + +func TestRelease_DeletesTheEIP(t *testing.T) { + a, fake, store := newTest(t, 0) + ctx := context.Background() + ip, err := a.Allocate(ctx, external.AllocateRequest{Purpose: "eni-public", ENIID: "eni-1"}) + require.NoError(t, err) + + require.NoError(t, a.Release(ctx, pool, ip, "eni-1")) + assert.Empty(t, fake.IDs()) + _, ok := binding(t, store, ip) + assert.False(t, ok) +} + +func TestRelease_InstanceTeardownKeepsAnElasticIP(t *testing.T) { + a, fake, store := newTest(t, 0) + ctx := context.Background() + ip, err := a.Allocate(ctx, external.AllocateRequest{Purpose: "eip", AllocationID: "eipalloc-1"}) + require.NoError(t, err) + require.NoError(t, a.Release(ctx, pool, ip, "eni-1")) // scoped: teardown of an associated instance + assert.Len(t, fake.IDs(), 1) + _, ok := binding(t, store, ip) + assert.True(t, ok) + + require.NoError(t, a.Release(ctx, pool, ip, "")) // ReleaseAddress + assert.Empty(t, fake.IDs()) +} + +func TestRelease_StaleOwnerIsANoop(t *testing.T) { + a, fake, _ := newTest(t, 0) + ctx := context.Background() + ip, err := a.Allocate(ctx, external.AllocateRequest{Purpose: "eni-public", ENIID: "eni-new"}) + require.NoError(t, err) + require.NoError(t, a.Release(ctx, pool, ip, "eni-old")) + assert.Len(t, fake.IDs(), 1) +} + +func TestRelease_EIPAlreadyGoneAtExoscaleSucceeds(t *testing.T) { + a, fake, _ := newTest(t, 0) + ctx := context.Background() + ip, err := a.Allocate(ctx, external.AllocateRequest{Purpose: "eni-public", ENIID: "eni-1"}) + require.NoError(t, err) + for _, id := range fake.IDs() { + require.NoError(t, fake.DeleteElasticIP(ctx, id)) + } + require.NoError(t, a.Release(ctx, pool, ip, "eni-1")) +} + +func TestRelease_DeleteFailureIsReported(t *testing.T) { + a, fake, _ := newTest(t, 0) + ctx := context.Background() + ip, err := a.Allocate(ctx, external.AllocateRequest{Purpose: "eni-public", ENIID: "eni-1"}) + require.NoError(t, err) + fake.Fail["delete"] = errors.New("api down") + err = a.Release(ctx, pool, ip, "eni-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "api down") +} + +// A crash between create and record leaves a marked EIP with no binding; the +// reconcile must collect it and must leave everything unmarked or foreign alone. +func TestReconcile_CollectsOnlyOurLeaks(t *testing.T) { + a, fake, store := newTest(t, 0) + ctx := context.Background() + + kept, err := a.Allocate(ctx, external.AllocateRequest{Purpose: "eni-public", ENIID: "eni-1"}) + require.NoError(t, err) + + leak, err := fake.CreateElasticIP(ctx, "spinifex:"+nodeID+":eni-crashed") + require.NoError(t, err) + require.NoError(t, fake.AttachElasticIP(ctx, nodeID, leak.ID)) + fake.Seed(exoscale.ElasticIP{ID: "operator", Address: netip.MustParseAddr("89.145.162.53"), Description: "mulga1"}) + fake.Seed(exoscale.ElasticIP{ID: "peer", Address: netip.MustParseAddr("89.145.162.54"), Description: "spinifex:" + otherID + ":eni-9"}) + + res, err := a.Reconcile(ctx) + require.NoError(t, err) + assert.Equal(t, []string{leak.ID}, res.Collected) + assert.Equal(t, 2, res.Skipped) + assert.NotContains(t, fake.IDs(), leak.ID) + assert.Contains(t, fake.IDs(), "operator") + assert.Contains(t, fake.IDs(), "peer") + _, ok := binding(t, store, kept) + assert.True(t, ok) +} + +func TestReconcile_DropsOnlyThisNodesStaleBindings(t *testing.T) { + a, _, store := newTest(t, 0) + ctx := context.Background() + require.NoError(t, store.Mutate(ctx, pool, func(r *Record) (bool, error) { + r.Bindings["194.182.160.9"] = Binding{EIPID: "gone", NodeInstanceID: nodeID} + r.Bindings["194.182.160.10"] = Binding{EIPID: "gone-too", NodeInstanceID: otherID} + return true, nil + })) + + res, err := a.Reconcile(ctx) + require.NoError(t, err) + assert.Equal(t, []string{"194.182.160.9"}, res.Stale) + rec, err := store.Get(ctx, pool) + require.NoError(t, err) + assert.Contains(t, rec.Bindings, "194.182.160.10") + assert.NotContains(t, rec.Bindings, "194.182.160.9") +} + +func TestReconcile_ListFailureChangesNothing(t *testing.T) { + a, fake, _ := newTest(t, 0) + fake.Fail["list"] = errors.New("api down") + _, err := a.Reconcile(context.Background()) + require.Error(t, err) + assert.NotContains(t, fake.Calls, "delete") +} diff --git a/spinifex/network/external/exonet/reconcile.go b/spinifex/network/external/exonet/reconcile.go new file mode 100644 index 000000000..de60df575 --- /dev/null +++ b/spinifex/network/external/exonet/reconcile.go @@ -0,0 +1,104 @@ +package exonet + +import ( + "context" + "errors" + "fmt" + "log/slog" + + "github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore" +) + +// ReconcileResult reports what a pass found. +type ReconcileResult struct { + // Collected is the IDs of marked EIPs deleted as leaks. + Collected []string + // Stale is the binding keys dropped because Exoscale no longer has the EIP. + Stale []string + // Skipped counts EIPs without this node's marker, left alone. + Skipped int +} + +// Reconcile squares the record against Exoscale in both directions: +// +// - A marked EIP with no binding leaked from an interrupted Allocate or +// Release. It bills and holds one of the organisation's few EIPs, and +// nothing else will ever find it. Delete it. +// - A binding whose EIP Exoscale no longer has was deleted behind our back. +// Drop it, so the address is not reported to a customer as theirs. +// +// It never touches an EIP without this node's marker. The organisation can hold +// the operator's own addresses, and the marker is the whole safety property. +// Run it before the allocator serves, since it cannot tell a leak from an +// allocation that is between its create and its record write. +func (a *PoolAllocator) Reconcile(ctx context.Context) (ReconcileResult, error) { + var res ReconcileResult + + live, err := a.client.ListElasticIPs(ctx) + if err != nil { + return res, fmt.Errorf("exonet reconcile: list elastic IPs: %w", err) + } + rec, err := a.store.Get(ctx, a.cfg.Pool.Name) + if err != nil && !errors.Is(err, kvstore.ErrNotFound) { + return res, fmt.Errorf("exonet reconcile: read bindings: %w", err) + } + + claimed := make(map[string]struct{}, len(rec.Bindings)) + for _, b := range rec.Bindings { + claimed[b.EIPID] = struct{}{} + } + + liveIDs := make(map[string]struct{}, len(live)) + for _, e := range live { + liveIDs[e.ID] = struct{}{} + if !a.ours(e) { + res.Skipped++ + continue + } + if _, ok := claimed[e.ID]; ok { + continue + } + if err := a.detachAndDelete(ctx, a.cfg.InstanceID, e.ID); err != nil { + return res, fmt.Errorf("exonet reconcile: collect leaked %s: %w", e.ID, err) + } + slog.WarnContext(ctx, "exonet reconcile collected a leaked elastic IP", + "pool", a.cfg.Pool.Name, "eip_id", e.ID, "public_ip", e.Address.String(), "description", e.Description) + res.Collected = append(res.Collected, e.ID) + } + + // Only bindings attached to this node are this node's to judge. + var stale []string + for key, b := range rec.Bindings { + if _, ok := liveIDs[b.EIPID]; ok || b.NodeInstanceID != a.cfg.InstanceID { + continue + } + stale = append(stale, key) + } + if len(stale) == 0 { + return res, nil + } + err = a.store.Mutate(ctx, a.cfg.Pool.Name, func(r *Record) (bool, error) { + changed := false + for _, key := range stale { + b, ok := r.Bindings[key] + if !ok || b.NodeInstanceID != a.cfg.InstanceID { + continue + } + if _, still := liveIDs[b.EIPID]; still { + continue + } + delete(r.Bindings, key) + changed = true + } + return changed, nil + }) + if err != nil { + return res, fmt.Errorf("exonet reconcile: drop stale bindings: %w", err) + } + for _, key := range stale { + slog.WarnContext(ctx, "exonet reconcile dropped a stale binding", + "pool", a.cfg.Pool.Name, "public_ip", key) + } + res.Stale = stale + return res, nil +} diff --git a/spinifex/network/external/exonet/store.go b/spinifex/network/external/exonet/store.go new file mode 100644 index 000000000..b01509267 --- /dev/null +++ b/spinifex/network/external/exonet/store.go @@ -0,0 +1,66 @@ +package exonet + +import ( + "context" + "fmt" + + "github.com/nats-io/nats.go/jetstream" + + "github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore" +) + +const ( + // KVBucketExoscaleBindings holds the address → Exoscale EIP mapping, in a + // bucket of its own for the same reason OCI's bindings have one. + KVBucketExoscaleBindings = "spinifex-exoscale-bindings" + KVBucketExoscaleBindingsHistory = 5 + kvCASRetries = 10 +) + +func bucketConfig() kvstore.Config { + return kvstore.Config{ + Name: KVBucketExoscaleBindings, + History: KVBucketExoscaleBindingsHistory, + Attempts: kvCASRetries, + Missing: "exonet: no JetStream client configured", + Exhausted: func(key string, attempts int) error { + return fmt.Errorf("exonet: pool %s contended after %d attempts", key, attempts) + }, + } +} + +// KVStore is the JetStream-backed Store. +type KVStore struct { + store *kvstore.Store[Record] +} + +var _ Store = (*KVStore)(nil) + +// NewKVStore creates the bindings bucket if it is missing. +func NewKVStore(js jetstream.JetStream) *KVStore { + return &KVStore{store: kvstore.New[Record](js, bucketConfig())} +} + +// Mutate implements Store. Upsert, because a pool's first allocation is what +// creates its record, and that allocation has already made a billable EIP. +func (s *KVStore) Mutate(ctx context.Context, poolName string, fn func(*Record) (bool, error)) error { + return s.store.Upsert(ctx, poolName, func(rec *Record) (bool, error) { + if rec.Bindings == nil { + rec.Bindings = map[string]Binding{} + } + return fn(rec) + }) +} + +// Get implements Store. A pool with no record reports kvstore.ErrNotFound, +// which callers read as an empty binding set. +func (s *KVStore) Get(ctx context.Context, poolName string) (Record, error) { + rec, _, err := s.store.Get(ctx, poolName) + if err != nil { + return Record{}, err + } + if rec == nil || rec.Bindings == nil { + return Record{Bindings: map[string]Binding{}}, nil + } + return *rec, nil +} diff --git a/spinifex/network/external/exonet/store_test.go b/spinifex/network/external/exonet/store_test.go new file mode 100644 index 000000000..a005bcc34 --- /dev/null +++ b/spinifex/network/external/exonet/store_test.go @@ -0,0 +1,63 @@ +package exonet + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mulgadc/spinifex/internal/testkit" + "github.com/mulgadc/spinifex/spinifex/cloud/exoscale" + "github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore" + "github.com/mulgadc/spinifex/spinifex/network/external" +) + +func TestKVStore_UnwrittenPoolIsNotFound(t *testing.T) { + _, _, js := testutil.StartTestJetStream(t) + _, err := NewKVStore(js).Get(context.Background(), pool) + assert.ErrorIs(t, err, kvstore.ErrNotFound) +} + +// A daemon restart builds a new allocator over the same bucket. Its reconcile +// must find the live binding and leave the EIP alone, not collect it as a leak. +func TestKVStore_BindingSurvivesARestartReconcile(t *testing.T) { + _, _, js := testutil.StartTestJetStream(t) + ctx := context.Background() + fake := exoscale.NewFake("de-fra-1", 0) + cfg := Config{Pool: external.ExternalPoolConfig{Name: pool, Source: external.SourceExoscale}, InstanceID: nodeID} + + first, err := New(fake, NewKVStore(js), cfg) + require.NoError(t, err) + ip, err := first.Allocate(ctx, external.AllocateRequest{PoolName: pool, Purpose: "eni-public", ENIID: "eni-1"}) + require.NoError(t, err) + + second, err := New(fake, NewKVStore(js), cfg) + require.NoError(t, err) + res, err := second.Reconcile(ctx) + require.NoError(t, err) + assert.Empty(t, res.Collected) + assert.Empty(t, res.Stale) + assert.Len(t, fake.IDs(), 1) + + require.NoError(t, second.Release(ctx, pool, ip, "eni-1")) + assert.Empty(t, fake.IDs()) + rec, err := NewKVStore(js).Get(ctx, pool) + require.NoError(t, err) + assert.NotContains(t, rec.Bindings, ip.String()) +} + +func TestFromPoolConfig_RefusesAnUnusableBinary(t *testing.T) { + _, _, js := testutil.StartTestJetStream(t) + _, err := FromPoolConfig(context.Background(), js, external.ExternalPoolConfig{ + Name: pool, Source: external.SourceExoscale, ExoscaleZone: "de-fra-1", + ExoscaleInstanceID: nodeID, ExoscaleBinary: "/bin/false", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "exo CLI is not usable") +} + +func TestFromPoolConfig_RefusesAnotherSource(t *testing.T) { + _, err := FromPoolConfig(context.Background(), nil, external.ExternalPoolConfig{Name: pool, Source: "static"}) + require.Error(t, err) +} diff --git a/spinifex/network/external/exonet/wiring.go b/spinifex/network/external/exonet/wiring.go new file mode 100644 index 000000000..0e39e241a --- /dev/null +++ b/spinifex/network/external/exonet/wiring.go @@ -0,0 +1,37 @@ +package exonet + +import ( + "context" + "fmt" + "log/slog" + + "github.com/nats-io/nats.go/jetstream" + + "github.com/mulgadc/spinifex/spinifex/cloud/exoscale" + "github.com/mulgadc/spinifex/spinifex/network/external" +) + +// FromPoolConfig builds a live allocator for one source="exoscale" pool and +// proves the exo binary runs before anything depends on it. It does not +// reconcile; the caller runs that pass before the allocator serves. +func FromPoolConfig(ctx context.Context, js jetstream.JetStream, pool external.ExternalPoolConfig) (*PoolAllocator, error) { + if !pool.IsExoscale() { + return nil, fmt.Errorf("exonet: pool %q has source %q, not %q", pool.Name, pool.Source, external.SourceExoscale) + } + client, err := exoscale.NewCLIClient(exoscale.CLIConfig{ + Binary: pool.ExoscaleBinary, + ConfigFile: pool.ExoscaleConfigFile, + Account: pool.ExoscaleAccount, + Zone: pool.ExoscaleZone, + }) + if err != nil { + return nil, fmt.Errorf("exonet: pool %q: %w", pool.Name, err) + } + version, err := client.Version(ctx) + if err != nil { + return nil, fmt.Errorf("exonet: pool %q: exo CLI is not usable: %w", pool.Name, err) + } + slog.InfoContext(ctx, "exonet using exo CLI", "pool", pool.Name, "version", version, + "zone", pool.ExoscaleZone, "instance_id", pool.ExoscaleInstanceID) + return New(client, NewKVStore(js), Config{Pool: pool, InstanceID: pool.ExoscaleInstanceID}) +} diff --git a/spinifex/network/external/types.go b/spinifex/network/external/types.go index 797b4aa06..edf86b06a 100644 --- a/spinifex/network/external/types.go +++ b/spinifex/network/external/types.go @@ -37,6 +37,13 @@ type ExternalPoolConfig struct { // principal: no dynamic group, no IAM policy, and no metadata read. OCIConfigFile string OCIConfigProfile string + // Exoscale* configure a source="exoscale" pool: the zone the EIPs live in, + // the instance they attach to, and how the exo CLI is invoked. + ExoscaleZone string + ExoscaleInstanceID string + ExoscaleConfigFile string + ExoscaleAccount string + ExoscaleBinary string } // IsDHCP reports whether the pool sources IPs from an upstream DHCP server. @@ -49,6 +56,11 @@ func (p *ExternalPoolConfig) IsOCI() bool { return p != nil && p.Source == SourceOCI } +// IsExoscale reports whether the pool sources IPs from Exoscale Elastic IPs. +func (p *ExternalPoolConfig) IsExoscale() bool { + return p != nil && p.Source == SourceExoscale +} + const ( // SourceStatic is the default pool source (inline range math, KV-backed). SourceStatic = "static" @@ -59,6 +71,10 @@ const ( // it, so the addresses have to be created through the provider rather than // computed from a range. SourceOCI = "oci" + // SourceExoscale delegates allocation to Exoscale Elastic IPs, created and + // attached through the exo CLI. A manual EIP arrives addressed to itself, + // so unlike OCI there is no private half to map. + SourceExoscale = "exoscale" // DHCPMACDerived leases with deterministic per-client-id 02:xx MACs. DHCPMACDerived = "derived" diff --git a/spinifex/network/host/ipsec.go b/spinifex/network/host/ipsec.go index d13f02cab..4a61a254f 100644 --- a/spinifex/network/host/ipsec.go +++ b/spinifex/network/host/ipsec.go @@ -4,6 +4,7 @@ import ( "bytes" "errors" "fmt" + "slices" "strings" "github.com/mulgadc/spinifex/spinifex/utils" @@ -88,12 +89,9 @@ func GetNBGlobalIPSec(nbAddr string) (bool, error) { func isNBUnreachable(msg string) bool { lower := strings.ToLower(msg) - for _, pattern := range nbUnreachablePatterns { - if strings.Contains(lower, pattern) { - return true - } - } - return false + return slices.ContainsFunc(nbUnreachablePatterns, func(pattern string) bool { + return strings.Contains(lower, pattern) + }) } // SetNBGlobalIPSec writes NB_Global.ipsec, triggering ovn-controller to add diff --git a/spinifex/network/ovn/live.go b/spinifex/network/ovn/live.go index 3e815aaf7..177c09f14 100644 --- a/spinifex/network/ovn/live.go +++ b/spinifex/network/ovn/live.go @@ -349,7 +349,7 @@ func (c *LiveClient) CreateLogicalSwitchPortInGroups(ctx context.Context, switch ops := append(createOps, switchMutateOps...) for _, pgName := range portGroupNames { - pg, err := c.getPortGroup(ctx, pgName) + pg, err := c.GetPortGroup(ctx, pgName) if err != nil { return fmt.Errorf("get port group %s for port add: %w", pgName, err) } @@ -1370,7 +1370,7 @@ func (c *LiveClient) CreatePortGroup(ctx context.Context, name string, ports []s } func (c *LiveClient) EnsurePortGroup(ctx context.Context, name string, ports []string) (*nbdb.PortGroup, bool, error) { - if existing, err := c.getPortGroup(ctx, name); err == nil { + if existing, err := c.GetPortGroup(ctx, name); err == nil { return existing, false, nil } pg := &nbdb.PortGroup{ @@ -1404,7 +1404,7 @@ func (c *LiveClient) waitForCachedPortGroup(ctx context.Context, name string) (* deadline := time.Now().Add(ensureRefetchTimeout) var lastErr error for { - v, err := c.getPortGroup(ctx, name) + v, err := c.GetPortGroup(ctx, name) if err == nil { return v, nil } @@ -1421,7 +1421,7 @@ func (c *LiveClient) waitForCachedPortGroup(ctx context.Context, name string) (* } func (c *LiveClient) DeletePortGroup(ctx context.Context, name string) error { - pg, err := c.getPortGroup(ctx, name) + pg, err := c.GetPortGroup(ctx, name) if err != nil { return fmt.Errorf("delete port group lookup: %w", err) } @@ -1446,7 +1446,7 @@ func (c *LiveClient) UpdatePortGroupMemberships(ctx context.Context, lspName str var ops []ovsdb.Operation appendPGOps := func(pgName, mutator string) error { - pg, err := c.getPortGroup(ctx, pgName) + pg, err := c.GetPortGroup(ctx, pgName) if err != nil { return fmt.Errorf("port group %s lookup: %w", pgName, err) } @@ -1479,7 +1479,8 @@ func (c *LiveClient) UpdatePortGroupMemberships(ctx context.Context, lspName str return nil } -func (c *LiveClient) getPortGroup(ctx context.Context, name string) (*nbdb.PortGroup, error) { +// GetPortGroup returns the named port group. +func (c *LiveClient) GetPortGroup(ctx context.Context, name string) (*nbdb.PortGroup, error) { var pgs []nbdb.PortGroup err := c.client.WhereCache(func(pg *nbdb.PortGroup) bool { return pg.Name == name @@ -1493,11 +1494,6 @@ func (c *LiveClient) getPortGroup(ctx context.Context, name string) (*nbdb.PortG return &pgs[0], nil } -// GetPortGroup returns the named port group. -func (c *LiveClient) GetPortGroup(ctx context.Context, name string) (*nbdb.PortGroup, error) { - return c.getPortGroup(ctx, name) -} - func (c *LiveClient) ListPortGroups(ctx context.Context) ([]nbdb.PortGroup, error) { var pgs []nbdb.PortGroup if err := c.client.List(ctx, &pgs); err != nil { @@ -1510,7 +1506,7 @@ func (c *LiveClient) ListPortGroups(ctx context.Context) ([]nbdb.PortGroup, erro // addresses on the existing row. Wait-op serialises concurrent writers (NB has // no unique-Name constraint). Returns the persisted row UUID. func (c *LiveClient) EnsureAddressSet(ctx context.Context, name string, addresses []string) (string, error) { - if existing, err := c.getAddressSet(ctx, name); err == nil { + if existing, err := c.GetAddressSet(ctx, name); err == nil { return existing.UUID, c.convergeAddressSet(ctx, existing, addresses) } as := &nbdb.AddressSet{ @@ -1562,7 +1558,7 @@ func (c *LiveClient) waitForCachedAddressSet(ctx context.Context, name string) ( deadline := time.Now().Add(ensureRefetchTimeout) var lastErr error for { - v, err := c.getAddressSet(ctx, name) + v, err := c.GetAddressSet(ctx, name) if err == nil { return v, nil } @@ -1580,10 +1576,6 @@ func (c *LiveClient) waitForCachedAddressSet(ctx context.Context, name string) ( // GetAddressSet returns the named address set. func (c *LiveClient) GetAddressSet(ctx context.Context, name string) (*nbdb.AddressSet, error) { - return c.getAddressSet(ctx, name) -} - -func (c *LiveClient) getAddressSet(ctx context.Context, name string) (*nbdb.AddressSet, error) { var sets []nbdb.AddressSet err := c.client.WhereCache(func(as *nbdb.AddressSet) bool { return as.Name == name @@ -1622,7 +1614,7 @@ func (c *LiveClient) AddACLs(ctx context.Context, portGroupName string, specs [] if len(specs) == 0 { return nil } - pg, err := c.getPortGroup(ctx, portGroupName) + pg, err := c.GetPortGroup(ctx, portGroupName) if err != nil { return fmt.Errorf("add ACLs port group lookup: %w", err) } @@ -1675,7 +1667,7 @@ func (c *LiveClient) AddACLs(ctx context.Context, portGroupName string, specs [] // ClearACLs removes every ACL row referenced by the port group and detaches // them from the port group's ACLs set in one transaction. func (c *LiveClient) ClearACLs(ctx context.Context, portGroupName string) error { - pg, err := c.getPortGroup(ctx, portGroupName) + pg, err := c.GetPortGroup(ctx, portGroupName) if err != nil { return fmt.Errorf("clear ACLs port group lookup: %w", err) } @@ -1730,7 +1722,7 @@ func (c *LiveClient) aclSetUnchanged(ctx context.Context, pg *nbdb.PortGroup, sp // No-ops when the current ACL set already matches specs, so an unchanged SG does // not churn NB rows (and force ovn-northd flow recompute) on every drift tick. func (c *LiveClient) ReplaceACLs(ctx context.Context, portGroupName string, specs []ACLSpec) error { - pg, err := c.getPortGroup(ctx, portGroupName) + pg, err := c.GetPortGroup(ctx, portGroupName) if err != nil { return fmt.Errorf("replace ACLs port group lookup: %w", err) } diff --git a/spinifex/network/reconcile/intent.go b/spinifex/network/reconcile/intent.go index d00c2b93d..99fa6cf35 100644 --- a/spinifex/network/reconcile/intent.go +++ b/spinifex/network/reconcile/intent.go @@ -8,6 +8,7 @@ import ( "log/slog" "net" "net/netip" + "slices" "strings" handlers_ec2_eip "github.com/mulgadc/spinifex/spinifex/handlers/ec2/eip" @@ -657,14 +658,9 @@ func loadNATGWs( if rt.VpcId != rec.VpcId { continue } - hasNatRoute := false - for _, r := range rt.Routes { - if r.NatGatewayId == rec.NatGatewayId { - hasNatRoute = true - break - } - } - if !hasNatRoute { + if !slices.ContainsFunc(rt.Routes, func(r handlers_ec2_routetable.RouteRecord) bool { + return r.NatGatewayId == rec.NatGatewayId + }) { continue } for _, assoc := range rt.Associations { diff --git a/spinifex/operator/host/systemd/reconcile.go b/spinifex/operator/host/systemd/reconcile.go index db2cfcf0d..192a8e4a5 100644 --- a/spinifex/operator/host/systemd/reconcile.go +++ b/spinifex/operator/host/systemd/reconcile.go @@ -9,11 +9,12 @@ package systemd import ( "errors" "fmt" + "maps" "os" "os/exec" "path/filepath" "regexp" - "sort" + "slices" "strconv" "strings" "time" @@ -54,23 +55,17 @@ type Result struct { // HasChanges reports whether any unit is missing or stale — i.e. whether // applying (DryRun: false) would write anything to disk. func (r Result) HasChanges() bool { - for _, s := range r.Statuses { - if s.Action == ActionInstall || s.Action == ActionReplace { - return true - } - } - return false + return slices.ContainsFunc(r.Statuses, func(s UnitStatus) bool { + return s.Action == ActionInstall || s.Action == ActionReplace + }) } // HasConflicts reports whether any unit is at the current version but was // modified on disk — drift Reconcile deliberately refuses to overwrite. func (r Result) HasConflicts() bool { - for _, s := range r.Statuses { - if s.Action == ActionConflict { - return true - } - } - return false + return slices.ContainsFunc(r.Statuses, func(s UnitStatus) bool { + return s.Action == ActionConflict + }) } // ErrRootRequired is returned when Reconcile has pending changes but root @@ -103,12 +98,7 @@ var systemctlDaemonReload = func() error { } func sortedUnitNames() []string { - names := make([]string, 0, len(Units)) - for n := range Units { - names = append(names, n) - } - sort.Strings(names) - return names + return slices.Sorted(maps.Keys(Units)) } // Reconcile compares each embedded unit (Units, generated from diff --git a/spinifex/paging/ec2.go b/spinifex/paging/ec2.go new file mode 100644 index 000000000..b6fd0b01b --- /dev/null +++ b/spinifex/paging/ec2.go @@ -0,0 +1,64 @@ +package paging + +import ( + "errors" + + "github.com/aws/aws-sdk-go/aws" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" +) + +// ec2MinResults is the smallest MaxResults the network Describe calls accept. +const ec2MinResults = 5 + +// EC2 is one EC2 Describe operation's paging contract. The messages are AWS's +// own, which differ in wording and casing between operations. +type EC2 struct { + // MaxResults is the largest MaxResults the operation accepts. + MaxResults int64 + // TooLarge and TooSmall answer a MaxResults outside the accepted range; + // %d is the requested value. + TooLarge string + TooSmall string + // WithIDs answers MaxResults sent together with explicit resource IDs. + WithIDs string + // PaginationTokenError selects the InvalidPaginationToken answer to a bad + // token that some operations give instead of InvalidParameterValue. + PaginationTokenError bool +} + +// Parse validates MaxResults, its combination with ids explicit resource IDs, +// and NextToken, and returns the page they request. +func (p EC2) Parse(maxResults *int64, nextToken *string, ids int) (Request, error) { + if maxResults != nil { + n := *maxResults + if n > p.MaxResults { + return Request{}, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, p.TooLarge, n) + } + if n < ec2MinResults { + return Request{}, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, p.TooSmall, n) + } + if ids > 0 { + return Request{}, awserrors.Errorf(awserrors.ErrorInvalidParameterCombination, "%s", p.WithIDs) + } + } + + token := aws.StringValue(nextToken) + from, err := DecodeToken(token) + if errors.Is(err, ErrInvalidToken) { + if p.PaginationTokenError { + return Request{}, awserrors.Errorf(awserrors.ErrorInvalidPaginationToken, "Next token '%s' is invalid", token) + } + return Request{}, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, + "Value ( %s ) for parameter NextToken is invalid. The token is invalid.", token) + } + return Request{Limit: int(aws.Int64Value(maxResults)), From: from}, nil +} + +// EC2Page is Page with the next token in the SDK's form: nil on the last page. +func EC2Page[T any](items []T, key func(T) string, req Request) ([]T, *string) { + page, next := Page(items, key, req) + if next == "" { + return page, nil + } + return page, aws.String(next) +} diff --git a/spinifex/paging/paging.go b/spinifex/paging/paging.go new file mode 100644 index 000000000..5b451da37 --- /dev/null +++ b/spinifex/paging/paging.go @@ -0,0 +1,64 @@ +// Package paging pages a listing with opaque resume tokens. Each API keeps its +// own limits and error text; this package only owns the token and the slicing. +package paging + +import ( + "encoding/base64" + "errors" + "slices" + "strings" +) + +// ErrInvalidToken reports a token this package did not issue. +var ErrInvalidToken = errors.New("paging: token was not issued by this service") + +// Marks a token as ours, so arbitrary base64 is rejected rather than resumed. +const tokenPrefix = "spx-page-v1:" //nolint:gosec // a format marker, not a credential + +// Request is a validated page request. The zero value asks for everything. +type Request struct { + // Limit caps the page; zero or less means no cap. + Limit int + // From is the key to resume at, decoded from the caller's token. + From string +} + +// EncodeToken returns the opaque token that resumes a listing at key. +func EncodeToken(key string) string { + return base64.RawURLEncoding.EncodeToString([]byte(tokenPrefix + key)) +} + +// DecodeToken returns the key a token resumes at. An empty token is the start. +func DecodeToken(token string) (string, error) { + if token == "" { + return "", nil + } + decoded, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return "", ErrInvalidToken + } + key, ok := strings.CutPrefix(string(decoded), tokenPrefix) + if !ok || key == "" { + return "", ErrInvalidToken + } + return key, nil +} + +// Page sorts items by key in place and returns the page req selects, plus the +// token for the next page, or "" on the last page. A resume starts at the first +// key not below From, so deleting the item a token names skips nothing. +func Page[T any](items []T, key func(T) string, req Request) ([]T, string) { + slices.SortStableFunc(items, func(a, b T) int { return strings.Compare(key(a), key(b)) }) + + start := 0 + if req.From != "" { + start, _ = slices.BinarySearchFunc(items, req.From, func(item T, target string) int { + return strings.Compare(key(item), target) + }) + } + rest := items[start:] + if req.Limit <= 0 || len(rest) <= req.Limit { + return rest, "" + } + return rest[:req.Limit], EncodeToken(key(rest[req.Limit])) +} diff --git a/spinifex/paging/paging_test.go b/spinifex/paging/paging_test.go new file mode 100644 index 000000000..82abce477 --- /dev/null +++ b/spinifex/paging/paging_test.go @@ -0,0 +1,111 @@ +package paging_test + +import ( + "encoding/base64" + "fmt" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" + "github.com/mulgadc/spinifex/spinifex/paging" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func identity(s string) string { return s } + +func TestPage_WalksEveryItemOnceInKeyOrder(t *testing.T) { + items := []string{"d", "a", "e", "c", "b"} + var seen []string + req := paging.Request{Limit: 2} + for range len(items) { + page, next := paging.Page(items, identity, req) + seen = append(seen, page...) + if next == "" { + break + } + from, err := paging.DecodeToken(next) + require.NoError(t, err) + req.From = from + } + assert.Equal(t, []string{"a", "b", "c", "d", "e"}, seen) +} + +func TestPage_ResumesPastADeletedItem(t *testing.T) { + _, next := paging.Page([]string{"a", "b", "c", "d"}, identity, paging.Request{Limit: 2}) + from, err := paging.DecodeToken(next) + require.NoError(t, err) + require.Equal(t, "c", from) + + page, _ := paging.Page([]string{"a", "b", "d"}, identity, paging.Request{Limit: 2, From: from}) + assert.Equal(t, []string{"d"}, page) +} + +func TestPage_NoLimitReturnsEverythingWithoutToken(t *testing.T) { + page, next := paging.Page([]string{"b", "a"}, identity, paging.Request{}) + assert.Equal(t, []string{"a", "b"}, page) + assert.Empty(t, next) + + page, next = paging.Page([]string{"a", "b"}, identity, paging.Request{Limit: 2}) + assert.Len(t, page, 2) + assert.Empty(t, next, "an exactly full last page carries no token") +} + +func TestDecodeToken_RejectsTokensItDidNotIssue(t *testing.T) { + for _, token := range []string{ + "garbage!", + base64.RawURLEncoding.EncodeToString([]byte("subnet-123")), + paging.EncodeToken(""), + } { + _, err := paging.DecodeToken(token) + assert.ErrorIs(t, err, paging.ErrInvalidToken, token) + } +} + +func requireAWSError(t *testing.T, err error, code, message string) { + t.Helper() + got, msg, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok, "error %v carries no AWS code", err) + assert.Equal(t, code, got) + assert.Equal(t, message, msg) +} + +func TestEC2Parse(t *testing.T) { + p := paging.EC2{ + MaxResults: 1000, + TooLarge: "too large %d", + TooSmall: "too small %d", + WithIDs: "ids and max", + } + + req, err := p.Parse(aws.Int64(1000), aws.String(paging.EncodeToken("subnet-b")), 0) + require.NoError(t, err) + assert.Equal(t, paging.Request{Limit: 1000, From: "subnet-b"}, req) + + req, err = p.Parse(nil, nil, 2) + require.NoError(t, err, "IDs without MaxResults are fine") + assert.Equal(t, paging.Request{}, req) + + _, err = p.Parse(aws.Int64(5), nil, 0) + require.NoError(t, err) + + _, err = p.Parse(aws.Int64(1001), nil, 0) + requireAWSError(t, err, awserrors.ErrorInvalidParameterValue, "too large 1001") + for _, n := range []int64{4, 0, -1} { + _, err = p.Parse(aws.Int64(n), nil, 0) + requireAWSError(t, err, awserrors.ErrorInvalidParameterValue, fmt.Sprintf("too small %d", n)) + } + + _, err = p.Parse(aws.Int64(2000), nil, 1) + requireAWSError(t, err, awserrors.ErrorInvalidParameterValue, "too large 2000") + _, err = p.Parse(aws.Int64(5), nil, 1) + requireAWSError(t, err, awserrors.ErrorInvalidParameterCombination, "ids and max") + + _, err = p.Parse(nil, aws.String("garbage"), 0) + requireAWSError(t, err, awserrors.ErrorInvalidParameterValue, + "Value ( garbage ) for parameter NextToken is invalid. The token is invalid.") + + p.PaginationTokenError = true + _, err = p.Parse(nil, aws.String("garbage"), 0) + requireAWSError(t, err, awserrors.ErrorInvalidPaginationToken, "Next token 'garbage' is invalid") +} diff --git a/spinifex/providers/cloud/oci/apiclient_test.go b/spinifex/providers/cloud/oci/apiclient_test.go index b5e9e1981..2be86031f 100644 --- a/spinifex/providers/cloud/oci/apiclient_test.go +++ b/spinifex/providers/cloud/oci/apiclient_test.go @@ -70,10 +70,10 @@ func TestListPrivateIPsFollowsEveryPage(t *testing.T) { c, seen := testClient(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Query().Get("page") { case "": - w.Header().Set("opc-next-page", "page2") + w.Header().Set("Opc-Next-Page", "page2") _, _ = w.Write([]byte(`[{"id":"p1","ipAddress":"10.200.0.100","vnicId":"v1"}]`)) case "page2": - w.Header().Set("opc-next-page", "page3") + w.Header().Set("Opc-Next-Page", "page3") _, _ = w.Write([]byte(`[{"id":"p2","ipAddress":"10.200.0.101","vnicId":"v1"}]`)) default: _, _ = w.Write([]byte(`[{"id":"p3","ipAddress":"10.200.0.102","vnicId":"v1"}]`)) diff --git a/spinifex/providers/cloud/oci/fake.go b/spinifex/providers/cloud/oci/fake.go index ddd790120..efefc15b9 100644 --- a/spinifex/providers/cloud/oci/fake.go +++ b/spinifex/providers/cloud/oci/fake.go @@ -3,7 +3,9 @@ package oci import ( "context" "fmt" + "maps" "net/netip" + "slices" "sync" ) @@ -71,22 +73,14 @@ func (f *Fake) SeedPublicIP(p PublicIP) { func (f *Fake) PrivateIPs() []PrivateIP { f.mu.Lock() defer f.mu.Unlock() - out := make([]PrivateIP, 0, len(f.privateIPs)) - for _, p := range f.privateIPs { - out = append(out, p) - } - return out + return slices.Collect(maps.Values(f.privateIPs)) } // PublicIPs returns a snapshot. func (f *Fake) PublicIPs() []PublicIP { f.mu.Lock() defer f.mu.Unlock() - out := make([]PublicIP, 0, len(f.publicIPs)) - for _, p := range f.publicIPs { - out = append(out, p) - } - return out + return slices.Collect(maps.Values(f.publicIPs)) } // FailOp stages err for the next call to op. Locked, so a test may stage one diff --git a/spinifex/providers/ebs/conformance/bench.go b/spinifex/providers/ebs/conformance/bench.go index 39a37826a..eb11e582b 100644 --- a/spinifex/providers/ebs/conformance/bench.go +++ b/spinifex/providers/ebs/conformance/bench.go @@ -181,5 +181,8 @@ func benchCapabilities(b *testing.B, provider ebsprovider.EBSProvider) ebsprovid if err != nil { b.Fatalf("get capabilities: %v", err) } + if resp == nil { + b.Fatal("get capabilities: provider returned a nil response with a nil error") + } return resp.Capabilities } diff --git a/spinifex/providers/ebs/conformance/nbdclient.go b/spinifex/providers/ebs/conformance/nbdclient.go index 0dee84eb0..92e371066 100644 --- a/spinifex/providers/ebs/conformance/nbdclient.go +++ b/spinifex/providers/ebs/conformance/nbdclient.go @@ -11,6 +11,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" "testing" "time" @@ -302,13 +303,9 @@ func RunNBDClientSuiteWithConfig(t *testing.T, newProvider func(t *testing.T) eb capabilities := capabilitiesOf(t, provider) pub := publishForNBD(t, provider, cfg, cfg.VolumePrefix+"-extents", false) - holes := false - for _, extent := range nbdMap(t, dialURI(t, pub.NBDURI)) { - if strings.Contains(extent.Description, "hole") { - holes = true - break - } - } + holes := slices.ContainsFunc(nbdMap(t, dialURI(t, pub.NBDURI)), func(extent nbdExtent) bool { + return strings.Contains(extent.Description, "hole") + }) assert.Equalf(t, capabilities.SparseExtentReporting, holes, "SparseExtentReporting=%v but a volume with nothing written to it reports holes=%v", capabilities.SparseExtentReporting, holes) diff --git a/spinifex/providers/ebs/natsserve/handlers_test.go b/spinifex/providers/ebs/natsserve/handlers_test.go index 58b9305eb..c2eb63fc0 100644 --- a/spinifex/providers/ebs/natsserve/handlers_test.go +++ b/spinifex/providers/ebs/natsserve/handlers_test.go @@ -432,11 +432,43 @@ func (p *countingProvider) PublishVolume(ctx context.Context, req ebsprovider.Pu return p.EBSProvider.PublishVolume(ctx, req) } +// handlerSettle is how long a handler count is watched after it reaches the +// expected value, to catch a delivery that should not have happened arriving +// late. Generous, because a false pass here is a silently broken queue group. +const handlerSettle = 250 * time.Millisecond + +// requireCapabilitiesHandlers waits for the capabilities handlers to have run +// the expected number of times, then confirms the count does not move. +// +// Both halves are needed. How many handlers a message reaches is the server's +// decision — a queue group picks one member, a plain subscription reaches +// every member — so the count converges on the answer from below and cannot +// overshoot it. But a broken queue group that delivered to both members would +// pass through the correct value on its way to the wrong one, so converging +// alone would accept it. +func requireCapabilitiesHandlers(t *testing.T, workers []*countingProvider, want int32) { + t.Helper() + total := func() int32 { + var sum int32 + for _, worker := range workers { + sum += worker.capabilities.Load() + } + return sum + } + require.Eventuallyf(t, func() bool { return total() == want }, requestTimeout, 5*time.Millisecond, + "capabilities never reached %d handlers", want) + time.Sleep(handlerSettle) + require.Equal(t, want, total(), "capabilities handler count moved after it had settled") +} + // TestServeQueueGroupBalancesSharedSubjects is the difference between two -// workers sharing a workload and both doing all of it. The mount subject is -// never queue-grouped, so once both servers have handled a mount, message -// ordering on the shared connection guarantees the earlier capabilities -// request has already been delivered to everyone that was going to get it. +// workers sharing a workload and both doing all of it. +// +// The handler counts are waited for rather than read once. Each subscription +// dispatches on its own goroutine, so there is no ordering between a worker's +// mount handler and its capabilities handler: the mount having landed on both +// workers says nothing about whether the earlier capabilities message has been +// counted yet. func TestServeQueueGroupBalancesSharedSubjects(t *testing.T) { for _, tc := range []struct { name string @@ -470,7 +502,7 @@ func TestServeQueueGroupBalancesSharedSubjects(t *testing.T) { return workers[0].publishes.Load()+workers[1].publishes.Load() == 2 }, requestTimeout, 5*time.Millisecond, "mount is never queue-grouped, so both servers must see it") - assert.Equal(t, tc.wantHandlers, workers[0].capabilities.Load()+workers[1].capabilities.Load()) + requireCapabilitiesHandlers(t, workers, tc.wantHandlers) }) } } diff --git a/spinifex/runtime/compute/cache/cache.go b/spinifex/runtime/compute/cache/cache.go index 08288a052..fb00e2a38 100644 --- a/spinifex/runtime/compute/cache/cache.go +++ b/spinifex/runtime/compute/cache/cache.go @@ -260,6 +260,9 @@ func (c *Cache) setActive(lw *liveWatcher) { c.watcherMu.Unlock() } +// markResynced is called with c.mu held, so the mark is published by the same +// unlock that publishes readiness. A reader that takes the lock and sees a +// ready cache cannot then be told the cache has never resynced. func (c *Cache) markResynced() { c.lastResync.Store(time.Now().UnixNano()) c.degraded.Store(false) @@ -455,6 +458,7 @@ func (c *Cache) installSync(lw *liveWatcher, entries map[string]*vm.VM, index ma c.mu.Lock() c.entries, c.index = entries, index c.ready = true + c.markResynced() c.mu.Unlock() c.setActive(lw) go c.drain(lw) @@ -481,7 +485,6 @@ func (c *Cache) resyncUntilSuccess(ctx context.Context, oldLw *liveWatcher) *liv continue } c.installSync(lw, entries, index, oldLw) - c.markResynced() return lw } } @@ -509,7 +512,6 @@ func (c *Cache) periodicResync(ctx context.Context) { return } c.installSync(lw, entries, index, oldLw) - c.markResynced() } // replaceWatcher retires a dead watcher and installs a fresh one under the diff --git a/spinifex/runtime/compute/cache/cache_test.go b/spinifex/runtime/compute/cache/cache_test.go index 31f8d93b0..d07dc337b 100644 --- a/spinifex/runtime/compute/cache/cache_test.go +++ b/spinifex/runtime/compute/cache/cache_test.go @@ -45,7 +45,7 @@ func newTestCache(t *testing.T, mutate ...func(*Config)) (*Cache, jetstream.KeyV require.NoError(t, err) cfg := Config{ - Bucket: kvstore.Config{Name: bucket, History: 1, Replicas: 1}, + Bucket: kvstore.Config{Name: bucket, History: 1}, Prefix: testPrefix, VisibleToCaller: instance.IsInstanceVisibleToCaller, FallbackAccountID: utils.GlobalAccountID, @@ -256,7 +256,7 @@ func TestPeriodicResync_TTLExpiredRecordRemovedWithNoWatchEvent(t *testing.T) { require.NoError(t, err) c := New(js, Config{ - Bucket: kvstore.Config{Name: bucket, History: 1, Replicas: 1}, + Bucket: kvstore.Config{Name: bucket, History: 1}, Prefix: testPrefix, VisibleToCaller: instance.IsInstanceVisibleToCaller, FallbackAccountID: utils.GlobalAccountID, diff --git a/spinifex/runtime/compute/cache/liveness_test.go b/spinifex/runtime/compute/cache/liveness_test.go index 7491885ac..0c1e5c450 100644 --- a/spinifex/runtime/compute/cache/liveness_test.go +++ b/spinifex/runtime/compute/cache/liveness_test.go @@ -23,7 +23,7 @@ func newTestLiveness(t *testing.T) (*instancecache.Liveness, jetstream.KeyValue) kv, err := js.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: bucket, History: 1}) require.NoError(t, err) - l := instancecache.NewLiveness(js, kvstore.Config{Name: bucket, History: 1, Replicas: 1}) + l := instancecache.NewLiveness(js, kvstore.Config{Name: bucket, History: 1}) return l, kv } diff --git a/spinifex/runtime/compute/cache/resume_from_revision_test.go b/spinifex/runtime/compute/cache/resume_from_revision_test.go index 5bf2bad41..89073a9a4 100644 --- a/spinifex/runtime/compute/cache/resume_from_revision_test.go +++ b/spinifex/runtime/compute/cache/resume_from_revision_test.go @@ -25,7 +25,7 @@ func TestResumeFromRevision_ReplaysWhatTheSnapshotMissed(t *testing.T) { _, _, js := testutil.StartTestJetStream(t) ctx := context.Background() - store := kvstore.New[vm.InstanceRecord](js, kvstore.Config{Name: "resume-bucket", History: 1, Replicas: 1}) + store := kvstore.New[vm.InstanceRecord](js, kvstore.Config{Name: "resume-bucket", History: 1}) rec := func(id string) *vm.InstanceRecord { r := &vm.InstanceRecord{} diff --git a/spinifex/runtime/compute/vm/eni_hotplug.go b/spinifex/runtime/compute/vm/eni_hotplug.go index 790921e51..f1441ba4d 100644 --- a/spinifex/runtime/compute/vm/eni_hotplug.go +++ b/spinifex/runtime/compute/vm/eni_hotplug.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "slices" "time" ) @@ -233,13 +234,9 @@ func waitForPCIDevice(dc DeviceController, deviceID string, wantPresent bool) er if err != nil { return fmt.Errorf("query-pci attempt %d: %w", attempt, err) } - found := false - for _, d := range devs { - if d.QDevID == deviceID { - found = true - break - } - } + found := slices.ContainsFunc(devs, func(d PCIDevice) bool { + return d.QDevID == deviceID + }) if found == wantPresent { return nil } diff --git a/spinifex/runtime/compute/vm/lifecycle.go b/spinifex/runtime/compute/vm/lifecycle.go index 389e7a764..c64bc6a38 100644 --- a/spinifex/runtime/compute/vm/lifecycle.go +++ b/spinifex/runtime/compute/vm/lifecycle.go @@ -12,6 +12,7 @@ import ( "net" "os" "path/filepath" + "slices" "strconv" "strings" "syscall" @@ -900,12 +901,9 @@ func qmpGreetingTimeout(v *VM) time.Duration { // monitor answers. Every production guest boots from one; a VM without is a // unit test. func hasNetworkBackedDrive(v *VM) bool { - for _, req := range v.EBSRequests.Requests { - if req.NBDURI != "" { - return true - } - } - return false + return slices.ContainsFunc(v.EBSRequests.Requests, func(req types.EBSRequest) bool { + return req.NBDURI != "" + }) } // removeStaleQMPSocket unlinks a leftover QMP socket inode from a prior QEMU so diff --git a/spinifex/runtime/compute/vm/manager.go b/spinifex/runtime/compute/vm/manager.go index 2251f0de6..6e96267b3 100644 --- a/spinifex/runtime/compute/vm/manager.go +++ b/spinifex/runtime/compute/vm/manager.go @@ -2,6 +2,7 @@ package vm import ( "maps" + "slices" "sync" "time" ) @@ -191,11 +192,7 @@ func (m *Manager) ForEach(fn func(*VM)) { func (m *Manager) Snapshot() []*VM { m.mu.Lock() defer m.mu.Unlock() - out := make([]*VM, 0, len(m.vms)) - for _, v := range m.vms { - out = append(out, v) - } - return out + return slices.Collect(maps.Values(m.vms)) } // SnapshotMap returns a copy of the id→VM map for serialization outside the lock. diff --git a/spinifex/runtime/compute/vm/manager_deps_test.go b/spinifex/runtime/compute/vm/manager_deps_test.go index c18709a5c..b5e116eb0 100644 --- a/spinifex/runtime/compute/vm/manager_deps_test.go +++ b/spinifex/runtime/compute/vm/manager_deps_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "maps" + "slices" "sync" "github.com/mulgadc/spinifex/spinifex/types" @@ -76,11 +77,7 @@ func (f *fakeStateStore) DeleteStoppedInstance(id string) error { func (f *fakeStateStore) ListStoppedInstances() ([]*VM, error) { f.mu.Lock() defer f.mu.Unlock() - out := make([]*VM, 0, len(f.stopped)) - for _, v := range f.stopped { - out = append(out, v) - } - return out, nil + return slices.Collect(maps.Values(f.stopped)), nil } // ClaimStoppedInstance mimics the real atomic-delete claim for tests: under @@ -107,11 +104,7 @@ func (f *fakeStateStore) WriteTerminatedInstance(id string, v *VM) error { func (f *fakeStateStore) ListTerminatedInstances() ([]*VM, error) { f.mu.Lock() defer f.mu.Unlock() - out := make([]*VM, 0, len(f.terminated)) - for _, v := range f.terminated { - out = append(out, v) - } - return out, nil + return slices.Collect(maps.Values(f.terminated)), nil } func (f *fakeStateStore) DeleteTerminatedInstance(id string) error { diff --git a/spinifex/runtime/compute/vm/mock/mock.go b/spinifex/runtime/compute/vm/mock/mock.go index 56a055d93..86c7b8937 100644 --- a/spinifex/runtime/compute/vm/mock/mock.go +++ b/spinifex/runtime/compute/vm/mock/mock.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "maps" + "slices" "sync" "github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore" @@ -170,11 +171,7 @@ func (s *StateStore) ListStoppedInstances() ([]*vm.VM, error) { if s.ListStoppedErr != nil { return nil, s.ListStoppedErr } - out := make([]*vm.VM, 0, len(s.Stopped)) - for _, v := range s.Stopped { - out = append(out, v) - } - return out, nil + return slices.Collect(maps.Values(s.Stopped)), nil } // ClaimStoppedInstance mimics the real atomic delete-as-claim: under the @@ -237,11 +234,7 @@ func (s *StateStore) ListTerminatedInstances() ([]*vm.VM, error) { if s.ListTerminatedErr != nil { return nil, s.ListTerminatedErr } - out := make([]*vm.VM, 0, len(s.Terminated)) - for _, v := range s.Terminated { - out = append(out, v) - } - return out, nil + return slices.Collect(maps.Values(s.Terminated)), nil } func (s *StateStore) DeleteTerminatedInstance(id string) error { diff --git a/spinifex/runtime/roles/nats/nats.go b/spinifex/runtime/roles/nats/nats.go index 14b17e3a1..2acd5abe9 100644 --- a/spinifex/runtime/roles/nats/nats.go +++ b/spinifex/runtime/roles/nats/nats.go @@ -47,22 +47,6 @@ func (svc *Service) Start() (int, error) { return os.Getpid(), nil } -func (svc *Service) Stop() (err error) { - return utils.StopProcessAt(svc.Config.DataDir, serviceName) -} - -func (svc *Service) Status() (string, error) { - return utils.ServiceStatus(svc.Config.DataDir, serviceName) -} - -func (svc *Service) Shutdown() (err error) { - return svc.Stop() -} - -func (svc *Service) Reload() (err error) { - return nil -} - func launchService(config *Config) (err error) { // Create proper server options var opts *server.Options diff --git a/spinifex/runtime/roles/nats/servername.go b/spinifex/runtime/roles/nats/servername.go new file mode 100644 index 000000000..647443cbb --- /dev/null +++ b/spinifex/runtime/roles/nats/servername.go @@ -0,0 +1,27 @@ +package nats + +import "strings" + +// ServerNamePrefix is what nats.conf prefixes a node name with to build +// server_name. JetStream reports stream peers by server name, so anything +// mapping a replica back to the node holding it has to go through here rather +// than assume the two are the same string — they are not. +const ServerNamePrefix = "spinifex-nats-" + +// ServerName is the NATS server_name for a node in this cluster. +func ServerName(node string) string { return ServerNamePrefix + node } + +// NodeFromServerName is ServerName's inverse. The second result is false for a +// name this cluster did not generate, so a caller can tell "not our node" from +// "a node called the empty string". +// +// The bare prefix is rejected too. It is a name we could not have generated, and +// mapping it to the empty node would silently create a phantom peer in anything +// counting nodes. +func NodeFromServerName(server string) (string, bool) { + node, ok := strings.CutPrefix(server, ServerNamePrefix) + if !ok || node == "" { + return "", false + } + return node, true +} diff --git a/spinifex/runtime/roles/qemunbdd/service.go b/spinifex/runtime/roles/qemunbdd/service.go index 1951b6782..885ad60f4 100644 --- a/spinifex/runtime/roles/qemunbdd/service.go +++ b/spinifex/runtime/roles/qemunbdd/service.go @@ -54,22 +54,6 @@ func (svc *Service) Start() (int, error) { return os.Getpid(), nil } -func (svc *Service) Stop() (err error) { - return utils.StopProcessAt(svc.Config.BaseDir, serviceName) -} - -func (svc *Service) Status() (string, error) { - return utils.ServiceStatus(svc.Config.BaseDir, serviceName) -} - -func (svc *Service) Shutdown() (err error) { - return svc.Stop() -} - -func (svc *Service) Reload() (err error) { - return nil -} - // launchService connects to NATS, roots a qcow2 provider at cfg.BaseDir and // serves ebs.provider.v1.* until SIGINT/SIGTERM, then unsubscribes. It blocks // for the life of the process. diff --git a/spinifex/runtime/roles/qmpcollector/qmpcollector.go b/spinifex/runtime/roles/qmpcollector/qmpcollector.go index 44b2c88d4..318c0939c 100644 --- a/spinifex/runtime/roles/qmpcollector/qmpcollector.go +++ b/spinifex/runtime/roles/qmpcollector/qmpcollector.go @@ -72,26 +72,6 @@ func (svc *Service) Start() (int, error) { return os.Getpid(), nil } -// Stop stops the qmp-collector service. -func (svc *Service) Stop() error { - return utils.StopProcessAt(svc.Config.BaseDir, serviceName) -} - -// Status returns the qmp-collector service status. -func (svc *Service) Status() (string, error) { - return utils.ServiceStatus(svc.Config.BaseDir, serviceName) -} - -// Shutdown gracefully shuts down the qmp-collector service. -func (svc *Service) Shutdown() error { - return svc.Stop() -} - -// Reload reloads the qmp-collector service configuration. -func (svc *Service) Reload() error { - return nil -} - func launchService(cfg *Config) error { if cfg.RuntimeDir == "" { cfg.RuntimeDir = utils.RuntimeDir() diff --git a/spinifex/runtime/roles/spinifex/spinifex.go b/spinifex/runtime/roles/spinifex/spinifex.go index 009f92cad..5599a27ec 100644 --- a/spinifex/runtime/roles/spinifex/spinifex.go +++ b/spinifex/runtime/roles/spinifex/spinifex.go @@ -44,22 +44,6 @@ func (svc *Service) Start() (int, error) { return os.Getpid(), nil } -func (svc *Service) Stop() (err error) { - return utils.StopProcessAt(svc.Config.NodeBaseDir(), serviceName) -} - -func (svc *Service) Status() (string, error) { - return utils.ServiceStatus(svc.Config.NodeBaseDir(), serviceName) -} - -func (svc *Service) Shutdown() (err error) { - return svc.Stop() -} - -func (svc *Service) Reload() (err error) { - return nil -} - func launchService(config *config.ClusterConfig, configPath string) (err error) { d, err := daemon.NewDaemon(config) if err != nil { diff --git a/spinifex/runtime/roles/spinifexui/spinifexui.go b/spinifex/runtime/roles/spinifexui/spinifexui.go index f1aa63105..39628cad7 100644 --- a/spinifex/runtime/roles/spinifexui/spinifexui.go +++ b/spinifex/runtime/roles/spinifexui/spinifexui.go @@ -15,7 +15,6 @@ import ( "os/signal" "path/filepath" "strings" - "sync" "syscall" "time" @@ -83,8 +82,6 @@ func clusterConfigHandler(region string, ochreEnabled bool) http.HandlerFunc { // Service represents the spinifex-ui service. type Service struct { Config *Config - server *http.Server - mu sync.Mutex } // New creates a new spinifex-ui service. @@ -141,35 +138,6 @@ func (svc *Service) Start() (int, error) { return os.Getpid(), nil } -// Stop stops the spinifex-ui service. -func (svc *Service) Stop() error { - return utils.StopProcessAt(svc.Config.BaseDir, serviceName) -} - -// Status returns the status of the spinifex-ui service. -func (svc *Service) Status() (string, error) { - return utils.ServiceStatus(svc.Config.BaseDir, serviceName) -} - -// Shutdown gracefully shuts down the spinifex-ui service. -func (svc *Service) Shutdown() error { - svc.mu.Lock() - server := svc.server - svc.mu.Unlock() - - if server != nil { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - return server.Shutdown(ctx) - } - return svc.Stop() -} - -// Reload reloads the spinifex-ui service configuration. -func (svc *Service) Reload() error { - return nil -} - // launchService starts the HTTP server. // newSPAHandler serves the embedded build, falling back to index.html so the // router can resolve client-side paths. @@ -296,10 +264,6 @@ func (svc *Service) launchService() error { ReadHeaderTimeout: 5 * time.Second, } - svc.mu.Lock() - svc.server = server - svc.mu.Unlock() - // Setup graceful shutdown sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) diff --git a/spinifex/runtime/roles/spinifexui/spinifexui_test.go b/spinifex/runtime/roles/spinifexui/spinifexui_test.go index fcb948260..3ae20fc5e 100644 --- a/spinifex/runtime/roles/spinifexui/spinifexui_test.go +++ b/spinifex/runtime/roles/spinifexui/spinifexui_test.go @@ -19,6 +19,7 @@ import ( "path/filepath" "regexp" "strconv" + "syscall" "testing" "time" @@ -114,27 +115,6 @@ func TestNew_InvalidConfigTypeInt(t *testing.T) { assert.Error(t, err) } -func TestStatus_ReturnsValidState(t *testing.T) { - svc := &Service{ - Config: &Config{}, - } - - status, err := svc.Status() - - assert.NoError(t, err) - // On a dev machine the spinifex-ui PID file may exist, so accept either outcome - assert.True(t, status == "stopped" || len(status) > 0, "status should be non-empty") -} - -func TestReload_ReturnsNil(t *testing.T) { - svc := &Service{ - Config: &Config{}, - } - - err := svc.Reload() - assert.NoError(t, err) -} - func TestServiceName(t *testing.T) { assert.Equal(t, "spinifex-ui", serviceName) } @@ -459,28 +439,6 @@ func TestStart_WritesPidFileToNestedBaseDir(t *testing.T) { assert.Equal(t, strconv.Itoa(os.Getpid()), string(data)) } -func TestShutdown_WithServer(t *testing.T) { - // Create a real server on a random port so Shutdown exercises the non-nil path - mux := http.NewServeMux() - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - srv := &http.Server{Handler: mux} - ln, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - - go srv.Serve(ln) - - svc := &Service{ - Config: &Config{}, - server: srv, - } - - err = svc.Shutdown() - assert.NoError(t, err) -} - // writeTestTLSFiles generates a self-signed cert/key pair on disk for a // launchService test, which needs real file paths (tls.LoadX509KeyPair), // unlike loadTLSCert's in-memory tls.Certificate used by the split-listener tests. @@ -529,15 +487,16 @@ func freePort(t *testing.T) int { } // TestStart_GracefulShutdownIsNotAnError guards against Start propagating -// http.ErrServerClosed -- Serve's documented return value after a deliberate -// Shutdown -- as a failure. Before the fix this made a clean stop look like +// http.ErrServerClosed -- Serve's documented return value after the SIGTERM +// handler's Shutdown -- as a failure. Before the fix this made a clean stop look like // a crash (exit code 1) to systemd. func TestStart_GracefulShutdownIsNotAnError(t *testing.T) { t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) certPath, keyPath := writeTestTLSFiles(t) + port := freePort(t) svc, err := New(&Config{ - Port: freePort(t), + Port: port, Host: "127.0.0.1", TLSCert: certPath, TLSKey: keyPath, @@ -550,19 +509,25 @@ func TestStart_GracefulShutdownIsNotAnError(t *testing.T) { startErrCh <- startErr }() + // Start registers for SIGTERM before it listens, so a successful dial means + // the signal will not kill the test binary. + addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) require.Eventually(t, func() bool { - svc.mu.Lock() - defer svc.mu.Unlock() - return svc.server != nil - }, 2*time.Second, 10*time.Millisecond, "server never registered before shutdown") + conn, err := net.DialTimeout("tcp", addr, time.Second) + if err != nil { + return false + } + conn.Close() + return true + }, 2*time.Second, 10*time.Millisecond, "server never listened on %s", addr) - require.NoError(t, svc.Shutdown()) + require.NoError(t, syscall.Kill(os.Getpid(), syscall.SIGTERM)) select { case startErr := <-startErrCh: - assert.NoError(t, startErr, "Start must treat a deliberate Shutdown's ErrServerClosed as success") + assert.NoError(t, startErr, "Start must treat a graceful shutdown's ErrServerClosed as success") case <-time.After(2 * time.Second): - t.Fatal("Start did not return after Shutdown") + t.Fatal("Start did not return after SIGTERM") } } diff --git a/spinifex/runtime/service/service.go b/spinifex/runtime/service/service.go index 325047626..9ba713b0b 100644 --- a/spinifex/runtime/service/service.go +++ b/spinifex/runtime/service/service.go @@ -17,10 +17,6 @@ import ( type Service interface { Start() (int, error) - Stop() error - Status() (string, error) - Shutdown() error - Reload() error } var ( diff --git a/spinifex/services/awsgw/awsgw.go b/spinifex/services/awsgw/awsgw.go index 4941e5d6b..17e914a95 100644 --- a/spinifex/services/awsgw/awsgw.go +++ b/spinifex/services/awsgw/awsgw.go @@ -4,14 +4,15 @@ import ( "context" "crypto/tls" "crypto/x509" - "errors" "fmt" "github.com/mulgadc/spinifex/spinifex/foundation/netaddr" "log/slog" + "maps" "net" "net/http" "os" "path/filepath" + "slices" "strings" "time" @@ -23,6 +24,7 @@ import ( "github.com/mulgadc/spinifex/spinifex/daemon" "github.com/mulgadc/spinifex/spinifex/foundation/lifecycle/reconciler" "github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore" + "github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil" "github.com/mulgadc/spinifex/spinifex/gateway" gateway_bedrock "github.com/mulgadc/spinifex/spinifex/gateway/bedrock" gateway_ec2_instance "github.com/mulgadc/spinifex/spinifex/gateway/ec2/instance" @@ -88,22 +90,6 @@ func (svc *Service) Start() (int, error) { return os.Getpid(), nil } -func (svc *Service) Stop() (err error) { - return utils.StopProcessAt(svc.Config.NodeBaseDir(), serviceName) -} - -func (svc *Service) Status() (string, error) { - return utils.ServiceStatus(svc.Config.NodeBaseDir(), serviceName) -} - -func (svc *Service) Shutdown() (err error) { - return svc.Stop() -} - -func (svc *Service) Reload() (err error) { - return nil -} - // awsgwTOML is the top-level structure of awsgw.toml used to extract the // ratelimit and quota sections. Other fields are parsed elsewhere (e.g. region, // debug). @@ -152,53 +138,15 @@ func loadAWSGWConfig(path string) (awsgwTOML, error) { // openAccountUsageBucket opens (or idempotently creates) the gateway-owned // per-account vCPU usage bucket. History is 1: each account key holds a single // CAS-updated integer counter, so no revision beyond the latest is worth keeping. -func openAccountUsageBucket(ctx context.Context, js jetstream.KeyValueManager, replicas int) (jetstream.KeyValue, error) { - kv, err := js.KeyValue(ctx, handlers_quota.KVBucketAccountUsage) - if err == nil { - return kv, nil - } - if !errors.Is(err, jetstream.ErrBucketNotFound) { - return nil, fmt.Errorf("open account usage bucket: %w", err) - } - - kv, err = js.CreateKeyValue(ctx, jetstream.KeyValueConfig{ - Bucket: handlers_quota.KVBucketAccountUsage, - History: 1, - Replicas: max(replicas, 1), - }) - if errors.Is(err, jetstream.ErrBucketExists) { - return js.KeyValue(ctx, handlers_quota.KVBucketAccountUsage) - } - if err != nil { - return nil, fmt.Errorf("create account usage bucket: %w", err) - } - return kv, nil +func openAccountUsageBucket(ctx context.Context, js jetstream.KeyValueManager) (jetstream.KeyValue, error) { + return kvutil.GetOrCreateBucket(ctx, js, handlers_quota.KVBucketAccountUsage, 1) } // openAccountQuotaBucket opens (or idempotently creates) the per-account quota // override bucket. History is 1: each key holds the current override set, and // no earlier revision of a limit is worth keeping. -func openAccountQuotaBucket(ctx context.Context, js jetstream.KeyValueManager, replicas int) (jetstream.KeyValue, error) { - kv, err := js.KeyValue(ctx, handlers_quota.KVBucketAccountQuota) - if err == nil { - return kv, nil - } - if !errors.Is(err, jetstream.ErrBucketNotFound) { - return nil, fmt.Errorf("open account quota bucket: %w", err) - } - - kv, err = js.CreateKeyValue(ctx, jetstream.KeyValueConfig{ - Bucket: handlers_quota.KVBucketAccountQuota, - History: 1, - Replicas: max(replicas, 1), - }) - if errors.Is(err, jetstream.ErrBucketExists) { - return js.KeyValue(ctx, handlers_quota.KVBucketAccountQuota) - } - if err != nil { - return nil, fmt.Errorf("create account quota bucket: %w", err) - } - return kv, nil +func openAccountQuotaBucket(ctx context.Context, js jetstream.KeyValueManager) (jetstream.KeyValue, error) { + return kvutil.GetOrCreateBucket(ctx, js, handlers_quota.KVBucketAccountQuota, 1) } func launchService(config *config.ClusterConfig) error { @@ -241,14 +189,14 @@ func launchService(config *config.ClusterConfig) error { // Initialize IAM service with NATS KV backend (required for auth). // On multi-node clusters, JetStream KV requires cluster quorum which may // not be available yet if nodes start concurrently. Retry with backoff. - iamService, err := handlers_iam.NewIAMServiceWithRetry(janitorCtx, natsConn, masterKey, len(config.Nodes)) + iamService, err := handlers_iam.NewIAMServiceWithRetry(janitorCtx, natsConn, masterKey) if err != nil { return fmt.Errorf("initialize IAM service: %w", err) } // STS service shares the IAM master key (single envelope for at-rest // secrets + session-token HMACs) and resolves roles via IAMService. - stsService, err := handlers_sts.NewSTSServiceImpl(janitorCtx, natsConn, iamService, masterKey, len(config.Nodes)) + stsService, err := handlers_sts.NewSTSServiceImpl(janitorCtx, natsConn, iamService, masterKey) if err != nil { return fmt.Errorf("initialize STS service: %w", err) } @@ -340,7 +288,7 @@ func launchService(config *config.ClusterConfig) error { if err != nil { return fmt.Errorf("jetstream client: %w", err) } - signingKey, verifyKeys, err := gateway_ecrauth.LoadOrCreateSigningKey(janitorCtx, js, masterKey, len(config.Nodes)) + signingKey, verifyKeys, err := gateway_ecrauth.LoadOrCreateSigningKey(janitorCtx, js, masterKey) if err != nil { return fmt.Errorf("ECR auth bridge: load signing key: %w", err) } @@ -370,19 +318,19 @@ func launchService(config *config.ClusterConfig) error { if key := os.Getenv("OCHRE_ANTHROPIC_API_KEY"); key != "" { bedrockPlatformDefaults["anthropic"] = key } - bedrockCredentials := gateway_bedrock.NewCredentialStore(js, masterKey, len(config.Nodes), bedrockPlatformDefaults) + bedrockCredentials := gateway_bedrock.NewCredentialStore(js, masterKey, bedrockPlatformDefaults) // Bedrock self-host weights: a model's serving spec (VRAM, instance type, // vLLM args) ships in-tree, but which staged snapshot serves it is // deployment-local state. tieredCatalog/GetFoundationModel read this // through the package-level resolver rather than a parameter, since they // are called from gateway/bedrock.go's fixed-arity route table. - gateway_bedrock.SetWeightsResolver(gateway_bedrock.NewWeightsStore(js, len(config.Nodes))) + gateway_bedrock.SetWeightsResolver(gateway_bedrock.NewWeightsStore(js)) // Bedrock model access: grants live in the bedrock-model-access KV bucket // and are deny-by-default, so a fresh deployment serves no models until an // operator grants them (spx admin ochre access grant). - bedrockAccess := gateway_bedrock.NewModelAccessStore(js, len(config.Nodes)) + bedrockAccess := gateway_bedrock.NewModelAccessStore(js) // Deny-by-default would otherwise leave a fresh install with a catalog // nobody can see, so seed the platform admin account — the operator's own @@ -434,13 +382,13 @@ func launchService(config *config.ClusterConfig) error { // gateway_bedrock's narrow EndpointProvisioner (see provisioned_adapter.go // for why the adapter, not handlers_bedrock.EndpointService, is what // gateway_bedrock depends on). - bedrockProvisioned := gateway_bedrock.NewProvisionedStore(js, len(config.Nodes), nodeConfig.Region, + bedrockProvisioned := gateway_bedrock.NewProvisionedStore(js, nodeConfig.Region, handlers_bedrock.NewProvisionedEndpointAdapter(bedrockEndpointSvc)) // Bedrock guardrails: control-plane CRUD only at this stage — the record // stores the full policy config so a later stage's filter engine and // inference enforcement can read it back, but nothing enforces it yet. - bedrockGuardrails := gateway_bedrock.NewGuardrailStore(js, len(config.Nodes), nodeConfig.Region) + bedrockGuardrails := gateway_bedrock.NewGuardrailStore(js, nodeConfig.Region) // bedrock-agent knowledge-base + data-source resource metadata: gateway- // owned (D-arch), opened directly against JetStream the same way the @@ -456,8 +404,8 @@ func launchService(config *config.ClusterConfig) error { // deliveryConsumer to any account with a configured destination bucket // and a metadata-only log line. bedrockLoggingConfig separately gates // whether the record written to that bucket includes body text. - bedrockLoggingConfig := gateway_bedrock.NewLoggingConfigStore(js, len(config.Nodes)) - if _, err := gateway_bedrock.EnsureInvocationStream(janitorCtx, js, len(config.Nodes)); err != nil { + bedrockLoggingConfig := gateway_bedrock.NewLoggingConfigStore(js) + if _, err := gateway_bedrock.EnsureInvocationStream(janitorCtx, js); err != nil { return fmt.Errorf("bedrock: ensure invocation stream: %w", err) } deliveryConsumer, err := gateway_bedrock.EnsureDeliveryConsumer(janitorCtx, js) @@ -472,8 +420,8 @@ func launchService(config *config.ClusterConfig) error { // updates per-account/model/period counters, deduping on RequestID so // at-least-once redelivery never double-counts. bedrockPrices resolves // KV price overrides over the catalog's in-tree defaults. - bedrockUsage := gateway_bedrock.NewUsageStore(js, len(config.Nodes)) - bedrockPrices := gateway_bedrock.NewPriceStore(js, len(config.Nodes)) + bedrockUsage := gateway_bedrock.NewUsageStore(js) + bedrockPrices := gateway_bedrock.NewPriceStore(js) usageConsumer, err := gateway_bedrock.EnsureUsageConsumer(janitorCtx, js) if err != nil { return fmt.Errorf("bedrock: ensure usage metering consumer: %w", err) @@ -483,10 +431,7 @@ func launchService(config *config.ClusterConfig) error { // nodeIDs is the configured cluster node set, in the same namespace as the // daemon reply header, so a fan-out's completeness can be judged by // responder identity rather than by count. - nodeIDs := make([]string, 0, len(config.Nodes)) - for nodeID := range config.Nodes { - nodeIDs = append(nodeIDs, nodeID) - } + nodeIDs := slices.Collect(maps.Keys(config.Nodes)) gw := gateway.GatewayConfig{ Debug: nodeConfig.AWSGW.Debug, @@ -528,7 +473,7 @@ func launchService(config *config.ClusterConfig) error { // Rotate the ECR signing key on a 30-day cadence, retaining the previous keys // until their tokens expire. The rotator keeps the issuer/verifier current as // keys roll. Bound to the same lifetime context as the STS janitor. - keyRotator, err := gateway_ecrauth.NewRotator(janitorCtx, js, masterKey, len(config.Nodes), gw.ECRTokenIssuer, gw.ECRTokenVerifier) + keyRotator, err := gateway_ecrauth.NewRotator(janitorCtx, js, masterKey, gw.ECRTokenIssuer, gw.ECRTokenVerifier) if err != nil { return fmt.Errorf("ECR auth bridge: signing-key rotator: %w", err) } @@ -544,11 +489,11 @@ func launchService(config *config.ClusterConfig) error { // config builds a no-op Service whose Exempt short-circuits every check. var usageBucket, quotaOverrides jetstream.KeyValue if quotaCfg.Enabled { - usageBucket, err = openAccountUsageBucket(janitorCtx, js, len(config.Nodes)) + usageBucket, err = openAccountUsageBucket(janitorCtx, js) if err != nil { return fmt.Errorf("init account usage bucket: %w", err) } - quotaOverrides, err = openAccountQuotaBucket(janitorCtx, js, len(config.Nodes)) + quotaOverrides, err = openAccountQuotaBucket(janitorCtx, js) if err != nil { return fmt.Errorf("init account quota bucket: %w", err) } @@ -572,13 +517,13 @@ func launchService(config *config.ClusterConfig) error { // quotaCfg.Enabled); this loop only pushes a periodic observability // snapshot to KV and is itself a no-op when the RPM dimension is // disabled. - go gw.Quota.RunBedrockRPMSync(janitorCtx, js, len(config.Nodes)) + go gw.Quota.RunBedrockRPMSync(janitorCtx, js) // Instance cache: a read-only informer over the live instance record space, // started now so it is warm well before anything reads it. The describe // path still serves from the fan-out and KV; only status synthesis reads it. instanceCache := instancecache.New(js, instancecache.Config{ - Bucket: kvstore.Config{Name: daemon.InstanceStateBucket, History: 1, Replicas: len(config.Nodes)}, + Bucket: kvstore.Config{Name: daemon.InstanceStateBucket, History: 1}, Prefix: daemon.InstanceRecordPrefix, VisibleToCaller: handlers_ec2_instance.IsInstanceVisibleToCaller, FallbackAccountID: utils.GlobalAccountID, @@ -590,7 +535,7 @@ func launchService(config *config.ClusterConfig) error { gw.InstanceStatus = gateway_ec2_instance.StatusSynthesis{ Records: instanceCache, Liveness: instancecache.NewLiveness(js, kvstore.Config{ - Name: daemon.ClusterStateBucket, History: 1, Replicas: len(config.Nodes), + Name: daemon.ClusterStateBucket, History: 1, }), } @@ -636,7 +581,7 @@ func launchService(config *config.ClusterConfig) error { func runQuotaReconcile(ctx context.Context, quota *handlers_quota.Service, natsConn *nats.Conn, js jetstream.JetStream, accounts handlers_quota.AccountLister, replicas int) { holder, _ := os.Hostname() - cfg := kvstore.Config{Name: daemon.InstanceStateBucket, History: 1, Replicas: replicas} + cfg := kvstore.Config{Name: daemon.InstanceStateBucket, History: 1} list := handlers_quota.RecordVCPULister( kvstore.New[vm.InstanceRecord](js, cfg), daemon.InstanceRecordPrefix) diff --git a/spinifex/services/awsgw/quota_reconcile_test.go b/spinifex/services/awsgw/quota_reconcile_test.go index 766c8a48c..c9158d1ef 100644 --- a/spinifex/services/awsgw/quota_reconcile_test.go +++ b/spinifex/services/awsgw/quota_reconcile_test.go @@ -24,7 +24,7 @@ func TestOpenAccountUsageBucketPreservesExistingConfiguration(t *testing.T) { }) require.NoError(t, err) - bucket, err := openAccountUsageBucket(t.Context(), js, 1) + bucket, err := openAccountUsageBucket(t.Context(), js) require.NoError(t, err) status, err := bucket.Status(t.Context()) require.NoError(t, err) @@ -63,7 +63,7 @@ func instanceRecordBucket(t *testing.T, js jetstream.JetStream) jetstream.KeyVal func TestRunQuotaReconcileCountsFromTheRecordSpace(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - bucket, err := openAccountUsageBucket(t.Context(), js, 1) + bucket, err := openAccountUsageBucket(t.Context(), js) require.NoError(t, err) quota := handlers_quota.New(handlers_quota.Limits{Enabled: true, VCPUs: 100}, bucket) @@ -99,7 +99,7 @@ func TestRunQuotaReconcileCountsFromTheRecordSpace(t *testing.T) { func TestRunQuotaReconcileFollowsARecordChange(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - bucket, err := openAccountUsageBucket(t.Context(), js, 1) + bucket, err := openAccountUsageBucket(t.Context(), js) require.NoError(t, err) quota := handlers_quota.New(handlers_quota.Limits{Enabled: true, VCPUs: 100}, bucket) @@ -135,7 +135,7 @@ func TestRunQuotaReconcileFollowsARecordChange(t *testing.T) { func TestRunQuotaReconcileRecomputesOnlyTheChangedAccount(t *testing.T) { _, nc, _ := testutil.StartTestJetStream(t) js := testutil.NewJetStream(t, nc) - bucket, err := openAccountUsageBucket(t.Context(), js, 1) + bucket, err := openAccountUsageBucket(t.Context(), js) require.NoError(t, err) quota := handlers_quota.New(handlers_quota.Limits{Enabled: true, VCPUs: 100}, bucket) diff --git a/spinifex/services/northstar/bootstrap.go b/spinifex/services/northstar/bootstrap.go index 756ae7c35..d70ba99b7 100644 --- a/spinifex/services/northstar/bootstrap.go +++ b/spinifex/services/northstar/bootstrap.go @@ -3,7 +3,8 @@ package northstar import ( "fmt" "log/slog" - "sort" + "maps" + "slices" "strings" "time" @@ -150,10 +151,5 @@ func ensureZone(s3cfg *nsconfig.S3Config, seed nsconfig.BaseZoneSeed) error { // nodeNames returns the sorted node keys of the cluster, for diagnostics. func nodeNames(cluster *config.ClusterConfig) []string { - names := make([]string, 0, len(cluster.Nodes)) - for name := range cluster.Nodes { - names = append(names, name) - } - sort.Strings(names) - return names + return slices.Sorted(maps.Keys(cluster.Nodes)) } diff --git a/spinifex/services/northstar/northstar.go b/spinifex/services/northstar/northstar.go index f0dfe8511..4d866ceac 100644 --- a/spinifex/services/northstar/northstar.go +++ b/spinifex/services/northstar/northstar.go @@ -126,31 +126,3 @@ func (svc *Service) subscribeReload() { } slog.Info("northstar: subscribed to live zone reload", "subject", handlers_dns.SubjectZoneReload) } - -// Stop signals a running northstar service via its PID file. -func (svc *Service) Stop() error { - return utils.StopProcessAt(svc.Config.BasePath, serviceName) -} - -// Status returns the status of the northstar service. -func (svc *Service) Status() (string, error) { - return utils.ServiceStatus(svc.Config.BasePath, serviceName) -} - -// Shutdown gracefully shuts down the in-process server, falling back to Stop. -func (svc *Service) Shutdown() error { - if svc.server != nil { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - return svc.server.Shutdown(ctx) - } - return svc.Stop() -} - -// Reload re-reads the zone database without restarting the listeners. -func (svc *Service) Reload() error { - if svc.server != nil { - return svc.server.Reload() - } - return nil -} diff --git a/spinifex/services/northstar/northstar_test.go b/spinifex/services/northstar/northstar_test.go index e6dd371f4..ca8c72c60 100644 --- a/spinifex/services/northstar/northstar_test.go +++ b/spinifex/services/northstar/northstar_test.go @@ -31,9 +31,3 @@ func TestStartRequiresConfigPath(t *testing.T) { _, err = svc.Start() require.Error(t, err) } - -func TestReloadNilServerIsNoop(t *testing.T) { - svc, err := New(&Config{}) - require.NoError(t, err) - require.NoError(t, svc.Reload()) -} diff --git a/spinifex/services/predastore/predastore.go b/spinifex/services/predastore/predastore.go index 6b8dd7832..2f2d4663f 100644 --- a/spinifex/services/predastore/predastore.go +++ b/spinifex/services/predastore/predastore.go @@ -7,7 +7,6 @@ import ( "log/slog" "os" "os/signal" - "sync" "syscall" "github.com/mulgadc/bluebottle/pkg/masterkey" @@ -30,8 +29,7 @@ type Config struct { TlsKey string // BasePath is where this service keeps its pid file. Predastore itself has - // no base path — its directories come from the configuration file — but - // Stop and Status find the running process through this one. + // no base path; its directories come from the configuration file. BasePath string // EncryptionKeyFile is the path to this node's 32-byte AES-256 master @@ -48,12 +46,6 @@ type Config struct { // Service runs one predastore host in this process. type Service struct { Config *Config - - // stop cancels the process context Start runs under, so Shutdown can - // drain the gate and the nodes together. Guarded because Shutdown is - // called from a different goroutine than Start. - mu sync.Mutex - stop context.CancelFunc } // New creates a new predastore service. @@ -93,15 +85,11 @@ func (svc *Service) Start() (int, error) { return 0, fmt.Errorf("load predastore master key: %w", err) } - // One context for the whole service: a signal, a fatal node error or - // Shutdown stops the gate and every local node together. + // One context for the whole service: a signal or a fatal node error stops + // the gate and every local node together. ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - svc.mu.Lock() - svc.stop = stop - svc.mu.Unlock() - if err := pds.Run(ctx, pds.Options{ Config: cfg, HostID: hostID, @@ -160,33 +148,3 @@ func (svc *Service) mergeHost(cfg *pds.Config) (pds.HostID, error) { return hostID, cfg.Validate() } - -// Stop stops the predastore service. -func (svc *Service) Stop() error { - return utils.StopProcessAt(svc.Config.BasePath, serviceName) -} - -// Status returns the status of the predastore service. -func (svc *Service) Status() (string, error) { - return utils.ServiceStatus(svc.Config.BasePath, serviceName) -} - -// Shutdown gracefully shuts down the predastore service. When this process is -// the one running the host it cancels the service context, which drains the -// gate and every local node; otherwise it signals the running process. -func (svc *Service) Shutdown() error { - svc.mu.Lock() - stop := svc.stop - svc.mu.Unlock() - - if stop != nil { - stop() - return nil - } - return svc.Stop() -} - -// Reload reloads the predastore service configuration. -func (svc *Service) Reload() error { - return nil -} diff --git a/spinifex/services/predastore/predastore_start_test.go b/spinifex/services/predastore/predastore_start_test.go index 7e0e291f4..026b90d0c 100644 --- a/spinifex/services/predastore/predastore_start_test.go +++ b/spinifex/services/predastore/predastore_start_test.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "strconv" + "syscall" "testing" "time" @@ -166,10 +167,10 @@ func TestStartRejectsUnprotectedMasterKey(t *testing.T) { assert.Contains(t, err.Error(), "load predastore master key") } -// TestStartServesUntilShutdown covers the service lifecycle end to end: Start -// blocks for as long as the host serves, and Shutdown cancels the context the +// TestStartServesUntilSIGTERM covers the service lifecycle end to end: Start +// blocks for as long as the host serves, and SIGTERM cancels the context the // gate and the local nodes share, which is what lets Start return at all. -func TestStartServesUntilShutdown(t *testing.T) { +func TestStartServesUntilSIGTERM(t *testing.T) { dir := t.TempDir() basePath := t.TempDir() gatePort := freePort(t) @@ -191,8 +192,8 @@ func TestStartServesUntilShutdown(t *testing.T) { done <- result{pid: pid, err: err} }() - // Shutdown must not run before Start has published its cancel func, and an - // accepted connection is the first observable point at which it has. + // SIGTERM must not arrive before Start has registered for it, or it kills + // the test binary; an accepted connection is the first point it has. addr := net.JoinHostPort(startAddr, strconv.Itoa(gatePort)) require.Eventually(t, func() bool { conn, err := net.DialTimeout("tcp", addr, time.Second) @@ -204,13 +205,13 @@ func TestStartServesUntilShutdown(t *testing.T) { }, 60*time.Second, 100*time.Millisecond, "gate never accepted a connection on %s", addr) assert.FileExists(t, filepath.Join(basePath, serviceName+".pid")) - require.NoError(t, svc.Shutdown()) + require.NoError(t, syscall.Kill(os.Getpid(), syscall.SIGTERM)) select { case res := <-done: require.NoError(t, res.err) assert.Equal(t, os.Getpid(), res.pid) case <-time.After(60 * time.Second): - t.Fatal("Start did not return after Shutdown") + t.Fatal("Start did not return after SIGTERM") } } diff --git a/spinifex/services/predastore/predastore_test.go b/spinifex/services/predastore/predastore_test.go index 95e12c25f..65acffd53 100644 --- a/spinifex/services/predastore/predastore_test.go +++ b/spinifex/services/predastore/predastore_test.go @@ -1,16 +1,13 @@ package predastore import ( - "context" "fmt" "os" - "os/exec" "path/filepath" "strings" "testing" pds "github.com/mulgadc/predastore" - "github.com/mulgadc/spinifex/spinifex/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -148,15 +145,6 @@ func TestServiceNameConstant(t *testing.T) { assert.Equal(t, "predastore", serviceName) } -// TestReloadIsANoOp pins the current contract: predastore has no reload, and -// the service reports success rather than an error a caller would have to -// special-case. -func TestReloadIsANoOp(t *testing.T) { - svc, err := New(&Config{}) - require.NoError(t, err) - assert.NoError(t, svc.Reload()) -} - // TestMergeHostFlagsBeatFile covers the precedence spinifex.toml relies on: // the service flags own the host-local fields, so a value from them replaces // whatever the predastore file carried. @@ -326,75 +314,3 @@ func TestStartRejectsMissingEncryptionKey(t *testing.T) { assert.Zero(t, pid) assert.Contains(t, err.Error(), "encryption key file is required") } - -// TestStatusReportsStoppedWithoutPidFile covers the resting state: no pid file -// under BasePath means nothing is running, not an error. -func TestStatusReportsStoppedWithoutPidFile(t *testing.T) { - svc, err := New(&Config{BasePath: t.TempDir()}) - require.NoError(t, err) - - status, err := svc.Status() - - require.NoError(t, err) - assert.Equal(t, "stopped", status) -} - -// TestStatusAndStopUseThePidFile covers the out-of-process path: Stop and -// Status find a predastore started by an earlier invocation through the pid -// file BasePath holds, and Stop clears it once the process is gone. -func TestStatusAndStopUseThePidFile(t *testing.T) { - basePath := t.TempDir() - svc, err := New(&Config{BasePath: basePath}) - require.NoError(t, err) - - child := exec.CommandContext(t.Context(), "sleep", "60") - require.NoError(t, child.Start()) - // Reaped as it dies, not at cleanup: an unwaited child of this process - // lingers as a zombie, which still answers signal 0, so Stop would wait out - // its whole grace period before escalating to SIGKILL. - reaped := make(chan struct{}) - go func() { - defer close(reaped) - _ = child.Wait() - }() - t.Cleanup(func() { <-reaped }) - require.NoError(t, utils.WritePidFileTo(basePath, serviceName, child.Process.Pid)) - - status, err := svc.Status() - require.NoError(t, err) - assert.Equal(t, fmt.Sprintf("running (pid: %d)", child.Process.Pid), status) - - require.NoError(t, svc.Stop()) - assert.NoFileExists(t, filepath.Join(basePath, serviceName+".pid")) - - status, err = svc.Status() - require.NoError(t, err) - assert.Equal(t, "stopped", status) -} - -// TestShutdownWithoutRunningClusterFallsBackToPidFile covers the fallback: with -// no cluster in this process there is no context to cancel, so Shutdown goes -// through the pid file, which reports rather than signalling anything. -func TestShutdownWithoutRunningClusterFallsBackToPidFile(t *testing.T) { - svc, err := New(&Config{BasePath: t.TempDir()}) - require.NoError(t, err) - - assert.Error(t, svc.Shutdown()) -} - -// TestShutdownCancelsTheServiceContext covers the in-process path: once Start -// has published its cancel func, Shutdown cancels the context the gate and the -// local nodes share instead of signalling through the pid file. -func TestShutdownCancelsTheServiceContext(t *testing.T) { - svc, err := New(&Config{BasePath: t.TempDir()}) - require.NoError(t, err) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - svc.mu.Lock() - svc.stop = cancel - svc.mu.Unlock() - - require.NoError(t, svc.Shutdown()) - assert.Error(t, ctx.Err()) -} diff --git a/spinifex/services/viperblockd/nbdkit_recovery.go b/spinifex/services/viperblockd/nbdkit_recovery.go index 6291bc606..4fbda8fec 100644 --- a/spinifex/services/viperblockd/nbdkit_recovery.go +++ b/spinifex/services/viperblockd/nbdkit_recovery.go @@ -373,12 +373,9 @@ func cmdlineReferencesEndpoint(cmdline []byte, disc discoveredNbdkit) bool { if disc.Socket != "" { needle := []byte(disc.Socket) - for _, arg := range args { - if bytes.Contains(arg, needle) { - return true - } - } - return false + return slices.ContainsFunc(args, func(arg []byte) bool { + return bytes.Contains(arg, needle) + }) } if disc.Port <= 0 { diff --git a/spinifex/services/viperblockd/provider_handlers.go b/spinifex/services/viperblockd/provider_handlers.go index da4d3e8d9..a174c4766 100644 --- a/spinifex/services/viperblockd/provider_handlers.go +++ b/spinifex/services/viperblockd/provider_handlers.go @@ -48,7 +48,7 @@ var providerObjectStoreFactory = func(cfg *Config) objectstore.ObjectStore { return objectstore.NewS3ObjectStoreFromConfig(netaddr.DialTarget(cfg.S3Host), cfg.Region, cfg.AccessKey, cfg.SecretKey) } -// registerProviderSubjects subscribes the ebs.provider.v1.* handlers that +// RegisterProviderSubjects subscribes the ebs.provider.v1.* handlers that // serve the ebsprovider.EBSProvider NATS contract from this daemon, moving // viperblock engine construction out of the EC2 control-plane handlers and // into the storage daemon that owns BaseDir and the mounted-volume registry. @@ -57,19 +57,13 @@ var providerObjectStoreFactory = func(cfg *Config) objectstore.ObjectStore { // / UnpublishSubject already route to one node), so they are only registered // when cfg.NodeName is set; there is no queue-group fallback the way the // legacy ebs.mount/ebs.unmount subjects have. -// RegisterProviderSubjects serves the provider contract from cfg on nc without -// launching the rest of the daemon. It exists for harnesses that need a real -// provider behind the control plane, which otherwise has none to call. +// Exported so harnesses can put a real provider behind the control plane. func RegisterProviderSubjects(cfg *Config, nc *nats.Conn) error { - return registerProviderSubjects(cfg, nc) -} - -func registerProviderSubjects(cfg *Config, nc *nats.Conn) error { // The lease store has to exist before any subject is served: a handler // that reaches an engine open without one refuses, and refusing every // publish is a worse failure than not starting. if cfg.leases == nil { - leases, err := newVolumeLeases(context.Background(), nc, cfg.leaseOwner(), cfg.KVReplicas) + leases, err := newVolumeLeasesWaiting(context.Background(), nc, cfg.leaseOwner()) if err != nil { return fmt.Errorf("volume leases: %w", err) } @@ -80,7 +74,7 @@ func registerProviderSubjects(cfg *Config, nc *nats.Conn) error { // Same reasoning as the lease store: a mount that cannot consult the dirty // marker cannot tell a stale cross-node start from a routine one. if cfg.dirty == nil { - dirty, err := newVolumeDirty(context.Background(), nc, cfg.leaseOwner(), cfg.KVReplicas) + dirty, err := newVolumeDirtyWaiting(context.Background(), nc, cfg.leaseOwner()) if err != nil { return fmt.Errorf("volume dirty markers: %w", err) } @@ -1736,7 +1730,7 @@ func unmountVolume(ctx context.Context, cfg *Config, volumeName string) (types.E // handlePublishVolume serves ebs.provider.v1..mount, the provider-neutral // front for the same nbdkit mount path ebs.mount uses. It is node-addressed -// (registerProviderSubjects only subscribes it when cfg.NodeName is set), so +// (RegisterProviderSubjects only subscribes it when cfg.NodeName is set), so // unlike the legacy handler there is no queue-group fallback to reason about. func handlePublishVolume(ctx context.Context, cfg *Config, nc *nats.Conn, msg *nats.Msg) { var req ebsprovider.PublishVolumeRequest diff --git a/spinifex/services/viperblockd/provider_handlers_test.go b/spinifex/services/viperblockd/provider_handlers_test.go index c0724f568..0ba219501 100644 --- a/spinifex/services/viperblockd/provider_handlers_test.go +++ b/spinifex/services/viperblockd/provider_handlers_test.go @@ -1,6 +1,6 @@ package viperblockd -// Tests for the ebs.provider.v1.* handlers registered by registerProviderSubjects. +// Tests for the ebs.provider.v1.* handlers registered by RegisterProviderSubjects. // Engine-backed cases (CreateVolume idempotency, ExpandVolume in-use, CreateSnapshot) // use a live mounted VB on the file backend (createTestVBWithState from // viperblockd_handlers_test.go), matching this package's existing convention for @@ -81,7 +81,7 @@ type versionedErrorResponse struct { Error *ebsprovider.ProviderError `json:"error,omitempty"` } -// startProviderSubjects wires registerProviderSubjects onto a fresh NATS +// startProviderSubjects wires RegisterProviderSubjects onto a fresh NATS // connection for cfg, without launching the rest of launchService (mount, // unmount, sync, config, delete), which are unrelated to the ebs.provider.v1.* // contract under test here. @@ -90,7 +90,7 @@ func startProviderSubjects(t *testing.T, cfg *Config, natsURL string) *nats.Conn nc, err := nats.Connect(natsURL) require.NoError(t, err) t.Cleanup(nc.Close) - require.NoError(t, registerProviderSubjects(cfg, nc)) + require.NoError(t, RegisterProviderSubjects(cfg, nc)) return nc } @@ -943,7 +943,7 @@ func TestProviderHandlers_UnpublishVolume_AbsentIsIdempotent(t *testing.T) { } // TestProviderHandlers_PublishUnpublish_RequireNodeName covers -// registerProviderSubjects' NodeName gate: PublishVolume/UnpublishVolume must +// RegisterProviderSubjects' NodeName gate: PublishVolume/UnpublishVolume must // not be registered when cfg.NodeName is empty (PublishSubject has no node to // address), while every other ebs.provider.v1.* subject still is. func TestProviderHandlers_PublishUnpublish_RequireNodeName(t *testing.T) { diff --git a/spinifex/services/viperblockd/viperblockd.go b/spinifex/services/viperblockd/viperblockd.go index 729f80f53..1dd7feaa4 100644 --- a/spinifex/services/viperblockd/viperblockd.go +++ b/spinifex/services/viperblockd/viperblockd.go @@ -143,11 +143,6 @@ type Config struct { // If empty, falls back to generic ebs.mount / ebs.unmount with queue group (single-node compat). NodeName string - // KVReplicas is the replica count for the volume lease and dirty buckets, - // which is the cluster's node count. 0 means one, for a single node and for - // tests. - KVReplicas int - // NBDTransport controls the transport type: "socket" (default) or "tcp" // Socket is faster for local connections, TCP required for remote/DPU scenarios NBDTransport NBDTransport @@ -681,22 +676,6 @@ func (svc *Service) Start() (int, error) { return os.Getpid(), nil } -func (svc *Service) Stop() (err error) { - return utils.StopProcessAt(svc.Config.BaseDir, serviceName) -} - -func (svc *Service) Status() (string, error) { - return utils.ServiceStatus(svc.Config.BaseDir, serviceName) -} - -func (svc *Service) Shutdown() (err error) { - return svc.Stop() -} - -func (svc *Service) Reload() (err error) { - return nil -} - func launchService(cfg *Config) (err error) { nc, err := utils.ConnectNATSWithRetry(netaddr.DialTarget(cfg.NatsHost), cfg.NatsToken, cfg.NatsCACert) if err != nil { @@ -721,7 +700,7 @@ func launchService(cfg *Config) (err error) { // Bound before recovery, which opens engines: without the store every // engine open refuses, and the daemon would come up unable to adopt the // exports that outlived it. - leases, err := newVolumeLeases(context.Background(), nc, cfg.leaseOwner(), cfg.KVReplicas) + leases, err := newVolumeLeasesWaiting(context.Background(), nc, cfg.leaseOwner()) if err != nil { return fmt.Errorf("volume leases: %w", err) } @@ -1068,7 +1047,7 @@ func launchService(cfg *Config) (err error) { return fmt.Errorf("failed to subscribe to %s: %w", mountTopic, err) } - if err := registerProviderSubjects(cfg, nc); err != nil { + if err := RegisterProviderSubjects(cfg, nc); err != nil { return err } diff --git a/spinifex/services/viperblockd/viperblockd_integration_test.go b/spinifex/services/viperblockd/viperblockd_integration_test.go index ccd0dc951..1761e3d62 100644 --- a/spinifex/services/viperblockd/viperblockd_integration_test.go +++ b/spinifex/services/viperblockd/viperblockd_integration_test.go @@ -17,6 +17,7 @@ import ( "testing" "time" + "github.com/mulgadc/spinifex/spinifex/clustersize" "github.com/mulgadc/spinifex/spinifex/types" "github.com/nats-io/nats-server/v2/server" natstest "github.com/nats-io/nats-server/v2/test" @@ -42,6 +43,9 @@ func setupEmbeddedNATS(t *testing.T) (*server.Server, string) { } t.Cleanup(ns.Shutdown) + // One embedded server is a one-node cluster, and bucket creation refuses to + // guess a replica count it was never told. + clustersize.DeclareForTest(t, 1) return ns, ns.ClientURL() } @@ -54,7 +58,7 @@ func installTestVolumeLeases(t *testing.T, cfg *Config, natsURL string) { require.NoError(t, err) t.Cleanup(nc.Close) - leases, err := newVolumeLeases(t.Context(), nc, cfg.leaseOwner(), 1) + leases, err := newVolumeLeases(t.Context(), nc, cfg.leaseOwner()) require.NoError(t, err) cfg.leases = leases } @@ -84,7 +88,7 @@ func setupTestConfig(t *testing.T, natsURL string) *Config { cfg := &Config{ NatsHost: natsURL, - S3Host: "http://127.0.0.1:1", + S3Host: fastFailingS3Host(t), Bucket: "test-bucket", Region: "us-east-1", AccessKey: "test-access-key", diff --git a/spinifex/services/viperblockd/viperblockd_test.go b/spinifex/services/viperblockd/viperblockd_test.go index 554919914..cdc166969 100644 --- a/spinifex/services/viperblockd/viperblockd_test.go +++ b/spinifex/services/viperblockd/viperblockd_test.go @@ -53,60 +53,6 @@ func TestNewWithNilConfig(t *testing.T) { assert.NotNil(t, svc.Config) } -// TestServiceMethods tests the service interface methods. -func TestServiceMethods(t *testing.T) { - cfg := &Config{ - NatsHost: "nats://localhost:4222", - BaseDir: "/tmp/viperblock", - } - - svc, err := New(cfg) - assert.NoError(t, err) - - // Test Status - returns "stopped" when no PID file exists - status, err := svc.Status() - assert.NoError(t, err) - assert.Equal(t, "stopped", status) - - // Test Reload - returns no error - err = svc.Reload() - assert.NoError(t, err) -} - -// TestServiceShutdown tests that Shutdown calls Stop. -func TestServiceShutdown(t *testing.T) { - cfg := &Config{ - NatsHost: "nats://localhost:4222", - BaseDir: "/tmp/viperblock", - } - - svc, err := New(cfg) - assert.NoError(t, err) - - // Shutdown should call Stop internally - // Since we can't actually start the service (requires NATS), - // we just verify it doesn't panic - err = svc.Shutdown() - // Will error because no PID file exists, which is expected - assert.Error(t, err) -} - -// TestServiceStop tests the Stop method. -func TestServiceStop(t *testing.T) { - cfg := &Config{ - NatsHost: "nats://localhost:4222", - BaseDir: "/tmp/viperblock", - } - - svc, err := New(cfg) - assert.NoError(t, err) - - // Stop should try to read PID file and stop the process - err = svc.Stop() - // Will error because no PID file exists, which is expected in tests - assert.Error(t, err) -} - // TestServiceNameConstant tests the serviceName constant. func TestServiceNameConstant(t *testing.T) { assert.Equal(t, "viperblock", serviceName) diff --git a/spinifex/services/viperblockd/volume_bucket_replicas_test.go b/spinifex/services/viperblockd/volume_bucket_replicas_test.go index ec207fff7..5acbb5de7 100644 --- a/spinifex/services/viperblockd/volume_bucket_replicas_test.go +++ b/spinifex/services/viperblockd/volume_bucket_replicas_test.go @@ -1,11 +1,12 @@ package viperblockd //test:in-package — both bucket constructors are unexported, and the replica -//count they are given has no exported surface to read it back from. +//count they are created with has no exported surface to read it back from. import ( "testing" + "github.com/mulgadc/spinifex/spinifex/clustersize" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" "github.com/stretchr/testify/assert" @@ -15,28 +16,28 @@ import ( // The lease bucket decides which node may write a volume, and the dirty bucket // is read on every mount. Created on one replica, both live on one node, and a // cluster that loses that node can neither fence nor mount anywhere — which is -// the failure the whole recovery path is built to survive. So the replica count -// has to reach NATS, and these assert that it does in both directions: a count -// the server cannot satisfy fails loudly rather than quietly creating one -// replica, and the count asked for is the count the bucket is created with. -func TestVolumeBuckets_AreCreatedWithTheReplicaCountTheyAreGiven(t *testing.T) { +// the failure the whole recovery path is built to survive. So the cluster's node +// count has to reach NATS, and these assert that it does in both directions: a +// count the server cannot satisfy fails loudly rather than quietly creating one +// replica, and the cluster's count is the count the bucket is created with. +func TestVolumeBuckets_AreCreatedAtTheClusterReplicaCount(t *testing.T) { _, natsURL := setupEmbeddedNATS(t) nc, err := nats.Connect(natsURL) require.NoError(t, err) t.Cleanup(nc.Close) - leases, err := newVolumeLeases(t.Context(), nc, "node-a", 1) + leases, err := newVolumeLeases(t.Context(), nc, "node-a") require.NoError(t, err) assert.Equal(t, 1, bucketReplicas(t, leases.kv)) - dirty, err := newVolumeDirty(t.Context(), nc, "node-a", 1) + dirty, err := newVolumeDirty(t.Context(), nc, "node-a") require.NoError(t, err) assert.Equal(t, 1, bucketReplicas(t, dirty.kv)) } -// A single embedded server cannot hold three replicas, so asking for them is -// how a test proves the number is not being dropped on the way. +// A single embedded server cannot hold three replicas, so declaring a +// three-node cluster is how a test proves the number is not dropped on the way. func TestVolumeBuckets_RefuseAReplicaCountTheServerCannotHold(t *testing.T) { _, natsURL := setupEmbeddedNATS(t) @@ -44,13 +45,34 @@ func TestVolumeBuckets_RefuseAReplicaCountTheServerCannotHold(t *testing.T) { require.NoError(t, err) t.Cleanup(nc.Close) - _, err = newVolumeLeases(t.Context(), nc, "node-a", 3) + clustersize.RedeclareForTest(t, 3) + + _, err = newVolumeLeases(t.Context(), nc, "node-a") require.Error(t, err, "a lease bucket that cannot be made quorate must fail the service, not serve unreplicated") - _, err = newVolumeDirty(t.Context(), nc, "node-a", 3) + _, err = newVolumeDirty(t.Context(), nc, "node-a") require.Error(t, err, "a dirty bucket that cannot be made quorate must fail the service, not serve unreplicated") } +// TestVolumeBuckets_RefuseAnUndeclaredClusterSize pins the fail-closed choice at +// the call site that matters most: a process that never loaded a cluster config +// must not create these buckets on one node by default. +func TestVolumeBuckets_RefuseAnUndeclaredClusterSize(t *testing.T) { + _, natsURL := setupEmbeddedNATS(t) + + nc, err := nats.Connect(natsURL) + require.NoError(t, err) + t.Cleanup(nc.Close) + + clustersize.RedeclareForTest(t, 0) + + _, err = newVolumeLeases(t.Context(), nc, "node-a") + require.Error(t, err) + + _, err = newVolumeDirty(t.Context(), nc, "node-a") + require.Error(t, err) +} + func bucketReplicas(t *testing.T, kv jetstream.KeyValue) int { t.Helper() diff --git a/spinifex/services/viperblockd/volume_bucket_startup_test.go b/spinifex/services/viperblockd/volume_bucket_startup_test.go new file mode 100644 index 000000000..7f9bc4f52 --- /dev/null +++ b/spinifex/services/viperblockd/volume_bucket_startup_test.go @@ -0,0 +1,79 @@ +//test:in-package — the start-path bucket openers and the constructors beneath +//them are unexported, and the point is which of the two waits. + +package viperblockd + +import ( + "testing" + "time" + + "github.com/mulgadc/spinifex/spinifex/clustersize" + "github.com/nats-io/nats-server/v2/server" + natstest "github.com/nats-io/nats-server/v2/test" + "github.com/nats-io/nats.go" + "github.com/stretchr/testify/require" +) + +// jetStreamArrivesAfter is how long the buckets are left failing before +// JetStream appears. Only long enough to prove the first refusal did not end +// the attempt; the retry interval decides how much longer the open then takes. +const jetStreamArrivesAfter = 250 * time.Millisecond + +// TestVolumeBucketsWaitForJetStream is the cold multi-node start. Every service +// comes up at once against a cluster with no stream leader yet, and both of +// these opens are on the path to the daemon running at all — so a refusal that +// only means "not yet" must not become "this node has no block storage". +func TestVolumeBucketsWaitForJetStream(t *testing.T) { + // Deliberately no JetStream: the server answers, it just has nowhere to put + // a bucket, which is what a node whose cluster has not formed looks like. + ns := natstest.RunServer(&server.Options{Host: "127.0.0.1", Port: -1}) + require.NotNil(t, ns) + t.Cleanup(ns.Shutdown) + clustersize.DeclareForTest(t, 1) + + nc, err := nats.Connect(ns.ClientURL()) + require.NoError(t, err) + t.Cleanup(nc.Close) + + leaseErr := make(chan error, 1) + dirtyErr := make(chan error, 1) + go func() { _, err := newVolumeLeasesWaiting(t.Context(), nc, "node-a"); leaseErr <- err }() + go func() { _, err := newVolumeDirtyWaiting(t.Context(), nc, "node-a"); dirtyErr <- err }() + + select { + case err := <-leaseErr: + t.Fatalf("lease bucket concluded before JetStream existed: %v", err) + case err := <-dirtyErr: + t.Fatalf("dirty bucket concluded before JetStream existed: %v", err) + case <-time.After(jetStreamArrivesAfter): + } + + require.NoError(t, ns.EnableJetStream(&server.JetStreamConfig{StoreDir: t.TempDir()})) + + require.NoError(t, <-leaseErr, "the lease bucket must open once JetStream is there") + require.NoError(t, <-dirtyErr, "the dirty bucket must open once JetStream is there") +} + +// TestVolumeBucketsUnwaitingOpenStillRefusesAtOnce keeps the wait on the start +// path alone. The constructors are what the replica-count and cluster-size +// tests assert fail closed, and a refusal they have to wait out is one an +// operator tool and those tests would both read as a hang. +func TestVolumeBucketsUnwaitingOpenStillRefusesAtOnce(t *testing.T) { + ns := natstest.RunServer(&server.Options{Host: "127.0.0.1", Port: -1}) + require.NotNil(t, ns) + t.Cleanup(ns.Shutdown) + clustersize.DeclareForTest(t, 1) + + nc, err := nats.Connect(ns.ClientURL()) + require.NoError(t, err) + t.Cleanup(nc.Close) + + started := time.Now() + _, leaseOpenErr := newVolumeLeases(t.Context(), nc, "node-a") + _, dirtyOpenErr := newVolumeDirty(t.Context(), nc, "node-a") + + require.Error(t, leaseOpenErr) + require.Error(t, dirtyOpenErr) + require.Less(t, time.Since(started), jetStreamArrivesAfter, + "the plain constructors must report a refusal rather than retry it") +} diff --git a/spinifex/services/viperblockd/volume_dirty.go b/spinifex/services/viperblockd/volume_dirty.go index daae1e81a..308ccc7bc 100644 --- a/spinifex/services/viperblockd/volume_dirty.go +++ b/spinifex/services/viperblockd/volume_dirty.go @@ -8,7 +8,9 @@ import ( "log/slog" "time" - "github.com/mulgadc/spinifex/spinifex/foundation/telemetry" + "github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore" + "github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil" + telemetry "github.com/mulgadc/spinifex/spinifex/foundation/telemetry" "github.com/mulgadc/spinifex/spinifex/services/viperblockd/vbwire" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" @@ -26,22 +28,18 @@ type volumeDirty struct { } // newVolumeDirty binds the dirty bucket, creating it if this is the first node -// up. owner identifies this node in the entries it writes, and replicas is the -// cluster size: this is read on the mount path, so a single replica would make -// every mount depend on one node being up. -func newVolumeDirty(ctx context.Context, nc *nats.Conn, owner string, replicas int) (*volumeDirty, error) { +// up. owner identifies this node in the entries it writes. This is read on the +// mount path, so a single replica would make every mount in the cluster depend +// on one node being up. +func newVolumeDirty(ctx context.Context, nc *nats.Conn, owner string) (*volumeDirty, error) { js, err := jetstream.New(nc) if err != nil { return nil, fmt.Errorf("jetstream: %w", err) } - // Create-or-update rather than create: a bucket left behind by a build that - // made it single-replica is raised here, and a cluster cannot be asked to - // lose its markers to be repaired. - kv, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ - Bucket: vbwire.DirtyBucket, + kv, err := kvutil.GetOrCreateBucketWithOptions(ctx, js, kvutil.BucketOptions{ + Name: vbwire.DirtyBucket, Description: "volumes whose last seal failed, keyed by volume, naming the node holding the current copy", History: 1, - Replicas: max(replicas, 1), }) if err != nil { return nil, fmt.Errorf("volume dirty bucket: %w", err) @@ -49,6 +47,14 @@ func newVolumeDirty(ctx context.Context, nc *nats.Conn, owner string, replicas i return &volumeDirty{kv: kv, owner: owner}, nil } +// newVolumeDirtyWaiting is newVolumeDirty for a daemon that is starting, for the +// same reason as newVolumeLeasesWaiting: a cold cluster has no stream leader +// yet, and one refused open must not decide that this node has no storage. +func newVolumeDirtyWaiting(ctx context.Context, nc *nats.Conn, owner string) (*volumeDirty, error) { + return kvstore.OpenWithRetry(ctx, vbwire.DirtyBucket, kvstore.DefaultOpenWindow, + func(ctx context.Context) (*volumeDirty, error) { return newVolumeDirty(ctx, nc, owner) }) +} + // mark records that this node holds writes for volumeName at generation. // Conditional on the existing entry: a marker written by a later generation // belongs to a node that has taken the volume over, and overwriting it would @@ -234,7 +240,7 @@ func (cfg *Config) reportVolumeTakeover(ctx context.Context, volumeName string, slog.WarnContext(ctx, "opening a volume whose writes were last held by another node, from the backend checkpoint instead", "volume", volumeName, "previous_owner", record.Owner, "unsealed_since", record.Since.Format(time.RFC3339), "previous_reason", record.Reason) - otelsetup.RecordVolumeTakeover(ctx) + telemetry.RecordVolumeTakeover(ctx) // Take the marker over. The previous owner's copy is now behind this one, // so if that node returns it must not be treated as the better source. diff --git a/spinifex/services/viperblockd/volume_dirty_test.go b/spinifex/services/viperblockd/volume_dirty_test.go index 12fee61d7..546697e02 100644 --- a/spinifex/services/viperblockd/volume_dirty_test.go +++ b/spinifex/services/viperblockd/volume_dirty_test.go @@ -20,7 +20,7 @@ func newTestDirty(t *testing.T, natsURL, owner string) *volumeDirty { require.NoError(t, err) t.Cleanup(nc.Close) - dirty, err := newVolumeDirty(t.Context(), nc, owner, 1) + dirty, err := newVolumeDirty(t.Context(), nc, owner) require.NoError(t, err) return dirty } diff --git a/spinifex/services/viperblockd/volume_lease.go b/spinifex/services/viperblockd/volume_lease.go index bb9ade544..e712d317a 100644 --- a/spinifex/services/viperblockd/volume_lease.go +++ b/spinifex/services/viperblockd/volume_lease.go @@ -11,6 +11,8 @@ import ( "sync" "time" + "github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore" + "github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" ) @@ -127,24 +129,20 @@ type volumeLeases struct { held map[string]*volumeLease } -// newVolumeLeases binds the lease bucket, creating it if this is the first -// node up. owner identifies this node in the entries it writes, and replicas is -// the cluster size: this bucket decides who may write a volume, so a single -// replica would put every volume in the cluster behind one node staying up. -func newVolumeLeases(ctx context.Context, nc *nats.Conn, owner string, replicas int) (*volumeLeases, error) { +// newVolumeLeases binds the lease bucket, creating it if this is the first node +// up. owner identifies this node in the entries it writes. This bucket decides +// who may write a volume, so a single replica would put every volume in the +// cluster behind one node staying up. +func newVolumeLeases(ctx context.Context, nc *nats.Conn, owner string) (*volumeLeases, error) { js, err := jetstream.New(nc) if err != nil { return nil, fmt.Errorf("jetstream: %w", err) } - // Create-or-update rather than create: a bucket left behind by a build that - // made it single-replica is raised here, and a cluster cannot be asked to - // lose its leases to be repaired. - kv, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ - Bucket: volumeLeaseBucket, + kv, err := kvutil.GetOrCreateBucketWithOptions(ctx, js, kvutil.BucketOptions{ + Name: volumeLeaseBucket, Description: "one entry per volume with a viperblock engine open on it", TTL: volumeLeaseTTL, History: 1, - Replicas: max(replicas, 1), }) if err != nil { return nil, fmt.Errorf("volume lease bucket: %w", err) @@ -152,6 +150,14 @@ func newVolumeLeases(ctx context.Context, nc *nats.Conn, owner string, replicas return &volumeLeases{kv: kv, owner: owner, held: make(map[string]*volumeLease)}, nil } +// newVolumeLeasesWaiting is newVolumeLeases for a daemon that is starting. On a +// cold multi-node start every service races JetStream electing a leader, and a +// refusal that only means "not yet" must not leave this node without storage. +func newVolumeLeasesWaiting(ctx context.Context, nc *nats.Conn, owner string) (*volumeLeases, error) { + return kvstore.OpenWithRetry(ctx, volumeLeaseBucket, kvstore.DefaultOpenWindow, + func(ctx context.Context) (*volumeLeases, error) { return newVolumeLeases(ctx, nc, owner) }) +} + // volumeLease is one held lease and the goroutine keeping it alive. type volumeLease struct { leases *volumeLeases diff --git a/spinifex/services/viperblockd/volume_lease_test.go b/spinifex/services/viperblockd/volume_lease_test.go index e68f14f8a..823e6bb21 100644 --- a/spinifex/services/viperblockd/volume_lease_test.go +++ b/spinifex/services/viperblockd/volume_lease_test.go @@ -24,7 +24,7 @@ func newTestLeases(t *testing.T, natsURL, owner string) *volumeLeases { require.NoError(t, err) t.Cleanup(nc.Close) - leases, err := newVolumeLeases(t.Context(), nc, owner, 1) + leases, err := newVolumeLeases(t.Context(), nc, owner) require.NoError(t, err) return leases } @@ -561,7 +561,7 @@ func TestVolumeLease_UnreachableStoreIsRetryable(t *testing.T) { require.NoError(t, err) t.Cleanup(nc.Close) - leases, err := newVolumeLeases(t.Context(), nc, "node-a", 1) + leases, err := newVolumeLeases(t.Context(), nc, "node-a") require.NoError(t, err) // Close the connection under the bound store, which is the shape of a diff --git a/spinifex/utils/images.go b/spinifex/utils/images.go index 7d629b792..7d6b16868 100644 --- a/spinifex/utils/images.go +++ b/spinifex/utils/images.go @@ -24,6 +24,7 @@ import ( "os/signal" "path/filepath" "runtime" + "slices" "strings" "time" @@ -349,12 +350,9 @@ func SelectNewestImage(images []*ec2.Image, excludeTagKey string) (imageID, crea // imageHasTagKey reports whether img carries a tag with the given key. func imageHasTagKey(img *ec2.Image, key string) bool { - for _, t := range img.Tags { - if t != nil && aws.StringValue(t.Key) == key { - return true - } - } - return false + return slices.ContainsFunc(img.Tags, func(t *ec2.Tag) bool { + return t != nil && aws.StringValue(t.Key) == key + }) } type Images struct { @@ -591,28 +589,6 @@ var AvailableImages = map[string]Images{ Tags: map[string]string{"gpu-vendor": "nvidia"}, }, - // Bedrock self-host serving AMI. Resolved by spinifex:managed-by=bedrock + - // spinifex:bedrock-role=vllm-serving tags — LaunchServingVM's - // resolveServingAMI filters on both, never by name. - "ubuntu-26.04-vllm-serving-x86_64": { - Name: "ubuntu-26.04-vllm-serving-x86_64", - Description: "Ubuntu 26.04 Bedrock co-served bundle image — NVIDIA GPU base + vLLM and TEI baked side by side (uv-managed venv, TEI staged from HuggingFace's CUDA image), one bedrock-serve@ instance per bundle member against its own read-only weights mount", - Distro: "ubuntu", - Version: "26.04", - Arch: "x86_64", - Platform: "Linux/UNIX", - CreatedAt: time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC), - URL: "https://iso.mulgadc.com/system-ami/ubuntu-26.04-vllm-serving-x86_64.qcow2", - Checksum: "https://iso.mulgadc.com/system-ami/ubuntu-26.04-vllm-serving-x86_64.qcow2.sha256", - ChecksumType: "sha256", - BootMode: "uefi", - Tags: map[string]string{ - "spinifex:managed-by": "bedrock", - "spinifex:bedrock-role": "vllm-serving", - "gpu-vendor": "nvidia", - }, - }, - "ubuntu-26.04-amd-gpu-x86_64": { Name: "ubuntu-26.04-amd-gpu-x86_64", Description: "Ubuntu 26.04 AMD GPU base image — linux-firmware, ROCm CLI, Python toolchain, Docker", @@ -742,6 +718,35 @@ var AvailableImages = map[string]Images{ }, } +// WithheldImages are catalog entries whose artifact is not published, so an +// import of one would fail for every operator. They are kept here rather than +// deleted because the entry is the definition: moving one back into +// AvailableImages is the whole of publishing it. +var WithheldImages = map[string]Images{ + + // Bedrock self-host serving AMI. Resolved by spinifex:managed-by=bedrock + + // spinifex:bedrock-role=vllm-serving tags — LaunchServingVM's + // resolveServingAMI filters on both, never by name. + "ubuntu-26.04-vllm-serving-x86_64": { + Name: "ubuntu-26.04-vllm-serving-x86_64", + Description: "Ubuntu 26.04 Bedrock co-served bundle image — NVIDIA GPU base + vLLM and TEI baked side by side (uv-managed venv, TEI staged from HuggingFace's CUDA image), one bedrock-serve@ instance per bundle member against its own read-only weights mount", + Distro: "ubuntu", + Version: "26.04", + Arch: "x86_64", + Platform: "Linux/UNIX", + CreatedAt: time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC), + URL: "https://iso.mulgadc.com/system-ami/ubuntu-26.04-vllm-serving-x86_64.qcow2", + Checksum: "https://iso.mulgadc.com/system-ami/ubuntu-26.04-vllm-serving-x86_64.qcow2.sha256", + ChecksumType: "sha256", + BootMode: "uefi", + Tags: map[string]string{ + "spinifex:managed-by": "bedrock", + "spinifex:bedrock-role": "vllm-serving", + "gpu-vendor": "nvidia", + }, + }, +} + // ExtractDiskImageFromFile turns a downloaded artifact at imagepath into a bootable disk // image and returns its path. tar-based archives are expanded into tmpdir and their listing // is scanned for the member that carries the payload; single-file compressed artifacts diff --git a/spinifex/utils/images_test.go b/spinifex/utils/images_test.go index eb3807b01..027a29c34 100644 --- a/spinifex/utils/images_test.go +++ b/spinifex/utils/images_test.go @@ -21,12 +21,19 @@ import ( // resolveServingAMI filters purely on these two tags and never on the image // name, so a typo in either hides the serving image from Ochre and every // endpoint launch dies at "bedrock: no vllm-serving AMI found". -func TestAvailableImages_VLLMServingCarriesBedrockTags(t *testing.T) { +// +// The entry is withheld while its artifact is unpublished, so the contract is +// checked where the definition lives rather than dropped along with it. +func TestWithheldImages_VLLMServingCarriesBedrockTags(t *testing.T) { const key = "ubuntu-26.04-vllm-serving-x86_64" - img, ok := AvailableImages[key] + if _, offered := AvailableImages[key]; offered { + t.Fatalf("%s is offered for import while its artifact is unpublished", key) + } + + img, ok := WithheldImages[key] if !ok { - t.Fatalf("%s missing from AvailableImages", key) + t.Fatalf("%s missing from WithheldImages", key) } for tag, want := range map[string]string{ diff --git a/spinifex/utils/nats_trace.go b/spinifex/utils/nats_trace.go index 1401ff137..174772da6 100644 --- a/spinifex/utils/nats_trace.go +++ b/spinifex/utils/nats_trace.go @@ -2,6 +2,8 @@ package utils import ( "context" + "maps" + "slices" "github.com/nats-io/nats.go" "go.opentelemetry.io/otel" @@ -22,11 +24,7 @@ type natsHeaderCarrier nats.Header func (c natsHeaderCarrier) Get(key string) string { return nats.Header(c).Get(key) } func (c natsHeaderCarrier) Set(key, value string) { nats.Header(c).Set(key, value) } func (c natsHeaderCarrier) Keys() []string { - keys := make([]string, 0, len(c)) - for k := range c { - keys = append(keys, k) - } - return keys + return slices.Collect(maps.Keys(c)) } // InjectTraceContext writes ctx's span context into hdr (traceparent/tracestate). diff --git a/spinifex/utils/nbd.go b/spinifex/utils/nbd.go index c8aea0b59..0aa658d20 100644 --- a/spinifex/utils/nbd.go +++ b/spinifex/utils/nbd.go @@ -18,7 +18,7 @@ func GenerateSocketFile(name string) (string, error) { return "", errors.New("name is required") } - pidPath := pidPath() + pidPath := RuntimeDir() if pidPath == "" { return "", errors.New("pid path is empty") @@ -43,13 +43,13 @@ func GenerateUniqueSocketFile(volname string) (string, error) { return filepath.Join(dir, filename), nil } -// NBDSocketDir returns the NBD socket directory (/run/spinifex/nbd under systemd; pidPath() as fallback). +// NBDSocketDir returns the NBD socket directory (/run/spinifex/nbd under systemd; RuntimeDir() as fallback). func NBDSocketDir() string { const systemdNBDDir = "/run/spinifex/nbd" if dirExists(systemdNBDDir) { return systemdNBDDir } - return pidPath() + return RuntimeDir() } // IsSocketURI reports whether the NBD URI refers to a Unix socket (ends with ".sock" or contains "unix:"). diff --git a/spinifex/utils/pidfile.go b/spinifex/utils/pidfile.go index 3b00ea1ad..4f52516a5 100644 --- a/spinifex/utils/pidfile.go +++ b/spinifex/utils/pidfile.go @@ -12,7 +12,7 @@ import ( ) func ReadPidFile(name string) (int, error) { - pidPath := pidPath() + pidPath := RuntimeDir() pidFile, err := os.ReadFile(filepath.Join(pidPath, fmt.Sprintf("%s.pid", name))) @@ -30,7 +30,7 @@ func GeneratePidFile(name string) (string, error) { return "", errors.New("name is required") } - pidPath := pidPath() + pidPath := RuntimeDir() if pidPath == "" { return "", errors.New("pid path is empty") @@ -61,7 +61,7 @@ func WritePidFile(name string, pid int) error { return nil } -// WritePidFileTo writes a PID file to dir (or pidPath() if empty). +// WritePidFileTo writes a PID file to dir (or RuntimeDir() if empty). // Per-service data directories prevent PID file collisions on multi-node hosts. func WritePidFileTo(dir string, name string, pid int) error { if dir == "" { @@ -85,7 +85,7 @@ func WritePidFileTo(dir string, name string, pid int) error { } // ReadPidFileFrom reads a PID from a file in a specific directory. If dir is -// empty, falls back to the default pidPath(). +// empty, falls back to the default RuntimeDir(). func ReadPidFileFrom(dir string, name string) (int, error) { if dir == "" { return ReadPidFile(name) @@ -101,7 +101,7 @@ func ReadPidFileFrom(dir string, name string) (int, error) { } // RemovePidFileAt removes a PID file from a specific directory. If dir is -// empty, falls back to the default pidPath(). +// empty, falls back to the default RuntimeDir(). func RemovePidFileAt(dir string, name string) error { if dir == "" { return RemovePidFile(name) @@ -109,19 +109,6 @@ func RemovePidFileAt(dir string, name string) error { return os.Remove(filepath.Join(dir, fmt.Sprintf("%s.pid", name))) } -// ServiceStatus returns a human-readable status string for a service by -// checking its PID file. If dir is empty, the default pidPath() is used. -func ServiceStatus(dir, name string) (string, error) { - pid, err := ReadPidFileFrom(dir, name) - if err != nil { - if os.IsNotExist(err) { - return "stopped", nil - } - return "", fmt.Errorf("read pid file: %w", err) - } - return fmt.Sprintf("running (pid: %d)", pid), nil -} - // StopProcessAt stops a process using its PID file. Always removes the PID file, even if the process is already dead. func StopProcessAt(dir string, name string) error { pid, err := ReadPidFileFrom(dir, name) @@ -139,7 +126,7 @@ func StopProcessAt(dir string, name string) error { } func RemovePidFile(serviceName string) error { - pidPath := pidPath() + pidPath := RuntimeDir() err := os.Remove(filepath.Join(pidPath, fmt.Sprintf("%s.pid", serviceName))) if err != nil { @@ -149,11 +136,6 @@ func RemovePidFile(serviceName string) error { return nil } -// RuntimeDir returns the runtime directory used for PID files, sockets, and logs. -func RuntimeDir() string { - return pidPath() -} - // QMPTelemetryPrefix names the per-VM telemetry QMP socket and its sidecar // metadata file in the runtime dir: .sock / .json. const QMPTelemetryPrefix = "qmp-telemetry-" @@ -163,7 +145,8 @@ func TelemetryMetaPath(instanceID string) string { return filepath.Join(RuntimeDir(), QMPTelemetryPrefix+instanceID+".json") } -func pidPath() string { +// RuntimeDir returns the runtime directory used for PID files, sockets, and logs. +func RuntimeDir() string { if os.Getenv("XDG_RUNTIME_DIR") != "" { return os.Getenv("XDG_RUNTIME_DIR") } diff --git a/spinifex/utils/platform.go b/spinifex/utils/platform.go index d5bf4bef7..ace613a39 100644 --- a/spinifex/utils/platform.go +++ b/spinifex/utils/platform.go @@ -20,3 +20,23 @@ func PlatformFromDetails(platformDetails string) *string { p := PlatformWindows return &p } + +// usageOperations maps PlatformDetails to the billing code AWS reports as an +// image's UsageOperation, for the platforms checked against real AWS images. +var usageOperations = map[string]string{ + "Linux/UNIX": "RunInstances", + "Red Hat Enterprise Linux": "RunInstances:0010", + "SUSE Linux": "RunInstances:000g", + "Ubuntu Pro Linux": "RunInstances:0g00", + "Windows": "RunInstances:0002", +} + +// UsageOperationFromDetails returns the UsageOperation for platformDetails, or +// nil for a platform whose code has not been confirmed. +func UsageOperationFromDetails(platformDetails string) *string { + op, ok := usageOperations[platformDetails] + if !ok { + return nil + } + return &op +} diff --git a/spinifex/utils/platform_test.go b/spinifex/utils/platform_test.go index 9f2241b79..8706f5384 100644 --- a/spinifex/utils/platform_test.go +++ b/spinifex/utils/platform_test.go @@ -35,3 +35,28 @@ func TestPlatformFromDetails(t *testing.T) { }) } } + +func TestUsageOperationFromDetails(t *testing.T) { + tests := []struct { + platformDetails string + want string + }{ + {platformDetails: "Linux/UNIX", want: "RunInstances"}, + {platformDetails: "Windows", want: "RunInstances:0002"}, + {platformDetails: "Ubuntu Pro Linux", want: "RunInstances:0g00"}, + {platformDetails: "Windows BYOL", want: ""}, + {platformDetails: "", want: ""}, + } + for _, tt := range tests { + got := UsageOperationFromDetails(tt.platformDetails) + if tt.want == "" { + if got != nil { + t.Errorf("UsageOperationFromDetails(%q) = %q, want nil", tt.platformDetails, *got) + } + continue + } + if got == nil || *got != tt.want { + t.Errorf("UsageOperationFromDetails(%q) = %v, want %q", tt.platformDetails, got, tt.want) + } + } +} diff --git a/spinifex/utils/process.go b/spinifex/utils/process.go index 192d1f2dc..ade890d6b 100644 --- a/spinifex/utils/process.go +++ b/spinifex/utils/process.go @@ -21,25 +21,6 @@ func SetOOMScore(pid int, score int) error { return os.WriteFile(path, []byte(strconv.Itoa(score)), 0600) } -func StopProcess(serviceName string) error { - pid, err := ReadPidFile(serviceName) - if err != nil { - return err - } - - err = KillProcess(pid) - if err != nil { - return err - } - - err = RemovePidFile(serviceName) - if err != nil { - return err - } - - return nil -} - // ProcessAlive reports whether the process is still running, via a // signal-0 liveness probe. A PID file going missing does NOT imply the // process exited, so callers that must reap a process check this directly. diff --git a/spinifex/utils/utils.go b/spinifex/utils/utils.go index 30b81c9f8..1d77b865b 100644 --- a/spinifex/utils/utils.go +++ b/spinifex/utils/utils.go @@ -18,6 +18,7 @@ import ( "os" "os/signal" "reflect" + "slices" "strings" "time" @@ -91,8 +92,10 @@ func GenerateXMLPayload(locationName string, payload any) any { // NormalizeXMLOutput returns a copy of output with every nil slice field // (recursively) replaced by a non-nil empty slice, since aws-sdk-go's // xmlutil.BuildXML omits a nil slice's container element entirely but -// renders an empty one for a non-nil empty slice, unlike real AWS. -func NormalizeXMLOutput(output any) any { +// renders an empty one for a non-nil empty slice. AWS renders most empty lists +// but omits some; asSet names those fields, per struct type, and they are +// left as the handler set them so a nil one stays omitted. +func NormalizeXMLOutput(output any, asSet map[reflect.Type][]string) any { v := reflect.ValueOf(output) if !v.IsValid() { return output @@ -101,33 +104,34 @@ func NormalizeXMLOutput(output any) any { // can only Set fields through an addressable Value. ptr := reflect.New(v.Type()) ptr.Elem().Set(v) - normalizeNilSlices(ptr.Elem()) + normalizeNilSlices(ptr.Elem(), asSet) return ptr.Elem().Interface() } // normalizeNilSlices walks v in place, turning nil slice fields into empty // ones and recursing into structs, pointers, and existing slice elements. -func normalizeNilSlices(v reflect.Value) { +func normalizeNilSlices(v reflect.Value, asSet map[reflect.Type][]string) { switch v.Kind() { case reflect.Pointer: if !v.IsNil() { - normalizeNilSlices(v.Elem()) + normalizeNilSlices(v.Elem(), asSet) } case reflect.Struct: - for _, field := range v.Fields() { + keep := asSet[v.Type()] + for sf, field := range v.Fields() { switch field.Kind() { case reflect.Slice: if field.IsNil() { - if field.CanSet() { + if field.CanSet() && !slices.Contains(keep, sf.Name) { field.Set(reflect.MakeSlice(field.Type(), 0, 0)) } } else { for j := 0; j < field.Len(); j++ { - normalizeNilSlices(field.Index(j)) + normalizeNilSlices(field.Index(j), asSet) } } case reflect.Pointer, reflect.Struct: - normalizeNilSlices(field) + normalizeNilSlices(field, asSet) } } } diff --git a/spinifex/utils/utils_test.go b/spinifex/utils/utils_test.go index cd23684ef..86a5aeb17 100644 --- a/spinifex/utils/utils_test.go +++ b/spinifex/utils/utils_test.go @@ -162,7 +162,7 @@ func TestExecProcessAndKill(t *testing.T) { time.Sleep(20 * time.Millisecond) // Kill the process - err = StopProcess("utilsunittest") + err = StopProcessAt("", "utilsunittest") assert.NoError(t, err) // Test PID file removed @@ -722,36 +722,6 @@ func TestForceKillProcess_AlreadyExitedIsSuccess(t *testing.T) { "an already-exited process satisfies the request, it does not fail it") } -func TestStopProcess(t *testing.T) { - // Create and start a test process - cmd := exec.Command("sleep", "60") - err := cmd.Start() - require.NoError(t, err) - - // Write PID file - testName := "stopprocess-test" - err = WritePidFile(testName, cmd.Process.Pid) - require.NoError(t, err) - - // Reap in background so StopProcess can detect termination - var wg sync.WaitGroup - wg.Go(func() { - _ = cmd.Wait() - }) - - err = StopProcess(testName) - assert.NoError(t, err) - wg.Wait() - - // Verify PID file was removed - _, err = ReadPidFile(testName) - assert.Error(t, err, "PID file should be removed") - - // Test stopping non-existent process - err = StopProcess("nonexistent-process") - assert.Error(t, err, "Should error when stopping non-existent process") -} - // Test file extraction process func TestExtractDiskImageFromFile(t *testing.T) { @@ -994,7 +964,7 @@ func TestWritePidFileTo(t *testing.T) { } func TestWritePidFileTo_EmptyDir(t *testing.T) { - // With empty dir, should fall back to default pidPath() + // With empty dir, should fall back to default RuntimeDir() cmd := exec.Command("cat") require.NoError(t, cmd.Start()) defer cmd.Process.Kill() @@ -1115,7 +1085,7 @@ func TestRuntimeDir(t *testing.T) { func TestPidPath_XDG(t *testing.T) { t.Setenv("XDG_RUNTIME_DIR", "/tmp/test-xdg-runtime") - assert.Equal(t, "/tmp/test-xdg-runtime", pidPath()) + assert.Equal(t, "/tmp/test-xdg-runtime", RuntimeDir()) } func TestPidPath_HomeSpinifexFallback(t *testing.T) { @@ -1126,14 +1096,14 @@ func TestPidPath_HomeSpinifexFallback(t *testing.T) { t.Setenv("XDG_RUNTIME_DIR", "") t.Setenv("HOME", tmpHome) - assert.Equal(t, spinifexDir, pidPath()) + assert.Equal(t, spinifexDir, RuntimeDir()) } func TestPidPath_TempDirFallback(t *testing.T) { t.Setenv("XDG_RUNTIME_DIR", "") t.Setenv("HOME", "/nonexistent-home-dir-utils-test") - assert.Equal(t, os.TempDir(), pidPath()) + assert.Equal(t, os.TempDir(), RuntimeDir()) } func TestExtractDiskImagePath_NoMatch(t *testing.T) { @@ -1173,39 +1143,6 @@ func TestReadPidFileFrom_EmptyDir(t *testing.T) { assert.Error(t, err) } -func TestServiceStatus_Stopped(t *testing.T) { - dir := t.TempDir() - status, err := ServiceStatus(dir, fmt.Sprintf("no-such-svc-%d", time.Now().UnixNano())) - require.NoError(t, err) - assert.Equal(t, "stopped", status) -} - -func TestServiceStatus_Running(t *testing.T) { - dir := t.TempDir() - - cmd := exec.Command("sleep", "60") - require.NoError(t, cmd.Start()) - defer func() { - _ = cmd.Process.Kill() - _ = cmd.Wait() - }() - - require.NoError(t, WritePidFileTo(dir, "running-svc", cmd.Process.Pid)) - - status, err := ServiceStatus(dir, "running-svc") - require.NoError(t, err) - assert.Equal(t, fmt.Sprintf("running (pid: %d)", cmd.Process.Pid), status) -} - -func TestServiceStatus_CorruptPidFile(t *testing.T) { - dir := t.TempDir() - // Write a non-numeric pid file — ReadPidFileFrom should fail to parse it. - require.NoError(t, os.WriteFile(filepath.Join(dir, "bad-svc.pid"), []byte("not-a-pid"), 0o644)) - - _, err := ServiceStatus(dir, "bad-svc") - assert.Error(t, err) -} - func TestWaitForProcessExit_ProcessAlreadyDead(t *testing.T) { cmd := exec.Command("true") require.NoError(t, cmd.Start()) @@ -1891,11 +1828,11 @@ type normalizeOuter struct { func TestNormalizeXMLOutput_InvalidValue(t *testing.T) { var output any - assert.Nil(t, NormalizeXMLOutput(output)) + assert.Nil(t, NormalizeXMLOutput(output, nil)) } func TestNormalizeXMLOutput_TopLevelNilSlice(t *testing.T) { - out := NormalizeXMLOutput(normalizeOuter{}).(normalizeOuter) + out := NormalizeXMLOutput(normalizeOuter{}, nil).(normalizeOuter) require.NotNil(t, out.Names) assert.Empty(t, out.Names) } @@ -1903,28 +1840,39 @@ func TestNormalizeXMLOutput_TopLevelNilSlice(t *testing.T) { func TestNormalizeXMLOutput_NestedPointerStruct(t *testing.T) { out := NormalizeXMLOutput(normalizeOuter{ Nested: &normalizeInner{}, - }).(normalizeOuter) + }, nil).(normalizeOuter) require.NotNil(t, out.Nested) require.NotNil(t, out.Nested.Tags, "nil slice inside a pointer field must be normalized") assert.Empty(t, out.Nested.Tags) } func TestNormalizeXMLOutput_NestedValueStruct(t *testing.T) { - out := NormalizeXMLOutput(normalizeOuter{}).(normalizeOuter) + out := NormalizeXMLOutput(normalizeOuter{}, nil).(normalizeOuter) require.NotNil(t, out.Value.Tags, "nil slice inside a nested struct field must be normalized") } func TestNormalizeXMLOutput_RecursesIntoSliceElements(t *testing.T) { out := NormalizeXMLOutput(normalizeOuter{ Children: []*normalizeInner{{}, {Tags: []string{"a"}}}, - }).(normalizeOuter) + }, nil).(normalizeOuter) require.Len(t, out.Children, 2) require.NotNil(t, out.Children[0].Tags, "nil slice inside a slice element must be normalized") assert.Equal(t, []string{"a"}, out.Children[1].Tags, "an already-populated slice element must be left untouched") } +func TestNormalizeXMLOutput_AsSetFieldsKeepNil(t *testing.T) { + asSet := map[reflect.Type][]string{reflect.TypeFor[normalizeInner](): {"Tags"}} + out := NormalizeXMLOutput(normalizeOuter{ + Nested: &normalizeInner{}, + Children: []*normalizeInner{{Tags: []string{}}}, + }, asSet).(normalizeOuter) + assert.Nil(t, out.Nested.Tags, "a nil as-set field must stay nil so BuildXML omits it") + require.NotNil(t, out.Children[0].Tags, "an empty as-set field must stay empty so BuildXML renders it") + require.NotNil(t, out.Names, "fields outside asSet are still normalized") +} + func TestNormalizeXMLOutput_NilPointerFieldUntouched(t *testing.T) { - out := NormalizeXMLOutput(normalizeOuter{}).(normalizeOuter) + out := NormalizeXMLOutput(normalizeOuter{}, nil).(normalizeOuter) assert.Nil(t, out.Nested, "a nil pointer field must not be allocated") } diff --git a/spinifex/vpcd/imds_instance_state.go b/spinifex/vpcd/imds_instance_state.go index d9a99a1ce..10b506098 100644 --- a/spinifex/vpcd/imds_instance_state.go +++ b/spinifex/vpcd/imds_instance_state.go @@ -86,7 +86,7 @@ type recordLoader interface { // this node does not hold locally resolves to nothing, and IMDS serves that as // "no such instance" — a guest that boots with no key and no error anywhere. func newInstanceRecordLoader(ctx context.Context, nc *nats.Conn) (recordLoader, error) { - jsm, err := daemon.NewJetStreamManager(nc, 1) + jsm, err := daemon.NewJetStreamManager(nc) if err != nil { return nil, fmt.Errorf("jetstream manager: %w", err) } diff --git a/spinifex/vpcd/vpcd.go b/spinifex/vpcd/vpcd.go index 7ab816e79..45c3cf916 100644 --- a/spinifex/vpcd/vpcd.go +++ b/spinifex/vpcd/vpcd.go @@ -13,6 +13,7 @@ import ( "os/exec" "os/signal" "path/filepath" + "slices" "strings" "syscall" "time" @@ -212,26 +213,6 @@ func (svc *Service) Start() (int, error) { return os.Getpid(), nil } -// Stop stops the vpcd service. -func (svc *Service) Stop() error { - return utils.StopProcessAt(svc.Config.BaseDir, serviceName) -} - -// Status returns the vpcd service status. -func (svc *Service) Status() (string, error) { - return utils.ServiceStatus(svc.Config.BaseDir, serviceName) -} - -// Shutdown gracefully shuts down the vpcd service. -func (svc *Service) Shutdown() error { - return svc.Stop() -} - -// Reload reloads the vpcd service configuration. -func (svc *Service) Reload() error { - return nil -} - // checkBrInt verifies the OVS integration bridge (br-int) exists. // This is the bridge that all VM TAP devices connect to. var checkBrInt = func() error { @@ -863,14 +844,9 @@ func startDHCPManagerIfNeeded(ctx context.Context, nc *nats.Conn, js jetstream.J if cfg == nil || cfg.ExternalMode == "" { return nil, nil, nil } - wantDHCP := false - for _, p := range cfg.ExternalPools { - if p.Source == external.SourceDHCP { - wantDHCP = true - break - } - } - if !wantDHCP { + if !slices.ContainsFunc(cfg.ExternalPools, func(p external.ExternalPoolConfig) bool { + return p.Source == external.SourceDHCP + }) { return nil, nil, nil } diff --git a/tests/bench/ebsctl/compare.go b/tests/bench/ebsctl/compare.go index 0bbbc3841..59afc2e0b 100644 --- a/tests/bench/ebsctl/compare.go +++ b/tests/bench/ebsctl/compare.go @@ -3,8 +3,10 @@ package ebsctl import ( "encoding/json" "fmt" + "maps" "math/rand/v2" "os" + "slices" "sort" "strconv" "strings" @@ -74,12 +76,7 @@ func unionOps(a, b *RunResult) []string { for name := range b.Operations { seen[name] = struct{}{} } - out := make([]string, 0, len(seen)) - for name := range seen { - out = append(out, name) - } - sort.Strings(out) - return out + return slices.Sorted(maps.Keys(seen)) } // seriesFor picks the api or settle series from op, or nil if op is nil or diff --git a/tests/e2e/Makefile b/tests/e2e/Makefile index 413ba5e22..6ea508985 100644 --- a/tests/e2e/Makefile +++ b/tests/e2e/Makefile @@ -18,9 +18,9 @@ SUITE_TAG ?= $(notdir $(patsubst %/...,%,$(firstword $(PKG)))) # BIN_DIR/.test so CI can ship binaries (not source + go toolchain) to # the remote VM. BIN_DIR ?= $(CURDIR)/_bin -SUITES_BUILD ?= cert lb eks ecs single iam ecr bedrock gpu natuplink multinode storagegrowth partialblock rds quota diskperf storagefault instancerecovery lbrecovery +SUITES_BUILD ?= cert lb eks ecs single iam ecr bedrock gpu natuplink multinode storagegrowth partialblock rds quota diskperf storagefault instancerecovery lbrecovery kvquorum -.PHONY: e2e e2e-quota e2e-cert e2e-lb e2e-eks e2e-ecs e2e-single e2e-iam e2e-ecr e2e-bedrock e2e-multinode e2e-bm e2e-reboot e2e-tofu e2e-gpu e2e-natuplink e2e-storagegrowth e2e-partialblock e2e-diskperf e2e-rds e2e-storagefault e2e-instancerecovery e2e-lbrecovery e2e-all clean e2e-build +.PHONY: e2e e2e-quota e2e-cert e2e-lb e2e-eks e2e-ecs e2e-single e2e-iam e2e-ecr e2e-bedrock e2e-multinode e2e-bm e2e-reboot e2e-tofu e2e-gpu e2e-natuplink e2e-storagegrowth e2e-partialblock e2e-diskperf e2e-rds e2e-storagefault e2e-instancerecovery e2e-lbrecovery e2e-kvquorum e2e-all clean e2e-build RUN_FLAG = $(if $(RUN),-run '$(RUN)',) @@ -74,6 +74,9 @@ e2e-instancerecovery: ; $(MAKE) e2e PKG=./instancerecovery/... TIMEOUT=60m # Stands a load balancer and its backends up before it breaks anything, then # takes the load balancer's node away and brings it back. e2e-lbrecovery: ; $(MAKE) e2e PKG=./lbrecovery/... TIMEOUT=75m +# Audits every KV bucket, then takes a node away and brings it back. Most of its +# budget is the node stopping and starting, not the assertions. +e2e-kvquorum: ; $(MAKE) e2e PKG=./kvquorum/... MODE=multinode TIMEOUT=40m e2e-all: ; $(MAKE) e2e e2e-build: diff --git a/tests/e2e/ddil/scenarios/coverage_drift_test.go b/tests/e2e/ddil/scenarios/coverage_drift_test.go index 09eb243fe..b4c3f8f21 100644 --- a/tests/e2e/ddil/scenarios/coverage_drift_test.go +++ b/tests/e2e/ddil/scenarios/coverage_drift_test.go @@ -6,11 +6,12 @@ import ( "go/ast" "go/parser" "go/token" + "maps" "os" "path/filepath" "regexp" "runtime" - "sort" + "slices" "strings" "testing" ) @@ -117,12 +118,7 @@ func lettersFromDoc(path string) ([]string, error) { } func sortedKeys(m map[string]struct{}) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - sort.Strings(out) - return out + return slices.Sorted(maps.Keys(m)) } // setDiff returns elements in a that are not in b (both inputs must be sorted). diff --git a/tests/e2e/ecr/crane_skopeo_test.go b/tests/e2e/ecr/crane_skopeo_test.go index 6f224c7f2..2bf30e111 100644 --- a/tests/e2e/ecr/crane_skopeo_test.go +++ b/tests/e2e/ecr/crane_skopeo_test.go @@ -22,7 +22,7 @@ const ociCmdTimeout = 2 * time.Minute func writeLayerTar(t *testing.T, dir string) string { t.Helper() path := filepath.Join(dir, "layer.tar") - fh, err := os.Create(path) //nolint:gosec // test-controlled path under TmpDir + fh, err := os.Create(path) require.NoError(t, err) defer fh.Close() diff --git a/tests/e2e/ecs/ecs_test.go b/tests/e2e/ecs/ecs_test.go index 13629492b..bd9a80900 100644 --- a/tests/e2e/ecs/ecs_test.go +++ b/tests/e2e/ecs/ecs_test.go @@ -527,7 +527,7 @@ func guestUserData(t *testing.T, env *harness.Env, fx *ecsFixture) string { t.Helper() caPath, err := harness.ResolveCACert(env) require.NoError(t, err, "resolve CA cert") - caBytes, err := os.ReadFile(caPath) //nolint:gosec // CA path from harness env + caBytes, err := os.ReadFile(caPath) require.NoError(t, err, "read CA cert") var ca strings.Builder diff --git a/tests/e2e/eks/eks_test.go b/tests/e2e/eks/eks_test.go index 77cda0944..4c526d676 100644 --- a/tests/e2e/eks/eks_test.go +++ b/tests/e2e/eks/eks_test.go @@ -69,7 +69,7 @@ func TestEKS(t *testing.T) { t.Run("DescribeKubeconfigArtifacts", func(t *testing.T) { requireClusterReady(t, fx) path := writeKubeconfig(t, artifacts, fx.Cluster) - raw, err := os.ReadFile(path) //nolint:gosec // artifact path built by the test + raw, err := os.ReadFile(path) require.NoError(t, err) kc := string(raw) assert.Contains(t, kc, "server: "+aws.StringValue(fx.Cluster.Endpoint), "kubeconfig server = cluster endpoint") @@ -1018,7 +1018,7 @@ func setupClusterFixture(t *testing.T, c *harness.AWSClient, env *harness.Env, a t.Cleanup(func() { harness.DeleteWorkerEgress(t, c, fx.Egress) }) harness.Phase(t, "Creating cluster %q", fx.ClusterName) - roleArn := fmt.Sprintf("arn:aws:iam::%s:role/%s-role", fx.AccountID, fx.ClusterName) + roleArn := harness.CreateEKSClusterRole(t, c, fx.ClusterName+"-role") _, err = c.EKS.CreateCluster(&eks.CreateClusterInput{ Name: aws.String(fx.ClusterName), RoleArn: aws.String(roleArn), diff --git a/tests/e2e/gpu/ecs_gpu_test.go b/tests/e2e/gpu/ecs_gpu_test.go index 3fef7145f..1388b84a8 100644 --- a/tests/e2e/gpu/ecs_gpu_test.go +++ b/tests/e2e/gpu/ecs_gpu_test.go @@ -235,7 +235,7 @@ func ecsGPUUserData(t *testing.T, env *harness.Env, cluster string) string { t.Helper() caPath, err := harness.ResolveCACert(env) require.NoError(t, err, "resolve CA cert") - caBytes, err := os.ReadFile(caPath) //nolint:gosec // CA path from harness env + caBytes, err := os.ReadFile(caPath) require.NoError(t, err, "read CA cert") var ca strings.Builder diff --git a/tests/e2e/gpu/eks_gpu_test.go b/tests/e2e/gpu/eks_gpu_test.go index 0bb024e9a..438a58795 100644 --- a/tests/e2e/gpu/eks_gpu_test.go +++ b/tests/e2e/gpu/eks_gpu_test.go @@ -157,7 +157,7 @@ func TestEKSGPUPodExposure(t *testing.T) { clusterName := fmt.Sprintf("%s-%d", eksGPUClusterPfx, time.Now().Unix()) harness.Phase(t, "Creating cluster %q", clusterName) - roleArn := fmt.Sprintf("arn:aws:iam::%s:role/%s-role", accountID, clusterName) + roleArn := harness.CreateEKSClusterRole(t, c, clusterName+"-role") // e2e:allow-create — the GPU cluster is the subject under test (GPU worker join + device-plugin exposure). _, err = c.EKS.CreateCluster(&eks.CreateClusterInput{ Name: aws.String(clusterName), diff --git a/tests/e2e/harness/account.go b/tests/e2e/harness/account.go index 9ed44bba3..5be8cdf3f 100644 --- a/tests/e2e/harness/account.go +++ b/tests/e2e/harness/account.go @@ -3,7 +3,8 @@ package harness import ( - "sort" + "maps" + "slices" "sync" "testing" ) @@ -59,10 +60,5 @@ func (a *AccountCarousel) Get(name string) *Profile { func (a *AccountCarousel) Names() []string { a.mu.RLock() defer a.mu.RUnlock() - out := make([]string, 0, len(a.profiles)) - for k := range a.profiles { - out = append(out, k) - } - sort.Strings(out) - return out + return slices.Sorted(maps.Keys(a.profiles)) } diff --git a/tests/e2e/harness/discover.go b/tests/e2e/harness/discover.go index 02c7b5bde..f5d8f5250 100644 --- a/tests/e2e/harness/discover.go +++ b/tests/e2e/harness/discover.go @@ -65,11 +65,11 @@ func DiscoverNanoInstanceType(t *testing.T, fx *Fixture) (instanceType, arch str if err != nil { t.Fatalf("DiscoverNanoInstanceType: %v", err) } - parts := strings.SplitN(combined, "|", 2) - if len(parts) != 2 { + instanceType, arch, ok := strings.Cut(combined, "|") + if !ok { t.Fatalf("DiscoverNanoInstanceType: malformed memo value %q", combined) } - return parts[0], parts[1] + return instanceType, arch } // DiscoverUbuntuAMI returns the AMI ID for the architecture-appropriate Ubuntu diff --git a/tests/e2e/harness/ec2helpers.go b/tests/e2e/harness/ec2helpers.go index 5a102ca2a..7009ecbd7 100644 --- a/tests/e2e/harness/ec2helpers.go +++ b/tests/e2e/harness/ec2helpers.go @@ -3,10 +3,8 @@ package harness import ( - "bytes" "context" "fmt" - "os/exec" "strconv" "strings" "testing" @@ -72,25 +70,7 @@ func InstancePrivateIP(t *testing.T, c *AWSClient, instanceID string) string { func LsblkRootGiB(t *testing.T, tgt SSHTarget) int { t.Helper() cmd := `SRC=$(findmnt -n -o SOURCE /); PKN=$(lsblk -n -o PKNAME "$SRC" 2>/dev/null | head -1); DEV=${PKN:-$(basename "$SRC")}; lsblk -b -d -n -o SIZE "/dev/$DEV"` - args := []string{ - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "LogLevel=ERROR", - "-o", "ConnectTimeout=5", - "-o", "BatchMode=yes", - "-p", strconv.Itoa(tgt.Port), - "-i", tgt.KeyPath, - tgt.User + "@" + tgt.Host, - cmd, - } - var stdout, stderr bytes.Buffer - sshCmd := exec.Command("ssh", args...) - sshCmd.Stdout = &stdout - sshCmd.Stderr = &stderr - if err := sshCmd.Run(); err != nil { - t.Fatalf("ssh lsblk %s@%s:%d failed: %v\nstderr: %s", tgt.User, tgt.Host, tgt.Port, err, stderr.String()) - } - raw := strings.TrimSpace(stdout.String()) + raw := strings.TrimSpace(RunSSH(t, tgt, cmd)) bytesN, err := strconv.ParseInt(raw, 10, 64) if err != nil { t.Fatalf("ssh lsblk %s@%s:%d: parse %q: %v", tgt.User, tgt.Host, tgt.Port, raw, err) diff --git a/tests/e2e/harness/fixtures.go b/tests/e2e/harness/fixtures.go index 7704b9c0e..450ee1ab7 100644 --- a/tests/e2e/harness/fixtures.go +++ b/tests/e2e/harness/fixtures.go @@ -45,7 +45,7 @@ import ( type Fixture struct { // parent is the testing.T that constructed the Fixture in test mode. // nil in process mode (NewProcessFixture). Cleanup routing forks on - // this — see registerCleanup. + // this — see RegisterCleanup. parent *testing.T EC2 ec2iface.EC2API @@ -165,13 +165,16 @@ func (f *Fixture) Close() error { return fmt.Errorf("%d resource(s) left behind:\n %s", len(leaks), strings.Join(leaks, "\n ")) } -// registerCleanup routes a teardown callback to the appropriate sink based +// RegisterCleanup routes a teardown callback to the appropriate sink based // on construction mode. Test mode: t.Cleanup on the bound parent. Process // mode: append to the LIFO drained by Close(). // // Each callback is isolated from the rest: a panic in one teardown would // otherwise abort the whole chain and strand every resource behind it. -func (f *Fixture) registerCleanup(fn func()) { +// +// Use it for resources created mid-suite outside ensureOnce, which must outlive +// the calling subtest rather than be torn down before a dependent one runs. +func (f *Fixture) RegisterCleanup(fn func()) { isolated := func() { defer func() { if r := recover(); r != nil { @@ -219,19 +222,6 @@ func (f *Fixture) logf(format string, args ...any) { // for tests that need to assert against names they didn't construct directly. func (f *Fixture) Scratch() string { return f.scratch } -// RegisterCleanup runs fn at fixture teardown, not at the end of the -// calling subtest. Use for resources created mid-suite by code paths that -// don't flow through ensureOnce (e.g. IAM users threaded across multiple -// phase subtests) — registering on the calling subtest would tear them -// down before the next dependent subtest runs. -// -// Test mode (NewFixture): fn runs at parent t.Cleanup. -// Process mode (NewProcessFixture): fn runs at f.Close() (typically called -// from TestMain after m.Run()). -func (f *Fixture) RegisterCleanup(fn func()) { - f.registerCleanup(fn) -} - // ensureOnce returns the cached resource ID for key, or calls create exactly // once (via singleflight) and caches the result. On failure no cleanup is // registered and the cache is untouched, so a retry triggers a fresh create. @@ -267,7 +257,7 @@ func (f *Fixture) ensureOnce(t *testing.T, key string, create func() (string, fu f.mu.Unlock() if !dup && cleanup != nil { - f.registerCleanup(func() { + f.RegisterCleanup(func() { if cerr := cleanup(); cerr != nil { f.reportLeak("%s (%s): %v", key, id, cerr) } diff --git a/tests/e2e/harness/fixtures_test.go b/tests/e2e/harness/fixtures_test.go index 472b5ddf2..17634c246 100644 --- a/tests/e2e/harness/fixtures_test.go +++ b/tests/e2e/harness/fixtures_test.go @@ -428,9 +428,7 @@ func TestPollUntil_TimeoutWrapsLastErr(t *testing.T) { // Compile-time check: every exported Ensure* compiles with the Fixture. // Never invoked at runtime; pure type assertion against the public API. -// -//nolint:unused,deadcode // exists only to fail compile when an Ensure* signature drifts -func _ensureCompileCheck(t *testing.T, fx *Fixture) { +var _ = func(t *testing.T, fx *Fixture) { _, _ = EnsureKeyPair(t, fx) _ = EnsureAMI(t, fx, AMISource{Existing: "ami-0"}) _ = EnsureDefaultVPC(t, fx) diff --git a/tests/e2e/harness/guest_data.go b/tests/e2e/harness/guest_data.go index 88dec1bc9..7a084539a 100644 --- a/tests/e2e/harness/guest_data.go +++ b/tests/e2e/harness/guest_data.go @@ -5,8 +5,10 @@ package harness import ( "context" "fmt" + "maps" "os/exec" "regexp" + "slices" "strconv" "strings" "testing" @@ -52,18 +54,7 @@ func GuestExec(tgt SSHTarget, cmd string) (string, error) { func GuestExecTimeout(tgt SSHTarget, cmd string, timeout time.Duration) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - args := []string{ - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "LogLevel=ERROR", - "-o", "ConnectTimeout=5", - "-o", "BatchMode=yes", - "-p", strconv.Itoa(tgt.Port), - "-i", tgt.KeyPath, - tgt.User + "@" + tgt.Host, - cmd, - } - out, err := exec.CommandContext(ctx, "ssh", args...).CombinedOutput() + out, err := exec.CommandContext(ctx, "ssh", guestSSHArgs(tgt, 5, cmd)...).CombinedOutput() if err != nil && ctx.Err() == context.DeadlineExceeded { return string(out), fmt.Errorf("guest command exceeded its %s timeout (still running when the deadline hit): %w: %w", timeout, context.DeadlineExceeded, err) } @@ -115,10 +106,7 @@ func WaitForNewGuestDisk(t *testing.T, tgt SSHTarget, before map[string]struct{} return nil } } - names := make([]string, 0, len(now)) - for name := range now { - names = append(names, name) - } + names := slices.Collect(maps.Keys(now)) return fmt.Errorf("no new disk yet (visible: %s)", strings.Join(names, ",")) }, timeout, 2*time.Second) return found diff --git a/tests/e2e/harness/iam.go b/tests/e2e/harness/iam.go index 0a9907df5..ebff0898a 100644 --- a/tests/e2e/harness/iam.go +++ b/tests/e2e/harness/iam.go @@ -16,6 +16,21 @@ func IAMRoleARN(account, name string) string { return "arn:aws:iam::" + account + ":role/" + name } +// CreateEKSClusterRole creates a role EKS can assume as a cluster role and +// deletes it at cleanup. CreateCluster refuses a role that does not exist. +func CreateEKSClusterRole(t *testing.T, c *AWSClient, name string) string { + t.Helper() + const eksTrustPolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"eks.amazonaws.com"},"Action":"sts:AssumeRole"}]}` + out, err := c.IAM.CreateRole(&iam.CreateRoleInput{ + RoleName: aws.String(name), + AssumeRolePolicyDocument: aws.String(eksTrustPolicy), + Description: aws.String("E2E EKS cluster role"), + }) + require.NoError(t, err, "create-role (cluster) %s", name) + t.Cleanup(func() { IAMDeleteRoleAndProfilesBestEffort(c, name, nil) }) + return aws.StringValue(out.Role.Arn) +} + // IAMPolicyARN builds the canonical policy ARN for a given account + policy // "key" (a bare name, or "path/name" without the leading slash). func IAMPolicyARN(account, key string) string { diff --git a/tests/e2e/harness/jetstream.go b/tests/e2e/harness/jetstream.go index f04f3587e..fe9b03172 100644 --- a/tests/e2e/harness/jetstream.go +++ b/tests/e2e/harness/jetstream.go @@ -3,9 +3,15 @@ package harness import ( + "fmt" + "sort" + "strings" "testing" + "github.com/mulgadc/spinifex/spinifex/clustersize" + "github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil" "github.com/mulgadc/spinifex/spinifex/utils" + "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" ) @@ -42,3 +48,149 @@ func KVReplicaFactors(t *testing.T, env *Env, buckets ...string) map[string]int } return out } + +// WantKVReplicas is how many nodes a KV bucket must be replicated across on a +// cluster of this size. +// +// It defers to the production rule rather than restating it. A test that carried +// its own copy would keep passing after the policy changed, which is the one +// thing this assertion must not do. +func WantKVReplicas(nodes int) int { + return clustersize.ReplicasFor(nodes) +} + +// DialClusterNATS opens a NATS connection to the cluster, using the caller's +// env for the address and credentials. The connection is closed on cleanup. +func DialClusterNATS(t *testing.T, env *Env) *nats.Conn { + t.Helper() + host, token, ca := natsConn(t, env) + nc, err := utils.ConnectNATS(host, token, ca) + if err != nil { + t.Fatalf("connect NATS %s: %v", host, err) + } + t.Cleanup(nc.Close) + return nc +} + +// KVReplication reports every KV bucket in the cluster: its replica count, what +// the cluster's size entitles it to, and which nodes hold it. +// +// The node count is declared here because this process never loaded a cluster +// config, and the audit refuses to guess one rather than assuming a single +// replica. Declaring is write-once, so the reset makes a second call with the +// same count safe and a third with a different one a visible failure. +func KVReplication(t *testing.T, env *Env, nodes int) []kvutil.BucketReport { + t.Helper() + clustersize.ResetForTest() + if err := clustersize.Declare(nodes); err != nil { + t.Fatalf("declare cluster size %d: %v", nodes, err) + } + js, err := jetstream.New(DialClusterNATS(t, env)) + if err != nil { + t.Fatalf("jetstream context: %v", err) + } + reports, err := kvutil.AuditBucketReplicas(t.Context(), js) + if err != nil { + t.Fatalf("audit KV bucket replicas: %v", err) + } + return reports +} + +// RequireKVQuorum fails unless every KV bucket in the cluster is replicated +// across every node, and unless each one is actually held by that many distinct +// nodes rather than merely configured for it. +// +// A bucket below the node count lives on fewer nodes than depend on it, so +// losing one of them makes it unreadable and unwritable from every node at +// once — a cluster-wide outage caused by a single-node event. This runs in +// every multi-node suite rather than one of them, because a bucket created by +// any service on any cell is the one that can be wrong. +func RequireKVQuorum(t *testing.T, env *Env, nodes int) { + t.Helper() + want := WantKVReplicas(nodes) + + reports := KVReplication(t, env, nodes) + if len(reports) == 0 { + t.Fatal("the cluster reports no KV buckets at all, so nothing was checked") + } + + var problems []string + for _, r := range reports { + if r.Replicas < want { + problems = append(problems, fmt.Sprintf( + "%s is on %d replicas, want %d: losing the node holding it takes it down cluster-wide", + r.Bucket, r.Replicas, want)) + continue + } + // Configured, placed and serving are three facts. A stream can carry the + // right count while its group is short of peers or those peers are not + // caught up, and either reads as healthy right up until the node it is + // really on goes away. + if held := distinctPeers(r.Peers); held < want { + problems = append(problems, fmt.Sprintf( + "%s is configured for %d replicas but held by %d node(s) (%s)", + r.Bucket, r.Replicas, held, strings.Join(r.Peers, ","))) + continue + } + if r.Online < want { + problems = append(problems, fmt.Sprintf( + "%s is held by %d node(s) but only %d are caught up and reachable (leader %s)", + r.Bucket, len(r.Peers), r.Online, r.Leader)) + } + } + if len(problems) > 0 { + sort.Strings(problems) + t.Fatalf("%d of %d KV buckets are not replicated across the cluster's %d nodes:\n %s", + len(problems), len(reports), nodes, strings.Join(problems, "\n ")) + } + Detail(t, "kv_buckets", len(reports), "replicas", want, "nodes", nodes) +} + +// RequireKVSingleNode fails unless a one-node cluster's buckets are exactly what +// one node can give them: one replica each, all of them serving. +// +// It is the counterpart assertion to RequireKVQuorum, and it is not the same +// check with a smaller number. On one node R1 is correct rather than a defect, so +// what has to be proved is the opposite: that nothing declared a count this +// deployment cannot place, and that a bucket which cannot be raised has not been +// left unreadable by the attempt. +func RequireKVSingleNode(t *testing.T, env *Env) { + t.Helper() + + reports := KVReplication(t, env, 1) + if len(reports) == 0 { + t.Fatal("the cluster reports no KV buckets at all, so nothing was checked") + } + + var problems []string + for _, r := range reports { + if r.Replicas != 1 { + problems = append(problems, fmt.Sprintf( + "%s is configured for %d replicas on a single node, which JetStream cannot place", + r.Bucket, r.Replicas)) + continue + } + if !r.HasQuorum() { + problems = append(problems, fmt.Sprintf("%s is not serving: %d of %d replicas online", + r.Bucket, r.Online, r.Replicas)) + } + } + if len(problems) > 0 { + sort.Strings(problems) + t.Fatalf("%d of %d KV buckets are wrong for a single-node cluster:\n %s", + len(problems), len(reports), strings.Join(problems, "\n ")) + } + Detail(t, "kv_buckets", len(reports), "replicas", 1, "nodes", 1) +} + +// distinctPeers counts the unique node names holding a stream, since the leader +// is reported separately from the followers and could repeat. +func distinctPeers(peers []string) int { + seen := make(map[string]struct{}, len(peers)) + for _, p := range peers { + if p != "" { + seen[p] = struct{}{} + } + } + return len(seen) +} diff --git a/tests/e2e/harness/kubectl.go b/tests/e2e/harness/kubectl.go index d5b8cc0bb..b3fc403c8 100644 --- a/tests/e2e/harness/kubectl.go +++ b/tests/e2e/harness/kubectl.go @@ -43,7 +43,7 @@ func NewKubectl(t *testing.T, kubeconfig string, awsEnv []string) *Kubectl { // output. Errors are returned (not fatal) so callers can poll readiness. func (k *Kubectl) Run(timeout time.Duration, args ...string) (string, error) { full := append([]string{"--kubeconfig", k.Kubeconfig}, args...) - cmd := exec.Command(k.Bin, full...) //nolint:gosec // bin is LookPath-resolved, args test-controlled + cmd := exec.Command(k.Bin, full...) env := os.Environ() env = append(env, k.AWSEnv...) cmd.Env = env @@ -116,7 +116,7 @@ func (k *Kubectl) execPluginProbe(timeout time.Duration) string { if err != nil { return "exec plugin " + argv[0] + " not on PATH: " + err.Error() + "\n" } - cmd := exec.Command(bin, argv[1:]...) //nolint:gosec // argv comes from the kubeconfig this test wrote + cmd := exec.Command(bin, argv[1:]...) cmd.Env = append(os.Environ(), k.AWSEnv...) var stdout, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &stdout, &stderr diff --git a/tests/e2e/harness/multinode_cluster.go b/tests/e2e/harness/multinode_cluster.go index 4870b6f67..3cd2e93e8 100644 --- a/tests/e2e/harness/multinode_cluster.go +++ b/tests/e2e/harness/multinode_cluster.go @@ -30,6 +30,9 @@ type natsRoutezResponse struct { // WaitNATSPeers polls every node's NATS /routez until each reports at least want // distinct peers (timeout 60s, interval 2s). NATS monitor binds 127.0.0.1:8222 // only, so queries run via PeerSSH + curl rather than dialling node.Addr directly. +// +// want is a count of *other* nodes, because /routez reports routes to peers and +// not a node's own membership. A fully formed cluster is len(c.Nodes)-1. // Nodes passed to WithSkipNodes are excluded — use it when a node has been // stopped so its dead monitor port doesn't fail the poll. func (c *Cluster) WaitNATSPeers(t *testing.T, want int, opts ...PollOpt) { diff --git a/tests/e2e/harness/registry.go b/tests/e2e/harness/registry.go index 0d1acfd1c..68cc24921 100644 --- a/tests/e2e/harness/registry.go +++ b/tests/e2e/harness/registry.go @@ -55,7 +55,7 @@ func NewSkopeo(t *testing.T, dockerConfig string) *ExternalCLI { // Run executes the client with args and returns combined output. Errors are // returned (not fatal) so callers can assert on output or poll. func (c *ExternalCLI) Run(timeout time.Duration, args ...string) (string, error) { - cmd := exec.Command(c.Bin, args...) //nolint:gosec // bin is LookPath-resolved, args test-controlled + cmd := exec.Command(c.Bin, args...) cmd.Env = append(os.Environ(), c.Env...) var buf bytes.Buffer cmd.Stdout = &buf @@ -78,7 +78,7 @@ func (c *ExternalCLI) Run(timeout time.Duration, args ...string) (string, error) // RunStdin is Run with data piped to the process stdin (docker login // --password-stdin). func (c *ExternalCLI) RunStdin(timeout time.Duration, stdin string, args ...string) (string, error) { - cmd := exec.Command(c.Bin, args...) //nolint:gosec // bin is LookPath-resolved, args test-controlled + cmd := exec.Command(c.Bin, args...) cmd.Env = append(os.Environ(), c.Env...) cmd.Stdin = strings.NewReader(stdin) var buf bytes.Buffer diff --git a/tests/e2e/harness/ssh.go b/tests/e2e/harness/ssh.go index 9c1dae0f6..9efdb085f 100644 --- a/tests/e2e/harness/ssh.go +++ b/tests/e2e/harness/ssh.go @@ -3,14 +3,21 @@ package harness import ( + "bytes" "context" "fmt" "net" "os" "os/exec" "strconv" + "strings" + "sync/atomic" + "testing" "time" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" + "golang.org/x/crypto/ssh" ) @@ -28,24 +35,181 @@ type SSH interface { // RunGuestSSH executes cmd in a guest addressed by an SSHTarget. It returns // combined output so callers can retry assertions without terminating a test. func RunGuestSSH(ctx context.Context, target SSHTarget, cmd string) ([]byte, error) { - args := []string{ - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "LogLevel=ERROR", - "-o", "ConnectTimeout=5", - "-o", "BatchMode=yes", - "-p", strconv.Itoa(target.Port), - "-i", target.KeyPath, - target.User + "@" + target.Host, - cmd, - } - out, err := exec.CommandContext(ctx, "ssh", args...).CombinedOutput() + out, err := exec.CommandContext(ctx, "ssh", guestSSHArgs(target, 5, cmd)...).CombinedOutput() if err != nil { return out, fmt.Errorf("guest ssh %s@%s:%d: %w: %s", target.User, target.Host, target.Port, err, out) } return out, nil } +// guestSSHArgs is the non-interactive, no-host-key-check ssh(1) argument list +// every guest probe uses; connectTimeout is in seconds. +func guestSSHArgs(tgt SSHTarget, connectTimeout int, cmd string) []string { + return []string{ + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "LogLevel=ERROR", + "-o", "ConnectTimeout=" + strconv.Itoa(connectTimeout), + "-o", "BatchMode=yes", + "-p", strconv.Itoa(tgt.Port), + "-i", tgt.KeyPath, + tgt.User + "@" + tgt.Host, + cmd, + } +} + +// RunSSH runs command in the guest and returns stdout. It t.Fatals on a +// non-zero exit so callers can chain assertions on the output. +func RunSSH(t *testing.T, tgt SSHTarget, command string) string { + t.Helper() + var stdout, stderr bytes.Buffer + cmd := exec.Command("ssh", guestSSHArgs(tgt, 5, command)...) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + t.Fatalf("ssh %s@%s:%d %q failed: %v\nstderr: %s", + tgt.User, tgt.Host, tgt.Port, command, err, stderr.String()) + } + return stdout.String() +} + +// RunSSHCombined runs command in the guest and returns combined stdout+stderr +// regardless of exit status, for probes (ping, curl) where a non-zero exit is +// an expected outcome. +func RunSSHCombined(tgt SSHTarget, command string) (string, error) { + out, err := exec.Command("ssh", guestSSHArgs(tgt, 5, command)...).CombinedOutput() + return string(out), err +} + +// SSHReadyBudget bounds the first SSH-handshake pass. Baremetal boots q35 +// guests slower (OVMF + NBD disks), so the harness widens it via +// SPINIFEX_SSH_READY_TIMEOUT (Go duration); default 3m bounds shared runners. +var SSHReadyBudget = durationEnv("SPINIFEX_SSH_READY_TIMEOUT", 3*time.Minute) + +// sshReprimeBudget bounds the second SSH pass after an ARP re-prime; short +// because a helpful re-prime lands quickly. SPINIFEX_SSH_REPRIME_TIMEOUT. +var sshReprimeBudget = durationEnv("SPINIFEX_SSH_REPRIME_TIMEOUT", 60*time.Second) + +// wanBridge is the host bridge an EIP is presented on, i.e. the L2 segment the +// runner ARPs to reach a guest's public IP. Override via SPINIFEX_WAN_BRIDGE. +var wanBridge = stringEnv("SPINIFEX_WAN_BRIDGE", "br-wan") + +func durationEnv(key string, def time.Duration) time.Duration { + if v := os.Getenv(key); v != "" { + if d, err := time.ParseDuration(v); err == nil && d > 0 { + return d + } + } + return def +} + +func stringEnv(key, def string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return def +} + +// SSHHealth is a suite's sticky SSH-datapath verdict: the first handshake +// timeout marks it broken and later SSH-dependent tests skip rather than +// re-run the same multi-minute wait. Each suite keeps its own value. +type SSHHealth struct { + broken atomic.Bool +} + +// Require skips t if an earlier SSH probe in this suite has failed. +func (h *SSHHealth) Require(t *testing.T) { + t.Helper() + if h.broken.Load() { + t.Skipf("skipping: earlier SSH probe failed; " + + "not retrying to keep suite time bounded") + } +} + +// WaitReady waits for a full SSH handshake to host:port. TCP reachability is +// not enough: sshd accepts while pam/cloud-init finish, and the first real +// command then times out during banner exchange. +func (h *SSHHealth) WaitReady(t *testing.T, host string, port int, keyPath string) { + t.Helper() + h.Require(t) + addr := net.JoinHostPort(host, strconv.Itoa(port)) + Step(t, "waiting for SSH handshake %s", addr) + if h.TryReady(host, port, keyPath, SSHReadyBudget) { + return + } + // OVN emits no GARP on a same-chassis EIP rebind, so a decayed host neigh + // entry can blackhole host->guest SSH past the budget. Re-prime and retry + // once before declaring the datapath broken. + Step(t, "SSH handshake %s missed %s budget; re-priming ARP + retrying", addr, SSHReadyBudget) + reprimeSSHReachability(t, host) + if h.TryReady(host, port, keyPath, sshReprimeBudget) { + return + } + h.broken.Store(true) + t.Fatalf("Eventually: condition not met within %s (+%s after ARP re-prime): "+ + "[SSH handshake %s never completed] "+ + "(sticky-skip enabled for downstream)", SSHReadyBudget, sshReprimeBudget, addr) +} + +// TryReady is WaitReady without the re-prime or t.Fatal: it reports whether +// the handshake completed within budget and never marks the suite broken. +func (h *SSHHealth) TryReady(host string, port int, keyPath string, budget time.Duration) bool { + if h.broken.Load() { + return false + } + tgt := SSHTarget{User: "ubuntu", Host: host, Port: port, KeyPath: keyPath} + deadline := time.Now().Add(budget) + for { + if exec.Command("ssh", guestSSHArgs(tgt, 3, "true")...).Run() == nil { + return true + } + if time.Now().After(deadline) { + return false + } + time.Sleep(2 * time.Second) + } +} + +// reprimeSSHReachability best-effort flushes the host ARP entry for host on +// the WAN bridge so the next pass re-resolves it. Needs ip(8) and passwordless +// sudo; non-fatal so a missing tool never masks the real result. +func reprimeSSHReachability(t *testing.T, host string) { + t.Helper() + if _, err := exec.LookPath("ip"); err != nil { + Step(t, "skip ARP re-prime: ip(8) unavailable (%v)", err) + return + } + if err := exec.Command("sudo", "-n", "true").Run(); err != nil { + Step(t, "skip ARP re-prime: passwordless sudo unavailable (%v)", err) + return + } + Step(t, "flushing host neigh for %s dev %s", host, wanBridge) + out, err := exec.Command("sudo", "-n", "ip", "neigh", "flush", "to", host, "dev", wanBridge).CombinedOutput() + if err != nil { + Step(t, "ARP re-prime flush failed (best-effort): %v: %s", err, strings.TrimSpace(string(out))) + } +} + +// WaitForInstanceStateSoft is the cleanup-time analogue of +// WaitForInstanceState: it polls and returns an error instead of t.Fatal. +func WaitForInstanceStateSoft(c *AWSClient, id, target string, wait time.Duration) error { + deadline := time.Now().Add(wait) + for { + out, err := c.EC2.DescribeInstances(&ec2.DescribeInstancesInput{ + InstanceIds: []*string{aws.String(id)}, + }) + if err == nil && len(out.Reservations) > 0 && len(out.Reservations[0].Instances) > 0 { + if aws.StringValue(out.Reservations[0].Instances[0].State.Name) == target { + return nil + } + } + if time.Now().After(deadline) { + return fmt.Errorf("instance %s did not reach %s within %s", id, target, wait) + } + time.Sleep(2 * time.Second) + } +} + // sshClient is the production SSH transport, backed by // golang.org/x/crypto/ssh. One client is opened per (user, key, node) tuple // on first use and cached for the harness lifetime. diff --git a/tests/e2e/harness/system_account.go b/tests/e2e/harness/system_account.go index 9173de656..45b0f4ff6 100644 --- a/tests/e2e/harness/system_account.go +++ b/tests/e2e/harness/system_account.go @@ -35,7 +35,7 @@ func SystemAWSClient(t *testing.T, env *Env) *AWSClient { t.Fatalf("SystemAWSClient: no config dir located; set SPINIFEX_CONFIG_DIR") } path := filepath.Join(env.ConfigDir, systemCredentialsFile) - out, err := exec.Command("sudo", "-n", "cat", path).Output() //nolint:gosec // path is env-derived, not caller input + out, err := exec.Command("sudo", "-n", "cat", path).Output() if err != nil { t.Fatalf("SystemAWSClient: sudo -n cat %s: %v", path, err) } diff --git a/tests/e2e/iam/authorization_test.go b/tests/e2e/iam/authorization_test.go index 871ff8bd7..fc9a8edff 100644 --- a/tests/e2e/iam/authorization_test.go +++ b/tests/e2e/iam/authorization_test.go @@ -46,7 +46,7 @@ func runIAMAuthorization(t *testing.T, fix *Fixture) { createKeyPair(t, fix, principal, granted) - harness.ExpectError(t, "AccessDenied", func() error { + harness.ExpectError(t, "UnauthorizedOperation", func() error { _, err := principal.Client.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{KeyName: aws.String(sibling)}) // e2e:allow-create — the create is the authorization probe return err }) @@ -80,7 +80,7 @@ func runIAMAuthorization(t *testing.T, fix *Fixture) { // Same client, same key: the credential is a pointer into live IAM state, // not a capability minted when the key was issued. - harness.ExpectError(t, "AccessDenied", func() error { + harness.ExpectError(t, "UnauthorizedOperation", func() error { _, err := principal.Client.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{ // e2e:allow-create — the create is the authorization probe KeyName: aws.String(run + "-deny-2"), }) @@ -123,7 +123,7 @@ func runIAMAuthorization(t *testing.T, fix *Fixture) { _, err = fix.AWS.IAM.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: aws.String(grantARN)}) require.NoError(t, err, "delete-policy %s", grantARN) - harness.ExpectError(t, "AccessDenied", func() error { + harness.ExpectError(t, "UnauthorizedOperation", func() error { _, err := principal.Client.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{ // e2e:allow-create — the create is the authorization probe KeyName: aws.String(run + "-attached-2"), }) @@ -163,7 +163,7 @@ func runIAMAuthorization(t *testing.T, fix *Fixture) { require.NoError(t, err, "create-policy-version %s", policyARN) t.Cleanup(func() { deleteSecondPolicyVersionBestEffort(t, fix, policyARN) }) - harness.ExpectError(t, "AccessDenied", func() error { + harness.ExpectError(t, "UnauthorizedOperation", func() error { _, err := principal.Client.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{ // e2e:allow-create — the create is the authorization probe KeyName: aws.String(run + "-version-2"), }) @@ -190,7 +190,7 @@ func runIAMAuthorization(t *testing.T, fix *Fixture) { }) require.NoError(t, err, "delete-access-key %s", principal.KeyID) - // Not AccessDenied: a deleted key no longer authenticates, so the request + // Not UnauthorizedOperation: a deleted key no longer authenticates, so the request // never reaches policy evaluation. harness.ExpectError(t, "InvalidClientTokenId", func() error { _, err := principal.Client.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{ // e2e:allow-create — the create is the authorization probe @@ -242,7 +242,7 @@ func runIAMAuthorization(t *testing.T, fix *Fixture) { }) createKeyPair(t, fix, principal, run+"-user-1") - harness.ExpectError(t, "AccessDenied", func() error { + harness.ExpectError(t, "UnauthorizedOperation", func() error { _, err := principal.Client.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{ // e2e:allow-create — the create is the authorization probe KeyName: aws.String(run + "-role-1"), }) @@ -263,7 +263,7 @@ func runIAMAuthorization(t *testing.T, fix *Fixture) { createKeyPair(t, fix, &authzPrincipal{Client: session}, run+"-role-2") // The grant that carried the assuming user does not follow the session: // the role's policies are the whole of what it holds. - harness.ExpectError(t, "AccessDenied", func() error { + harness.ExpectError(t, "UnauthorizedOperation", func() error { _, err := session.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{ // e2e:allow-create — the create is the authorization probe KeyName: aws.String(run + "-user-2"), }) @@ -288,7 +288,7 @@ func runIAMAuthorization(t *testing.T, fix *Fixture) { Resource: []string{keyPairARN(account, run+"-ip-*")}, Condition: sourceIPCondition(authzForeignCIDR), }) - harness.ExpectError(t, "AccessDenied", func() error { + harness.ExpectError(t, "UnauthorizedOperation", func() error { _, err := elsewhere.Client.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{ // e2e:allow-create — the create is the authorization probe KeyName: aws.String(run + "-ip-2"), }) @@ -309,7 +309,7 @@ func runIAMAuthorization(t *testing.T, fix *Fixture) { Resource: []string{"*"}, Condition: sourceIPCondition(here), }) - harness.ExpectError(t, "AccessDenied", func() error { + harness.ExpectError(t, "UnauthorizedOperation", func() error { _, err := fenced.Client.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{ // e2e:allow-create — the create is the authorization probe KeyName: aws.String(run + "-ip-3"), }) @@ -382,7 +382,7 @@ func addAccessKey(t *testing.T, fix *Fixture, principal *authzPrincipal) *authzP client := harness.NewAWSClientWithCreds(t, fix.Env, keyID, aws.StringValue(out.AccessKey.SecretAccessKey)) harness.EventuallyErr(t, func() error { _, err := client.EC2.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{}) - if err == nil || harness.ErrorCodeIs(err, "AccessDenied") { + if err == nil || harness.ErrorCodeIs(err, "UnauthorizedOperation") { return nil } return fmt.Errorf("credentials for %s are not live yet: %w", principal.UserName, err) diff --git a/tests/e2e/iam/datapath_helpers_test.go b/tests/e2e/iam/datapath_helpers_test.go index 1ba317a08..14afec0ea 100644 --- a/tests/e2e/iam/datapath_helpers_test.go +++ b/tests/e2e/iam/datapath_helpers_test.go @@ -5,205 +5,18 @@ package iam import ( "bufio" "bytes" - "fmt" - "net" "os" - "os/exec" "path/filepath" - "strconv" "strings" - "sync/atomic" "testing" - "time" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ec2" "github.com/mulgadc/spinifex/tests/e2e/harness" ) -// SSH / datapath helpers ported from the single suite so the IMDS and -// instance-profile tests run self-contained inside iam. The single suite keeps -// its own copy — other single tests still use these. They are separate test -// binaries (separate processes), so the sticky-skip state below is per-suite. - -// sshDatapathBroken is set the first time an SSH probe in this suite times out. -// Downstream SSH-dependent assertions call requireSSHHealthy(t) and skip rather -// than re-running the same multi-minute timeout against the same broken datapath. -var sshDatapathBroken atomic.Bool - -// sshReadyBudget bounds the first SSH-handshake pass. Widen via -// SPINIFEX_SSH_READY_TIMEOUT (Go duration); default 3m bounds shared runners. -var sshReadyBudget = resolveDurationEnv("SPINIFEX_SSH_READY_TIMEOUT", 3*time.Minute) - -// sshReprimeBudget bounds the second SSH pass after an ARP re-prime. Short by -// design — if the re-prime helps, the handshake lands quickly. Override via -// SPINIFEX_SSH_REPRIME_TIMEOUT. -var sshReprimeBudget = resolveDurationEnv("SPINIFEX_SSH_REPRIME_TIMEOUT", 60*time.Second) - -// wanBridge is the host bridge an EIP is presented on — the L2 segment the -// runner ARPs to reach a guest's public IP. Default br-wan; override for -// non-standard pool fixtures via SPINIFEX_WAN_BRIDGE. -var wanBridge = resolveStringEnv("SPINIFEX_WAN_BRIDGE", "br-wan") - -func resolveDurationEnv(key string, def time.Duration) time.Duration { - if v := os.Getenv(key); v != "" { - if d, err := time.ParseDuration(v); err == nil && d > 0 { - return d - } - } - return def -} - -func resolveStringEnv(key, def string) string { - if v := strings.TrimSpace(os.Getenv(key)); v != "" { - return v - } - return def -} - -// requireSSHHealthy skips the calling test if a prior SSH probe in this suite -// has already failed, cutting the cascade cost of a broken datapath. -func requireSSHHealthy(t *testing.T) { - t.Helper() - if sshDatapathBroken.Load() { - t.Skipf("skipping: earlier SSH probe failed; " + - "not retrying to keep suite time bounded") - } -} - -// waitForSSHReady probes a full SSH handshake (BatchMode + ConnectTimeout) -// against host:port, retrying until the daemon completes banner exchange. -// TCP-port reachability alone is insufficient — sshd accepts the connect -// while pam/cloud-init are still finishing, and the first real runSSH then -// hits "Connection timed out during banner exchange". -func waitForSSHReady(t *testing.T, host string, port int, keyPath string) { - t.Helper() - requireSSHHealthy(t) - addr := net.JoinHostPort(host, strconv.Itoa(port)) - harness.Step(t, "waiting for SSH handshake %s", addr) - if trySSHReady(host, port, keyPath, sshReadyBudget) { - return - } - // First budget elapsed. OVN emits no GARP on a same-chassis EIP rebind, so a - // decayed host neigh entry for the EIP can blackhole host->guest SSH until it - // ages out — past our budget. Re-prime the ARP entry and retry once before - // declaring the datapath broken, riding out the transient learning gap. - harness.Step(t, "SSH handshake %s missed %s budget; re-priming ARP + retrying", addr, sshReadyBudget) - reprimeSSHReachability(t, host) - if trySSHReady(host, port, keyPath, sshReprimeBudget) { - return - } - sshDatapathBroken.Store(true) - t.Fatalf("Eventually: condition not met within %s (+%s after ARP re-prime): "+ - "[SSH handshake %s never completed] "+ - "(sticky-skip enabled for downstream)", sshReadyBudget, sshReprimeBudget, addr) -} - -// reprimeSSHReachability best-effort flushes the host ARP entry for the EIP on -// the WAN bridge, forcing a fresh broadcast re-resolution before the next SSH -// pass. Flush (not replace) is mode-agnostic: it needs no MAC and can't install -// a wrong centralised-mode entry. Gated on ip + passwordless sudo like the IMDS -// datapath dump; non-fatal so a missing tool never masks the real result. -func reprimeSSHReachability(t *testing.T, host string) { - t.Helper() - if _, err := exec.LookPath("ip"); err != nil { - harness.Step(t, "skip ARP re-prime: ip(8) unavailable (%v)", err) - return - } - if err := exec.Command("sudo", "-n", "true").Run(); err != nil { - harness.Step(t, "skip ARP re-prime: passwordless sudo unavailable (%v)", err) - return - } - harness.Step(t, "flushing host neigh for %s dev %s", host, wanBridge) - out, err := exec.Command("sudo", "-n", "ip", "neigh", "flush", "to", host, "dev", wanBridge).CombinedOutput() - if err != nil { - harness.Step(t, "ARP re-prime flush failed (best-effort): %v: %s", err, strings.TrimSpace(string(out))) - } -} - -// waitForSSHHandshake is an alias kept for the imds_test.go call site. -func waitForSSHHandshake(t *testing.T, host string, port int, keyPath string) { - waitForSSHReady(t, host, port, keyPath) -} - -// trySSHReady mirrors waitForSSHReady but returns reachability as a bool -// instead of t.Fatal-ing on timeout. -func trySSHReady(host string, port int, keyPath string, timeout time.Duration) bool { - if sshDatapathBroken.Load() { - return false - } - deadline := time.Now().Add(timeout) - for { - cmd := exec.Command("ssh", - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "LogLevel=ERROR", - "-o", "ConnectTimeout=3", - "-o", "BatchMode=yes", - "-p", strconv.Itoa(port), - "-i", keyPath, - "ubuntu@"+host, - "true") - if cmd.Run() == nil { - return true - } - if time.Now().After(deadline) { - return false - } - time.Sleep(2 * time.Second) - } -} - -// runSSH is a thin wrapper around `ssh` matching the option set used by -// harness.LsblkRootGiB. Returns stdout. t.Fatal on non-zero exit so callers -// can chain assertions on the output without nil-checking err. -func runSSH(t *testing.T, tgt harness.SSHTarget, command string) string { - t.Helper() - args := []string{ - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "LogLevel=ERROR", - "-o", "ConnectTimeout=5", - "-o", "BatchMode=yes", - "-p", strconv.Itoa(tgt.Port), - "-i", tgt.KeyPath, - tgt.User + "@" + tgt.Host, - command, - } - var stdout, stderr bytes.Buffer - cmd := exec.Command("ssh", args...) - cmd.Stdout = &stdout - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - t.Fatalf("ssh %s@%s:%d %q failed: %v\nstderr: %s", - tgt.User, tgt.Host, tgt.Port, command, err, stderr.String()) - } - return stdout.String() -} - -// runSSHCombined runs `command` over SSH against tgt and returns combined -// stdout+stderr regardless of exit status. Unlike runSSH it does not t.Fatal -// on non-zero exit — needed for probes (e.g. curl status codes) where a -// non-zero exit is an expected outcome. -func runSSHCombined(tgt harness.SSHTarget, command string) (string, error) { - args := []string{ - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "LogLevel=ERROR", - "-o", "ConnectTimeout=5", - "-o", "BatchMode=yes", - "-p", strconv.Itoa(tgt.Port), - "-i", tgt.KeyPath, - tgt.User + "@" + tgt.Host, - command, - } - var combined bytes.Buffer - cmd := exec.Command("ssh", args...) - cmd.Stdout = &combined - cmd.Stderr = &combined - err := cmd.Run() - return combined.String(), err -} +// sshHealth is this suite's sticky SSH-datapath verdict; see harness.SSHHealth. +var sshHealth harness.SSHHealth // primaryENI returns the NetworkInterfaceId of an instance's first ENI. // t.Fatal if the instance has no ENI — every running EC2 instance must. @@ -219,26 +32,6 @@ func primaryENI(t *testing.T, inst *ec2.Instance) string { return eni } -// waitForInstanceStateSoft is the cleanup-time analogue of -// harness.WaitForInstanceState — no t.Fatal, just polls and returns. -func waitForInstanceStateSoft(c *harness.AWSClient, id, target string, timeout time.Duration) error { - deadline := time.Now().Add(timeout) - for { - out, err := c.EC2.DescribeInstances(&ec2.DescribeInstancesInput{ - InstanceIds: []*string{aws.String(id)}, - }) - if err == nil && len(out.Reservations) > 0 && len(out.Reservations[0].Instances) > 0 { - if aws.StringValue(out.Reservations[0].Instances[0].State.Name) == target { - return nil - } - } - if time.Now().After(deadline) { - return fmt.Errorf("instance %s did not reach %s within %s", id, target, timeout) - } - time.Sleep(2 * time.Second) - } -} - // detectPoolMode reads external_mode from spinifex.toml. Defaults to false // (dev_networking) which is the single-node CI fixture. Any non-empty // external_mode value ("pool" / "nat") means external IPAM is in play, which diff --git a/tests/e2e/iam/imds_test.go b/tests/e2e/iam/imds_test.go index 9d35b5f84..dbbaa545b 100644 --- a/tests/e2e/iam/imds_test.go +++ b/tests/e2e/iam/imds_test.go @@ -104,7 +104,7 @@ type imdsCredDoc struct { func runIMDS(t *testing.T, fix *Fixture) { harness.Phase(t, "Single — IMDSv2 Host-Served Instance Metadata") harness.SkipIfNoOVN(t) - requireSSHHealthy(t) + sshHealth.Require(t) adminAccount := harness.IAMAccountID(t, fix.AWS) keyName, keyPath := needKeyPair(t, fix) @@ -228,7 +228,7 @@ func runIMDS(t *testing.T, fix *Fixture) { "public-hostname must be the AWS-shaped EC2 name for public-ipv4") harness.Step(t, "resolve IMDS public-hostname %s from the guest", gotHost) - resolved := runSSH(t, tgtX, "getent ahostsv4 "+gotHost) + resolved := harness.RunSSH(t, tgtX, "getent ahostsv4 "+gotHost) require.Containsf(t, strings.Fields(resolved), pubIP, "public-hostname %s did not resolve to public-ipv4 %s: %s", gotHost, pubIP, resolved) } else { @@ -420,7 +420,7 @@ func imdsProbe(t *testing.T, fix *Fixture, keyPath string, spec imdsVMSpec) (str _, _ = fix.AWS.EC2.TerminateInstances(&ec2.TerminateInstancesInput{ InstanceIds: []*string{aws.String(id)}, }) - _ = waitForInstanceStateSoft(fix.AWS, id, "terminated", 5*time.Minute) + _ = harness.WaitForInstanceStateSoft(fix.AWS, id, "terminated", 5*time.Minute) }) inst := harness.WaitForInstanceState(t, fix.AWS, id, "running") @@ -430,7 +430,7 @@ func imdsProbe(t *testing.T, fix *Fixture, keyPath string, spec imdsVMSpec) (str host, port := harness.InstancePublicSSHHost(t, inst) harness.Step(t, "wait for %s SSH at %s:%d", id, host, port) - waitForSSHHandshake(t, host, port, keyPath) + sshHealth.WaitReady(t, host, port, keyPath) return id, priv, eni, harness.SSHTarget{User: "ubuntu", Host: host, Port: port, KeyPath: keyPath} } @@ -540,34 +540,34 @@ func imdsAssertBootFromIMDS(t *testing.T, tgt harness.SSHTarget, privIP string) t.Helper() harness.Step(t, "boot-from-IMDS: cloud-init selected the aws (Ec2) datasource") ds := strings.ToLower(strings.TrimSpace( - runSSH(t, tgt, "cloud-id 2>/dev/null || cloud-init query --format '{{datasource}}'"))) + harness.RunSSH(t, tgt, "cloud-id 2>/dev/null || cloud-init query --format '{{datasource}}'"))) require.Truef(t, strings.Contains(ds, "aws") || strings.Contains(ds, "ec2"), "cloud-init must report the aws/Ec2 datasource (got %q) — not the retired NoCloud seed", ds) harness.Step(t, "boot-from-IMDS: no NoCloud cidata seed device attached") - labels := strings.ToLower(runSSH(t, tgt, "lsblk -no LABEL")) + labels := strings.ToLower(harness.RunSSH(t, tgt, "lsblk -no LABEL")) require.NotContainsf(t, labels, "cidata", "no cidata-labelled device may be attached — the seed ISO is retired (lsblk LABELs: %q)", labels) harness.Step(t, "boot-from-IMDS: login is the AMI stock default user %q", tgt.User) - require.Equal(t, tgt.User, strings.TrimSpace(runSSH(t, tgt, "id -un")), + require.Equal(t, tgt.User, strings.TrimSpace(harness.RunSSH(t, tgt, "id -un")), "in-guest login must be the AMI stock default user, not a Spinifex-forced account") harness.Step(t, "boot-from-IMDS: AWS-form hostname ip-") wantHost := "ip-" + strings.ReplaceAll(privIP, ".", "-") - gotHost := strings.TrimSpace(runSSH(t, tgt, "hostname")) + gotHost := strings.TrimSpace(harness.RunSSH(t, tgt, "hostname")) require.Truef(t, strings.HasPrefix(gotHost, wantHost), "hostname must be the AWS form %q rendered from IMDS local-hostname (got %q)", wantHost, gotHost) harness.Step(t, "boot-from-IMDS: primary NIC up with the VPC IP %s", privIP) - addrs := runSSH(t, tgt, "ip -4 -o addr show scope global") + addrs := harness.RunSSH(t, tgt, "ip -4 -o addr show scope global") require.Containsf(t, addrs, privIP, "the Ec2 datasource must render the primary NIC with the VPC IP %s from IMDS (ip addr: %q)", privIP, addrs) harness.Step(t, "boot-from-IMDS: user-data runcmd executed") deadline := time.Now().Add(90 * time.Second) for { - out, _ := runSSHCombined(tgt, "cat "+imdsUDDoneFile+" 2>/dev/null") + out, _ := harness.RunSSHCombined(tgt, "cat "+imdsUDDoneFile+" 2>/dev/null") if strings.Contains(out, imdsUDMarker) { break } @@ -777,7 +777,7 @@ func imdsAwaitToken(t *testing.T, fix *Fixture, tgt harness.SSHTarget, subnetID, for { cmd := fmt.Sprintf( `curl -sf -X PUT "%s/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 120"`, metaURL) - out, err := runSSHCombined(tgt, cmd) + out, err := harness.RunSSHCombined(tgt, cmd) switch { case err != nil: lastErr = fmt.Errorf("token PUT failed: %w (out=%q)", err, out) @@ -797,11 +797,11 @@ func imdsAwaitToken(t *testing.T, fix *Fixture, tgt harness.SSHTarget, subnetID, // imdsGet fetches a token-gated path from inside the guest and returns the // trimmed body. -sf turns any HTTP >= 400 into a non-zero exit, so a 4xx/5xx -// fails the test loudly with the server status visible via runSSH's stderr. +// fails the test loudly with the server status visible via harness.RunSSH's stderr. func imdsGet(t *testing.T, tgt harness.SSHTarget, token, path string) string { t.Helper() cmd := fmt.Sprintf(`curl -sf -H "X-aws-ec2-metadata-token: %s" %s%s`, token, metaURL, path) - return strings.TrimSpace(runSSH(t, tgt, cmd)) + return strings.TrimSpace(harness.RunSSH(t, tgt, cmd)) } // imdsExpectedPubKey derives the OpenSSH public key (type + base64) the IMDS @@ -826,7 +826,7 @@ func imdsExpectedPubKey(t *testing.T, pemPath string) (keyType, base64Key string // argument (empty for the tokenless probe). func imdsCode(tgt harness.SSHTarget, headerArg, path string) string { cmd := fmt.Sprintf(`curl -s -o /dev/null -w '%%{http_code}' %s %s%s`, headerArg, metaURL, path) - out, _ := runSSHCombined(tgt, cmd) + out, _ := harness.RunSSHCombined(tgt, cmd) return strings.TrimSpace(out) } diff --git a/tests/e2e/kvquorum/main_test.go b/tests/e2e/kvquorum/main_test.go new file mode 100644 index 000000000..5a534864d --- /dev/null +++ b/tests/e2e/kvquorum/main_test.go @@ -0,0 +1,123 @@ +//go:build e2e + +// Package kvquorum proves that the cluster's own state survives losing a node. +// +// Every control-plane service keeps its state in a JetStream KV bucket, and a +// bucket is replicated across however many nodes it was created with. At one +// replica it lives on one JetStream-chosen server recorded in no config, so +// losing that node makes the bucket unreadable and unwritable from every node +// at once. Leader leases are the worst case: nobody can acquire one whose +// bucket has no quorum, so every reconciler sharing it stops cluster-wide at +// exactly the moment a node has failed. +// +// It is its own suite because it takes a node away, so anything sharing the +// environment would report that outage as its own failure, and because the +// question it answers — "is our internal state actually replicated" — has to +// read as itself rather than as one more red subtest somewhere else. +package kvquorum + +import ( + "fmt" + "os" + "sync" + "testing" + "time" + + "github.com/mulgadc/spinifex/tests/e2e/harness" +) + +const ( + // How long a survivor is given to answer for every bucket after a node + // goes away. A raft group only has to elect a new leader for the streams + // the departed node led, which is seconds on a healthy cluster; the budget + // is generous because a failure here is the whole point of the suite and a + // flake would be read as one. + quorumRecoveryBudget = 2 * time.Minute + + // How long the returning node is given to rejoin and carry its share again. + rejoinBudget = 5 * time.Minute + + // How often a survivor is polled while quorum is expected to return. + pollInterval = 5 * time.Second +) + +var ( + pkgFixOnce sync.Once + pkgFix *Fixture + pkgFixErr error +) + +func TestMain(m *testing.M) { + code := m.Run() + if pkgFix != nil && pkgFix.Harness != nil { + if err := pkgFix.Harness.Close(); err != nil { + fmt.Fprintf(os.Stderr, "e2e teardown: %v\n", err) + code = 1 + } + } + os.Exit(code) +} + +// Fixture carries per-process state shared across this package's tests. +type Fixture struct { + Env *harness.Env + AWS *harness.AWSClient + Harness *harness.Fixture + Cluster *harness.Cluster +} + +// requireFixture returns the package-scoped Fixture, building it on first call. +// +// Three nodes is the floor. On two, a majority is two, so losing either one +// leaves no quorum for any stream and there is nothing to assert beyond "raft +// needs a majority" — which is true of NATS and not a property of ours. +func requireFixture(t *testing.T) *Fixture { + t.Helper() + pkgFixOnce.Do(func() { + if os.Getenv("SPINIFEX_E2E") == "" { + return + } + env := harness.LoadEnv(t) + cluster, err := harness.ClusterFromEnv() + if err != nil { + pkgFixErr = fmt.Errorf("this suite needs node SSH to take a node away: %w", err) + return + } + awsCli := harness.NewAWSClient(t, env) + h, err := harness.NewProcessFixture(awsCli) + if err != nil { + pkgFixErr = err + return + } + pkgFix = &Fixture{Env: env, AWS: awsCli, Harness: h, Cluster: cluster} + }) + if pkgFixErr != nil { + t.Fatalf("kvquorum fixture init: %v", pkgFixErr) + } + if pkgFix == nil { + t.Skip("SPINIFEX_E2E unset") + } + if n := len(pkgFix.Cluster.Nodes); n < 3 { + t.Skipf("quorum under node loss needs a surviving majority, have %d node(s)", n) + } + return pkgFix +} + +// TestKVBucketsAreReplicatedAcrossTheCluster is read-only and runs first, so a +// cluster that is already wrong is reported as wrong rather than as a failure +// to survive a node loss. +func TestKVBucketsAreReplicatedAcrossTheCluster(t *testing.T) { + runBucketsAreReplicated(t, requireFixture(t)) +} + +// TestKVReplicasCommandReportsHealthy proves the operator surface agrees with +// the cluster, on every node, and that its exit status can gate a deployment. +func TestKVReplicasCommandReportsHealthy(t *testing.T) { + runReplicasCommand(t, requireFixture(t)) +} + +// TestKVBucketsSurviveANodeLoss is the assertion the suite exists for, and the +// only one that produces the fault rather than inspecting for it. +func TestKVBucketsSurviveANodeLoss(t *testing.T) { + runBucketsSurviveANodeLoss(t, requireFixture(t)) +} diff --git a/tests/e2e/kvquorum/node_loss_test.go b/tests/e2e/kvquorum/node_loss_test.go new file mode 100644 index 000000000..0dcb42008 --- /dev/null +++ b/tests/e2e/kvquorum/node_loss_test.go @@ -0,0 +1,187 @@ +//go:build e2e + +package kvquorum + +import ( + "fmt" + "maps" + "slices" + "sort" + "strings" + "testing" + + "github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil" + natssvc "github.com/mulgadc/spinifex/spinifex/services/nats" + "github.com/mulgadc/spinifex/tests/e2e/harness" + "github.com/stretchr/testify/require" +) + +// runBucketsSurviveANodeLoss produces the fault rather than inspecting for it. +// +// The node taken away is the one leading the most buckets, which is the worst +// one to lose and the one a test that picked arbitrarily would usually miss. +// On a correctly replicated cluster the survivors elect new leaders and keep +// answering; on a cluster of single-replica buckets, every bucket that lived +// on that node answers "no responders" from every node at once, which is the +// outage this whole change exists to remove. +func runBucketsSurviveANodeLoss(t *testing.T, fix *Fixture) { + harness.Phase(t, "KV quorum — the cluster's state survives losing a node") + + nodes := len(fix.Cluster.Nodes) + want := harness.WantKVReplicas(nodes) + + harness.Step(t, "audit the cluster before anything is broken") + before := harness.KVReplication(t, fix.Env, nodes) + require.NotEmpty(t, before, "no KV buckets to lose") + harness.RequireKVQuorum(t, fix.Env, nodes) + + victim, led := busiestLeader(fix.Cluster.Nodes, before) + require.NotZerof(t, victim.Index, + "no bucket named a leader this test can map to a node; peers were %s", peerNames(before)) + harness.Detail(t, "victim", victim.Name, "buckets_led", led, "buckets_total", len(before)) + + survivors := fix.Cluster.Peers(victim) + + harness.Step(t, "take %s away", victim.Name) + // Registered before the stop, so a panic or a cancelled run still brings + // the node back rather than leaving the cluster a node short. + t.Cleanup(func() { + harness.StartNode(t, victim) + harness.WaitNodeServiceReady(t, victim, harness.WithTimeout(rejoinBudget)) + }) + harness.StopNode(t, victim) + + harness.Step(t, "every survivor must still answer for every bucket") + for _, node := range survivors { + requireNodeSeesEveryBucket(t, node, before, want) + } + + harness.Step(t, "bring %s back", victim.Name) + harness.StartNode(t, victim) + harness.WaitNodeServiceReady(t, victim, harness.WithTimeout(rejoinBudget)) + fix.Cluster.WaitNATSPeers(t, nodes-1, harness.WithTimeout(rejoinBudget)) + + harness.Step(t, "the returning node must carry its share again") + for _, node := range fix.Cluster.Nodes { + requireNodeSeesEveryBucket(t, node, before, want) + } + + // Configured replicas must be unchanged. A cluster that repaired itself by + // lowering a count would pass every assertion above and have quietly traded + // the durability this suite is about for a green run. + harness.Step(t, "no bucket lost replicas on the way through") + after := harness.KVReplication(t, fix.Env, nodes) + requireNoBucketLostReplicas(t, before, after) + harness.RequireKVQuorum(t, fix.Env, nodes) +} + +// requireNodeSeesEveryBucket polls a node until its own audit names every +// bucket the cluster had before the outage, with none under-replicated. +// +// Polling rather than asserting once: a raft group whose leader has gone has to +// elect a new one, and that is the interval this test is required to tolerate. +// What it is not required to tolerate is a bucket that never comes back. +func requireNodeSeesEveryBucket(t *testing.T, node harness.Node, before []kvutil.BucketReport, want int) { + t.Helper() + wantBuckets := bucketNames(before) + + harness.EventuallyErr(t, func() error { + reports, err := nodeReplicaReport(t, node) + if err != nil { + return fmt.Errorf("%s: %w", node.Name, err) + } + if missing := missingFrom(wantBuckets, bucketNames(reports)); len(missing) > 0 { + return fmt.Errorf("%s cannot read %d bucket(s): %s", + node.Name, len(missing), strings.Join(missing, ", ")) + } + if under := underReplicated(reports, want); len(under) > 0 { + return fmt.Errorf("%s reports %d under-replicated bucket(s): %s", + node.Name, len(under), strings.Join(under, ", ")) + } + return nil + }, quorumRecoveryBudget, pollInterval) + + harness.Detail(t, "node", node.Name, "buckets_readable", len(wantBuckets)) +} + +// requireNoBucketLostReplicas fails if any bucket came back with fewer replicas +// than it had, which is a silent downgrade rather than a repair. +func requireNoBucketLostReplicas(t *testing.T, before, after []kvutil.BucketReport) { + t.Helper() + was := make(map[string]int, len(before)) + for _, r := range before { + was[r.Bucket] = r.Replicas + } + for _, r := range after { + if prev, ok := was[r.Bucket]; ok && r.Replicas < prev { + t.Fatalf("KV bucket %s went from %d replicas to %d across the outage", r.Bucket, prev, r.Replicas) + } + } +} + +// busiestLeader returns the node leading the most buckets, and how many. +// Leadership is where the loss hurts most: a follower going away costs a raft +// group a peer, a leader going away costs it an election. +// +// The leader is read from the field that holds it rather than guessed at the head +// of the peer list. It is a NATS server name, which is the node name behind a +// prefix rather than the node name itself, so it goes through the helper that +// owns that prefix. +func busiestLeader(nodes []harness.Node, reports []kvutil.BucketReport) (harness.Node, int) { + led := make(map[string]int, len(nodes)) + for _, r := range reports { + if node, ok := natssvc.NodeFromServerName(r.Leader); ok { + led[node]++ + } + } + + var best harness.Node + bestN := -1 + for _, n := range nodes { + if c := led[n.Name]; c > bestN { + best, bestN = n, c + } + } + if bestN <= 0 { + return harness.Node{}, 0 + } + return best, bestN +} + +// bucketNames returns the sorted bucket names in a report set. +func bucketNames(reports []kvutil.BucketReport) []string { + out := make([]string, 0, len(reports)) + for _, r := range reports { + out = append(out, r.Bucket) + } + sort.Strings(out) + return out +} + +// missingFrom returns every name in want that is absent from got. +func missingFrom(want, got []string) []string { + have := make(map[string]struct{}, len(got)) + for _, g := range got { + have[g] = struct{}{} + } + var out []string + for _, w := range want { + if _, ok := have[w]; !ok { + out = append(out, w) + } + } + return out +} + +// peerNames is diagnostic only: it says what the audit did report when no +// leader could be mapped to a node, which is otherwise an opaque failure. +func peerNames(reports []kvutil.BucketReport) string { + seen := map[string]struct{}{} + for _, r := range reports { + for _, p := range r.Peers { + seen[p] = struct{}{} + } + } + out := slices.Sorted(maps.Keys(seen)) + return strings.Join(out, ",") +} diff --git a/tests/e2e/kvquorum/replication_test.go b/tests/e2e/kvquorum/replication_test.go new file mode 100644 index 000000000..b0a82ce2a --- /dev/null +++ b/tests/e2e/kvquorum/replication_test.go @@ -0,0 +1,148 @@ +//go:build e2e + +package kvquorum + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "testing" + "time" + + "github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil" + "github.com/mulgadc/spinifex/tests/e2e/harness" + "github.com/stretchr/testify/require" +) + +// replicasCmd is the operator surface under test. No --config: on a node the +// command finds /etc/spinifex/spinifex.toml itself, which is how anyone +// gating a deployment on it would actually run it. +const replicasCmd = "sudo spx admin kv replicas --json" + +// runBucketsAreReplicated sweeps every KV bucket in the cluster rather than a +// named list. A replica count is set where a bucket is created, and there is a +// bucket-creating call in most services, so a named list only ever covers the +// ones somebody remembered. +func runBucketsAreReplicated(t *testing.T, fix *Fixture) { + harness.Phase(t, "KV quorum — every bucket is replicated across the cluster") + + nodes := len(fix.Cluster.Nodes) + want := harness.WantKVReplicas(nodes) + harness.Detail(t, "cluster_nodes", nodes, "want_replicas", want) + + harness.Step(t, "audit every KV bucket") + reports := harness.KVReplication(t, fix.Env, nodes) + require.NotEmpty(t, reports, "the cluster reports no KV buckets at all, so nothing was checked") + + for _, r := range reports { + harness.Detail(t, "bucket", r.Bucket, "replicas", r.Replicas, "held_by", strings.Join(r.Peers, ",")) + } + harness.RequireKVQuorum(t, fix.Env, nodes) + + // The leader-lease buckets are named as well as swept. They were the worst + // case of the defect this suite guards — created with no replica count at + // all — and a regression that reached only them would otherwise be one more + // line in a long list rather than the headline. + harness.Step(t, "confirm the leader-lease buckets are among those checked") + leases := leaseBuckets(reports) + require.NotEmpty(t, leases, + "no leader-lease bucket was found; either the cluster has never run a reconciler or the audit missed them") + harness.Detail(t, "lease_buckets", strings.Join(leases, ",")) +} + +// leaseBuckets returns the names of the reconciler leader-lease buckets, which +// are the ones a reconciler must acquire before it can do anything at all. +func leaseBuckets(reports []kvutil.BucketReport) []string { + var out []string + for _, r := range reports { + if strings.HasSuffix(r.Bucket, "-leader") || strings.HasSuffix(r.Bucket, "-reconcile") { + out = append(out, r.Bucket) + } + } + sort.Strings(out) + return out +} + +// runReplicasCommand proves the operator surface on every node, not one. The +// command reads cluster-global raft metadata, so every node must give the same +// answer — a node that disagrees is reporting on a cluster it is not fully part +// of, which is worth knowing before anything is gated on its exit status. +func runReplicasCommand(t *testing.T, fix *Fixture) { + harness.Phase(t, "KV quorum — spx admin kv replicas agrees on every node") + + want := harness.WantKVReplicas(len(fix.Cluster.Nodes)) + + counts := make(map[string]int, len(fix.Cluster.Nodes)) + for _, node := range fix.Cluster.Nodes { + harness.Step(t, "run the replica audit on %s", node.Name) + reports, err := nodeReplicaReport(t, node) + require.NoErrorf(t, err, + "%s could not audit its own KV buckets, so nothing can be gated on its exit status", node.Name) + + under := underReplicated(reports, want) + harness.Detail(t, "node", node.Name, "buckets", len(reports), "under_replicated", len(under)) + require.Emptyf(t, under, + "%s reports %d under-replicated bucket(s): %s", node.Name, len(under), strings.Join(under, ", ")) + counts[node.Name] = len(reports) + } + + harness.Step(t, "confirm every node sees the same set of buckets") + var first string + for _, node := range fix.Cluster.Nodes { + if first == "" { + first = node.Name + continue + } + require.Equalf(t, counts[first], counts[node.Name], + "%s sees %d KV buckets and %s sees %d; the two are not looking at the same cluster", + first, counts[first], node.Name, counts[node.Name]) + } +} + +// nodeReplicaReport runs the replica audit on one node and decodes it. A +// non-zero exit is returned as an error rather than fatalled, because callers +// polling a recovering cluster need to retry it. +func nodeReplicaReport(t *testing.T, node harness.Node) ([]kvutil.BucketReport, error) { + t.Helper() + ssh := harness.NewPeerSSH() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + out, err := ssh.Run(ctx, node.Addr, replicasCmd) + if err != nil { + // The command exits 1 when a bucket is under-replicated, and it still + // prints its report, so the output is decoded either way — a report + // naming the bucket is a better failure than "exit status 1". + if len(out) == 0 { + return nil, fmt.Errorf("%s: %w", node.Name, err) + } + } + var reports []kvutil.BucketReport + if jsonErr := json.Unmarshal(trimToJSON(out), &reports); jsonErr != nil { + return nil, fmt.Errorf("%s: decode replica report (%q): %w", node.Name, string(out), jsonErr) + } + return reports, nil +} + +// trimToJSON drops anything before the JSON document, since sudo and the login +// shell can both write to the same stream. +func trimToJSON(out []byte) []byte { + if i := strings.IndexAny(string(out), "[{"); i > 0 { + return out[i:] + } + return out +} + +// underReplicated names every bucket below want replicas. +func underReplicated(reports []kvutil.BucketReport, want int) []string { + var out []string + for _, r := range reports { + if r.Replicas < want { + out = append(out, fmt.Sprintf("%s(%d/%d)", r.Bucket, r.Replicas, want)) + } + } + sort.Strings(out) + return out +} diff --git a/tests/e2e/manifest-lint/lint.go b/tests/e2e/manifest-lint/lint.go index 1946947e9..ed2c1218a 100644 --- a/tests/e2e/manifest-lint/lint.go +++ b/tests/e2e/manifest-lint/lint.go @@ -21,7 +21,9 @@ package manifestlint import ( "bufio" + "maps" "os" + "slices" "sort" "strings" ) @@ -75,11 +77,7 @@ func WriteBaseline(path string, keys []string) error { for _, k := range keys { uniq[k] = true } - out := make([]string, 0, len(uniq)) - for k := range uniq { - out = append(out, k) - } - sort.Strings(out) + out := slices.Sorted(maps.Keys(uniq)) var b strings.Builder b.WriteString("# manifest-lint baseline — pre-existing drift accepted by the ratchet.\n") diff --git a/tests/e2e/manifest-lint/subjects.go b/tests/e2e/manifest-lint/subjects.go index d6479e950..4e445a08d 100644 --- a/tests/e2e/manifest-lint/subjects.go +++ b/tests/e2e/manifest-lint/subjects.go @@ -5,9 +5,11 @@ import ( "go/ast" "go/parser" "go/token" + "maps" "os" "path/filepath" "regexp" + "slices" "sort" "strconv" "strings" @@ -137,12 +139,7 @@ func serviceDirs(m *manifestcheck.Manifest) []string { set[p] = true } } - out := make([]string, 0, len(set)) - for d := range set { - out = append(out, d) - } - sort.Strings(out) - return out + return slices.Sorted(maps.Keys(set)) } // scanSubjectsInDir walks dir recursively, parsing every non-test .go file and diff --git a/tests/e2e/multinode/jetstream_replicas_test.go b/tests/e2e/multinode/jetstream_replicas_test.go index 48573dece..e91977fe5 100644 --- a/tests/e2e/multinode/jetstream_replicas_test.go +++ b/tests/e2e/multinode/jetstream_replicas_test.go @@ -11,34 +11,49 @@ import ( "github.com/stretchr/testify/require" ) -// jetStreamReplicaBuckets are the daemon-owned KV buckets created at replicas=1 -// during initJetStream and bumped to the cluster size by upgradeJetStreamReplicas. -// A regression there leaves them stuck at 1, silently losing EC2/cluster state -// on a single node loss — exactly what this guards (the upgrade path is 0% unit). +// jetStreamReplicaBuckets are the daemon-owned KV buckets. They are named +// explicitly as well as swept, so a run that somehow enumerates nothing still +// fails rather than passing on an empty set. var jetStreamReplicaBuckets = []string{ daemon.InstanceStateBucket, daemon.ClusterStateBucket, daemon.TerminatedInstanceBucket, } -// runJetStreamReplicas asserts every daemon KV bucket's configured replica factor -// matches the cluster node count, proving upgradeJetStreamReplicas ran end-to-end -// (the assembled NATS path no unit test reaches). Read-only over NATS. +// runJetStreamReplicas asserts that every KV bucket in the cluster is +// replicated across every node, not just the three the daemon owns. +// +// The sweep is the assertion that matters. A replica count is set where a +// bucket is created, and there is a bucket-creating call in most services, so +// naming buckets here would only ever cover the ones somebody remembered. A +// bucket below the node count lives on fewer nodes than depend on it: losing +// one of them makes it unreadable from every node at once, which is how a +// single-node event becomes a cluster-wide outage. func runJetStreamReplicas(t *testing.T, fix *Fixture) { harness.Phase(t, "Multinode — JetStream KV Replica Factor") - want := len(fix.Cluster.Nodes) - if want < 2 { - t.Skipf("cluster has %d node(s); replica upgrade is a no-op below 2", want) + nodes := len(fix.Cluster.Nodes) + if nodes < 2 { + t.Skipf("cluster has %d node(s); replication across nodes is a no-op below 2", nodes) } - harness.Detail(t, "cluster_nodes", want, "buckets", strings.Join(jetStreamReplicaBuckets, ",")) + want := harness.WantKVReplicas(nodes) + harness.Detail(t, "cluster_nodes", nodes, "want_replicas", want) - harness.Step(t, "introspect KV stream replica factors") + harness.Step(t, "introspect the daemon's own KV stream replica factors") got := harness.KVReplicaFactors(t, fix.Env, jetStreamReplicaBuckets...) for _, b := range jetStreamReplicaBuckets { harness.Detail(t, "bucket", b, "replicas", got[b]) require.Equalf(t, want, got[b], - "KV bucket %s replicas=%d want=%d — upgradeJetStreamReplicas did not match cluster size; a single node loss would lose this bucket", + "KV bucket %s replicas=%d want=%d — a single node loss would take this bucket down cluster-wide", b, got[b], want) } + + harness.Step(t, "sweep every KV bucket in the cluster") + reports := harness.KVReplication(t, fix.Env, nodes) + names := make([]string, 0, len(reports)) + for _, r := range reports { + names = append(names, r.Bucket) + } + harness.Detail(t, "buckets", len(reports), "names", strings.Join(names, ",")) + harness.RequireKVQuorum(t, fix.Env, nodes) } diff --git a/tests/e2e/single/createimage_test.go b/tests/e2e/single/createimage_test.go index 29de465ab..69c0a2332 100644 --- a/tests/e2e/single/createimage_test.go +++ b/tests/e2e/single/createimage_test.go @@ -145,10 +145,10 @@ func runBootVerifyAMI(t *testing.T, fix *Fixture, amiID, label string) { probeInst := harness.WaitForInstanceState(t, fix.AWS, probeInstanceID, "running") host, port := harness.InstancePublicSSHHost(t, probeInst) - waitForSSHReady(t, host, port, keyPath) + sshHealth.WaitReady(t, host, port, keyPath) tgt := harness.SSHTarget{User: "ubuntu", Host: host, Port: port, KeyPath: keyPath} - idOut := runSSH(t, tgt, "id") + idOut := harness.RunSSH(t, tgt, "id") assert.Containsf(t, idOut, "ubuntu", "boot-verify %s: ssh id should report ubuntu\n%s", label, idOut) harness.Detail(t, "boot_verify_"+label, "ok") diff --git a/tests/e2e/single/datapath_test.go b/tests/e2e/single/datapath_test.go index 63d049c92..b8dab3f72 100644 --- a/tests/e2e/single/datapath_test.go +++ b/tests/e2e/single/datapath_test.go @@ -105,7 +105,7 @@ func sgDatapathRevokeRounds() int { func runSGPolicyDatapath(t *testing.T, fix *Fixture) { harness.Phase(t, "Single — Security Group Policy on a Real Datapath (OVN)") harness.SkipIfNoOVN(t) - requireSSHHealthy(t) + sshHealth.Require(t) // Bootstrap every prereq up front. runSGEInstance / primaryENI use // `fix.AMIID / InstanceType / KeyName / KeyPath` indirectly; resolve @@ -257,7 +257,7 @@ func runSGPolicyDatapath(t *testing.T, fix *Fixture) { // Non-fatal probe so a timeout dumps the guest console + OVN/datapath // state before Fatal. A full 2min unreachable window on a fresh public-IP // VM is the flake signature; capture it from CI artifacts alone. - if !trySSHReady(clientHost, clientPort, keyPath, 2*time.Minute) { + if !sshHealth.TryReady(clientHost, clientPort, keyPath, 2*time.Minute) { harness.DumpVPCFlowDiagnostics(t, fix.AWS, clientID, fmt.Sprintf("8e-3 client-vm SSH timeout — vpc=%s sg=%s pub=%s", def.VPCID, clientSG, clientHost), harness.VPCDiagnosticsOpts{ @@ -326,7 +326,7 @@ func runSGPolicyDatapath(t *testing.T, fix *Fixture) { // so the only variable left is ovn-controller installing the // chassis flows. Bash used 30 attempts at 2s; keep that budget. harness.EventuallyErr(t, func() error { - out, err := runSSHCombined(clientTgt, curlCmd) + out, err := harness.RunSSHCombined(clientTgt, curlCmd) if err != nil { return fmt.Errorf("client -> target:8080 failed: %w (out=%q)", err, out) } @@ -343,7 +343,7 @@ func runSGPolicyDatapath(t *testing.T, fix *Fixture) { // merits even if the 8080 path above failed. t.Run("DeniedTraffic", func(t *testing.T) { harness.Step(t, "8e-5 denied traffic client -> target:22 (no SSH ingress)") - if _, err := runSSHCombined(clientTgt, fmt.Sprintf("nc -z -w 5 %s 22", targetPriv)); err == nil { + if _, err := harness.RunSSHCombined(clientTgt, fmt.Sprintf("nc -z -w 5 %s 22", targetPriv)); err == nil { t.Fatalf("FAIL: client reached target:22 — default-deny ACL not enforced") } harness.Detail(t, "step5", "denied_traffic_ok") @@ -449,7 +449,7 @@ func runSGPolicyDatapath(t *testing.T, fix *Fixture) { derived, clientLL, iface) targetLL := linkLocalFromMAC(eniMAC(t, fix, targetENI)).String() - out, err := runSSHCombined(clientTgt, fmt.Sprintf("ping -6 -c 3 -W 3 %s%%%s", targetLL, iface)) + out, err := harness.RunSSHCombined(clientTgt, fmt.Sprintf("ping -6 -c 3 -W 3 %s%%%s", targetLL, iface)) require.Errorf(t, err, "FAIL: client reached target over IPv6 at %s\n%s", targetLL, out) // A non-zero exit alone also covers a broken ssh session, a missing @@ -498,7 +498,7 @@ func runSGPolicyDatapath(t *testing.T, fix *Fixture) { // Fresh TCP connection — conntrack does not affect new // connections. Bash treats a single curl success as failure. harness.Step(t, "8e-6 verify client -> target:8080 now blocked") - if _, err := runSSHCombined(clientTgt, curlCmd); err == nil { + if _, err := harness.RunSSHCombined(clientTgt, curlCmd); err == nil { t.Fatalf("FAIL: client still reached target:8080 after revoke — propagation not immediate") } harness.Detail(t, "step6", "revoke_blocked_ok") @@ -529,7 +529,7 @@ func runSGPolicyDatapath(t *testing.T, fix *Fixture) { require.NoError(t, err, "re-authorize tcp/8080 from %s", clientSG) harness.EventuallyErr(t, func() error { - out, err := runSSHCombined(clientTgt, curlCmd) + out, err := harness.RunSSHCombined(clientTgt, curlCmd) if err != nil { return fmt.Errorf("client -> target:8080 still blocked after re-add: %w (out=%q)", err, out) } @@ -706,12 +706,12 @@ func linkLocalFromMAC(mac net.HardwareAddr) net.IP { // link-local address configured on it. func guestLinkLocal(t *testing.T, tgt harness.SSHTarget) (string, string) { t.Helper() - iface, err := runSSHCombined(tgt, "ip -o -4 route show default | awk '{print $5; exit}'") + iface, err := harness.RunSSHCombined(tgt, "ip -o -4 route show default | awk '{print $5; exit}'") require.NoError(t, err, "guest could not report its default-route interface") iface = strings.TrimSpace(iface) require.NotEmpty(t, iface, "guest has no default-route interface") - ll, err := runSSHCombined(tgt, + ll, err := harness.RunSSHCombined(tgt, fmt.Sprintf("ip -6 -o addr show dev %s scope link | awk '{print $4; exit}' | cut -d/ -f1", iface)) require.NoError(t, err, "guest could not report its link-local address") ll = strings.TrimSpace(ll) diff --git a/tests/e2e/single/guestchurn_test.go b/tests/e2e/single/guestchurn_test.go index ae2a62647..ee3da6d92 100644 --- a/tests/e2e/single/guestchurn_test.go +++ b/tests/e2e/single/guestchurn_test.go @@ -4,8 +4,10 @@ package single import ( "fmt" + "maps" "net" "os/exec" + "slices" "strconv" "strings" "testing" @@ -118,13 +120,13 @@ func runGuestChurnDurability(t *testing.T, fix *Fixture) { addr := net.JoinHostPort(host, strconv.Itoa(port)) harness.Step(t, "waiting for SSH handshake %s", addr) - waitForSSHHandshake(t, host, port, keyPath) + sshHealth.WaitReady(t, host, port, keyPath) _ = addr tgt := harness.SSHTarget{User: "ubuntu", Host: host, Port: port, KeyPath: keyPath} harness.Step(t, "ssh id") - idOut := runSSH(t, tgt, "id") + idOut := harness.RunSSH(t, tgt, "id") assert.Containsf(t, idOut, "ubuntu", "ssh id should report ubuntu\n%s", idOut) harness.Step(t, "lsblk root-volume cross-check vs API") @@ -149,7 +151,7 @@ func runGuestChurnDurability(t *testing.T, fix *Fixture) { harness.Detail(t, "guest_gib", guestGiB, "api_gib", apiGiB) harness.Step(t, "ssh hostname") - hn := strings.TrimSpace(runSSH(t, tgt, "hostname")) + hn := strings.TrimSpace(harness.RunSSH(t, tgt, "hostname")) // Bash uses `spinifex-vm-` and treats // a missing prefix as a non-fatal warning. Replicate the soft check via // t.Logf rather than asserting — the spx hostname format is not part of @@ -249,7 +251,7 @@ func runGuestChurnRound(t *testing.T, fix *Fixture, instanceID, origType, keyPat t.Run("HotplugENI", func(t *testing.T) { harness.SkipIfNoOVN(t) - requireSSHHealthy(t) + sshHealth.Require(t) tgt := resolveGuestSSHTarget(t, fix, instanceID, keyPath) def := harness.EnsureDefaultVPC(t, fix.Harness) @@ -473,11 +475,11 @@ func runGuestChurnRound(t *testing.T, fix *Fixture, instanceID, origType, keyPat host, port := harness.InstancePublicSSHHost(t, runInst) harness.Detail(t, "ssh_host", host, "ssh_port", port) - waitForSSHReady(t, host, port, keyPath) + sshHealth.WaitReady(t, host, port, keyPath) tgt := harness.SSHTarget{User: "ubuntu", Host: host, Port: port, KeyPath: keyPath} harness.Step(t, "ssh nproc") - nprocOut := strings.TrimSpace(runSSH(t, tgt, "nproc")) + nprocOut := strings.TrimSpace(harness.RunSSH(t, tgt, "nproc")) vmVCPUs, err := strconv.ParseInt(nprocOut, 10, 64) require.NoErrorf(t, err, "parse nproc output %q", nprocOut) require.Equalf(t, expectedVCPUs, vmVCPUs, @@ -485,7 +487,7 @@ func runGuestChurnRound(t *testing.T, fix *Fixture, instanceID, origType, keyPat harness.Detail(t, "vm_vcpus", vmVCPUs) harness.Step(t, "ssh MemTotal") - memOut := strings.TrimSpace(runSSH(t, tgt, "awk '/MemTotal/ {print $2}' /proc/meminfo")) + memOut := strings.TrimSpace(harness.RunSSH(t, tgt, "awk '/MemTotal/ {print $2}' /proc/meminfo")) vmMemKB, err := strconv.ParseInt(memOut, 10, 64) require.NoErrorf(t, err, "parse MemTotal output %q", memOut) vmMemMiB := vmMemKB / 1024 @@ -520,7 +522,7 @@ func runGuestChurnRound(t *testing.T, fix *Fixture, instanceID, origType, keyPat runInst := harness.WaitForInstanceState(t, fix.AWS, instanceID, "running") host, port := harness.InstancePublicSSHHost(t, runInst) - waitForSSHReady(t, host, port, keyPath) + sshHealth.WaitReady(t, host, port, keyPath) tgt := harness.SSHTarget{User: "ubuntu", Host: host, Port: port, KeyPath: keyPath} gotSha := harness.GuestReadSentinelSha(t, tgt, "/dev/disk/by-label/"+guestChurnSentinelLabel, guestChurnSentinelLabel) require.Equalf(t, wantSha, gotSha, "sha256 mismatch after stop/start") @@ -530,12 +532,12 @@ func runGuestChurnRound(t *testing.T, fix *Fixture, instanceID, origType, keyPat t.Run("Reboot", func(t *testing.T) { inst := describeSingletonInstance(t, fix, instanceID) host, port := harness.InstancePublicSSHHost(t, inst) - waitForSSHReady(t, host, port, keyPath) + sshHealth.WaitReady(t, host, port, keyPath) // The boot ID is what proves a reboot happened. Uptime cannot on its // own: a reboot is asynchronous, so the guest answering shortly after // the call may still be the one that has not shut down yet. - preBootID := strings.TrimSpace(runSSH(t, + preBootID := strings.TrimSpace(harness.RunSSH(t, harness.SSHTarget{User: "ubuntu", Host: host, Port: port, KeyPath: keyPath}, "cat /proc/sys/kernel/random/boot_id")) require.NotEmpty(t, preBootID, "guest returned an empty boot ID before the reboot") @@ -579,7 +581,7 @@ func runGuestChurnRound(t *testing.T, fix *Fixture, instanceID, origType, keyPat require.NotEmpty(t, descPost.Reservations[0].Instances, "no instances post-reboot") postInst := descPost.Reservations[0].Instances[0] host, port = harness.InstancePublicSSHHost(t, postInst) - waitForSSHReady(t, host, port, keyPath) + sshHealth.WaitReady(t, host, port, keyPath) tgt := harness.SSHTarget{User: "ubuntu", Host: host, Port: port, KeyPath: keyPath} waitForNewBootID(t, tgt, preBootID) @@ -587,7 +589,7 @@ func runGuestChurnRound(t *testing.T, fix *Fixture, instanceID, origType, keyPat // Now that the guest is known to be on a new boot, uptime is a second // reading of the same fact rather than a guess at it. harness.Step(t, "ssh uptime") - uptimeOut := strings.TrimSpace(runSSH(t, tgt, "cat /proc/uptime | cut -d. -f1")) + uptimeOut := strings.TrimSpace(harness.RunSSH(t, tgt, "cat /proc/uptime | cut -d. -f1")) uptimeSecs, err := strconv.ParseInt(uptimeOut, 10, 64) require.NoErrorf(t, err, "parse uptime output %q", uptimeOut) require.LessOrEqualf(t, uptimeSecs, int64(120), @@ -665,7 +667,7 @@ func resolveGuestSSHTarget(t *testing.T, fix *Fixture, instanceID, keyPath strin t.Helper() inst := describeSingletonInstance(t, fix, instanceID) host, port := harness.InstancePublicSSHHost(t, inst) - waitForSSHReady(t, host, port, keyPath) + sshHealth.WaitReady(t, host, port, keyPath) return harness.SSHTarget{User: "ubuntu", Host: host, Port: port, KeyPath: keyPath} } @@ -692,11 +694,7 @@ func guestMACSet(t *testing.T, tgt harness.SSHTarget) map[string]struct{} { // setKeys returns the keys of a string set as a slice for log/Detail output. func setKeys(m map[string]struct{}) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - return keys + return slices.Collect(maps.Keys(m)) } // describeENI returns the single ENI record for eniID, erroring if absent. diff --git a/tests/e2e/single/imagecatalog_test.go b/tests/e2e/single/imagecatalog_test.go index 2bb2f36c3..2ee348a16 100644 --- a/tests/e2e/single/imagecatalog_test.go +++ b/tests/e2e/single/imagecatalog_test.go @@ -34,6 +34,10 @@ const catalogHeadTimeout = 30 * time.Second // purpose. It is the check most likely to catch a problem that is not ours, // and the sooner it is seen the smaller the window in which a released binary // points at a dead URL. +// +// utils.WithheldImages is deliberately not walked: those are the entries whose +// artifact is known to be unpublished, so HEADing them would assert a failure +// we already know about and hide the ones we do not. func runImageCatalogReachable(t *testing.T, _ *Fixture) { harness.Phase(t, "Single — Image catalog URLs still answer") diff --git a/tests/e2e/single/instance_test.go b/tests/e2e/single/instance_test.go index 1859fc8ae..053a9e7ae 100644 --- a/tests/e2e/single/instance_test.go +++ b/tests/e2e/single/instance_test.go @@ -3,9 +3,6 @@ package single import ( - "bytes" - "os/exec" - "strconv" "testing" "github.com/aws/aws-sdk-go/aws" @@ -85,30 +82,3 @@ func runConsoleOutput(t *testing.T, fix *Fixture) { "has_output", out.Output != nil && aws.StringValue(out.Output) != "", ) } - -// runSSH is a thin wrapper around `ssh` matching the option set used by -// harness.LsblkRootGiB. Returns stdout. t.Fatal on non-zero exit so callers -// can chain assertions on the output without nil-checking err. -func runSSH(t *testing.T, tgt harness.SSHTarget, command string) string { - t.Helper() - args := []string{ - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "LogLevel=ERROR", - "-o", "ConnectTimeout=5", - "-o", "BatchMode=yes", - "-p", strconv.Itoa(tgt.Port), - "-i", tgt.KeyPath, - tgt.User + "@" + tgt.Host, - command, - } - var stdout, stderr bytes.Buffer - cmd := exec.Command("ssh", args...) - cmd.Stdout = &stdout - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - t.Fatalf("ssh %s@%s:%d %q failed: %v\nstderr: %s", - tgt.User, tgt.Host, tgt.Port, command, err, stderr.String()) - } - return stdout.String() -} diff --git a/tests/e2e/single/kvreplication_test.go b/tests/e2e/single/kvreplication_test.go new file mode 100644 index 000000000..0335b74b1 --- /dev/null +++ b/tests/e2e/single/kvreplication_test.go @@ -0,0 +1,118 @@ +//go:build e2e + +package single + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + "testing" + "time" + + "github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil" + "github.com/mulgadc/spinifex/tests/e2e/harness" + "github.com/stretchr/testify/require" +) + +// runKVReplication proves what a single-node cluster's own state should look +// like, which is not the multi-node assertion with a smaller number in it. +// +// On one node R1 is the correct answer rather than the defect, so the thing worth +// proving is the opposite: that nothing asked for a replica count this deployment +// cannot place, that the operator surface says so plainly, and that repairing a +// cluster with nothing to repair changes nothing. The multi-node suites cannot +// cover any of that, because on three nodes the unplaceable count is the right +// one. +func runKVReplication(t *testing.T, fix *Fixture) { + harness.Phase(t, "Single — the cluster's own state on one node") + + harness.Step(t, "every bucket is on the one replica a single node can hold") + harness.RequireKVSingleNode(t, fix.Env) + + harness.Step(t, "the operator surface reports a healthy single node") + before, err := localReplicaReport(t) + require.NoError(t, err, + "spx admin kv replicas failed on a healthy single node, so nothing can be gated on it") + require.NotEmpty(t, before, "the command reported no buckets at all, so nothing was checked") + harness.Detail(t, "buckets", len(before), "want_replicas", before[0].Want) + + // Exit 0 is the contract a deployment gate reads. On one node every bucket is + // at the count it should be and serving, so anything else is a bug in the + // audit rather than in the cluster. + require.Equal(t, 1, before[0].Want, + "a single-node cluster wants one replica per bucket, not %d", before[0].Want) + for _, r := range before { + require.Truef(t, r.HasQuorum(), + "%s reports %d of %d replicas online on a single node", r.Bucket, r.Online, r.Replicas) + } + + // Twice, because --repair is documented as safe to run on a healthy cluster + // and safe to run twice. A repair that rewrote a stream would show up as a + // changed replica count or a bucket that stopped serving. + harness.Step(t, "--repair is idempotent and changes nothing it should not") + for i := 1; i <= 2; i++ { + out, repairErr := runLocal(t, "sudo -n spx admin kv replicas --repair --json") + require.NoErrorf(t, repairErr, "repair pass %d failed: %s", i, string(out)) + } + + after, err := localReplicaReport(t) + require.NoError(t, err, "the audit stopped working after two repair passes") + requireSameReplication(t, before, after) + harness.Detail(t, "repair_passes", 2, "buckets", len(after)) +} + +// requireSameReplication fails if any bucket's replication changed across the +// repair passes, naming the bucket rather than reporting that two lists differ. +func requireSameReplication(t *testing.T, before, after []kvutil.BucketReport) { + t.Helper() + was := make(map[string]kvutil.BucketReport, len(before)) + for _, r := range before { + was[r.Bucket] = r + } + require.Len(t, after, len(before), "the number of KV buckets changed across the repair passes") + for _, now := range after { + prev, ok := was[now.Bucket] + require.Truef(t, ok, "%s appeared during a repair that should create nothing", now.Bucket) + require.Equalf(t, prev.Replicas, now.Replicas, + "%s went from %d replicas to %d across a repair that had nothing to repair", + now.Bucket, prev.Replicas, now.Replicas) + require.Truef(t, now.HasQuorum(), + "%s stopped serving after a repair that had nothing to repair", now.Bucket) + } +} + +// localReplicaReport runs the replica audit on this node and decodes it. The +// suite runs on the node it is testing, so there is no ssh hop. +func localReplicaReport(t *testing.T) ([]kvutil.BucketReport, error) { + t.Helper() + out, err := runLocal(t, "sudo -n spx admin kv replicas --json") + if err != nil && len(out) == 0 { + return nil, err + } + var reports []kvutil.BucketReport + if jsonErr := json.Unmarshal(trimToJSON(out), &reports); jsonErr != nil { + return nil, fmt.Errorf("decode replica report (%q): %w", string(out), jsonErr) + } + return reports, err +} + +// runLocal runs a shell command on this node, returning its combined output +// whether or not it succeeded — the audit prints its report even when it exits +// non-zero, and a report naming a bucket is a better failure than an exit status. +func runLocal(t *testing.T, cmd string) ([]byte, error) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + return exec.CommandContext(ctx, "sh", "-c", cmd).CombinedOutput() +} + +// trimToJSON drops anything before the JSON document, since sudo and the login +// shell can both write to the same stream. +func trimToJSON(out []byte) []byte { + if i := strings.IndexAny(string(out), "[{"); i > 0 { + return out[i:] + } + return out +} diff --git a/tests/e2e/single/launchtemplate_test.go b/tests/e2e/single/launchtemplate_test.go index 60269043c..1c3402ac2 100644 --- a/tests/e2e/single/launchtemplate_test.go +++ b/tests/e2e/single/launchtemplate_test.go @@ -134,7 +134,7 @@ func runLaunchTemplateBoot(t *testing.T, fix *Fixture) { host, port := harness.InstancePublicSSHHost(t, inst) harness.Detail(t, "instance_id", instanceID, "ssh_host", host, "ssh_port", port) - waitForSSHReady(t, host, port, keyPath) + sshHealth.WaitReady(t, host, port, keyPath) tgt := harness.SSHTarget{User: "ubuntu", Host: host, Port: port, KeyPath: keyPath} harness.Step(t, "guest: template user-data ran") diff --git a/tests/e2e/single/lifecycle_test.go b/tests/e2e/single/lifecycle_test.go index 8292ba4d8..859f6b138 100644 --- a/tests/e2e/single/lifecycle_test.go +++ b/tests/e2e/single/lifecycle_test.go @@ -3,13 +3,7 @@ package single import ( - "net" - "os" - "os/exec" - "strconv" - "sync/atomic" "testing" - "time" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ec2" @@ -17,36 +11,8 @@ import ( "github.com/stretchr/testify/require" ) -// sshDatapathBroken is set the first time a single-node SSH probe times -// out. Downstream SSH-dependent tests call requireSSHHealthy(t) and skip -// rather than re-running the same 3-minute timeout against the same -// broken datapath. -var sshDatapathBroken atomic.Bool - -// sshReadyBudget bounds the single-node SSH-handshake probes. Baremetal boots -// q35 guests slower (OVMF + NBD disks), so the harness widens it via -// SPINIFEX_SSH_READY_TIMEOUT (Go duration); default 3m bounds shared runners. -var sshReadyBudget = resolveSSHReadyBudget(3 * time.Minute) - -func resolveSSHReadyBudget(def time.Duration) time.Duration { - if v := os.Getenv("SPINIFEX_SSH_READY_TIMEOUT"); v != "" { - if d, err := time.ParseDuration(v); err == nil && d > 0 { - return d - } - } - return def -} - -// requireSSHHealthy skips the calling test if a prior SSH probe on this -// single-node VM has already failed. Cuts the cascade cost of a broken -// datapath from 4×3min down to one fail + four skips. -func requireSSHHealthy(t *testing.T) { - t.Helper() - if sshDatapathBroken.Load() { - t.Skipf("skipping: earlier single-node SSH probe failed; " + - "not retrying to keep suite time bounded") - } -} +// sshHealth is this suite's sticky SSH-datapath verdict; see harness.SSHHealth. +var sshHealth harness.SSHHealth // runStopStart stops fix.Instance and waits for it to reach the // "stopped" state, then asserts that rebooting a stopped instance is @@ -157,58 +123,3 @@ func runAttachToStoppedError(t *testing.T, fix *Fixture) { require.NoError(t, err, "delete-volume %s", stoppedVolID) cleaned = true } - -// waitForSSHReady probes a full SSH handshake (BatchMode + ConnectTimeout) -// against host:port, retrying until the daemon completes banner exchange. -// TCP-port reachability alone is insufficient — sshd accepts the connect -// while pam/cloud-init are still finishing, and the first real runSSH then -// hits "Connection timed out during banner exchange" (CI run 26034322018). -func waitForSSHReady(t *testing.T, host string, port int, keyPath string) { - t.Helper() - requireSSHHealthy(t) - addr := net.JoinHostPort(host, strconv.Itoa(port)) - harness.Step(t, "waiting for SSH handshake %s", addr) - if !trySSHReady(host, port, keyPath, sshReadyBudget) { - sshDatapathBroken.Store(true) - t.Fatalf("Eventually: condition not met within %s: "+ - "[SSH handshake %s never completed] "+ - "(sticky-skip enabled for downstream)", sshReadyBudget, addr) - } -} - -// waitForSSHHandshake is an alias kept for guestchurn_test.go's call site. -func waitForSSHHandshake(t *testing.T, host string, port int, keyPath string) { - waitForSSHReady(t, host, port, keyPath) -} - -// trySSHReady mirrors waitForSSHReady but returns reachability as a bool -// instead of t.Fatal-ing on timeout. Matches run-e2e.sh ~2850–2860, which -// treats SSH-via-public-IP timeout as a WARN ("bridge not ready") rather -// than a hard test failure — new-subnet IGW SNAT/DNAT -// flow installation is timing-sensitive on shared CI runners and not part -// of the EC2 surface contract this test exists to validate. -func trySSHReady(host string, port int, keyPath string, timeout time.Duration) bool { - if sshDatapathBroken.Load() { - return false - } - deadline := time.Now().Add(timeout) - for { - cmd := exec.Command("ssh", - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "LogLevel=ERROR", - "-o", "ConnectTimeout=3", - "-o", "BatchMode=yes", - "-p", strconv.Itoa(port), - "-i", keyPath, - "ubuntu@"+host, - "true") - if cmd.Run() == nil { - return true - } - if time.Now().After(deadline) { - return false - } - time.Sleep(2 * time.Second) - } -} diff --git a/tests/e2e/single/rebootliveness_test.go b/tests/e2e/single/rebootliveness_test.go index 13797b277..3b5e27bd6 100644 --- a/tests/e2e/single/rebootliveness_test.go +++ b/tests/e2e/single/rebootliveness_test.go @@ -34,10 +34,10 @@ func runEarlyRebootLiveness(t *testing.T, fix *Fixture) { instanceID := launchBaselineInstance(t, fix, amiID, instType, keyName, vpc.SubnetID, []string{vpc.SGID}) inst := describeSingletonInstance(t, fix, instanceID) host, port := harness.InstancePublicSSHHost(t, inst) - waitForSSHReady(t, host, port, keyPath) + sshHealth.WaitReady(t, host, port, keyPath) tgt := harness.SSHTarget{User: "ubuntu", Host: host, Port: port, KeyPath: keyPath} - bootID := strings.TrimSpace(runSSH(t, tgt, "cat /proc/sys/kernel/random/boot_id")) + bootID := strings.TrimSpace(harness.RunSSH(t, tgt, "cat /proc/sys/kernel/random/boot_id")) require.NotEmpty(t, bootID, "fresh guest returned an empty boot ID") waitForCloudInitTarget(t, tgt) diff --git a/tests/e2e/single/securitygroup_test.go b/tests/e2e/single/securitygroup_test.go index f589dc120..90aaef2d9 100644 --- a/tests/e2e/single/securitygroup_test.go +++ b/tests/e2e/single/securitygroup_test.go @@ -3,10 +3,7 @@ package single import ( - "bytes" - "os/exec" "regexp" - "strconv" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ec2" @@ -40,28 +37,3 @@ func authorizeAllowAllEgress(c *harness.AWSClient, sgID string) error { }) return err } - -// runSSHCombined runs `command` over SSH against tgt and returns combined -// stdout+stderr regardless of exit status. Unlike runSSH (instance_test.go) -// it does not t.Fatal on non-zero exit — needed because `ping` legitimately -// exits non-zero when packets are dropped, which is the expected outcome of -// the revoke test. -func runSSHCombined(tgt harness.SSHTarget, command string) (string, error) { - args := []string{ - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "LogLevel=ERROR", - "-o", "ConnectTimeout=5", - "-o", "BatchMode=yes", - "-p", strconv.Itoa(tgt.Port), - "-i", tgt.KeyPath, - tgt.User + "@" + tgt.Host, - command, - } - var combined bytes.Buffer - cmd := exec.Command("ssh", args...) - cmd.Stdout = &combined - cmd.Stderr = &combined - err := cmd.Run() - return combined.String(), err -} diff --git a/tests/e2e/single/sgreachability_test.go b/tests/e2e/single/sgreachability_test.go index b87e38e69..7cfa0bdf0 100644 --- a/tests/e2e/single/sgreachability_test.go +++ b/tests/e2e/single/sgreachability_test.go @@ -85,7 +85,7 @@ func runSGReachabilityPolicy(t *testing.T, fix *Fixture) { // No ingress rules yet; egress is allow-all by default so only inbound // is gated. Probe a short window to confirm the default-deny ACL is // applied and stable. This overlaps guest boot, and Authorize below - // still pays the full boot wait via trySSHReady, so a longer window + // still pays the full boot wait via sshHealth.TryReady, so a longer window // buys little extra coverage. harness.Step(t, "asserting tcp/22 stays blocked under default-deny SG") deadline := time.Now().Add(10 * time.Second) @@ -99,12 +99,12 @@ func runSGReachabilityPolicy(t *testing.T, fix *Fixture) { authorizeOK := t.Run("Authorize", func(t *testing.T) { harness.Step(t, "authorizing tcp/22 ingress, expecting reachability") harness.AuthorizeSSHIngress(t, fix.AWS, sgID) - require.Truef(t, trySSHReady(pubIP, 22, keyPath, sshReadyBudget), + require.Truef(t, sshHealth.TryReady(pubIP, 22, keyPath, harness.SSHReadyBudget), "tcp/22 to %s never became reachable after authorizing ingress — "+ "default subnet egress/IGW datapath is broken", pubIP) tgt := harness.SSHTarget{User: "ubuntu", Host: pubIP, Port: 22, KeyPath: keyPath} - idOut := runSSH(t, tgt, "id") + idOut := harness.RunSSH(t, tgt, "id") assert.Containsf(t, idOut, "ubuntu", "ssh id after authorize\n%s", idOut) }) if !authorizeOK { @@ -193,7 +193,7 @@ func runSGReachabilityPolicy(t *testing.T, fix *Fixture) { harness.Detail(t, "probe_wan", wanIP) probeWAN := func() string { - out, _ := runSSHCombined(tgt, fmt.Sprintf("ping -c 3 -W 2 %s", wanIP)) + out, _ := harness.RunSSHCombined(tgt, fmt.Sprintf("ping -c 3 -W 2 %s", wanIP)) return out } diff --git a/tests/e2e/single/smtpblock_test.go b/tests/e2e/single/smtpblock_test.go index 47b800ce6..0a92d588f 100644 --- a/tests/e2e/single/smtpblock_test.go +++ b/tests/e2e/single/smtpblock_test.go @@ -66,7 +66,7 @@ func runWANEgressSMTPBlock(t *testing.T, fix *Fixture) { harness.Step(t, "authorizing tcp/22 ingress, expecting reachability") harness.AuthorizeSSHIngress(t, fix.AWS, sgID) - require.Truef(t, trySSHReady(pubIP, 22, keyPath, sshReadyBudget), + require.Truef(t, sshHealth.TryReady(pubIP, 22, keyPath, harness.SSHReadyBudget), "tcp/22 to %s never became reachable after authorizing ingress", pubIP) tgt := harness.SSHTarget{User: "ubuntu", Host: pubIP, Port: 22, KeyPath: keyPath} diff --git a/tests/e2e/single/tests_test.go b/tests/e2e/single/tests_test.go index a1d4b5b8f..8d11a67c3 100644 --- a/tests/e2e/single/tests_test.go +++ b/tests/e2e/single/tests_test.go @@ -30,6 +30,13 @@ func TestAccountScoping(t *testing.T) { runAccountScoping(t, requireSingleNodeFixture(t)) } +// TestKVReplication reads the cluster's own state and runs a repair that has +// nothing to repair, so it mutates nothing another test can see. +func TestKVReplication(t *testing.T) { + t.Parallel() + runKVReplication(t, requireSingleNodeFixture(t)) +} + // Reaches the public internet rather than the cluster: it asks whether the // upstreams our image catalog names are still publishing what it claims. func TestImageCatalogReachable(t *testing.T) { diff --git a/tests/e2e/single/vpcegress_test.go b/tests/e2e/single/vpcegress_test.go index 9e0f4f524..55d6e95a2 100644 --- a/tests/e2e/single/vpcegress_test.go +++ b/tests/e2e/single/vpcegress_test.go @@ -268,7 +268,7 @@ func runVPCEgressPaths(t *testing.T, fix *Fixture) { _, _ = c.EC2.TerminateInstances(&ec2.TerminateInstancesInput{ InstanceIds: []*string{aws.String(pubInstanceID)}, }) - _ = waitForInstanceStateSoft(c, pubInstanceID, "terminated", 5*time.Minute) + _ = harness.WaitForInstanceStateSoft(c, pubInstanceID, "terminated", 5*time.Minute) }) harness.Detail(t, "instance", pubInstanceID) @@ -281,7 +281,7 @@ func runVPCEgressPaths(t *testing.T, fix *Fixture) { harness.Detail(t, "public_ip", pubIP, "private_ip", pubPrivIP) pubOK := t.Run("PublicSubnetEgress", func(t *testing.T) { - if !trySSHReady(pubIP, 22, keyPath, sshReadyBudget) { + if !sshHealth.TryReady(pubIP, 22, keyPath, harness.SSHReadyBudget) { harness.DumpVPCFlowDiagnostics(t, c, pubInstanceID, fmt.Sprintf("PublicSubnetEgress SSH timeout — vpc=%s igw=%s pub=%s", vpcID, igwID, pubIP), harness.VPCDiagnosticsOpts{ @@ -289,13 +289,13 @@ func runVPCEgressPaths(t *testing.T, fix *Fixture) { LogicalIP: pubPrivIP, ArtifactDir: fix.ArtifactDir(t), }) - t.Fatalf("SSH handshake %s:22 never completed within %s (see diagnostics above)", pubIP, sshReadyBudget) + t.Fatalf("SSH handshake %s:22 never completed within %s (see diagnostics above)", pubIP, harness.SSHReadyBudget) } tgt := harness.SSHTarget{User: "ubuntu", Host: pubIP, Port: 22, KeyPath: keyPath} harness.Step(t, "ssh id (smoke)") - idOut := runSSH(t, tgt, "id") + idOut := harness.RunSSH(t, tgt, "id") require.Containsf(t, idOut, "ubuntu", "ssh id did not report ubuntu\n%s", idOut) // Verify external connectivity through the IGW + 0.0.0.0/0 route. ping @@ -405,7 +405,7 @@ func runVPCEgressPaths(t *testing.T, fix *Fixture) { harness.Detail(t, "instance", instanceID, "public_ip", rbPubIP) harness.Step(t, "expecting external SSH to instance in fresh-VPC public subnet") - if !trySSHReady(rbPubIP, 22, keyPath, sshReadyBudget) { + if !sshHealth.TryReady(rbPubIP, 22, keyPath, harness.SSHReadyBudget) { harness.DumpVPCFlowDiagnostics(t, c, instanceID, fmt.Sprintf("RouteBeforeSubnet SSH timeout — vpc=%s igw=%s pub=%s", vID, iID, rbPubIP), harness.VPCDiagnosticsOpts{ @@ -419,7 +419,7 @@ func runVPCEgressPaths(t *testing.T, fix *Fixture) { } tgt := harness.SSHTarget{User: "ubuntu", Host: rbPubIP, Port: 22, KeyPath: keyPath} - idOut := runSSH(t, tgt, "id") + idOut := harness.RunSSH(t, tgt, "id") assert.Containsf(t, idOut, "ubuntu", "ssh id in fresh-VPC subnet\n%s", idOut) }) @@ -442,7 +442,7 @@ func runVPCEgressPaths(t *testing.T, fix *Fixture) { _, _ = c.EC2.TerminateInstances(&ec2.TerminateInstancesInput{ InstanceIds: []*string{aws.String(privInstanceID)}, }) - _ = waitForInstanceStateSoft(c, privInstanceID, "terminated", 5*time.Minute) + _ = harness.WaitForInstanceStateSoft(c, privInstanceID, "terminated", 5*time.Minute) }) privInst := harness.WaitForInstanceState(t, c, privInstanceID, "running") @@ -456,7 +456,7 @@ func runVPCEgressPaths(t *testing.T, fix *Fixture) { // Copy the keypair to the bastion so it can hop into the private guest. harness.Step(t, "scp keypair -> bastion:/tmp/key.pem") scpKey(t, keyPath, pubIP) - _ = runSSH(t, bastionTgt, "chmod 600 /tmp/key.pem") + _ = harness.RunSSH(t, bastionTgt, "chmod 600 /tmp/key.pem") privProbe := fmt.Sprintf( "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "+ @@ -727,7 +727,7 @@ func runVPCEgressPaths(t *testing.T, fix *Fixture) { } harness.Step(t, "ssh to guest via %s after restart", newIP) - if !trySSHReady(newIP, 22, keyPath, sshReadyBudget) { + if !sshHealth.TryReady(newIP, 22, keyPath, harness.SSHReadyBudget) { harness.DumpVPCFlowDiagnostics(t, c, pubInstanceID, fmt.Sprintf("StopStartAddress SSH timeout — was=%s now=%s instance=%s", pubIP, newIP, pubInstanceID), harness.VPCDiagnosticsOpts{ @@ -735,9 +735,9 @@ func runVPCEgressPaths(t *testing.T, fix *Fixture) { LogicalIP: pubPrivIP, ArtifactDir: fix.ArtifactDir(t), }) - t.Fatalf("guest unreachable via %s within %s after restart (see diagnostics above)", newIP, sshReadyBudget) + t.Fatalf("guest unreachable via %s within %s after restart (see diagnostics above)", newIP, harness.SSHReadyBudget) } - out := runSSH(t, harness.SSHTarget{User: "ubuntu", Host: newIP, Port: 22, KeyPath: keyPath}, "id") + out := harness.RunSSH(t, harness.SSHTarget{User: "ubuntu", Host: newIP, Port: 22, KeyPath: keyPath}, "id") require.Containsf(t, out, "ubuntu", "ssh after restart did not report ubuntu\n%s", out) harness.Detail(t, "datapath", "reachable_on_new_address_ok") @@ -789,7 +789,7 @@ func runVPCEgressPaths(t *testing.T, fix *Fixture) { // association publishes; poll the SSH handshake rather than // asserting immediately. harness.Step(t, "ssh to guest via EIP %s", eipIP) - if !trySSHReady(eipIP, 22, keyPath, sshReadyBudget) { + if !sshHealth.TryReady(eipIP, 22, keyPath, harness.SSHReadyBudget) { harness.DumpVPCFlowDiagnostics(t, c, pubInstanceID, fmt.Sprintf("EIP SSH timeout — eip=%s instance=%s", eipIP, pubInstanceID), harness.VPCDiagnosticsOpts{ @@ -797,10 +797,10 @@ func runVPCEgressPaths(t *testing.T, fix *Fixture) { LogicalIP: pubPrivIP, ArtifactDir: fix.ArtifactDir(t), }) - t.Fatalf("guest unreachable via EIP %s within %s (see diagnostics above)", eipIP, sshReadyBudget) + t.Fatalf("guest unreachable via EIP %s within %s (see diagnostics above)", eipIP, harness.SSHReadyBudget) } tgt := harness.SSHTarget{User: "ubuntu", Host: eipIP, Port: 22, KeyPath: keyPath} - idOut := runSSH(t, tgt, "id") + idOut := harness.RunSSH(t, tgt, "id") require.Containsf(t, idOut, "ubuntu", "ssh via EIP id did not report ubuntu\n%s", idOut) harness.Detail(t, "datapath", "eip_reachable_ok") @@ -851,7 +851,7 @@ func runVPCEgressPaths(t *testing.T, fix *Fixture) { "instance came back on a different address than the Elastic IP associated to it") harness.Step(t, "ssh to guest via EIP %s after restart", eipIP) - if !trySSHReady(eipIP, 22, keyPath, sshReadyBudget) { + if !sshHealth.TryReady(eipIP, 22, keyPath, harness.SSHReadyBudget) { harness.DumpVPCFlowDiagnostics(t, c, pubInstanceID, fmt.Sprintf("EIP SSH timeout after restart — eip=%s instance=%s", eipIP, pubInstanceID), harness.VPCDiagnosticsOpts{ @@ -859,9 +859,9 @@ func runVPCEgressPaths(t *testing.T, fix *Fixture) { LogicalIP: aws.StringValue(started.PrivateIpAddress), ArtifactDir: fix.ArtifactDir(t), }) - t.Fatalf("guest unreachable via EIP %s within %s after restart (see diagnostics above)", eipIP, sshReadyBudget) + t.Fatalf("guest unreachable via EIP %s within %s after restart (see diagnostics above)", eipIP, harness.SSHReadyBudget) } - out := runSSH(t, harness.SSHTarget{User: "ubuntu", Host: eipIP, Port: 22, KeyPath: keyPath}, "id") + out := harness.RunSSH(t, harness.SSHTarget{User: "ubuntu", Host: eipIP, Port: 22, KeyPath: keyPath}, "id") require.Containsf(t, out, "ubuntu", "ssh via EIP after restart did not report ubuntu\n%s", out) harness.Detail(t, "datapath", "eip_reachable_after_restart_ok") }) @@ -958,26 +958,6 @@ func waitForNATGatewayStateSoft(c *harness.AWSClient, id, target string, timeout } } -// waitForInstanceStateSoft is the cleanup-time analogue of -// harness.WaitForInstanceState — no t.Fatal, just polls and returns. -func waitForInstanceStateSoft(c *harness.AWSClient, id, target string, timeout time.Duration) error { - deadline := time.Now().Add(timeout) - for { - out, err := c.EC2.DescribeInstances(&ec2.DescribeInstancesInput{ - InstanceIds: []*string{aws.String(id)}, - }) - if err == nil && len(out.Reservations) > 0 && len(out.Reservations[0].Instances) > 0 { - if aws.StringValue(out.Reservations[0].Instances[0].State.Name) == target { - return nil - } - } - if time.Now().After(deadline) { - return fmt.Errorf("instance %s did not reach %s within %s", id, target, timeout) - } - time.Sleep(2 * time.Second) - } -} - // scpKey copies the harness PEM to /tmp/key.pem on the bastion so it can // hop into a guest that has no direct SSH ingress of its own. func scpKey(t *testing.T, keyPath, host string) { @@ -1004,7 +984,7 @@ func scpKey(t *testing.T, keyPath, host string) { // EventuallyErr budget. The ctx deadline kills ssh regardless of which phase hangs. const sshQuietHardTimeout = 30 * time.Second -// runSSHQuiet is the EventuallyErr-friendly variant of runSSH: it returns +// runSSHQuiet is the EventuallyErr-friendly variant of harness.RunSSH: it returns // stdout+stderr and the error rather than calling t.Fatal, so polling // loops can iterate on transient failures (cloud-init not done, OVN // flow not installed yet, etc). diff --git a/tests/e2e/storagefault/stop_start_durability_test.go b/tests/e2e/storagefault/stop_start_durability_test.go index 3b6c6b949..e32e7aeb4 100644 --- a/tests/e2e/storagefault/stop_start_durability_test.go +++ b/tests/e2e/storagefault/stop_start_durability_test.go @@ -31,6 +31,10 @@ const ( // Generous because the stop is deliberately made to run against a backend // that cannot answer, which is the slowest this path ever gets. stopStartTimeout = 10 * time.Minute + + // nodeConfigPath is where the installed layout keeps the cluster config an + // spx subcommand run over SSH has to be pointed at. + nodeConfigPath = "/etc/spinifex/spinifex.toml" ) // viperblockBaseDir is where a node keeps per-volume local state. Overridable @@ -147,6 +151,12 @@ func TestCrossNodeStartAfterFailedSealTakesOverLoudly(t *testing.T) { thawPredastore(t, fix, freezeSet) time.Sleep(recoverySettle) + // Read after the thaw has settled, not before it. A marker is written when + // a volume opens and cleared by the seal, and the seal the stop gave up + // waiting for can still finish once the backend answers again. Only what + // survives that is genuinely left behind for the takeover to be loud about. + unsealed := requireUnsealedVolumes(t, fix, hostNode) + // The node has to be gone, not merely quiet. A live viperblockd goes on // renewing the lease on a volume whose seal failed, and that refusal is // correct: the node could still be writing. @@ -167,7 +177,7 @@ func TestCrossNodeStartAfterFailedSealTakesOverLoudly(t *testing.T) { // The loss is accepted; being unable to see it is not. This is the only // record that the volume opened from an older checkpoint than existed. - assertTakeoverLogged(t, fix, landed, hostNode, volID) + assertTakeoverLogged(t, fix, landed, hostNode, unsealed) host, port := harness.InstancePublicSSHHost(t, inst) if !harness.TryGuestSSHReady(host, port, "ubuntu", tgt.KeyPath, 5*time.Minute) { @@ -229,7 +239,7 @@ func guestDatasource(tgt harness.SSHTarget) string { // This is the whole guarantee. Losing the previous holder's unsealed writes is // a deliberate trade against the instance not running at all, and it is only // defensible while an operator can find out it happened. -func assertTakeoverLogged(t *testing.T, fix *Fixture, landed, previous *harness.Node, volID string) { +func assertTakeoverLogged(t *testing.T, fix *Fixture, landed, previous *harness.Node, unsealed []string) { t.Helper() if landed == nil { return @@ -239,27 +249,98 @@ func assertTakeoverLogged(t *testing.T, fix *Fixture, landed, previous *harness. defer cancel() cmd := "sudo journalctl -u spinifex-viperblock -u spinifex-daemon --since '-10 min' --no-pager 2>/dev/null | " + - "grep -F " + volID + " | grep -i 'last held by another node' | tail -5" + "grep -i 'last held by another node' | tail -20" out, err := fix.SSH.Run(ctx, *landed, cmd) if err != nil { t.Errorf("could not read %s's journal to confirm the takeover was logged: %v", landed.Name, err) return } + + // Any one of them: the instance's volumes are sealed together and the + // guarantee is that the operator is told, not that every volume is named. journal := string(out) - if strings.TrimSpace(journal) == "" { + var named string + for _, volID := range unsealed { + if strings.Contains(journal, volID) { + named = volID + break + } + } + if named == "" { t.Errorf("%s opened %s from the backend checkpoint while %s held unsealed writes, and logged "+ "nothing about it. The loss is acceptable; a silent loss is not.", - landed.Name, volID, previous.Name) + landed.Name, strings.Join(unsealed, ", "), previous.Name) return } // The marker records the spinifex node name, which is the host's own name // and not the harness's nodeN label, so accept either identity. if !strings.Contains(journal, previous.Name) && !strings.Contains(journal, previous.Addr) { t.Errorf("%s logged a takeover for %s but did not name %s (%s), so an operator cannot tell "+ - "whose writes were left behind:\n%s", landed.Name, volID, previous.Name, previous.Addr, journal) + "whose writes were left behind:\n%s", landed.Name, named, previous.Name, previous.Addr, journal) return } - harness.Step(t, "%s logged the takeover and named %s", landed.Name, previous.Name) + harness.Step(t, "%s logged the takeover of %s and named %s", landed.Name, named, previous.Name) +} + +// requireUnsealedVolumes returns the volumes the cluster records node as +// holding writes the backend may not have, and skips the test if there are none. +// +// The stop returning an error does not establish this. The unmount is a NATS +// request, and its caller giving up says nothing about the seal behind it, so +// local state kept with no seal receipt is equally what an in-flight seal looks +// like. A marker that is still there once the backend is answering again is not. +// +// Which volume is left unsealed is not the test's to choose. Freezing +// predastore fails a seal that has to read from it — a root volume loading its +// AMI snapshot — while a blank attached volume can seal straight through the +// outage. That is correct behaviour, so the takeover is asserted against +// whatever was actually left behind rather than a volume picked in advance. +func requireUnsealedVolumes(t *testing.T, fix *Fixture, node *harness.Node) []string { + t.Helper() + + held := unsealedVolumesHeldBy(t, fix, node) + if len(held) == 0 { + t.Skipf("no seal failed on %s while the backend was frozen, so there are no unsealed writes "+ + "to take over and this test would assert a warning that would be wrong", node.Name) + } + harness.Step(t, "%s still holds unsealed writes for %s after the backend came back", + node.Name, strings.Join(held, ", ")) + return held +} + +// unsealedVolumesHeldBy reads the cluster's unsealed-volume report from node and +// returns the volumes it names node as holding. The report is a block per +// volume: the id on its own line, then indented fields. +func unsealedVolumesHeldBy(t *testing.T, fix *Fixture, node *harness.Node) []string { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + out, err := fix.SSH.Run(ctx, *node, "sudo spx admin volumes unsealed --config "+nodeConfigPath) + if err != nil { + // The node is up and the report reads NATS, not the frozen backend, so + // this is worth seeing rather than retrying silently. + t.Logf("could not read the unsealed volumes from %s: %v", node.Name, err) + return nil + } + + var held []string + current := "" + for line := range strings.SplitSeq(string(out), "\n") { + trimmed := strings.TrimSpace(line) + switch { + case strings.HasPrefix(trimmed, "vol-") && !strings.Contains(trimmed, " "): + current = trimmed + case current != "" && strings.HasPrefix(trimmed, "held by:"): + owner := strings.TrimSpace(strings.TrimPrefix(trimmed, "held by:")) + if owner == node.Name || owner == node.Addr { + held = append(held, current) + } + current = "" + } + } + return held } // assertLocalStateKept checks the node kept the volume's local files and wrote diff --git a/tests/integration/assumed_role_test.go b/tests/integration/assumed_role_test.go index 6b46a4960..f17993ab9 100644 --- a/tests/integration/assumed_role_test.go +++ b/tests/integration/assumed_role_test.go @@ -75,7 +75,7 @@ func TestAssumedRoleControlPlaneEnforcement(t *testing.T) { // Zero-policy role: the session authenticates, then default-denies. _, err = sessionCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) - requireAWSErrorCode(t, err, "AccessDenied") + requireAWSErrorCode(t, err, "UnauthorizedOperation") policyOut, err := gw.IAMClient(t).CreatePolicy(&iam.CreatePolicyInput{ PolicyName: aws.String("are-ec2-describe-regions"), diff --git a/tests/integration/conformance-known-findings.md b/tests/integration/conformance-known-findings.md index 40ddf14af..062470f88 100644 --- a/tests/integration/conformance-known-findings.md +++ b/tests/integration/conformance-known-findings.md @@ -17,13 +17,7 @@ Twelve responses are reported separately as `errors_unmodelled`: four IAM and ei EC2's model declares no operation errors, so EC2 uses a separate checked-in catalog curated from AWS's official EC2 error-code reference. It contains every documented common and server error plus the documented action-specific codes Spinifex currently emits. The validator checks the EC2 XML envelope, catalog membership, and AWS's documented 4xx client / 5xx server classification. -The same baseline examined 42 EC2 error responses and exposed 16 violations. The -`InsufficientInstanceCapacity` status-class defect has since been fixed — that -code now returns 503, matching the other capacity errors — leaving 15 open: - -| Finding | Count | Classification | Implementation cause | -|---|---:|---|---| -| EC2 authorization failures return `AccessDenied`, which is absent from the EC2 catalog; EC2 documents `UnauthorizedOperation` | 15 | Defect | The shared policy evaluator returns the Query/IAM-oriented `AccessDenied` code for EC2 requests. | +The same baseline examined 42 EC2 error responses and exposed 16 violations. The `InsufficientInstanceCapacity` status-class defect has since been fixed — that code now returns 503, matching the other capacity errors. The 2026-09-29 run examined 50 EC2 error responses and found 23 violations, all EC2 policy denials answered with the IAM-oriented `AccessDenied`. EC2 now answers a policy denial, including an `iam:PassRole` denial, with `UnauthorizedOperation`, and a rerun of the same 50 responses reports no violations. Six additional production-referenced values are deliberately not accepted by the catalog because the chosen EC2 reference does not list them: `ExpiredToken`, `IamInstanceProfileAlreadyAssociated`, `InvalidIamInstanceProfile.NotFound`, `NoSuchAssociation`, `NoSuchEntity`, and `RequestEntityTooLarge`. A runtime occurrence remains a visible violation until it is either backed by an authoritative EC2 source or replaced with a documented EC2 code. @@ -41,19 +35,24 @@ A rejection that succeeds is always a finding. A refusal counts as a pass only w Request findings and undeclared errors block only for services listed under `promotedRequestServices` in `conformance-promoted-services.json`, which starts empty; response promotion does not carry over. -The 2026-09-28 baseline sent 3986 requests across 368 operations: 794 pass, 396 findings, 101 undeclared errors, 2695 inconclusive. None of it has been checked against AWS, so each finding below is a lead to verify before any fix. +The 2026-09-29 baseline sent 4047 requests across 371 operations: 980 pass, 506 findings, 96 undeclared errors, 2465 inconclusive. None of it had been checked against AWS, so each finding below is a lead to verify before any fix. Since then, EC2 range-checks `MaxResults` on six `Describe*` calls and refuses a tag resource type the operation cannot tag, as AWS answers both, which clears 12 EC2 findings. IAM tag and untag calls now refuse a tag with no value or outside the model's key and value character sets, count tag lengths in characters rather than bytes, treat keys that differ only in case as one key on users and roles but as two elsewhere, and refuse the reserved `aws:` prefix, each as AWS answered it. IAM also checks tags on every create, `PathPrefix` on every list call (and `ListAttached*Policies` now filters by it), the `Scope` and `PolicyUsageFilter` enums, role and policy `Description` length, role `MaxSessionDuration` against 3600–43200, OIDC client ID and thumbprint lengths, `ListEntitiesForPolicy` `PolicyArn` length and an empty `CreatePolicy` `Path`, and answers name and path length breaks with `ValidationError`, each verified against AWS. STS `GetSessionToken` refuses an out-of-range `DurationSeconds` rather than clamping it, as AWS answers `AssumeRole`'s, and both calls check `SerialNumber` and `TokenCode` against the model's length and pattern before refusing MFA. Every finding is traced to its cause, and grouped into fix items, in the mulga plan `docs/development/archive/bugs/aws-integration-conformance-findings.md`. | Finding | Count | Notes | |---|---:|---| -| A request breaking a model constraint is accepted | 336 | IAM 116, ECS 63, ELBv2 48, ECR 41, EC2 34, ACM 15, RDS 7, STS 6, EKS 6. Examples: IAM `List*` accept out-of-range `MaxItems` and `Marker`; IAM, ELBv2 and ECR accept a tag without `Key`; EC2 `Describe*` accept out-of-range `MaxResults`; ECS `CreateCapacityProvider` accepts every enum and range break inside `autoScalingGroupProvider`; STS `GetSessionToken` accepts a `SerialNumber` and `TokenCode` below their minimum length. | -| A model-valid request is refused as invalid | 60 | Mostly deliberate limitations that say so: RDS `ModifyDBInstance` members, STS session policies, tags and MFA, IAM `PermissionsBoundary`, ECS `availabilityZoneRebalancing`. Others return a generic message whose cause is unresolved (ECS, EC2, EKS), or the model leaves out a value AWS itself requires, such as an RDS `AllocatedStorage` below the engine minimum. | -| A refusal uses an undeclared error code | 101 | ECR (89) and EKS (1) answer with `InvalidParameterValueException`; both models declare `InvalidParameterException`. IAM (11) answers `CreateGroup` and `Put*Policy` constraint breaks with `InvalidInput`, which those operations do not declare. | +| A request breaking a model constraint is accepted | 420 | ECS 116, IAM 112, ACM 56, ELBv2 48, ECR 47, EC2 22, RDS 7, STS 6, EKS 6. Examples: ACM and ELBv2 accept a tag without `Key`, as IAM did; EC2 network `Describe*` accepted out-of-range `MaxResults`; ECS `CreateCapacityProvider` accepts every enum and range break inside `autoScalingGroupProvider` and `managedInstancesProvider`; ACM `RequestCertificate` accepts every break inside `DomainValidationOptions`; STS `GetSessionToken` accepted out-of-range `DurationSeconds` and a `SerialNumber` and `TokenCode` below their minimum length, and still accepts an out-of-range `MinimumSessionTokenSize`. | +| A model-valid request is refused as invalid | 74 | RDS 42, ECS 10, STS 10, EC2 8, EKS 2, IAM 2. Mostly deliberate limitations that say so: RDS `CreateDBInstance`, `ModifyDBInstance` and `RestoreDBInstanceFromDBSnapshot` members, STS session policies, tags and MFA, ECS `availabilityZoneRebalancing`. Others return a generic message whose cause is unresolved (ECS task and task-definition operations, EC2 `Modify*Attribute`), or add a member next to a seeded one that AWS may refuse in combination, such as EC2 `CreateCapacityReservation` `AvailabilityZoneId` beside `AvailabilityZone`. | +| A refusal uses an undeclared error code | 96 | ECR (90) and EKS (2) answer with `InvalidParameterValueException`; both models declare `InvalidParameterException`. IAM (4) answered `CreateGroup` and `Put*Policy` name-length breaks with `InvalidInput`, which those operations do not declare; they now answer `ValidationError`, as AWS does. | + +How the sweep reaches its targets: + +- Models mark conditionally required members optional. The members AWS requires anyway are seeded into every request for the operations listed in `conditionallyRequired` (`internal/awsmodel/request_hints.go`). For any other operation, an acceptance request refused with `MissingParameter` or `InvalidParameterCombination` is inconclusive, and so is every later refusal for that operation once its required-only request is refused. +- Members whose model shape accepts values AWS refuses, such as ARNs, IDs, zones, policy documents, certificates and ECR manifests, take hinted values of the right form (`request_hints.go`). An EC2 create's `TagSpecifications` names the resource type that operation creates, since AWS refuses any other. +- A pattern Go's regexp cannot compile, because it uses lookahead, is sampled through a hand-written RE2 equivalent (`re2Equivalents` in `internal/awsmodel/pattern.go`). An operation whose required member has a pattern with no equivalent is listed as `UNTESTED`; none is today. +- The runner creates an IAM role, managed policy, OIDC provider, instance profile, group and user, an ECS cluster and an ECR repository per run, and every operation except a create or delete names them. Other services have no fixture, so their tag, update and describe cases still end in not-found. Known limits of the sweep: -- Models mark conditionally required members optional, so an acceptance request refused with `MissingParameter` or `InvalidParameterCombination` is inconclusive, and so is every later refusal for that operation once its required-only request is refused. - Filter names and pagination tokens cannot be generated validly, so `Filters`, `Marker` and `NextToken` get rejection requests only, and a refusal of one is inconclusive. -- ACM `RequestCertificate` sends no requests: its `DomainName` pattern uses lookahead, which Go's regexp cannot sample. The report lists such operations as `UNTESTED`. - Constraints no request breaks are counted as `unbroken_constraints`: required URI labels, maximum lengths above the generated limit (such as IAM policy documents), map entry counts, and patterns no value of a valid length breaks. -- Most inconclusives are resource lookups that run before validation (`NoSuchEntity`, `*NotFound`), EC2 requests missing a conditionally required member (`MissingParameter`), EC2 subjects no daemon-lite answers (`InternalError`), and EKS operations whose backends are absent (`ServiceUnavailableException`, `NotImplementedException`). +- Most inconclusives are resource lookups that run before validation (`NoSuchEntity`, `*NotFound`), ECS's bare `InvalidParameterException` for a missing resource, EC2 requests missing a conditionally required member (`MissingParameter`), EC2 subjects no daemon-lite answers (`InternalError`), RDS `ServerInternal`, and EKS operations whose backends are absent (`ServiceUnavailableException`, `NotImplementedException`). - The EC2 query parser ignores parameters it does not know, which no generated request can reveal because every member it sends is modelled. diff --git a/tests/integration/conformance.go b/tests/integration/conformance.go index 374cfc725..cee3835e6 100644 --- a/tests/integration/conformance.go +++ b/tests/integration/conformance.go @@ -6,8 +6,10 @@ import ( "bytes" "fmt" "io" + "maps" "net/http" "net/url" + "slices" "sort" "strings" "sync" @@ -210,11 +212,7 @@ func (c *conformanceCollector) report(policy conformancePolicy, mode conformance for service := range policy.promoted { checkedServices[service] = true } - services := make([]awsmodel.Service, 0, len(checkedServices)) - for service := range checkedServices { - services = append(services, service) - } - sort.Slice(services, func(i, j int) bool { return services[i] < services[j] }) + services := slices.Sorted(maps.Keys(checkedServices)) for _, service := range services { fmt.Fprintf(&report, "CHECKED %s success=%d errors=%d unmodelled_errors=%d promoted=%t\n", service, c.checkedByService[service], c.errorCheckedByService[service], c.errorUnmodelledByService[service], policy.isPromoted(service)) diff --git a/tests/integration/conformance_policy.go b/tests/integration/conformance_policy.go index 486016a6b..8b9a85bd2 100644 --- a/tests/integration/conformance_policy.go +++ b/tests/integration/conformance_policy.go @@ -6,8 +6,9 @@ import ( _ "embed" "encoding/json" "fmt" + "maps" "os" - "sort" + "slices" "strings" "github.com/mulgadc/spinifex/internal/awsmodel" @@ -89,12 +90,7 @@ func (p conformancePolicy) isRequestPromoted(service awsmodel.Service) bool { } func (p conformancePolicy) services() []awsmodel.Service { - services := make([]awsmodel.Service, 0, len(p.promoted)) - for service := range p.promoted { - services = append(services, service) - } - sort.Slice(services, func(i, j int) bool { return services[i] < services[j] }) - return services + return slices.Sorted(maps.Keys(p.promoted)) } func conformanceModeFromEnvironment() (conformanceMode, error) { diff --git a/tests/integration/cross_tenant_test.go b/tests/integration/cross_tenant_test.go index e1ca20bcd..6b7269a8e 100644 --- a/tests/integration/cross_tenant_test.go +++ b/tests/integration/cross_tenant_test.go @@ -227,6 +227,9 @@ func crossTenantCases() []crossTenantCase { // a grant on another tenant's resource taking effect. func TestCrossTenant_ForeignResourceARNNeverAuthorizes(t *testing.T) { gw := StartGateway(t) + // Without the services behind NATS, the own/allowed probes end in + // no-responders, which the gateway renders as InternalError. + StartServiceDaemonLite(t, gw) home, creds := createProbePrincipal(t, gw, "cross-tenant-home") foreign, err := gw.Config.IAMService.CreateAccount("cross-tenant-foreign") diff --git a/tests/integration/harness.go b/tests/integration/harness.go index 8abdc2241..a3efd4b18 100644 --- a/tests/integration/harness.go +++ b/tests/integration/harness.go @@ -137,10 +137,10 @@ func startGateway(t *testing.T, collector *conformanceCollector, opts ...Option) masterKey, err := handlers_iam.GenerateMasterKey() require.NoError(t, err) - iamSvc, err := handlers_iam.NewIAMServiceWithRetry(t.Context(), nc, masterKey, 1) + iamSvc, err := handlers_iam.NewIAMServiceWithRetry(t.Context(), nc, masterKey) require.NoError(t, err) - stsSvc, err := handlers_sts.NewSTSServiceImpl(t.Context(), nc, iamSvc, masterKey, 1) + stsSvc, err := handlers_sts.NewSTSServiceImpl(t.Context(), nc, iamSvc, masterKey) require.NoError(t, err) encryptedSecret, err := handlers_iam.EncryptSecret(testSecretAccessKey, masterKey) @@ -155,10 +155,10 @@ func startGateway(t *testing.T, collector *conformanceCollector, opts ...Option) // ECR auth bridge signing key: reuses the IAM master key to encrypt the // signing key at rest in the same embedded JetStream KV, matching // production's awsgw-keys wiring (services/awsgw/awsgw.go). - signingKey, verifyKeys, err := gateway_ecrauth.LoadOrCreateSigningKey(t.Context(), js, masterKey, 1) + signingKey, verifyKeys, err := gateway_ecrauth.LoadOrCreateSigningKey(t.Context(), js, masterKey) require.NoError(t, err) - bedrockAccess := gateway_bedrock.NewModelAccessStore(js, 1) + bedrockAccess := gateway_bedrock.NewModelAccessStore(js) cfg := &gateway.GatewayConfig{ DisableLogging: true, diff --git a/tests/integration/iam_groups_test.go b/tests/integration/iam_groups_test.go index b658b3be8..87c4c4cb2 100644 --- a/tests/integration/iam_groups_test.go +++ b/tests/integration/iam_groups_test.go @@ -36,7 +36,7 @@ func TestIAMGroupEnforcement(t *testing.T) { // No policy anywhere yet: the active key authenticates, then default-denies. _, err = memberCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) - requireAWSErrorCode(t, err, "AccessDenied") + requireAWSErrorCode(t, err, "UnauthorizedOperation") // Group + policy granting ec2:DescribeRegions, then join the user. groupOut, err := gw.IAMClient(t).CreateGroup(&iam.CreateGroupInput{GroupName: aws.String("grp-enforce")}) @@ -71,7 +71,7 @@ func TestIAMGroupEnforcement(t *testing.T) { require.NoError(t, err, "remove-user-from-group") _, err = memberCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) - requireAWSErrorCode(t, err, "AccessDenied") + requireAWSErrorCode(t, err, "UnauthorizedOperation") } // TestIAMGroupInlineEnforcement proves an inline policy embedded in a group @@ -93,7 +93,7 @@ func TestIAMGroupInlineEnforcement(t *testing.T) { // No grant anywhere yet: the active key authenticates, then default-denies. _, err = memberCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) - requireAWSErrorCode(t, err, "AccessDenied") + requireAWSErrorCode(t, err, "UnauthorizedOperation") // Group with an inline policy granting ec2:DescribeRegions, then join the user. groupOut, err := gw.IAMClient(t).CreateGroup(&iam.CreateGroupInput{GroupName: aws.String("grp-inline-enforce")}) @@ -126,7 +126,7 @@ func TestIAMGroupInlineEnforcement(t *testing.T) { require.NoError(t, err, "delete-group-policy") _, err = memberCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) - requireAWSErrorCode(t, err, "AccessDenied") + requireAWSErrorCode(t, err, "UnauthorizedOperation") } // TestIAMUserInlineEnforcement proves the user-inline linchpin: a policy @@ -150,7 +150,7 @@ func TestIAMUserInlineEnforcement(t *testing.T) { // No grant anywhere yet: the active key authenticates, then default-denies. _, err = userCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) - requireAWSErrorCode(t, err, "AccessDenied") + requireAWSErrorCode(t, err, "UnauthorizedOperation") // Put an inline policy granting ec2:DescribeRegions directly on the user. const inlinePolicyName = "usr-inl-enf-describe-regions" @@ -174,7 +174,7 @@ func TestIAMUserInlineEnforcement(t *testing.T) { require.NoError(t, err, "delete-user-policy") _, err = userCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) - requireAWSErrorCode(t, err, "AccessDenied") + requireAWSErrorCode(t, err, "UnauthorizedOperation") } // Dedicated identifiers for the Groups lifecycle test, distinct from the diff --git a/tests/integration/iam_roles_test.go b/tests/integration/iam_roles_test.go index e9188942a..8df90a7be 100644 --- a/tests/integration/iam_roles_test.go +++ b/tests/integration/iam_roles_test.go @@ -108,10 +108,9 @@ func TestIAMRolesAndProfiles(t *testing.T) { require.Equal(t, "updated", aws.StringValue(got.Role.Description)) require.Equal(t, int64(7200), aws.Int64Value(got.Role.MaxSessionDuration)) - // Server-side MaxSessionDuration range guard (900-43200) isn't reachable - // via the AWS SDK: UpdateRoleInput carries min:"3600" so SDK.Validate() - // blocks values < 3600 before dispatch. The gateway guard is covered by - // handlers/iam/roles_test.go TestCreateRole_MaxSessionDuration_TooSmall. + // The MaxSessionDuration range guard (3600-43200) is unreachable here: the + // SDK refuses values below 3600 before dispatch. handlers/iam covers it in + // TestRole_MaxSessionDurationBounds. // UpdateAssumeRolePolicy — swap document, no enforcement yet. _, err = iamCli.UpdateAssumeRolePolicy(&iam.UpdateAssumeRolePolicyInput{ diff --git a/tests/integration/iam_test.go b/tests/integration/iam_test.go index 28f20ab4c..e88d0d2a3 100644 --- a/tests/integration/iam_test.go +++ b/tests/integration/iam_test.go @@ -234,7 +234,7 @@ func TestIAMAccessKeyLifecycle(t *testing.T) { // TestIAMUserAuthentication builds a scoped AWS client with alice's key and // confirms signing is honoured (active key) / rejected (deactivated key, bad // secret, bogus ID). Alice has no policy, so the authenticated call is -// authz-denied — AccessDenied (not an authn error) proves the active key +// authz-denied — UnauthorizedOperation (not an authn error) proves the active key // signed and was accepted. The final root DescribeInstances call needs the // EC2 daemon subjects stubbed since it actually dispatches over NATS; every // other assertion here is rejected before dispatch. @@ -252,11 +252,11 @@ func TestIAMUserAuthentication(t *testing.T) { aliceSecret := aws.StringValue(k.AccessKey.SecretAccessKey) // Active, correctly-signed key with no policy: the request authenticates - // then default-denies. AccessDenied (vs the InvalidClientTokenId / + // then default-denies. UnauthorizedOperation (vs the InvalidClientTokenId / // SignatureDoesNotMatch below) is the signal that authn succeeded. aliceCli := gw.ClientsWithCreds(t, aliceKeyID, aliceSecret) _, err = aliceCli.EC2.DescribeInstances(&ec2.DescribeInstancesInput{}) - requireAWSErrorCode(t, err, "AccessDenied") + requireAWSErrorCode(t, err, "UnauthorizedOperation") _, err = iamCli.UpdateAccessKey(&iam.UpdateAccessKeyInput{ UserName: aws.String(iamUserAlice), @@ -487,7 +487,7 @@ func TestIAMPolicyAttachmentEnforcement(t *testing.T) { // --- Enforcement --- _, err = charlieCli.EC2.DescribeInstances(&ec2.DescribeInstancesInput{}) - requireAWSErrorCode(t, err, "AccessDenied") + requireAWSErrorCode(t, err, "UnauthorizedOperation") _, err = charlieCli.IAM.ListUsers(&iam.ListUsersInput{}) requireAWSErrorCode(t, err, "AccessDenied") @@ -499,7 +499,7 @@ func TestIAMPolicyAttachmentEnforcement(t *testing.T) { require.NoError(t, err, "alice iam:ListUsers") _, err = aliceCli.EC2.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{}) - requireAWSErrorCode(t, err, "AccessDenied") + requireAWSErrorCode(t, err, "UnauthorizedOperation") _, err = aliceCli.IAM.CreateUser(&iam.CreateUserInput{UserName: aws.String("hack")}) requireAWSErrorCode(t, err, "AccessDenied") @@ -508,7 +508,7 @@ func TestIAMPolicyAttachmentEnforcement(t *testing.T) { _, err = bobCli.EC2.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{}) require.NoError(t, err, "bob ec2:DescribeKeyPairs") _, err = bobCli.EC2.TerminateInstances(&ec2.TerminateInstancesInput{InstanceIds: []*string{aws.String("i-fake")}}) - requireAWSErrorCode(t, err, "AccessDenied") + requireAWSErrorCode(t, err, "UnauthorizedOperation") _, err = bobCli.IAM.ListUsers(&iam.ListUsersInput{}) requireAWSErrorCode(t, err, "AccessDenied") @@ -533,7 +533,7 @@ func TestIAMPolicyAttachmentEnforcement(t *testing.T) { _, err = aliceCli.EC2.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{}) require.NoError(t, err, "alice ec2:DescribeKeyPairs (Describe*)") _, err = aliceCli.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{KeyName: aws.String("x")}) - requireAWSErrorCode(t, err, "AccessDenied") + requireAWSErrorCode(t, err, "UnauthorizedOperation") _, err = aliceCli.IAM.ListUsers(&iam.ListUsersInput{}) requireAWSErrorCode(t, err, "AccessDenied") @@ -583,7 +583,7 @@ func TestIAMPolicyLifecycle(t *testing.T) { _, err = iamCli.DetachUserPolicy(&iam.DetachUserPolicyInput{UserName: aws.String(iamUserAlice), PolicyArn: aws.String(descAllArn)}) require.NoError(t, err, "detach EC2DescribeAll") _, err = aliceCli.EC2.DescribeInstances(&ec2.DescribeInstancesInput{}) - requireAWSErrorCode(t, err, "AccessDenied") + requireAWSErrorCode(t, err, "UnauthorizedOperation") _, err = iamCli.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: aws.String(denyArn)}) requireAWSErrorCode(t, err, "DeleteConflict") diff --git a/tests/integration/predastore_test.go b/tests/integration/predastore_test.go index 12ac2b6e0..feae0ce87 100644 --- a/tests/integration/predastore_test.go +++ b/tests/integration/predastore_test.go @@ -90,7 +90,7 @@ func predastoreS3Client(t *testing.T, fixture *testpredastore.Fixture) *s3.S3 { t.Helper() httpClient := &http.Client{ Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // self-signed cert generated by this same fixture, localhost-only + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, }, } sess, err := session.NewSession(&aws.Config{ diff --git a/tests/integration/request_conformance_test.go b/tests/integration/request_conformance_test.go index 9c222f172..c15e51f35 100644 --- a/tests/integration/request_conformance_test.go +++ b/tests/integration/request_conformance_test.go @@ -8,6 +8,7 @@ import ( "io" "net" "net/http" + "strings" "testing" "time" @@ -50,8 +51,10 @@ func TestRequestConformance(t *testing.T) { startRequestConformanceBackends(t, gw) sender, err := newRequestSender(gw, service) require.NoError(t, err) + options := awsmodel.RequestOptions{AccountID: gw.AccountID, Region: testRegion, AccessKeyID: testAccessKeyID} + options.Fixtures = createRequestFixtures(t, sender, service, gw.AccountID) for _, operation := range coverage.Implemented { - plan, err := awsmodel.GenerateRequests(service, operation, awsmodel.RequestOptions{AccountID: gw.AccountID, Region: testRegion}) + plan, err := awsmodel.GenerateRequests(service, operation, options) require.NoError(t, err, operation) suiteRequestConformance.recordOperation(service, operation, plan) judge := plan.NewJudge() @@ -82,6 +85,56 @@ func startRequestConformanceBackends(t *testing.T, gw *Gateway) { StartServiceDaemonLite(t, gw) } +// requestFixture is the name every fixture resource takes. +const requestFixture = "conformance-fixture" + +// createRequestFixtures creates one resource of each kind the service's +// generated requests name, so a case reaches its resource instead of ending +// in not-found. It returns the members that name them. +func createRequestFixtures(t *testing.T, sender *requestSender, service awsmodel.Service, accountID string) map[string]string { + t.Helper() + create := func(operation string, input map[string]any) { + t.Helper() + result, err := sender.send(awsmodel.RequestCase{Operation: operation, Input: input}) + require.NoError(t, err, operation) + require.True(t, result.Status >= 200 && result.Status < 300, "create fixture: %s: %d %s: %s", operation, result.Status, result.Code, result.Message) + } + switch service { + case awsmodel.IAM: + const oidcHost = "fixture.example.com" + create("CreateRole", map[string]any{"RoleName": requestFixture, "AssumeRolePolicyDocument": `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}`}) + create("CreatePolicy", map[string]any{"PolicyName": requestFixture, "PolicyDocument": `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`}) + create("CreateOpenIDConnectProvider", map[string]any{"Url": "https://" + oidcHost, "ClientIDList": []any{"sts.amazonaws.com"}, "ThumbprintList": []any{strings.Repeat("a", 40)}}) + create("CreateInstanceProfile", map[string]any{"InstanceProfileName": requestFixture}) + create("CreateGroup", map[string]any{"GroupName": requestFixture}) + create("CreateUser", map[string]any{"UserName": requestFixture}) + return map[string]string{ + "RoleName": requestFixture, + "PolicyArn": "arn:aws:iam::" + accountID + ":policy/" + requestFixture, + "OpenIDConnectProviderArn": "arn:aws:iam::" + accountID + ":oidc-provider/" + oidcHost, + "InstanceProfileName": requestFixture, + "GroupName": requestFixture, + "UserName": requestFixture, + } + case awsmodel.ECS: + create("CreateCluster", map[string]any{"clusterName": requestFixture}) + return map[string]string{ + "cluster": requestFixture, + "clusters": requestFixture, + "resourceArn": "arn:aws:ecs:" + testRegion + ":" + accountID + ":cluster/" + requestFixture, + } + case awsmodel.ECR: + create("CreateRepository", map[string]any{"repositoryName": requestFixture}) + return map[string]string{ + "repositoryName": requestFixture, + "repositoryNames": requestFixture, + "resourceArn": "arn:aws:ecr:" + testRegion + ":" + accountID + ":repository/" + requestFixture, + } + default: + return nil + } +} + type requestSender struct { endpoint string service awsmodel.Service diff --git a/tests/integration/routetable_test.go b/tests/integration/routetable_test.go index c07b3e377..fa45cfcb1 100644 --- a/tests/integration/routetable_test.go +++ b/tests/integration/routetable_test.go @@ -390,7 +390,7 @@ func TestReplaceRouteConvergence(t *testing.T) { DestinationCidrBlock: aws.String("198.51.100.0/24"), GatewayId: aws.String(igw2), }) - requireAWSErrorCode(t, err, "InvalidRoute.NotFound") + requireAWSErrorCode(t, err, "InvalidParameterValue") // Local route is immutable. _, err = c.ReplaceRoute(&ec2.ReplaceRouteInput{ @@ -413,7 +413,7 @@ func TestReplaceRouteConvergence(t *testing.T) { DestinationCidrBlock: aws.String(dest), GatewayId: aws.String("igw-00000000000000000"), }) - requireAWSErrorCode(t, err, "InvalidInternetGatewayID.NotFound") + requireAWSErrorCode(t, err, "InvalidGatewayID.NotFound") // IGW attached to a different VPC than the route table. _, err = c.ReplaceRoute(&ec2.ReplaceRouteInput{ diff --git a/tests/integration/run_instances_passrole_test.go b/tests/integration/run_instances_passrole_test.go index 76a65497f..c313d561f 100644 --- a/tests/integration/run_instances_passrole_test.go +++ b/tests/integration/run_instances_passrole_test.go @@ -114,7 +114,7 @@ func testInstanceProfilePassRole(t *testing.T, path string) { // No PassRole grant: denied before any NATS dispatch, so no daemon stub // is needed for this call to resolve promptly. _, err = sessionCli.EC2.RunInstances(runInput) - requireAWSErrorCode(t, err, "AccessDenied") + requireAWSErrorCode(t, err, "UnauthorizedOperation") // An explicit path-scoped deny must override an otherwise unrestricted grant. denyPolicy, err := iamCli.CreatePolicy(&iam.CreatePolicyInput{ @@ -131,12 +131,12 @@ func testInstanceProfilePassRole(t *testing.T, path string) { }) require.NoError(t, err) _, err = sessionCli.EC2.RunInstances(runInput) - requireAWSErrorCode(t, err, "AccessDenied") + requireAWSErrorCode(t, err, "UnauthorizedOperation") _, err = sessionCli.EC2.AssociateIamInstanceProfile(&ec2.AssociateIamInstanceProfileInput{ InstanceId: aws.String("i-0123456789abcdef0"), IamInstanceProfile: runInput.IamInstanceProfile, }) - requireAWSErrorCode(t, err, "AccessDenied") + requireAWSErrorCode(t, err, "UnauthorizedOperation") _, err = iamCli.DetachRolePolicy(&iam.DetachRolePolicyInput{ RoleName: callerRoleOut.Role.RoleName, PolicyArn: denyPolicy.Policy.Arn, diff --git a/tests/integration/service_daemon_lite.go b/tests/integration/service_daemon_lite.go index 7315f0d61..7beb854fe 100644 --- a/tests/integration/service_daemon_lite.go +++ b/tests/integration/service_daemon_lite.go @@ -3,6 +3,7 @@ package integration import ( + "path/filepath" "testing" "github.com/mulgadc/spinifex/spinifex/config" @@ -27,6 +28,11 @@ func StartServiceDaemonLite(t *testing.T, gw *Gateway) { acm, err := handlers_acm.NewACMServiceImplWithNATS(t.Context(), cfg, nc, masterKey) require.NoError(t, err, "construct ACM service") + // Without a tenant CA no mode can issue, so every RequestCertificate would + // be refused. The generated requests name example.com domains. + caDir := t.TempDir() + acm.TenantCA, err = handlers_acm.LoadOrCreateTenantCA(filepath.Join(caDir, "ca.pem"), filepath.Join(caDir, "ca.key"), []string{"example.com"}) + require.NoError(t, err, "create ACM tenant CA") subscribeServiceMethods(t, nc, "acm", acm) elbv2, err := handlers_elbv2.NewELBv2ServiceImplWithNATS(cfg, nc, masterKey) @@ -35,7 +41,7 @@ func StartServiceDaemonLite(t *testing.T, gw *Gateway) { subscribeServiceMethods(t, nc, "elbv2", elbv2) eks, err := handlers_eks.NewEKSServiceImpl(handlers_eks.EKSServiceDeps{ - Config: cfg, NATSConn: nc, MasterKey: masterKey, Region: testRegion, HolderID: "integration-test-node", ClusterSize: 1, + Config: cfg, NATSConn: nc, MasterKey: masterKey, Region: testRegion, HolderID: "integration-test-node", }) require.NoError(t, err, "construct EKS service") t.Cleanup(eks.Shutdown) diff --git a/tests/integration/shared_nats.go b/tests/integration/shared_nats.go index ac285f2f8..0a3345609 100644 --- a/tests/integration/shared_nats.go +++ b/tests/integration/shared_nats.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/mulgadc/spinifex/spinifex/clustersize" "github.com/mulgadc/spinifex/tests/fixtures/scratch" "github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats.go" @@ -43,6 +44,14 @@ const natsStorePrefix = "spinifex-integration-nats-" // startSharedNATS boots the one embedded, JetStream-enabled NATS server the // whole package's tests connect into. func startSharedNATS() (*sharedNATS, error) { + // One embedded, non-clustered server, so every bucket this binary creates is + // on one replica. Bucket creation refuses until it is told, and this package + // builds its gateway by hand rather than through config.LoadConfig, which is + // where a real process declares it. + if err := clustersize.Declare(1); err != nil { + return nil, err + } + // The store cannot be a t.TempDir(): the server outlives every individual // test, so the first test to finish would delete it out from under it. scratch.SweepAbandoned(os.TempDir(), natsStorePrefix, scratch.DefaultMaxAge) diff --git a/tests/integration/sts_test.go b/tests/integration/sts_test.go index f2d6952b4..c4baecca3 100644 --- a/tests/integration/sts_test.go +++ b/tests/integration/sts_test.go @@ -176,7 +176,7 @@ func TestSTSAssumeRoleAndGetCallerIdentity(t *testing.T) { // permissions. The granted-policy positive case lives in // TestAssumedRoleControlPlaneEnforcement. _, err = sessionCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) - requireAWSErrorCode(t, err, "AccessDenied") + requireAWSErrorCode(t, err, "UnauthorizedOperation") // GetSessionToken is user-only: an assumed-role (ASIA) session must NOT // be able to mint a user session.