diff --git a/.github/scripts/suite-sets.sh b/.github/scripts/suite-sets.sh index 96266918a..2b1f53499 100644 --- a/.github/scripts/suite-sets.sh +++ b/.github/scripts/suite-sets.sh @@ -28,10 +28,19 @@ # that runs identically on either, and because a quota left switched on would # cap every suite sharing the environment — so it must be proved to restore # itself wherever it runs, not only on one node. -E2E_SUITES_SINGLE="single iam cert eks ecs storagegrowth partialblock rds quota" +# storagefault runs last in both lists and belongs to both topologies, for +# different reasons. On one node it is RS(1,0), so freezing the only predastore +# is a total outage and the guest-survival assertion is unambiguous. On three it +# is RS(2,1) with degraded writes on, so two must be frozen to break the shard +# floor — and only there is a cross-node restart possible at all, which is the +# case that takes a volume over from a node holding writes the backend never +# received. It is last because it SIGSTOPs a cluster-wide +# service: anything sharing the environment would see the same outage and report +# a failure that says nothing about itself. +E2E_SUITES_SINGLE="single iam cert eks ecs storagegrowth partialblock rds quota storagefault" # Suites runnable against a multi-node environment. -E2E_SUITES_MULTI="multinode lb cert quota" +E2E_SUITES_MULTI="multinode lb cert quota storagefault" # grep -xE alternation form, for narrowing a requested set to the eligible one. E2E_SUITES_SINGLE_RE="$(printf '%s' "${E2E_SUITES_SINGLE}" | tr ' ' '|')" @@ -44,6 +53,16 @@ E2E_SUITES_MULTI_RE="$(printf '%s' "${E2E_SUITES_MULTI}" | tr ' ' '|')" # nightly in their own dedicated cell, because each needs a guest AMI built # first and a service failure should not mask whether a host OS boots. These # are subsets, and e2e_suites_assert_subset below enforces that. +# +# storagefault has a dedicated cell for two reasons. It SIGSTOPs a cluster-wide +# service, so in a permutation cell every suite sharing that environment would +# report the outage as its own failure. And a full pass is ~63 minutes, nearly +# all of it deliberate waiting, which is too long to add to the PR gate — where +# it has never run, since the gate's default suite list does not name it. +# +# Its cell is three-node: the RS(2,1) shard floor and the cross-node takeover +# 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" E2E_SUITES_NIGHTLY_MULTI="multinode cert lb" @@ -61,6 +80,10 @@ e2e_suite_timeout() { # creates first. One pass is ~20 minutes on the bare-metal cell and a # baseline capture at SPINIFEX_DISKPERF_REPS=3 is three times that. diskperf) echo "180m" ;; + # storagefault is mostly deliberate waiting: one test alone holds predastore + # SIGSTOPped for seven minutes to prove a guest survives it. A full pass on + # dev-prod is ~63 minutes, so the 30m default truncated it mid-suite. + storagefault) echo "90m" ;; *) echo "30m" ;; esac } diff --git a/.github/workflows/e2e-nightly.yml b/.github/workflows/e2e-nightly.yml index f5eb0b729..5af2ff985 100644 --- a/.github/workflows/e2e-nightly.yml +++ b/.github/workflows/e2e-nightly.yml @@ -6,7 +6,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" + 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" required: false permissions: @@ -47,9 +47,10 @@ jobs: - { cell: 25, install: binary, topo: single, net: static, host: debian-13, arch: x86_64, runner: ci-single, extra: "eks", suite: "eks" } - { cell: 26, install: binary, topo: single, net: static, host: debian-13, arch: x86_64, runner: ci-single, extra: "ecs", suite: "ecs" } - { cell: 27, install: binary, topo: single, net: static, host: debian-13, arch: x86_64, runner: ci-single, extra: "rds", suite: "rds" } + - { cell: 29, install: binary, topo: real-multi, net: static, host: debian-13, arch: x86_64, runner: ci-multi, extra: "", suite: "storagefault", timeout_minutes: 100 } runs-on: [ self-hosted, "${{ matrix.runner }}" ] - timeout-minutes: 50 + timeout-minutes: ${{ matrix.timeout_minutes || 50 }} env: TOFU_LOG: /tmp/e2e-nightly-cell-${{ matrix.cell }}-${{ github.run_id }}.log ARTIFACT_DIR: /tmp/spinifex-e2e-nightly-cell-${{ matrix.cell }}-${{ github.run_id }} @@ -174,7 +175,7 @@ jobs: --test-tar "${RUNNER_TEMP}/spinifex-tests.tar" - name: Run Single Node E2E - if: steps.gate.outputs.selected == 'true' && matrix.topo == 'single' && matrix.extra == '' + if: steps.gate.outputs.selected == 'true' && matrix.topo == 'single' && matrix.extra == '' && matrix.suite == '' working-directory: mulga/scripts/tofu-cluster run: | set -euo pipefail @@ -224,7 +225,7 @@ jobs: if [ \$rc -ne 0 ]; then echo 'natuplink: FAILED' >> '${ARTIFACT_DIR}/suite-status.txt'; fi; exit \$rc" - name: Run Multi Node E2E - if: steps.gate.outputs.selected == 'true' && matrix.topo == 'real-multi' + if: steps.gate.outputs.selected == 'true' && matrix.topo == 'real-multi' && matrix.suite == '' working-directory: mulga/scripts/tofu-cluster run: | set -euo pipefail @@ -259,11 +260,11 @@ jobs: if [ \$rc -ne 0 ]; then echo \"\$SUITE: FAILED\" >> '${ARTIFACT_DIR}/suite-status.txt'; FAIL=1; fi; \ done; exit \$FAIL" - # Cells 25-27 only. Resolved once here rather than in each step below, - # which would repeat the same three lines five times. + # The dedicated suite cells only. Resolved once here rather than in each + # step below, which would repeat the same three lines five times. - name: Resolve VM SSH details id: vm - if: steps.gate.outputs.selected == 'true' && matrix.suite != '' + if: steps.gate.outputs.selected == 'true' && matrix.suite != '' && matrix.topo == 'single' working-directory: mulga/scripts/tofu-cluster run: | set -euo pipefail @@ -407,7 +408,7 @@ jobs: done - name: Run Service Suite E2E - if: steps.gate.outputs.selected == 'true' && matrix.suite != '' + if: steps.gate.outputs.selected == 'true' && matrix.suite != '' && matrix.topo == 'single' env: SUITE: ${{ matrix.suite }} run: | @@ -432,6 +433,46 @@ jobs: if [ \$rc -ne 0 ]; then echo \"${SUITE}: FAILED\" >> '${ARTIFACT_DIR}/suite-status.txt'; fi; \ exit \$rc" + # The multi-node twin of the step above. Separate rather than merged + # because the suite needs every node's address to reach the whole cluster, + # which the single-node path has no way to supply. + - name: Run Multi Suite E2E + if: steps.gate.outputs.selected == 'true' && matrix.suite != '' && matrix.topo == 'real-multi' + working-directory: mulga/scripts/tofu-cluster + env: + SUITE: ${{ matrix.suite }} + run: | + set -euo pipefail + . "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh" + e2e_suites_assert_subset "e2e-nightly cell-${{ matrix.cell }}" "${SUITE}" "${E2E_SUITES_MULTI}" + TMO="$(e2e_suite_timeout "${SUITE}")" + + INVENTORY=$(tofu output -json inventory) + NODE1_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip') + SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path') + SSH_KEY="${SSH_KEY/#\~/$HOME}" + + NODES_CSV="" + NODE_COUNT=$(echo "$INVENTORY" | jq '.nodes | length') + for i in $(seq 0 $((NODE_COUNT - 1))); do + ip=$(echo "$INVENTORY" | jq -r ".nodes[$i].wan_ip") + NODES_CSV="${NODES_CSV:+$NODES_CSV,}$ip" + done + + ssh -tt -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${NODE1_IP} \ + "set -u; mkdir -p '${ARTIFACT_DIR}'; cd \$HOME/e2e-tests/_bin; \ + echo \"::group::suite ${SUITE}\"; \ + date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-${SUITE}.start'; \ + GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=multinode \ + SPINIFEX_NODES='${NODES_CSV}' SPINIFEX_NODE_IPS='${NODES_CSV}' \ + SPINIFEX_SSH_USER=tf-user SPINIFEX_SSH_KEY=\$HOME/.ssh/tf-user-ap-southeast-2 \ + ARTIFACT_DIR='${ARTIFACT_DIR}' \ + ./${SUITE}.test -test.v -test.timeout=${TMO} \ + 2>&1 | tee '${ARTIFACT_DIR}/test-${SUITE}.log'; \ + rc=\${PIPESTATUS[0]}; echo \"::endgroup::\"; \ + if [ \$rc -ne 0 ]; then echo \"${SUITE}: FAILED\" >> '${ARTIFACT_DIR}/suite-status.txt'; fi; \ + exit \$rc" + - name: Import RDS PostgreSQL AMI if: steps.gate.outputs.selected == 'true' && matrix.extra == 'tofu-examples' working-directory: mulga/scripts/tofu-cluster diff --git a/cmd/spinifex/cmd/admin.go b/cmd/spinifex/cmd/admin.go index 27913cd8c..48c71d05a 100644 --- a/cmd/spinifex/cmd/admin.go +++ b/cmd/spinifex/cmd/admin.go @@ -40,6 +40,7 @@ import ( "github.com/mulgadc/spinifex/spinifex/hostdns" "github.com/mulgadc/spinifex/spinifex/network/host" "github.com/mulgadc/spinifex/spinifex/objectstore" + "github.com/mulgadc/spinifex/spinifex/services/viperblockd/vbwire" "github.com/mulgadc/spinifex/spinifex/utils" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" @@ -218,6 +219,18 @@ decision made per volume.`, Run: runVolumesOrphansCmd, } +var volumesUnsealedCmd = &cobra.Command{ + Use: "unsealed", + Short: "Report volumes whose writes have not been confirmed to the backend", + Long: `List volumes a node has open, or had open, without a confirmed seal to the +backend. The node named holds writes that predastore may not have. + +Starting such a volume elsewhere is allowed and is preferred over leaving the +instance unable to run, but it opens from the last checkpoint that did reach +the backend. This is the list of volumes where that would cost something.`, + Run: runVolumesUnsealedCmd, +} + var accountCmd = &cobra.Command{ Use: "account", Short: "Manage Spinifex accounts", @@ -318,6 +331,7 @@ func init() { adminCmd.AddCommand(volumesCmd) volumesCmd.AddCommand(volumesOrphansCmd) + volumesCmd.AddCommand(volumesUnsealedCmd) adminCmd.AddCommand(accountCmd) accountCmd.AddCommand(accountCreateCmd) @@ -3697,3 +3711,37 @@ func printChecksumError(w io.Writer, imageFile, imageName string, image utils.Im fmt.Fprintln(w, "The cached file was left in place. To re-download and retry:") fmt.Fprintf(w, " spx admin images import --name %s --force\n", imageName) } + +func runVolumesUnsealedCmd(_ *cobra.Command, _ []string) { + _, nc, err := loadConfigAndConnect() + if err != nil { + fmt.Fprintf(os.Stderr, "Could not connect to the cluster: %v\n", err) + os.Exit(1) + } + defer nc.Close() + + unsealed, err := vbwire.ListUnsealedVolumes(context.Background(), nc) + if err != nil { + fmt.Fprintln(os.Stderr, "Could not read the unsealed volumes:", err) + os.Exit(1) + } + if len(unsealed) == 0 { + fmt.Println("No unsealed volumes: every volume's writes are confirmed to the backend.") + return + } + + sort.Slice(unsealed, func(i, j int) bool { return unsealed[i].VolumeID < unsealed[j].VolumeID }) + fmt.Printf("%d volume(s) with writes the backend may not have:\n\n", len(unsealed)) + for _, volume := range unsealed { + fmt.Printf(" %s\n", volume.VolumeID) + fmt.Printf(" held by: %s\n", volume.Owner) + if !volume.Since.IsZero() { + fmt.Printf(" since: %s\n", volume.Since.Format(time.RFC3339)) + } + fmt.Printf(" reason: %s\n", volume.Reason) + } + fmt.Println() + fmt.Println("A volume open right now is listed here and that is normal — the entry") + fmt.Println("clears on its next successful seal. One naming a node that is gone is") + fmt.Println("the case worth acting on: starting it elsewhere loses writes made since.") +} diff --git a/docs/compute/vpc-networking/README.md b/docs/compute/vpc-networking/README.md index 4e7cc9099..ecdb22712 100644 --- a/docs/compute/vpc-networking/README.md +++ b/docs/compute/vpc-networking/README.md @@ -795,6 +795,103 @@ aws ec2 run-instances --image-id $AMI --instance-type t3.small \ Rule changes take effect immediately — no instance restart needed. +## The Instance-to-Host Plane (Metadata and VPC DNS) + +Security groups govern traffic **between instances** and **to the outside**. There +is a third path they do not touch, and it is the one most easily left exposed: +traffic from an instance to the **host it runs on**. + +Every instance reaches two link-local addresses served by the hypervisor: + +| Address | Service | Port | +| --- | --- | --- | +| `169.254.169.254` | Instance metadata (IMDS) — cloud-init, instance-role credentials | TCP 80 | +| `169.254.169.253` | VPC DNS resolver | UDP/TCP 53 | + +These are not guest addresses and not OVN routed. Each instance's tap has a +capture rule on the hypervisor that intercepts any packet addressed to `.254` or +`.253` and delivers it to a per-ENI internal port in the host network namespace +(named `ime-*`). That interception is **by destination address only — it does not +match the port.** A packet to `169.254.169.254:22` is delivered to the host just +as readily as one to `169.254.169.254:80`. + +That matters because host services — SSH, the AWS gateway on 9999, the console on +3000, the DNS server — bind the wildcard address, so they also answer on these +link-local addresses. Without a control on the `ime-*` path, an instance can +reach every one of them, with none of the source scoping that protects the same +services on the network. A resolver query sent straight to the host's DNS port +this way also bypasses the per-instance DNS rate limit. + +### This plane is invisible to a network firewall + +The critical point for anyone securing a deployment: **this traffic never crosses +the physical NIC.** It is intra-host, on the OVS bridge between the instance's tap +and the `ime-*` port. A cloud security group, an upstream firewall, `ufw`, or any +rule written against the WAN interface is not in this path and cannot filter it. +The only place to enforce it is on the `ime-*` interface in the host itself. + +### How it is protected + +Spinifex's host firewall carries a rule that scopes the `ime-*` path to exactly +the two legitimate endpoints and ports — IMDS on `.254:80`, DNS on `.253:53` — +and drops everything else. Whether you have that rule depends entirely on whether +the host firewall is on: + +| Install path | Host firewall | This plane | +| --- | --- | --- | +| **From the ISO** | on | protected | +| **Binary installer** (`curl \| bash`) or `setup.sh` | **off** | **exposed** | +| **Binary installer with `--firewall=on`** | on | protected | + +The ISO ships the firewall armed, so ISO deployments are protected out of the box. +**The binary installer ships it off** — deliberately, because it runs on servers +that may already have services the installer knows nothing about, and a +default-deny policy could cut them off. The consequence is that a binary install +left at its default has this plane wide open, and so does every host service on +the WAN besides. + +If you install any way other than the ISO, turn the host firewall on. At install +time: + +```bash +curl -fsSL https://install.mulgadc.com | bash -s -- --firewall=on +# or, from a source checkout: +sudo /usr/local/share/spinifex/setup.sh --firewall=on +``` + +or afterwards, in `/etc/spinifex/spinifex.toml`, followed by a daemon restart: + +```toml +[network] +firewall_enabled = true +``` + +Before enabling it, check what else the machine is serving — anything listening +outside the public port group stops accepting new connections. See +[Firewall and Cluster Membership](/docs/install-multi-node#firewall-and-cluster-membership) +for the full port policy and the cluster-formation steps. + +### Verifying it + +On the hypervisor, the `ime-*` accept should be scoped, not blanket: + +```bash +sudo nft list chain inet spinifex_filter input | grep ime +# Expect three rules naming 169.254.169.254 tcp dport 80 and 169.254.169.253 +# udp/tcp dport 53 — not a bare `iifname "ime-*" accept`. +``` + +From an instance, the two service ports work and nothing else does: + +```bash +curl -s -o /dev/null -w '%{http_code}\n' http://169.254.169.254/latest/meta-data/ # 401 (IMDSv2) +dig +short @169.254.169.253 example.com A # resolves +nc -vz 169.254.169.254 22 # must fail +``` + +A missing `spinifex_filter` table means the host firewall is off and this plane +is unprotected regardless of the rule above. + ## Elastic IPs Elastic IPs are static public IPs that persist across instance stop/start cycles. Unlike auto-assigned public IPs (which change on stop/start), an Elastic IP stays with your instance. diff --git a/docs/security/network-connections/README.md b/docs/security/network-connections/README.md index c5d0803ba..8c4b909d2 100644 --- a/docs/security/network-connections/README.md +++ b/docs/security/network-connections/README.md @@ -102,6 +102,7 @@ behavior in other words. | 5300 | northstar | DNS (UDP + TCP) | Cluster | Forward target for every node's per-instance DNS shim, dialled cross-node. Binds the wildcard by design. | None — restrict by firewall | | 6660 | predastore (blob node) | QUIC / UDP | Cluster | Erasure-coded object shard transport between hosts. Multi-node clusters only — see *Predastore ports* below. | TLS 1.3, server certificate verified against the cluster CA | | 7660 | predastore (meta node) | QUIC / UDP | Cluster | Raft consensus over global state — buckets and the object index — between hosts. Multi-node clusters only. | TLS 1.3, server certificate verified against the cluster CA | +| 8660 | predastore (admin) | HTTP | Cluster | `/healthz` and `/readyz`. Readiness names the blob peers a gate cannot reach, so it must not face the WAN. Binds the host's cluster `bind_addr`, never the wildcard. | None — unauthenticated by design; restrict by firewall | | 6641 | OVN Northbound DB (client) | OVSDB/TCP | Cluster | Logical network topology consumed by vpcd. Binds `127.0.0.1` plus the node's lan-plane address (`--lan-addr`), never the wildcard address. On a node with no separate lan plane that address is the public one, and a host firewall is the only remaining control. | Cluster network only; TLS planned | | 6642 | OVN Southbound DB (client) | OVSDB/TCP | Cluster | Chassis / port / MAC binding state. Binds `127.0.0.1` plus the node's lan-plane address (`--lan-addr`), never the wildcard address. On a node with no separate lan plane that address is the public one, and a host firewall is the only remaining control. | Cluster network only; TLS planned | | 6643 | OVN Northbound DB (RAFT) | OVSDB/TCP | Cluster | NB database RAFT replication between the 3 quorum nodes | Cluster network only; TLS planned | @@ -207,7 +208,7 @@ table inet spinifex_filter { # Cluster, from peer nodes only. Replace with your nodes' lan-plane # addresses — a CIDR does not generalise to nodes on different subnets. - ip saddr { 10.0.1.1, 10.0.1.2, 10.0.1.3 } tcp dport { 4222, 4248, 4432, 5300, 6641, 6642, 6643, 6644 } accept + ip saddr { 10.0.1.1, 10.0.1.2, 10.0.1.3 } tcp dport { 4222, 4248, 4432, 5300, 6641, 6642, 6643, 6644, 8660 } accept ip saddr { 10.0.1.1, 10.0.1.2, 10.0.1.3 } udp dport { 5300, 6660, 7660 } accept # Encap, from peer chassis only. Replace with your nodes' vpc-plane diff --git a/scripts/setup.sh b/scripts/setup.sh index 333e42a94..a0ab7ae61 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -371,7 +371,16 @@ table inet spinifex_filter { # here. Without this, cloud-init, instance-role credentials and all # guest DNS break. The ports exist only while instances run, so this # matches the prefix rather than an enumerated list. - iifname "ime-*" accept + # + # Scoped to the two link-local endpoints and their ports, not a blanket + # accept: the OVS demux delivers any guest packet to 169.254.169.254/.253 + # onto an ime-* port regardless of L4 port, so `iifname "ime-*" accept` + # would expose every wildcard-bound host service (sshd, the AWS gateway, + # northstar, the UI) to any tenant. IMDS is HTTP on .254:80; VPC DNS is + # 53 on .253. Everything else on these ports falls through to policy drop. + iifname "ime-*" ip daddr 169.254.169.254 tcp dport 80 accept + iifname "ime-*" ip daddr 169.254.169.253 udp dport 53 accept + iifname "ime-*" ip daddr 169.254.169.253 tcp dport 53 accept # PMTUD is not optional under a Geneve overlay. icmp type { echo-request, destination-unreachable, time-exceeded, parameter-problem } accept @@ -412,8 +421,10 @@ table inet spinifex_filter { # than an interface or a CIDR. 4432 stays here rather than joining the # public rule above: outside formation it is the daemon cluster manager, # whose /health and /local/* routes report node topology, service - # inventory and running instances to anyone who asks. - ip saddr $spinifex_peers tcp dport { 4222, 4248, 4432, 5300, 6641, 6642, 6643, 6644 } accept + # inventory and running instances to anyone who asks. 8660 is + # predastore's unauthenticated /healthz and /readyz, which name the + # peers a gate cannot reach, so it belongs here for the same reason. + ip saddr $spinifex_peers tcp dport { 4222, 4248, 4432, 5300, 6641, 6642, 6643, 6644, 8660 } accept ip saddr $spinifex_peers udp dport { 5300, 6660, 7660 } accept # Encap plane, peer-scoped: Geneve, IKE, NAT-T and ESP. Geneve is a diff --git a/spinifex/daemon/daemon.go b/spinifex/daemon/daemon.go index 887a51a30..5c3f51b8e 100644 --- a/spinifex/daemon/daemon.go +++ b/spinifex/daemon/daemon.go @@ -69,6 +69,7 @@ import ( "github.com/mulgadc/spinifex/spinifex/objectstore" "github.com/mulgadc/spinifex/spinifex/otelsetup" "github.com/mulgadc/spinifex/spinifex/preflight" + "github.com/mulgadc/spinifex/spinifex/services/viperblockd/vbwire" "github.com/mulgadc/spinifex/spinifex/types" "github.com/mulgadc/spinifex/spinifex/utils" "github.com/mulgadc/spinifex/spinifex/vm" @@ -1265,6 +1266,9 @@ func (d *Daemon) subscribeAll() error { // here, so the reconcile request flows daemon-ward. natsSub{dhcp.TopicLeaseChanged, d.handleDHCPLeaseChanged, "spinifex-workers"}, natsSub{dhcp.TopicOwnerCheck, d.handleDHCPOwnerCheck, "spinifex-workers"}, + // Node-addressed with no queue group: the fenced guest is on this host, + // so a worker on another node would find nothing to stop. + natsSub{vbwire.VolumeFencedSubject(d.node), d.handleVolumeFenced, ""}, ) return d.registerNatsSubs(subs) diff --git a/spinifex/daemon/daemon_handlers_fence.go b/spinifex/daemon/daemon_handlers_fence.go new file mode 100644 index 000000000..343ad954b --- /dev/null +++ b/spinifex/daemon/daemon_handlers_fence.go @@ -0,0 +1,45 @@ +package daemon + +import ( + "encoding/json" + "log/slog" + + "github.com/mulgadc/spinifex/spinifex/services/viperblockd/vbwire" + "github.com/mulgadc/spinifex/spinifex/utils" + "github.com/nats-io/nats.go" +) + +// handleVolumeFenced stops the local guest whose volume viperblockd has just +// torn the export out from under. +// +// By the time this arrives the disk is already gone, so the guest is doing no +// useful work and its writes cannot reach the backend. What is left is a QEMU +// process holding memory and taps, and an instance record claiming this node +// runs something that runs somewhere else now. +func (d *Daemon) handleVolumeFenced(msg *nats.Msg) string { + ctx, span := utils.StartConsumerSpan(msg) + defer span.End() + + var event vbwire.VolumeFencedEvent + if err := json.Unmarshal(msg.Data, &event); err != nil { + slog.ErrorContext(ctx, "volume fenced: bad event", "err", err) + return outcomeError + } + if event.Volume == "" { + slog.ErrorContext(ctx, "volume fenced: event named no volume") + return outcomeError + } + + instanceID := d.vmMgr.FenceVolume(ctx, event.Volume, event.Reason) + if instanceID == "" { + // The usual case: the volume outlived the guest that was using it, so + // there is nothing local left to stop. + slog.InfoContext(ctx, "volume fenced with no local guest using it", + "volumeId", event.Volume, "winner", event.Winner) + return outcomeSuccess + } + + slog.ErrorContext(ctx, "volume fenced, stopped the guest that was using it", + "volumeId", event.Volume, "instanceId", instanceID, "winner", event.Winner) + return outcomeSuccess +} diff --git a/spinifex/daemon/local_state.go b/spinifex/daemon/local_state.go index 803abf317..e6ee7d819 100644 --- a/spinifex/daemon/local_state.go +++ b/spinifex/daemon/local_state.go @@ -57,6 +57,11 @@ func MarshalLocalState(vms map[string]*vm.VM) ([]byte, error) { return data, nil } +// LocalStateFileMode is the mode of the on-disk instance state file. The daemon +// writes it and vpcd reads it to serve IMDS; they run as different users in the +// shared spinifex group, so owner-only would deny every IMDS local lookup. +const LocalStateFileMode = 0o640 + // WriteLocalStateBytes atomically writes pre-marshalled state JSON to path. func WriteLocalStateBytes(path string, data []byte) error { dir := filepath.Dir(path) @@ -65,11 +70,19 @@ func WriteLocalStateBytes(path string, data []byte) error { } tmp := path + ".tmp" - f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, LocalStateFileMode) if err != nil { return fmt.Errorf("open tmp %s: %w", tmp, err) } + // Explicitly, because OpenFile's mode is masked by the umask the service + // happens to inherit, and a 0600 here silently costs IMDS its local lookup. + if err := f.Chmod(LocalStateFileMode); err != nil { + f.Close() + os.Remove(tmp) + return fmt.Errorf("chmod tmp %s: %w", tmp, err) + } + if _, err := f.Write(data); err != nil { f.Close() os.Remove(tmp) diff --git a/spinifex/daemon/local_state_test.go b/spinifex/daemon/local_state_test.go index a94f5be4f..e43cc4c72 100644 --- a/spinifex/daemon/local_state_test.go +++ b/spinifex/daemon/local_state_test.go @@ -57,6 +57,19 @@ func TestWriteLocalState_MkdirParent(t *testing.T) { require.NoError(t, err) } +// TestWriteLocalState_GroupReadable pins the mode. vpcd serves IMDS and reads +// this file as a different user in the shared spinifex group, so an owner-only +// mode denies every local lookup and each guest boot falls back to the record +// space -- where a miss is served as "this instance has no key pair". +func TestWriteLocalState_GroupReadable(t *testing.T) { + path := filepath.Join(t.TempDir(), "instance-state.json") + require.NoError(t, writeLocalState(path, map[string]*vm.VM{})) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o640), info.Mode().Perm()) +} + func TestWriteLocalState_NoTmpLeftBehind(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "instance-state.json") diff --git a/spinifex/gateway/ec2/instance/StartInstances.go b/spinifex/gateway/ec2/instance/StartInstances.go index 432a0b8e0..52244ad3c 100644 --- a/spinifex/gateway/ec2/instance/StartInstances.go +++ b/spinifex/gateway/ec2/instance/StartInstances.go @@ -100,7 +100,7 @@ func startLiveInstance(ctx context.Context, natsConn *nats.Conn, instanceID, acc if responseError, parseErr := utils.ValidateErrorPayload(msg.Data); parseErr != nil { slog.ErrorContext(ctx, "StartInstances: owner returned error", "instance_id", instanceID, "code", *responseError.Code) - return nil, false, errors.New(*responseError.Code) + return nil, false, startRefusalError(responseError) } slog.InfoContext(ctx, "StartInstances: restarted via owner node", "instance_id", instanceID, "response", string(msg.Data)) @@ -132,9 +132,27 @@ func startStoppedInstance(ctx context.Context, natsConn *nats.Conn, instanceID, if responseError, parseErr := utils.ValidateErrorPayload(msg.Data); parseErr != nil { slog.ErrorContext(ctx, "StartInstances: Daemon returned error", "instance_id", instanceID, "code", *responseError.Code) - return nil, errors.New(*responseError.Code) + return nil, startRefusalError(responseError) } slog.InfoContext(ctx, "StartInstances: Command sent successfully", "instance_id", instanceID, "response", string(msg.Data)) return newStateChange(instanceID, 0, "pending", 80, "stopped"), nil } + +// startRefusalError rebuilds the daemon's error, keeping the message it sent. +// Reconstructing from the code alone renders the generic ErrorLookup text +// instead, which replaces the name of the node holding the volume with +// boilerplate about attaching volumes -- the one fact the operator needed. +func startRefusalError(responseError ec2.ResponseError) error { + var code, message string + if responseError.Code != nil { + code = *responseError.Code + } + if responseError.Message != nil { + message = *responseError.Message + } + if message == "" || message == code { + return errors.New(code) + } + return awserrors.Errorf(code, "%s", message) +} diff --git a/spinifex/gateway/ec2/volume/DetachVolume.go b/spinifex/gateway/ec2/volume/DetachVolume.go index 5075f4854..cac434ed1 100644 --- a/spinifex/gateway/ec2/volume/DetachVolume.go +++ b/spinifex/gateway/ec2/volume/DetachVolume.go @@ -8,6 +8,7 @@ import ( "log/slog" "time" + "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ec2" "github.com/mulgadc/spinifex/spinifex/awserrors" handlers_ec2_volume "github.com/mulgadc/spinifex/spinifex/handlers/ec2/volume" @@ -66,6 +67,8 @@ func DetachVolume(ctx context.Context, input *ec2.DetachVolumeInput, natsConn *n device = *input.Device } + force := input.Force != nil && *input.Force + command := types.EC2InstanceCommand{ ID: instanceID, Attributes: types.EC2CommandAttributes{ @@ -74,7 +77,7 @@ func DetachVolume(ctx context.Context, input *ec2.DetachVolumeInput, natsConn *n DetachVolumeData: &types.DetachVolumeData{ VolumeID: volumeID, Device: device, - Force: input.Force != nil && *input.Force, + Force: force, }, } @@ -93,6 +96,9 @@ func DetachVolume(ctx context.Context, input *ec2.DetachVolumeInput, natsConn *n if err != nil { slog.ErrorContext(ctx, "DetachVolume: NATS request failed", "instanceId", instanceID, "volumeId", volumeID, "err", err) if errors.Is(err, nats.ErrNoResponders) { + if force { + return forceDetach(ctx, natsConn, accountID, volumeID, instanceID) + } return output, errors.New(awserrors.ErrorInvalidInstanceIDNotFound) } return output, errors.New(awserrors.ErrorServerInternal) @@ -114,3 +120,31 @@ func DetachVolume(ctx context.Context, input *ec2.DetachVolumeInput, natsConn *n slog.InfoContext(ctx, "DetachVolume completed", "instanceId", instanceID, "volumeId", volumeID) return output, nil } + +// forceDetachSubject clears a volume's attachment in the control plane only and +// is answered by any node, unlike the ordinary detach, which routes to the host +// running the instance and so cannot help when that host is the problem. +const forceDetachSubject = "ec2.ForceDetachVolume" + +// forceDetach releases a volume whose instance has no responder. +// +// A volume outlives the instance it was attached to whenever teardown failed +// part-way, and the ordinary detach then has nowhere to go: there is no guest +// to unplug it from and no host to ask. Without this the volume is attached +// forever, which also makes it undeletable. Reaching here needs Force, which is +// the caller stating that no clean guest unmount is expected. +func forceDetach(ctx context.Context, natsConn *nats.Conn, accountID, volumeID, instanceID string) (ec2.VolumeAttachment, error) { + attachment, err := utils.NATSRequest[ec2.VolumeAttachment](ctx, natsConn, forceDetachSubject, + &ec2.DetachVolumeInput{VolumeId: &volumeID, Force: aws.Bool(true)}, 30*time.Second, accountID) + if err != nil { + slog.ErrorContext(ctx, "DetachVolume: force detach failed", "volumeId", volumeID, "instanceId", instanceID, "err", err) + return ec2.VolumeAttachment{}, err + } + + slog.WarnContext(ctx, "DetachVolume: forced, the instance had no responder", + "volumeId", volumeID, "instanceId", instanceID) + if attachment == nil { + return ec2.VolumeAttachment{}, nil + } + return *attachment, nil +} diff --git a/spinifex/gateway/ec2/volume/DetachVolume_test.go b/spinifex/gateway/ec2/volume/DetachVolume_test.go index 534494d1d..01760f646 100644 --- a/spinifex/gateway/ec2/volume/DetachVolume_test.go +++ b/spinifex/gateway/ec2/volume/DetachVolume_test.go @@ -1,12 +1,17 @@ package gateway_ec2_volume import ( + "context" + "encoding/json" "testing" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ec2" "github.com/mulgadc/spinifex/spinifex/awserrors" + "github.com/mulgadc/spinifex/spinifex/testutil" + "github.com/nats-io/nats.go" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestValidateDetachVolumeInput(t *testing.T) { @@ -92,3 +97,59 @@ func TestValidateDetachVolumeInput(t *testing.T) { }) } } + +// TestDetachVolume_ForceFallsBackWhenTheInstanceIsGone covers the deadlock the +// control-plane-only detach exists for. A teardown that fails part-way leaves a +// volume attached to an instance that no longer runs anywhere, and the ordinary +// detach routes to that instance's own subject, so it can never be answered. +// Without the fallback the volume is attached forever and therefore undeletable. +func TestDetachVolume_ForceFallsBackWhenTheInstanceIsGone(t *testing.T) { + _, nc := testutil.StartTestNATS(t) + + var forced []byte + sub, err := nc.Subscribe(forceDetachSubject, func(msg *nats.Msg) { + forced = msg.Data + data, err := json.Marshal(&ec2.VolumeAttachment{ + VolumeId: aws.String("vol-orphan"), State: aws.String("detached"), + }) + require.NoError(t, err) + require.NoError(t, msg.Respond(data)) + }) + require.NoError(t, err) + t.Cleanup(func() { _ = sub.Unsubscribe() }) + require.NoError(t, nc.Flush()) + + // No subscriber on ec2.cmd.i-gone, which is exactly a terminated instance. + out, err := DetachVolume(context.Background(), &ec2.DetachVolumeInput{ + VolumeId: aws.String("vol-orphan"), + InstanceId: aws.String("i-gone"), + Force: aws.Bool(true), + }, nc, "000000000001") + require.NoError(t, err, "force must release a volume whose instance has no responder") + + assert.Equal(t, "detached", aws.StringValue(out.State)) + assert.Contains(t, string(forced), "vol-orphan", "the force detach must name the volume") +} + +// TestDetachVolume_WithoutForceStillReportsTheMissingInstance guards the other +// direction: the fallback clears the attachment without touching the guest, so +// a caller who did not ask for force must keep getting the honest answer rather +// than a silent success. +func TestDetachVolume_WithoutForceStillReportsTheMissingInstance(t *testing.T) { + _, nc := testutil.StartTestNATS(t) + + sub, err := nc.Subscribe(forceDetachSubject, func(msg *nats.Msg) { + require.Fail(t, "force detach must not run without Force") + _ = msg.Respond(nil) + }) + require.NoError(t, err) + t.Cleanup(func() { _ = sub.Unsubscribe() }) + require.NoError(t, nc.Flush()) + + _, err = DetachVolume(context.Background(), &ec2.DetachVolumeInput{ + VolumeId: aws.String("vol-orphan"), + InstanceId: aws.String("i-gone"), + }, nc, "000000000001") + require.Error(t, err) + assert.Equal(t, awserrors.ErrorInvalidInstanceIDNotFound, err.Error()) +} diff --git a/spinifex/handlers/ec2/instance/service_impl.go b/spinifex/handlers/ec2/instance/service_impl.go index 4b00be443..ce6614e89 100644 --- a/spinifex/handlers/ec2/instance/service_impl.go +++ b/spinifex/handlers/ec2/instance/service_impl.go @@ -2323,6 +2323,9 @@ func (s *InstanceServiceImpl) StartStoppedInstance(ctx context.Context, input *S s.resourceMgr.Deallocate(instanceType) s.vmMgr.Delete(instance.ID) s.restoreClaimedStoppedInstance(ctx, instance) + if refusal := volumeHeldElsewhereError(err); refusal != nil { + return nil, refusal + } return nil, errors.New(awserrors.ErrorServerInternal) } @@ -2333,6 +2336,32 @@ func (s *InstanceServiceImpl) StartStoppedInstance(ctx context.Context, input *S return &StartStoppedInstanceOutput{Status: "running", InstanceID: instance.ID}, nil } +// volumeExclusionMarkers are the refusals that mean another node is writing this +// volume right now. Only the lease refuses: a volume whose writes are merely +// unsealed is opened here with a warning rather than blocked, so the instance +// still runs. The text arrives having crossed NATS from viperblockd. +var volumeExclusionMarkers = []string{ + "volume is leased by another owner", +} + +// volumeHeldElsewhereError converts a mount refused because another node holds +// the volume into a client-facing error carrying that reason. Returns nil for +// anything else. Without it the caller gets ServerInternal and is told to +// retry, which cannot succeed while the holder is down, and the text naming the +// holder never leaves the node's log. +func volumeHeldElsewhereError(err error) error { + if err == nil { + return nil + } + msg := err.Error() + for _, marker := range volumeExclusionMarkers { + if strings.Contains(msg, marker) { + return awserrors.Errorf(awserrors.ErrorIncorrectState, "%s", msg) + } + } + return nil +} + // restoreClaimedStoppedInstance writes instance back to the shared // stopped-instance store after a downstream failure that follows a // successful ClaimStoppedInstance (which already removed it), so the claim @@ -2442,25 +2471,21 @@ func (s *InstanceServiceImpl) deleteInstanceVolumes(ctx context.Context, instanc continue } - // User-visible volumes: DeleteOnTermination=false survives terminate, - // but terminate still implies detach (AWS semantics). Only the Boot - // volume can still be attached here — Stop's Unmount (daemon/vm_adapters.go) - // clears every non-Boot volume's attachment already, so a non-Boot - // DoT=false volume is genuinely a no-op skip. A DoT=false Boot volume - // is never touched by Unmount, so without this it would strand - // attached to the now-terminated instance forever. + // User-visible volumes: DeleteOnTermination=false survives terminate, but + // terminate still implies detach (AWS semantics) for every volume, Boot or + // not. Unmount clears a non-Boot attachment only when the seal succeeded, + // so a volume whose seal failed is still attached to an instance that is + // about to stop existing. if !ebsRequest.DeleteOnTermination { - if !ebsRequest.Boot { - slog.InfoContext(ctx, "TerminateStoppedInstance: volume has DeleteOnTermination=false, skipping", "name", ebsRequest.Name) - continue - } - slog.InfoContext(ctx, "TerminateStoppedInstance: boot volume has DeleteOnTermination=false, detaching without deleting", "name", ebsRequest.Name) + slog.InfoContext(ctx, "TerminateStoppedInstance: volume has DeleteOnTermination=false, detaching without deleting", "name", ebsRequest.Name) if s.volumeDeleter == nil { slog.WarnContext(ctx, "TerminateStoppedInstance: volume deleter not configured, skipping detach", "name", ebsRequest.Name) lastErr = errors.New("volume deleter not configured") continue } - if err := s.volumeDeleter.DetachVolumeOnTerminate(ctx, ebsRequest.Name, instance.AccountID); err != nil { + // A volume already gone is the detach's own goal, not a failure. + if err := s.volumeDeleter.DetachVolumeOnTerminate(ctx, ebsRequest.Name, instance.AccountID); err != nil && + !awserrors.IsNotFound(err) && !objectstore.IsNoSuchKeyError(err) { slog.ErrorContext(ctx, "TerminateStoppedInstance: failed to detach volume", "name", ebsRequest.Name, "err", err) lastErr = err } @@ -2877,13 +2902,14 @@ func (s *InstanceServiceImpl) buildInstanceStatus(v *vm.VM, systemImpaired bool) } // SystemStatus reflects host/node health, independent of the VM process: a - // running VM's host is reachable unless under memory pressure; non-running - // instances are not-applicable. + // running VM's host is reachable unless under memory pressure. A guest held + // on a backend I/O error is an infrastructure fault rather than a guest one, + // which is the distinction this check exists to draw. systemStatus, systemReach := instanceStatusOK, instanceStatusPassed switch { case v.Status != vm.StateRunning: systemStatus, systemReach = instanceStatusNotApplicable, instanceStatusNotApplicable - case systemImpaired: + case systemImpaired, v.Health.IOErrorResumes > 0: systemStatus, systemReach = instanceStatusImpaired, instanceStatusFailed } @@ -2928,6 +2954,18 @@ func instanceHealthSummary(v *vm.VM) (status, reachability string, impairedSince return instanceStatusImpaired, instanceStatusFailed, impPtr } + // Paused by werror=stop on a backend I/O error. The counter is cleared the + // moment a poll sees the guest running again, so this reports for exactly + // as long as the guest is actually held. + if v.Health.IOErrorResumes > 0 { + var impPtr *time.Time + if !v.Health.IOErrorSince.IsZero() { + since := v.Health.IOErrorSince + impPtr = &since + } + return instanceStatusImpaired, instanceStatusFailed, impPtr + } + // Grace period: freshly launched VMs report initializing until reachable. if v.Instance != nil && v.Instance.LaunchTime != nil && time.Since(*v.Instance.LaunchTime) < instanceInitializingGrace { diff --git a/spinifex/handlers/ec2/instance/service_impl_test.go b/spinifex/handlers/ec2/instance/service_impl_test.go index c44e0a1b5..20cf912c8 100644 --- a/spinifex/handlers/ec2/instance/service_impl_test.go +++ b/spinifex/handlers/ec2/instance/service_impl_test.go @@ -1811,8 +1811,8 @@ func TestTerminateStoppedInstance_UserVolumeDeleted(t *testing.T) { _, err := svc.TerminateStoppedInstance(context.Background(), &TerminateStoppedInstanceInput{InstanceID: id}, "acc") require.NoError(t, err) - assert.Equal(t, []string{"vol-user-001"}, vd.calls, "only DeleteOnTermination=true volumes deleted") - assert.Equal(t, []string{"vol-user-001"}, vd.deleted) + assert.Equal(t, []string{"vol-user-001"}, vd.deleted, "only DeleteOnTermination=true volumes deleted") + assert.Equal(t, []string{"vol-keep-001"}, vd.detached, "terminate implies detach even for a volume that survives it") } // TestTerminateStoppedInstance_StampsTeardownVolumesDone locks the @@ -1889,6 +1889,51 @@ func TestTerminateStoppedInstance_NonDoTBootVolumeDetachedNotDeleted(t *testing. assert.Equal(t, string(vm.TeardownDone), store.WroteTerminated[id].Teardown[vm.TeardownVolumes]) } +// TestTerminateStoppedInstance_NonDoTDataVolumeDetachedNotDeleted covers the +// non-Boot half. Stop's Unmount clears a data volume's attachment only when the +// seal succeeded, so a volume whose seal failed is still in-use and pointing at +// an instance that is about to stop existing. Skipping the detach here stranded +// it with no owner left to release it. +func TestTerminateStoppedInstance_NonDoTDataVolumeDetachedNotDeleted(t *testing.T) { + id := "i-nondot-data" + v := &vm.VM{ID: id, Status: vm.StateStopped, AccountID: "acc"} + v.EBSRequests.Requests = []spxtypes.EBSRequest{ + {Name: "vol-data-nondot", DeleteOnTermination: false}, + } + store := &vmmock.StateStore{Stopped: map[string]*vm.VM{id: v}} + vd := &fakeVolumeDeleter{} + svc := &InstanceServiceImpl{stoppedStore: store, volumeDeleter: vd} + + _, err := svc.TerminateStoppedInstance(context.Background(), &TerminateStoppedInstanceInput{InstanceID: id}, "acc") + require.NoError(t, err) + + assert.Equal(t, []string{"vol-data-nondot"}, vd.detached, + "a data volume left attached by a failed seal must still be detached on terminate") + assert.Empty(t, vd.deleted, "a DeleteOnTermination=false volume must never be deleted") +} + +// TestTerminateStoppedInstance_DetachOfAlreadyGoneVolumeIsNotAFailure pins the +// idempotent half: a volume whose metadata doc is gone has already reached the +// state the detach was asking for, so it must not stamp the teardown failed and +// keep the instance record alive for the leak reaper. +func TestTerminateStoppedInstance_DetachOfAlreadyGoneVolumeIsNotAFailure(t *testing.T) { + id := "i-nondot-gone" + v := &vm.VM{ID: id, Status: vm.StateStopped, AccountID: "acc"} + v.EBSRequests.Requests = []spxtypes.EBSRequest{ + {Name: "vol-data-gone", DeleteOnTermination: false}, + } + store := &vmmock.StateStore{Stopped: map[string]*vm.VM{id: v}} + vd := &fakeVolumeDeleter{err: errors.New(awserrors.ErrorInvalidVolumeNotFound)} + svc := &InstanceServiceImpl{stoppedStore: store, volumeDeleter: vd} + + _, err := svc.TerminateStoppedInstance(context.Background(), &TerminateStoppedInstanceInput{InstanceID: id}, "acc") + require.NoError(t, err) + + require.NotNil(t, store.WroteTerminated[id]) + assert.Equal(t, string(vm.TeardownDone), store.WroteTerminated[id].Teardown[vm.TeardownVolumes], + "a volume that is already gone is the detach's goal, not a teardown failure") +} + func TestTerminateStoppedInstance_NoVolumeDeleterSkipsGracefully(t *testing.T) { id := "i-no-vd" v := &vm.VM{ID: id, Status: vm.StateStopped, AccountID: "acc"} @@ -3967,6 +4012,58 @@ func TestBuildInstanceStatus_NeverSurfacesNonAWSName(t *testing.T) { } } +// A guest werror=stop has paused on a backend I/O error is unreachable, and the +// cause is ours rather than the guest's. Before this it reported ok/passed on +// both checks, because QMP stays perfectly responsive while the guest is held — +// so the one state a customer most needs to see was the one that looked fine. +func TestBuildInstanceStatus_IOErrorPauseIsImpaired(t *testing.T) { + owner := "111122223333" + paused := runningVM("i-io", owner) + paused.Health.IOErrorResumes = 1 + paused.Health.IOErrorSince = time.Now().Add(-45 * time.Second) + svc := instanceStatusService(t, "az-a", map[string]*vm.VM{paused.ID: paused}) + + is := svc.buildInstanceStatus(paused, false) + assert.Equal(t, "impaired", *is.InstanceStatus.Status) + assert.Equal(t, "failed", *is.InstanceStatus.Details[0].Status) + assert.Equal(t, "impaired", *is.SystemStatus.Status, + "a backend I/O error is an infrastructure fault, so the system check must fail too") + require.NotNil(t, is.InstanceStatus.Details[0].ImpairedSince) + assert.Equal(t, paused.Health.IOErrorSince, *is.InstanceStatus.Details[0].ImpairedSince, + "impairedSince must be when the guest first paused, not when it was escalated") +} + +// The counter is cleared by the first poll that sees the guest running, so +// clearing it must take the instance back to ok on both checks. An impairment +// that outlives the fault is as misleading as one that never appears. +func TestBuildInstanceStatus_ResumedGuestClearsImpairment(t *testing.T) { + owner := "111122223333" + resumed := runningVM("i-io-ok", owner) + resumed.Instance.LaunchTime = aws.Time(time.Now().Add(-time.Hour)) + resumed.Health.IOErrorResumes = 0 + svc := instanceStatusService(t, "az-a", map[string]*vm.VM{resumed.ID: resumed}) + + is := svc.buildInstanceStatus(resumed, false) + assert.Equal(t, "ok", *is.InstanceStatus.Status) + assert.Equal(t, "ok", *is.SystemStatus.Status) + assert.Nil(t, is.InstanceStatus.Details[0].ImpairedSince) +} + +// A guest that pauses inside the launch grace window is impaired, not +// initializing: the grace period exists to hide a guest that has not booted +// yet, and one held on a failed write is not that. +func TestBuildInstanceStatus_IOErrorBeatsLaunchGrace(t *testing.T) { + owner := "111122223333" + fresh := runningVM("i-io-new", owner) + fresh.Instance.LaunchTime = aws.Time(time.Now().Add(-10 * time.Second)) + fresh.Health.IOErrorResumes = 2 + fresh.Health.IOErrorSince = time.Now().Add(-5 * time.Second) + svc := instanceStatusService(t, "az-a", map[string]*vm.VM{fresh.ID: fresh}) + + is := svc.buildInstanceStatus(fresh, false) + assert.Equal(t, "impaired", *is.InstanceStatus.Status) +} + func TestDescribeInstanceStatus_IncludeAllSurfacesStopped(t *testing.T) { owner := "111122223333" stopped := runningVM("i-stop", owner) diff --git a/spinifex/handlers/ec2/instance/volume_held_elsewhere_test.go b/spinifex/handlers/ec2/instance/volume_held_elsewhere_test.go new file mode 100644 index 000000000..bbddbf69e --- /dev/null +++ b/spinifex/handlers/ec2/instance/volume_held_elsewhere_test.go @@ -0,0 +1,57 @@ +package handlers_ec2_instance + +//test:in-package — volumeHeldElsewhereError is unexported, and exporting it +//would widen the handler API for a test rather than for a caller. + +import ( + "errors" + "fmt" + "testing" + + "github.com/mulgadc/spinifex/spinifex/awserrors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestVolumeHeldElsewhere_NamesTheHolder covers both refusals that mean the +// data is on another node. An operator who gets ServerInternal is told to retry +// something that cannot succeed while that node is down, so the node name and +// the reason have to survive the trip out of the daemon. +func TestVolumeHeldElsewhere_NamesTheHolder(t *testing.T) { + cases := []struct { + name string + err error + }{ + { + name: "live holder, refused by the lease", + err: errors.New("failed to mount volume: volume is leased by another owner: " + + "held by node-b since 2026-09-02T17:04:39Z"), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := volumeHeldElsewhereError(tc.err) + require.Error(t, got, "a volume held elsewhere must not fall through to ServerInternal") + + code := awserrors.ValidErrorCodeFromError(got) + assert.NotEqual(t, awserrors.ErrorServerInternal, code, + "ServerInternal reads as a transient fault and invites a retry that cannot succeed") + assert.Contains(t, got.Error(), "node-b", "the caller has to learn where the data is") + }) + } +} + +// TestVolumeHeldElsewhere_IgnoresUnrelatedFailures guards the other direction: +// a genuine internal fault must keep reporting as one rather than being +// relabelled as somebody else's volume. +func TestVolumeHeldElsewhere_IgnoresUnrelatedFailures(t *testing.T) { + assert.NoError(t, volumeHeldElsewhereError(nil)) + assert.NoError(t, volumeHeldElsewhereError(errors.New("nbd endpoint not ready: timeout waiting for unix socket"))) + assert.NoError(t, volumeHeldElsewhereError(fmt.Errorf("qemu exited: %w", errors.New("no such file")))) + + // A volume whose writes are merely unsealed is opened with a warning rather + // than refused, so this text is not an exclusion and must not become one. + assert.NoError(t, volumeHeldElsewhereError(errors.New( + "took over from node-b, which held unsealed writes since 2026-09-02T17:04:39Z"))) +} diff --git a/spinifex/handlers/ec2/volume/service_impl.go b/spinifex/handlers/ec2/volume/service_impl.go index 86e27d69b..c05ca2e26 100644 --- a/spinifex/handlers/ec2/volume/service_impl.go +++ b/spinifex/handlers/ec2/volume/service_impl.go @@ -1116,10 +1116,12 @@ func (s *VolumeServiceImpl) DeleteVolume(ctx context.Context, input *ec2.DeleteV Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, Handle: meta.ProviderHandle, }); err != nil { slog.ErrorContext(ctx, "DeleteVolume: provider deletion failed", "volumeId", volumeID, "err", err) - return nil, errors.New(awserrors.ErrorServerInternal) + // The terminate reaper retries this every two minutes and logs whatever + // it gets back, so a bare code makes a persistent fault unreadable. + return nil, awserrors.Errorf(awserrors.ErrorServerInternal, "delete volume %s: %v", volumeID, err) } if err := s.metadata.DeleteVolume(ctx, accountID, volumeID); err != nil { - return nil, errors.New(awserrors.ErrorServerInternal) + return nil, awserrors.Errorf(awserrors.ErrorServerInternal, "delete volume metadata %s: %v", volumeID, err) } slog.InfoContext(ctx, "DeleteVolume completed", "volumeId", volumeID) diff --git a/spinifex/handlers/ec2/volume/service_impl_test.go b/spinifex/handlers/ec2/volume/service_impl_test.go index a3e2c2c0e..a9e6b9c07 100644 --- a/spinifex/handlers/ec2/volume/service_impl_test.go +++ b/spinifex/handlers/ec2/volume/service_impl_test.go @@ -1796,6 +1796,46 @@ func TestDetachVolumeOnTerminate_AlreadyGoneMetadataIsSuccess(t *testing.T) { require.NoError(t, err, "a volume with no metadata document is already gone, not a teardown failure") } +// failingDeleteProvider makes the provider's delete fail while leaving every +// other operation intact, which is the shape of a blob node that is down. +type failingDeleteProvider struct { + ebsprovider.EBSProvider + + err error +} + +func (p *failingDeleteProvider) DeleteVolume(context.Context, ebsprovider.DeleteVolumeRequest) error { + return p.err +} + +// TestDeleteVolume_ProviderFailureCarriesItsCause pins that a provider delete +// failure keeps its cause on the way out. The terminate reaper retries this +// every two minutes and logs whatever it is handed, so a bare ServerInternal +// makes a persistent fault indistinguishable from a transient one. +func TestDeleteVolume_ProviderFailureCarriesItsCause(t *testing.T) { + kv := setupTestVolumeKV(t) + store := objectstore.NewMemoryObjectStore() + svc := newTestVolumeServiceWithStore("ap-southeast-2a", store) + svc.snapshotKV = kv + + volumeID := "vol-provider-down" + createVolumeInStoreWithMeta(t, svc, store, volumeID, ebsmetadata.Volume{ + VolumeID: volumeID, CapacityGiB: 10, State: "available", + }) + svc.SetEBSProvider(&failingDeleteProvider{ + EBSProvider: svc.provider, + err: errors.New("dial node 6: quic dial 10.10.8.5:6660: timeout"), + }) + + _, err := svc.DeleteVolume(context.Background(), &ec2.DeleteVolumeInput{VolumeId: aws.String(volumeID)}, testVolAccountID) + require.Error(t, err) + + assert.Equal(t, awserrors.ErrorServerInternal, awserrors.ValidErrorCodeFromError(err), + "the code stays ServerInternal so callers classify it unchanged") + assert.Contains(t, err.Error(), "dial node 6", + "an operator reading the reaper log must learn why the delete keeps failing") +} + // TestDeleteVolumeOnTerminate_SurfacesDeleteFailure verifies a DeleteVolume // failure downstream of the attachment clear is returned, not swallowed — the // caller (deleteInstanceVolumes / instanceCleanerAdapter.DeleteVolumes) relies diff --git a/spinifex/handlers/imds/cacert_test.go b/spinifex/handlers/imds/cacert_test.go index b7a2126e7..da60c50b2 100644 --- a/spinifex/handlers/imds/cacert_test.go +++ b/spinifex/handlers/imds/cacert_test.go @@ -22,7 +22,8 @@ func newCATestService(t *testing.T, pem string) (http.Handler, string) { if pem != "" { require.NoError(t, os.WriteFile(path, []byte(pem), 0o600)) } - svc, _ := newTestService(&fakeResolver{eni: testENI()}, &fakeIAM{}, &fakeAssumer{}) + // A resolvable instance, because the meta-data listing now 500s without one. + svc, _ := newTestService(&fakeResolver{eni: testENI(), inst: &instanceFacts{}}, &fakeIAM{}, &fakeAssumer{}) svc.caCert = newCACertCache(path) return withTapENI(svc.httpHandler(), testENI()), path } diff --git a/spinifex/handlers/imds/metadata.go b/spinifex/handlers/imds/metadata.go index 907d4bbe6..7fdddeab9 100644 --- a/spinifex/handlers/imds/metadata.go +++ b/spinifex/handlers/imds/metadata.go @@ -260,6 +260,10 @@ func (s *IMDSServiceImpl) dispatch(w http.ResponseWriter, r *http.Request, eni * // mid-listing and falls back to DataSourceNone: public-hostname/public-ipv4 only // with a public IP, public-keys/ only with a key pair, iam/ only with an instance // profile — each omission matching real EC2, like the macs/ subtree in macKeys. +// +// Every omission is therefore a positive claim about the instance, which is why +// this 500s rather than shortening the listing when it cannot resolve one: a +// shorter listing is indistinguishable from a smaller instance and is believed. func (s *IMDSServiceImpl) serveMetaDataRoot(ctx context.Context, w http.ResponseWriter, eni *eniFacts) { keys := []string{ "ami-id", "ami-launch-index", "hostname", "instance-id", @@ -270,17 +274,27 @@ func (s *IMDSServiceImpl) serveMetaDataRoot(ctx context.Context, w http.Response if eni.publicIP != "" { keys = append(keys, "public-hostname", "public-ipv4") } - // Resolve the instance once: public-keys/ is listed only with a key pair, iam/ - // only with a resolvable instance profile. A backend error counts as absent so - // the listing never advertises a child whose leaf would 404 and break the crawl. - if inst, err := s.resolver.resolveInstance(ctx, eni); err == nil && inst != nil { - if inst.keyName != "" { - keys = append(keys, "public-keys/") - } - if inst.iamInstanceProfileArn != "" { - if p, err := s.iam.ResolveInstanceProfile(ctx, eni.iamAccountID(), inst.iamInstanceProfileArn); err == nil && p != nil { - keys = append(keys, "iam/") - } + // An unresolvable instance withholds the whole listing rather than shortening + // it: omitting public-keys/ reads to cloud-init as "this instance has no key + // pair", and the guest boots keyless with nothing anywhere calling it an error. + inst, err := s.resolver.resolveInstance(ctx, eni) + if err != nil || inst == nil { + slog.ErrorContext(ctx, "IMDS: withholding meta-data listing, instance did not resolve", + "instance_id", eni.instanceID, "err", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + if inst.keyName != "" { + keys = append(keys, "public-keys/") + } + if inst.iamInstanceProfileArn != "" { + p, err := s.iam.ResolveInstanceProfile(ctx, eni.iamAccountID(), inst.iamInstanceProfileArn) + switch { + case err != nil: + slog.ErrorContext(ctx, "IMDS: instance profile resolution failed, withholding iam/ from the listing", + "instance_id", eni.instanceID, "profile", inst.iamInstanceProfileArn, "err", err) + case p != nil: + keys = append(keys, "iam/") } } sort.Strings(keys) diff --git a/spinifex/handlers/imds/metadata_test.go b/spinifex/handlers/imds/metadata_test.go index 2bb39372f..cb64be985 100644 --- a/spinifex/handlers/imds/metadata_test.go +++ b/spinifex/handlers/imds/metadata_test.go @@ -572,6 +572,43 @@ func TestHTTP_DirectoryListingNoPublicIP(t *testing.T) { assert.Contains(t, body, "public-keys/") } +// An instance that does not resolve withholds the listing entirely. Shortening it +// instead would drop public-keys/, which cloud-init reads as "no key pair" — it +// writes no authorized_keys, and the guest boots keyless with nothing logged. +func TestHTTP_DirectoryListingUnresolvedInstanceIs500(t *testing.T) { + for name, res := range map[string]*fakeResolver{ + "backend error": {eni: testENI(), instErr: errors.New("jetstream: context deadline exceeded")}, + "not found": {eni: testENI(), inst: nil}, + } { + t.Run(name, func(t *testing.T) { + svc, _ := newTestService(res, &fakeIAM{}, &fakeAssumer{}) + h := withTapENI(svc.httpHandler(), testENI()) + token := issueToken(t, h) + rec := get(t, h, pathMetaDataRoot, token) + assert.Equal(t, http.StatusInternalServerError, rec.Code, + "an unresolved instance must not be served as a shorter listing") + assert.NotContains(t, rec.Body.String(), "ami-id") + }) + } +} + +// A resolvable instance whose profile lookup fails still serves the listing, minus +// iam/. The key pair is the field a guest cannot recover from losing; a missing +// role surfaces through the credentials leaf, which reports the miss on its own. +func TestHTTP_DirectoryListingProfileErrorKeepsTheListing(t *testing.T) { + res := &fakeResolver{eni: testENI(), inst: &instanceFacts{ + keyName: "e2e-key", + iamInstanceProfileArn: "arn:aws:iam::111122223333:instance-profile/app-profile", + }} + svc, _ := newTestService(res, &fakeIAM{profileErr: errors.New("iam unavailable")}, &fakeAssumer{}) + h := withTapENI(svc.httpHandler(), testENI()) + token := issueToken(t, h) + rec := get(t, h, pathMetaDataRoot, token) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "public-keys/") + assert.NotContains(t, rec.Body.String(), "iam/") +} + // An instance launched without a key pair omits public-keys/, whose leaf would 404. func TestHTTP_DirectoryListingNoKeyPair(t *testing.T) { res := &fakeResolver{eni: testENI(), inst: &instanceFacts{}} diff --git a/spinifex/handlers/imds/service.go b/spinifex/handlers/imds/service.go index 583b35cad..e8e785f9d 100644 --- a/spinifex/handlers/imds/service.go +++ b/spinifex/handlers/imds/service.go @@ -11,6 +11,7 @@ import ( "github.com/aws/aws-sdk-go/service/iam" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" + "github.com/mulgadc/spinifex/spinifex/kvstore" "github.com/mulgadc/spinifex/spinifex/otelsetup" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" @@ -111,13 +112,21 @@ func NewIMDSServiceImpl(ctx context.Context, natsConn *nats.Conn, sts stsAssumer return nil, fmt.Errorf("get JetStream context: %w", err) } - eniKV, err := js.KeyValue(ctx, kvBucketENIs) + // Every bucket below is retried for the same window before its policy + // applies. vpcd starts alongside NATS, so a bucket that is merely not ready + // yet must not decide how this node serves metadata for its whole lifetime. + openBucket := func(name string) (jetstream.KeyValue, error) { + return kvstore.OpenWithRetry(ctx, name, kvstore.DefaultOpenWindow, + func(ctx context.Context) (jetstream.KeyValue, error) { return js.KeyValue(ctx, name) }) + } + + eniKV, err := openBucket(kvBucketENIs) if err != nil { return nil, fmt.Errorf("open %s bucket: %w", kvBucketENIs, err) } // Open SG bucket best-effort; IMDS starts fine without it, degrading to raw IDs. - sgKV, err := js.KeyValue(ctx, kvBucketSecurityGroups) + sgKV, err := openBucket(kvBucketSecurityGroups) if err != nil { slog.Warn("IMDS: security-group bucket unavailable, /security-groups will serve IDs", "bucket", kvBucketSecurityGroups, "err", err) sgKV = nil @@ -125,12 +134,12 @@ func NewIMDSServiceImpl(ctx context.Context, natsConn *nats.Conn, sts stsAssumer // Open subnet/VPC buckets best-effort; the network-interfaces CIDR leaves 404 // (and drop from the listing) when a bucket is unavailable, IMDS still starts. - subnetKV, err := js.KeyValue(ctx, kvBucketSubnets) + subnetKV, err := openBucket(kvBucketSubnets) if err != nil { slog.Warn("IMDS: subnet bucket unavailable, network-interfaces subnet CIDR will 404", "bucket", kvBucketSubnets, "err", err) subnetKV = nil } - vpcKV, err := js.KeyValue(ctx, kvBucketVPCs) + vpcKV, err := openBucket(kvBucketVPCs) if err != nil { slog.Warn("IMDS: vpc bucket unavailable, network-interfaces VPC CIDR will 404", "bucket", kvBucketVPCs, "err", err) vpcKV = nil diff --git a/spinifex/kvstore/export_test.go b/spinifex/kvstore/export_test.go new file mode 100644 index 000000000..6334928ac --- /dev/null +++ b/spinifex/kvstore/export_test.go @@ -0,0 +1,11 @@ +package kvstore + +import "time" + +// SetOpenRetryInterval shortens the OpenWithRetry pause for tests and returns a +// function restoring the previous value. +func SetOpenRetryInterval(d time.Duration) func() { + prev := openRetryInterval + openRetryInterval = d + return func() { openRetryInterval = prev } +} diff --git a/spinifex/kvstore/wait.go b/spinifex/kvstore/wait.go new file mode 100644 index 000000000..855da2d70 --- /dev/null +++ b/spinifex/kvstore/wait.go @@ -0,0 +1,63 @@ +package kvstore + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/mulgadc/spinifex/spinifex/otelsetup" +) + +// DefaultOpenWindow is how long OpenWithRetry keeps trying. Long enough to +// cover JetStream electing a leader after a whole-cluster restart, which is the +// window in which every service starts at once and races it. +const DefaultOpenWindow = 90 * time.Second + +// openRetryInterval is the pause between attempts. Each attempt carries its own +// JetStream API deadline, so this only adds to it. A var so tests can shorten +// it; nothing in production writes it. +var openRetryInterval = 2 * time.Second + +// OpenWithRetry calls open until it succeeds or window expires, and reports how +// long it waited. A bucket that is merely not ready yet is the common case on a +// cold cluster; the caller decides what an exhausted window means. +func OpenWithRetry[T any](ctx context.Context, what string, window time.Duration, open func(context.Context) (T, error)) (T, error) { + var zero T + if window <= 0 { + window = DefaultOpenWindow + } + + started := time.Now() + deadline := started.Add(window) + attempt := 0 + + for { + attempt++ + v, err := open(ctx) + if err == nil { + if attempt > 1 { + slog.InfoContext(ctx, "kvstore: opened after retrying", "what", what, + "attempts", attempt, "waited_ms", otelsetup.Millis(time.Since(started))) + } + return v, nil + } + + if ctx.Err() != nil { + return zero, fmt.Errorf("open %s: %w", what, ctx.Err()) + } + if !time.Now().Add(openRetryInterval).Before(deadline) { + return zero, fmt.Errorf("open %s: giving up after %d attempts in %s: %w", + what, attempt, window, err) + } + + slog.WarnContext(ctx, "kvstore: not ready, retrying", "what", what, + "attempt", attempt, "window_ms", otelsetup.Millis(window), "err", err) + + select { + case <-ctx.Done(): + return zero, fmt.Errorf("open %s: %w", what, ctx.Err()) + case <-time.After(openRetryInterval): + } + } +} diff --git a/spinifex/kvstore/wait_test.go b/spinifex/kvstore/wait_test.go new file mode 100644 index 000000000..ea179989c --- /dev/null +++ b/spinifex/kvstore/wait_test.go @@ -0,0 +1,71 @@ +package kvstore_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/mulgadc/spinifex/spinifex/kvstore" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOpenWithRetry_SucceedsFirstAttempt(t *testing.T) { + calls := 0 + got, err := kvstore.OpenWithRetry(context.Background(), "bucket", time.Second, + func(context.Context) (string, error) { calls++; return "kv", nil }) + + require.NoError(t, err) + assert.Equal(t, "kv", got) + assert.Equal(t, 1, calls, "a bucket that opens must not be retried") +} + +// TestOpenWithRetry_SucceedsAfterNotReady is the case the whole helper exists +// for: vpcd starts alongside NATS, and a bucket that is not ready for the first +// few seconds is normal rather than a reason to degrade for the process's life. +func TestOpenWithRetry_SucceedsAfterNotReady(t *testing.T) { + defer kvstore.SetOpenRetryInterval(time.Millisecond)() + + calls := 0 + got, err := kvstore.OpenWithRetry(context.Background(), "bucket", time.Second, + func(context.Context) (string, error) { + calls++ + if calls < 3 { + return "", errors.New("context deadline exceeded") + } + return "kv", nil + }) + + require.NoError(t, err) + assert.Equal(t, "kv", got) + assert.Equal(t, 3, calls) +} + +func TestOpenWithRetry_GivesUpAndKeepsTheCause(t *testing.T) { + defer kvstore.SetOpenRetryInterval(time.Millisecond)() + cause := errors.New("no servers available") + + _, err := kvstore.OpenWithRetry(context.Background(), "spinifex-instance-state", 20*time.Millisecond, + func(context.Context) (string, error) { return "", cause }) + + require.Error(t, err) + assert.ErrorIs(t, err, cause, "the caller has to be able to see why, not just that") + assert.Contains(t, err.Error(), "spinifex-instance-state") +} + +func TestOpenWithRetry_StopsOnCancelledContext(t *testing.T) { + defer kvstore.SetOpenRetryInterval(time.Millisecond)() + ctx, cancel := context.WithCancel(context.Background()) + + calls := 0 + _, err := kvstore.OpenWithRetry(ctx, "bucket", time.Minute, + func(context.Context) (string, error) { + calls++ + cancel() + return "", errors.New("not ready") + }) + + require.ErrorIs(t, err, context.Canceled) + assert.Equal(t, 1, calls, "a cancelled shutdown must not sit out the window") +} diff --git a/spinifex/network/external/igw.go b/spinifex/network/external/igw.go index b1f43ba34..0188f7f04 100644 --- a/spinifex/network/external/igw.go +++ b/spinifex/network/external/igw.go @@ -157,7 +157,13 @@ func (m *igwManager) AttachIGW(ctx context.Context, spec IGWSpec) error { } // The gateway switch port on the shared switch is the per-VPC attach marker. - if _, err := m.ovn.GetLogicalSwitchPort(ctx, switchGWPortName); err == nil { + if existing, err := m.ovn.GetLogicalSwitchPort(ctx, switchGWPortName); err == nil { + // Converge rather than early-return: a VPC attached before EIPs were + // advertised carries no nat-addresses, and nothing else recreates this + // port, so its EIPs would stay invisible to the physical network. + if err := m.ensureNATAdvertisement(ctx, existing); err != nil { + return err + } // OVN state survives reboots but host routes do not: re-run the routed // leg so reconcile's replayed attach re-ensures ingress + SNAT. if m.natMode == policy.NATModeRouted { @@ -200,10 +206,7 @@ func (m *igwManager) AttachIGW(ctx context.Context, spec IGWSpec) error { // nat-addresses is only honoured on a router-type port; OVN silently ignores // it on the localnet, which leaves EIPs unadvertised on the physical network. - gwPortOpts := map[string]string{"router-port": gwPortName} - if m.gatewayOwnsNAT() { - gwPortOpts["nat-addresses"] = "router" - } + gwPortOpts := map[string]string{"router-port": gwPortName, natAdvertiseOption: "router"} if err := m.ovn.CreateLogicalSwitchPort(ctx, extSwitchName, &nbdb.LogicalSwitchPort{ Name: switchGWPortName, Type: "router", @@ -372,6 +375,35 @@ func (m *igwManager) gatewayLRPIP(ctx context.Context, vpcID string) string { return "" } +// natAdvertiseOption asks ovn-northd to publish the router's NAT external +// addresses on this port, which is what makes ovn-controller send a gratuitous +// ARP for an EIP. +// +// Needed in every NAT mode, not only the centralised ones. For a distributed +// dnat_and_snat northd scopes each entry with is_chassis_resident(), +// so the GARP leaves from the chassis running the instance rather than the +// gateway — which is exactly right, and only happens if this is set. +const natAdvertiseOption = "nat-addresses" + +// ensureNATAdvertisement adds the NAT advertisement option to a gateway switch +// port that predates it. Without it nothing announces an EIP when it moves, so +// any host on the segment keeps resolving it to the previous holder's MAC until +// its ARP cache ages out — a recycled EIP is unreachable for that whole window. +func (m *igwManager) ensureNATAdvertisement(ctx context.Context, port *nbdb.LogicalSwitchPort) error { + if port == nil || port.Options[natAdvertiseOption] == "router" { + return nil + } + if port.Options == nil { + port.Options = map[string]string{} + } + port.Options[natAdvertiseOption] = "router" + if err := m.ovn.UpdateLogicalSwitchPort(ctx, port); err != nil { + return fmt.Errorf("set %s on %s: %w", natAdvertiseOption, port.Name, err) + } + slog.Info("external: enabled EIP advertisement on gateway port", "port", port.Name) + return nil +} + // ensureSharedExternal idempotently creates the singleton shared external switch // and its single localnet port. NAT advertisement lives on the per-VPC gateway // port, not here — OVN ignores nat-addresses on a localnet port. @@ -387,12 +419,12 @@ func (m *igwManager) ensureSharedExternal(ctx context.Context, switchName, portN // advertisement moved to the gateway port still carry a stale nat-addresses // here, and it must not survive an upgrade. if existing, err := m.ovn.GetLogicalSwitchPort(ctx, portName); err == nil { - if _, stale := existing.Options["nat-addresses"]; !stale { + if _, stale := existing.Options[natAdvertiseOption]; !stale { return nil } - delete(existing.Options, "nat-addresses") + delete(existing.Options, natAdvertiseOption) if err := m.ovn.UpdateLogicalSwitchPort(ctx, existing); err != nil { - return fmt.Errorf("clear stale nat-addresses on %s: %w", portName, err) + return fmt.Errorf("clear stale %s on %s: %w", natAdvertiseOption, portName, err) } slog.Info("external: cleared stale nat-addresses from localnet port", "port", portName) return nil diff --git a/spinifex/network/external/igw_test.go b/spinifex/network/external/igw_test.go index f8615d47d..a81a39c0f 100644 --- a/spinifex/network/external/igw_test.go +++ b/spinifex/network/external/igw_test.go @@ -100,10 +100,13 @@ func TestAttachIGW_Distributed_LinkLocalLRP(t *testing.T) { _, hasNat := localnet.Options["nat-addresses"] assert.False(t, hasNat, "nat-addresses is never valid on a localnet port") + // Distributed mode needs this too. northd scopes each NAT entry with + // is_chassis_resident(), so the GARP leaves the chassis running + // the instance — and without it nothing announces an EIP when it moves. gwPort, err := m.GetLogicalSwitchPort(ctx, topology.GatewaySwitchPort("vpc-1")) require.NoError(t, err) - _, hasGwNat := gwPort.Options["nat-addresses"] - assert.False(t, hasGwNat, "distributed mode must NOT set nat-addresses") + assert.Equal(t, "router", gwPort.Options["nat-addresses"], + "an unadvertised EIP keeps resolving to the previous holder's MAC until the ARP cache ages out") // Gateway LRP exists with link-local network. lrp, err := m.GetLogicalRouterPort(ctx, topology.GatewayRouterPort("vpc-1")) @@ -195,6 +198,39 @@ func TestAttachIGW_ClearsStaleLocalnetNATAddresses(t *testing.T) { assert.Equal(t, "external", localnet.Options["network_name"], "converge must not drop network_name") } +// TestAttachIGW_ConvergesNATAdvertisementOnAnAttachedVPC covers the upgrade +// path, which is the only path that matters on a running cluster. A VPC +// attached before EIPs were advertised takes the already-attached early return, +// and nothing else ever rewrites this port — so without a converge its EIPs +// stay unannounced for the life of the VPC. +func TestAttachIGW_ConvergesNATAdvertisementOnAnAttachedVPC(t *testing.T) { + ctx := context.Background() + m := mock.New() + seedVPCRouter(t, m, "vpc-1", "10.0.0.0/16") + + extSwitchName := topology.ExternalSwitchShared() + gwPortName := topology.GatewaySwitchPort("vpc-1") + _, _, err := m.EnsureLogicalSwitch(ctx, &nbdb.LogicalSwitch{Name: extSwitchName}) + require.NoError(t, err) + require.NoError(t, m.CreateLogicalSwitchPort(ctx, extSwitchName, &nbdb.LogicalSwitchPort{ + Name: gwPortName, + Type: "router", + Addresses: []string{"router"}, + Options: map[string]string{"router-port": topology.GatewayRouterPort("vpc-1")}, + })) + + pool := &ExternalPoolConfig{Name: "p", Gateway: "192.168.1.1", PrefixLen: 24} + mgr, _ := newTestIGWManager(t, m, policy.NATModeDistributed, pool, LinkLocalAllocator{}, []string{"chassis-a"}) + require.NoError(t, mgr.AttachIGW(ctx, IGWSpec{VPCID: "vpc-1", InternetGatewayID: "igw-1"})) + + gwPort, err := m.GetLogicalSwitchPort(ctx, gwPortName) + require.NoError(t, err) + assert.Equal(t, "router", gwPort.Options["nat-addresses"], + "an already-attached VPC must gain EIP advertisement, not keep silence") + assert.Equal(t, topology.GatewayRouterPort("vpc-1"), gwPort.Options["router-port"], + "converge must not drop router-port") +} + // The LRP address is written once at attach, so a lease re-issued on a new IP // leaves the port answering ARP for an address nothing renews. func TestRebindGatewayIP_MovesLRPAndStamp(t *testing.T) { diff --git a/spinifex/network/reconcile/apply.go b/spinifex/network/reconcile/apply.go index 44c8e0a6d..335e5c8bd 100644 --- a/spinifex/network/reconcile/apply.go +++ b/spinifex/network/reconcile/apply.go @@ -8,6 +8,7 @@ import ( "maps" "net/netip" "strings" + "sync" "time" "github.com/mulgadc/spinifex/spinifex/network/external" @@ -38,6 +39,10 @@ var ( guestPortDatapathInterval = 5 * time.Second ) +// How many guest ports may wait for convergence at once. Bounded because each +// miss nudges a recompute, and an unbounded fan-out would nudge once per port. +var guestPortDatapathConcurrency = 8 + // After this many recompute misses with a binding still down, the readiness loops // check SB connectivity and, if the local ovn-controller is not "connected", // escalate once to sb-cluster-state-reset. A recompute re-evaluates flows from the @@ -45,6 +50,9 @@ var ( // reset re-syncs that view. Package var so tests can shrink it. var sbResetEscalateAfter = 3 +// Minimum gap between two sb-cluster-state-resets. Package var so tests shrink it. +var sbResetMinInterval = 2 * time.Minute + // Backoff applied to a guest port that burned its convergence deadline. Doubles // per consecutive failure from the base and holds at the cap. Package vars so // tests shorten them. @@ -601,6 +609,14 @@ func (r *reconciler) ensureGatewayDatapath(ctx context.Context, vpcID, gwIP, eip // Returns true when a reset was issued (caller stops escalating); false when the SB // is connected (recompute is the right tool) or the probe failed (retry next miss). func (r *reconciler) escalateSBReset(ctx context.Context, logKV ...any) bool { + r.sbResetMu.Lock() + defer r.sbResetMu.Unlock() + // One reset re-syncs the SB view for every port, so a second one moments + // later answers a question the first already answered. + if time.Since(r.sbResetLast) < sbResetMinInterval { + return true + } + status, err := r.gwClaim.SBConnectionState(ctx) if err != nil { slog.Warn("reconcile/apply: SB connection-status probe failed during escalation", append(logKV, "err", err)...) @@ -614,6 +630,7 @@ func (r *reconciler) escalateSBReset(ctx context.Context, logKV ...any) bool { if err := r.gwClaim.ResetSBClusterState(ctx); err != nil { slog.Warn("reconcile/apply: sb-cluster-state-reset failed", append(logKV, "err", err)...) } + r.sbResetLast = time.Now() return true } @@ -672,13 +689,36 @@ func (r *reconciler) ensureGatewayClaimed(ctx context.Context, crPortName string // whose port has not converged (e.g. just after a host reboot) stays dark while // every other signal is green. func (r *reconciler) applyEIPs(ctx context.Context, intent IntentState, _ ActualState, res *passResult) { - for _, spec := range r.floatingIPSpecs(intent) { + specs := r.floatingIPSpecs(intent) + + // The DNAT rows go in first and in order: AddEIP mutates NB and is the cheap + // half, so serialising it costs nothing and keeps NB writes single-threaded. + for _, spec := range specs { if err := r.nat.AddEIP(ctx, spec); err != nil { slog.Error("reconcile/apply: AddEIP failed", "external_ip", spec.ExternalIP, "logical_ip", spec.LogicalIP, "err", err) res.fail(classEIP, spec.ExternalIP, err) } - r.ensureGuestPortDatapath(ctx, spec) } + + // The convergence waits run together. Each one can burn the full + // guestPortDatapathTimeout, so serialising them made every port wait out + // every port ahead of it: a guest whose neighbours are gone was dark for + // N x 45s before its own probe even started. + sem := make(chan struct{}, guestPortDatapathConcurrency) + var wg sync.WaitGroup + for _, spec := range specs { + select { + case <-ctx.Done(): + wg.Wait() + return + case sem <- struct{}{}: + } + wg.Go(func() { + defer func() { <-sem }() + r.ensureGuestPortDatapath(ctx, spec) + }) + } + wg.Wait() } // floatingIPSpecs is every dnat_and_snat the datapath must carry: user EIPs diff --git a/spinifex/network/reconcile/apply_eip_concurrency_test.go b/spinifex/network/reconcile/apply_eip_concurrency_test.go new file mode 100644 index 000000000..4ac4dc0aa --- /dev/null +++ b/spinifex/network/reconcile/apply_eip_concurrency_test.go @@ -0,0 +1,211 @@ +package reconcile + +//test:in-package — drives applyEIPs and the unexported convergence bounds +//directly, neither of which is reachable from an external test package. + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/mulgadc/spinifex/spinifex/network/ovn/mock" + "github.com/mulgadc/spinifex/spinifex/network/ovn/nbdb" + "github.com/mulgadc/spinifex/spinifex/network/policy" +) + +// concurrentClaimVerifier reports livePort up and every other port permanently +// down, recording when the live port was first observed. Thread-safe because +// the ports are probed concurrently, which is the point of the test. +type concurrentClaimVerifier struct { + livePort string + + mu sync.Mutex + nudges int + liveSeen time.Time +} + +func (f *concurrentClaimVerifier) GuestPortUp(_ context.Context, lspName string) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + if lspName != f.livePort { + return false, nil + } + if f.liveSeen.IsZero() { + f.liveSeen = time.Now() + } + return true, nil +} + +func (f *concurrentClaimVerifier) NudgeRecompute(context.Context) error { + f.mu.Lock() + defer f.mu.Unlock() + f.nudges++ + return nil +} + +func (f *concurrentClaimVerifier) whenLiveSeen() time.Time { + f.mu.Lock() + defer f.mu.Unlock() + return f.liveSeen +} + +func (f *concurrentClaimVerifier) GatewayPortClaimed(context.Context, string) (bool, error) { + return true, nil +} +func (f *concurrentClaimVerifier) GatewayReachable(context.Context, string) (bool, error) { + return true, nil +} +func (f *concurrentClaimVerifier) EIPReachable(context.Context, string) (bool, error) { + return true, nil +} +func (f *concurrentClaimVerifier) RepairDatapath(context.Context) error { return nil } +func (f *concurrentClaimVerifier) SBConnectionState(context.Context) (string, error) { + return "connected", nil +} +func (f *concurrentClaimVerifier) ResetSBClusterState(context.Context) error { return nil } + +// countingNAT accepts every AddEIP. Only that method is exercised by applyEIPs, +// so the rest of NATManager is inherited and never called. +type countingNAT struct { + policy.NATManager + + mu sync.Mutex + n int +} + +func (c *countingNAT) AddEIP(context.Context, policy.EIPSpec) error { + c.mu.Lock() + defer c.mu.Unlock() + c.n++ + return nil +} + +func (c *countingNAT) count() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.n +} + +// ovnWithGuestLSPs returns a mock OVN client carrying every named port, which +// ensureGuestPortDatapath requires before it will probe. +func ovnWithGuestLSPs(t *testing.T, lspNames ...string) *mock.Client { + t.Helper() + m := mock.New() + if err := m.Connect(context.Background()); err != nil { + t.Fatalf("mock Connect: %v", err) + } + if err := m.CreateLogicalSwitch(context.Background(), &nbdb.LogicalSwitch{Name: "ls-test"}); err != nil { + t.Fatalf("CreateLogicalSwitch: %v", err) + } + for _, name := range lspNames { + if err := m.CreateLogicalSwitchPort(context.Background(), "ls-test", &nbdb.LogicalSwitchPort{Name: name}); err != nil { + t.Fatalf("CreateLogicalSwitchPort %s: %v", name, err) + } + } + return m +} + +// TestApplyEIPs_HealthyPortDoesNotWaitForDeadOnes is the reproduction of the +// dev-prod symptom: three ports belonging to long-stopped instances each burned +// the full convergence deadline in turn, so a newly launched guest's EIP was +// dark for the sum of them before its own probe even started. The journal shows +// three consecutive 45s burns and the live guest's AddEIP landing only after +// the first one finished. +// +// The assertion is wall time, so it holds whatever order the intent map yields. +func TestApplyEIPs_HealthyPortDoesNotWaitForDeadOnes(t *testing.T) { + withFastGuestPortBounds(t) + + const live = "port-eni-live" + dead := []string{"port-eni-dead1", "port-eni-dead2", "port-eni-dead3"} + + f := &concurrentClaimVerifier{livePort: live} + nat := &countingNAT{} + r := &reconciler{ + gwClaim: f, + nat: nat, + ovn: ovnWithGuestLSPs(t, append([]string{live}, dead...)...), + } + + intent := IntentState{EIPs: map[string]policy.EIPSpec{ + "10.0.0.1": {VPCID: "vpc-a", ExternalIP: "192.0.2.1", LogicalIP: "10.0.0.1", PortName: live}, + }} + for i, name := range dead { + ip := "10.0.0." + string(rune('2'+i)) + intent.EIPs[ip] = policy.EIPSpec{VPCID: "vpc-a", ExternalIP: "192.0.2.9", LogicalIP: ip, PortName: name} + } + + res := &passResult{} + start := time.Now() + r.applyEIPs(context.Background(), intent, ActualState{}, res) + elapsed := time.Since(start) + + if got := nat.count(); got != len(dead)+1 { + t.Errorf("AddEIP called %d times, want %d: every spec must still get its DNAT row", got, len(dead)+1) + } + + seen := f.whenLiveSeen() + if seen.IsZero() { + t.Fatal("the live port was never probed") + } + if waited := seen.Sub(start); waited >= guestPortDatapathTimeout { + t.Errorf("the live port waited %s before its first probe, which is at least one full deadline (%s): "+ + "it is queued behind an unrelated port instead of converging on its own schedule", + waited, guestPortDatapathTimeout) + } + + // Serially this is one deadline per dead port; concurrently it is one + // deadline for all of them. Two is comfortably between the two outcomes. + if budget := 2 * guestPortDatapathTimeout; elapsed >= budget { + t.Errorf("applyEIPs took %s for %d dead ports, want under %s: the convergence waits are still serialised", + elapsed, len(dead), budget) + } +} + +// TestEscalateSBReset_NotRepeatedWhileConcurrent covers the hazard the +// concurrency introduces: sb-cluster-state-reset re-syncs every chassis, so +// simultaneous guest-port probes must not each fire one. +func TestEscalateSBReset_NotRepeatedWhileConcurrent(t *testing.T) { + prev := sbResetMinInterval + sbResetMinInterval = time.Hour + t.Cleanup(func() { sbResetMinInterval = prev }) + + f := &countingSBVerifier{} + r := &reconciler{gwClaim: f} + + var wg sync.WaitGroup + for range 8 { + wg.Go(func() { r.escalateSBReset(context.Background()) }) + } + wg.Wait() + + if got := f.resets(); got != 1 { + t.Errorf("ResetSBClusterState called %d times, want 1: a cluster-wide reset must not be fired once per port", got) + } +} + +// countingSBVerifier reports the SB wedge and counts resets. +type countingSBVerifier struct { + concurrentClaimVerifier + + resetMu sync.Mutex + n int +} + +func (c *countingSBVerifier) SBConnectionState(context.Context) (string, error) { + return "not connected", nil +} + +func (c *countingSBVerifier) ResetSBClusterState(context.Context) error { + c.resetMu.Lock() + defer c.resetMu.Unlock() + c.n++ + return nil +} + +func (c *countingSBVerifier) resets() int { + c.resetMu.Lock() + defer c.resetMu.Unlock() + return c.n +} diff --git a/spinifex/network/reconcile/reconcile.go b/spinifex/network/reconcile/reconcile.go index f35c3611c..b523e83bb 100644 --- a/spinifex/network/reconcile/reconcile.go +++ b/spinifex/network/reconcile/reconcile.go @@ -151,6 +151,11 @@ type reconciler struct { // is gone stops paying the full nudge sequence every cycle. portBackoffMu sync.Mutex portBackoff map[string]portBackoffState + + // Serialises sb-cluster-state-reset. It is cluster-wide and re-syncs every + // chassis, so concurrent guest-port probes must not each fire one. + sbResetMu sync.Mutex + sbResetLast time.Time } // portBackoffState is one guest port's consecutive failure count and the instant diff --git a/spinifex/network/reconcile/testdata/kitchen_sink_nb.golden b/spinifex/network/reconcile/testdata/kitchen_sink_nb.golden index 6e04ee314..48646bc06 100644 --- a/spinifex/network/reconcile/testdata/kitchen_sink_nb.golden +++ b/spinifex/network/reconcile/testdata/kitchen_sink_nb.golden @@ -6,7 +6,7 @@ policy {UUID:X Priority:1100 Match:inport == "rtr-subnet-pub" && ip4.dst == 0.0. policy {UUID:X Priority:1200 Match:inport == "rtr-subnet-pub" && ip4.src == 10.0.1.10/32 && ip4.dst == 0.0.0.0/0 && ip4.dst != 10.0.0.0/16 Action:allow Nexthop: Nexthops:[] Options:map[] ExternalIDs:map[spinifex:output_port:gw-vpc-a spinifex:role:system-instance-egress spinifex:src_ip:10.0.1.10 spinifex:subnet:subnet-pub]} policy {UUID:X Priority:900 Match:inport == "rtr-subnet-priv" && ip4.dst == 0.0.0.0/0 && ip4.dst != 10.0.0.0/16 && ip4.dst != 169.254.0.0/16 Action:reroute Nexthop:PTR Nexthops:[] Options:map[] ExternalIDs:map[spinifex:output_port:gw-vpc-a spinifex:subnet:subnet-priv]} port {UUID:lsp:ext-port-shared Name:ext-port-shared Type:localnet Addresses:[unknown] PortSecurity:[] DHCPv4Options: Enabled: Up: ExternalIDs:map[spinifex:role:external-localnet] Options:map[network_name:external]} -port {UUID:lsp:gw-port-vpc-a Name:gw-port-vpc-a Type:router Addresses:[router] PortSecurity:[] DHCPv4Options: Enabled: Up: ExternalIDs:map[spinifex:igw_id:igw-a spinifex:role:gateway spinifex:vpc_id:vpc-a] Options:map[router-port:gw-vpc-a]} +port {UUID:lsp:gw-port-vpc-a Name:gw-port-vpc-a Type:router Addresses:[router] PortSecurity:[] DHCPv4Options: Enabled: Up: ExternalIDs:map[spinifex:igw_id:igw-a spinifex:role:gateway spinifex:vpc_id:vpc-a] Options:map[nat-addresses:router router-port:gw-vpc-a]} port {UUID:lsp:port-eni-priv Name:port-eni-priv Type: Addresses:[02:00:00:00:00:02 10.0.2.10] PortSecurity:[02:00:00:00:00:02 10.0.2.10] DHCPv4Options:PTR Enabled: Up: ExternalIDs:map[spinifex:eni_id:eni-priv spinifex:subnet_id:subnet-priv spinifex:vpc_id:vpc-a] Options:map[]} port {UUID:lsp:port-eni-pub Name:port-eni-pub Type: Addresses:[02:00:00:00:00:01 10.0.1.10] PortSecurity:[02:00:00:00:00:01 10.0.1.10] DHCPv4Options:PTR Enabled: Up: ExternalIDs:map[spinifex:eni_id:eni-pub spinifex:subnet_id:subnet-pub spinifex:vpc_id:vpc-a] Options:map[]} port {UUID:lsp:rtr-port-subnet-priv Name:rtr-port-subnet-priv Type:router Addresses:[router] PortSecurity:[] DHCPv4Options: Enabled: Up: ExternalIDs:map[spinifex:subnet_id:subnet-priv spinifex:vpc_id:vpc-a] Options:map[router-port:rtr-subnet-priv]} diff --git a/spinifex/otelsetup/metrics.go b/spinifex/otelsetup/metrics.go index 26e5c2ec7..3c730a5f0 100644 --- a/spinifex/otelsetup/metrics.go +++ b/spinifex/otelsetup/metrics.go @@ -23,6 +23,36 @@ const actionAttrKey = "rpc.method" // IDs — those belong in the accompanying log line. const leakKindAttrKey = "resource.kind" +// fenceOutcomeAttrKey names what a node did after losing a volume lease. The +// FenceOutcome values below are the whole domain, and none carries a volume or +// node identity: those belong in the log line beside the metric. +const fenceOutcomeAttrKey = "outcome" + +// FenceOutcome is what a node did after losing a volume lease. +type FenceOutcome string + +const ( + // FenceOutcomeTaken means another node held the lease, so this one gave up + // its export. The clearest signal in the set: a volume changed hands. + FenceOutcomeTaken FenceOutcome = "taken" + + // FenceOutcomeExpired means the entry was gone or unreadable with no + // successor. The node could not show it was the only writer, which is + // fenced on the same terms. + FenceOutcomeExpired FenceOutcome = "expired" + + // FenceOutcomeStalled means renewal could not be confirmed for long enough + // that the server TTL was about to admit another owner. This is the + // self-fence, and a rising rate is a NATS problem showing up as guest + // restarts. + FenceOutcomeStalled FenceOutcome = "stalled" + + // FenceOutcomeKillFailed means the export could not be torn down: nbdkit + // did not exit. Nothing downstream is safe after this -- the volume is + // neither fenced nor released -- so it is the one value here that is a page. + FenceOutcomeKillFailed FenceOutcome = "kill_failed" +) + var ( instrumentsOnce sync.Once requestCounter metric.Int64Counter @@ -30,6 +60,11 @@ var ( leakOnce sync.Once leakCounter metric.Int64Counter + + fenceOnce sync.Once + fenceCounter metric.Int64Counter + takeoverOnce sync.Once + takeoverCounter metric.Int64Counter ) // requestInstruments lazily creates the shared request instruments. The @@ -90,3 +125,47 @@ func RecordResourceLeak(ctx context.Context, kind string) { attribute.NewSet(attribute.String(leakKindAttrKey, kind)))) } } + +// RecordVolumeFence counts one volume whose lease this node stopped holding +// while an engine was open on it. outcome is one of the FenceOutcome constants. +// +// Any non-zero rate means guests are being stopped to protect a volume somebody +// else now owns, which is a correctness action with an availability cost and is +// worth paging on. FenceOutcomeKillFailed is the urgent one: the guest was not +// stopped, so the protection did not happen. +func RecordVolumeFence(ctx context.Context, outcome FenceOutcome) { + fenceOnce.Do(func() { + var err error + fenceCounter, err = otel.Meter(meterName).Int64Counter("mulga.volume.fence", + metric.WithDescription("Count of volume leases lost while an engine was open, by what the node did next."), + metric.WithUnit("{volume}")) + if err != nil { + otel.Handle(err) + } + }) + if fenceCounter != nil { + fenceCounter.Add(ctx, 1, metric.WithAttributeSet( + attribute.NewSet(attribute.String(fenceOutcomeAttrKey, string(outcome))))) + } +} + +// RecordVolumeTakeover counts one volume opened from the backend checkpoint +// while another node held writes the backend never received. +// +// This is the deliberate trade the placement design makes — an instance that +// runs with an older copy beats one that runs nowhere — so it is not an error. +// It is the only quantity that says how often that trade is being paid. +func RecordVolumeTakeover(ctx context.Context) { + takeoverOnce.Do(func() { + var err error + takeoverCounter, err = otel.Meter(meterName).Int64Counter("mulga.volume.takeover", + metric.WithDescription("Count of volumes opened from the backend while another node held unsealed writes."), + metric.WithUnit("{volume}")) + if err != nil { + otel.Handle(err) + } + }) + if takeoverCounter != nil { + takeoverCounter.Add(ctx, 1) + } +} diff --git a/spinifex/otelsetup/metrics_test.go b/spinifex/otelsetup/metrics_test.go new file mode 100644 index 000000000..f73ccd72c --- /dev/null +++ b/spinifex/otelsetup/metrics_test.go @@ -0,0 +1,120 @@ +package otelsetup_test + +import ( + "testing" + + "github.com/mulgadc/spinifex/spinifex/otelsetup" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" +) + +// collect installs an in-memory meter provider and returns a function that +// reads what has been recorded on it so far. +func collect(t *testing.T) func() metricdata.ResourceMetrics { + t.Helper() + + reader := metric.NewManualReader() + previous := otel.GetMeterProvider() + otel.SetMeterProvider(metric.NewMeterProvider(metric.WithReader(reader))) + t.Cleanup(func() { otel.SetMeterProvider(previous) }) + + return func() metricdata.ResourceMetrics { + var out metricdata.ResourceMetrics + require.NoError(t, reader.Collect(t.Context(), &out)) + return out + } +} + +// sumFor returns the total recorded on the named instrument, and whether the +// instrument exists at all. +func sumFor(rm metricdata.ResourceMetrics, name string) (int64, bool) { + for _, scope := range rm.ScopeMetrics { + for _, m := range scope.Metrics { + if m.Name != name { + continue + } + sum, ok := m.Data.(metricdata.Sum[int64]) + if !ok { + return 0, false + } + var total int64 + for _, dp := range sum.DataPoints { + total += dp.Value + } + return total, true + } + } + return 0, false +} + +// outcomesFor returns the set of outcome attribute values seen on a counter. +// The fence's whole diagnostic value is in that attribute: it separates a +// volume that changed hands from a node that lost contact with NATS. +func outcomesFor(rm metricdata.ResourceMetrics, name string) []string { + var seen []string + for _, scope := range rm.ScopeMetrics { + for _, m := range scope.Metrics { + if m.Name != name { + continue + } + if sum, ok := m.Data.(metricdata.Sum[int64]); ok { + for _, dp := range sum.DataPoints { + if v, found := dp.Attributes.Value("outcome"); found { + seen = append(seen, v.AsString()) + } + } + } + } + } + return seen +} + +// TestRecordVolumeFence_SeparatesTheOutcomes pins that the four fence outcomes +// stay distinguishable. They call for different responses — a takeover is +// working as designed, a kill_failed means the protection did not happen — so +// collapsing them into one number would make the metric unactionable. +func TestRecordVolumeFence_SeparatesTheOutcomes(t *testing.T) { + read := collect(t) + + otelsetup.RecordVolumeFence(t.Context(), otelsetup.FenceOutcomeTaken) + otelsetup.RecordVolumeFence(t.Context(), otelsetup.FenceOutcomeExpired) + otelsetup.RecordVolumeFence(t.Context(), otelsetup.FenceOutcomeStalled) + otelsetup.RecordVolumeFence(t.Context(), otelsetup.FenceOutcomeKillFailed) + + rm := read() + total, ok := sumFor(rm, "mulga.volume.fence") + require.True(t, ok, "the fence counter must be registered once a fence is recorded") + assert.EqualValues(t, 4, total) + assert.ElementsMatch(t, + []string{"taken", "expired", "stalled", "kill_failed"}, + outcomesFor(rm, "mulga.volume.fence")) +} + +// TestRecordVolumeTakeover_Counts covers the one quantity that says how often +// the platform is choosing availability over the newer copy of a volume. +func TestRecordVolumeTakeover_Counts(t *testing.T) { + read := collect(t) + + otelsetup.RecordVolumeTakeover(t.Context()) + otelsetup.RecordVolumeTakeover(t.Context()) + + total, ok := sumFor(read(), "mulga.volume.takeover") + require.True(t, ok, "the takeover counter must be registered once a takeover is recorded") + assert.EqualValues(t, 2, total) +} + +// TestRecordResourceLeak_CountsByKind covers the leak counter's attribute, +// which is what makes a rising number point at a resource class rather than +// just going up. +func TestRecordResourceLeak_CountsByKind(t *testing.T) { + read := collect(t) + + otelsetup.RecordResourceLeak(t.Context(), "public_ip") + + total, ok := sumFor(read(), "mulga.resource.leaked") + require.True(t, ok) + assert.EqualValues(t, 1, total) +} diff --git a/spinifex/services/viperblockd/mount_idempotency_test.go b/spinifex/services/viperblockd/mount_idempotency_test.go index 8bbee824e..e2860305e 100644 --- a/spinifex/services/viperblockd/mount_idempotency_test.go +++ b/spinifex/services/viperblockd/mount_idempotency_test.go @@ -2,6 +2,7 @@ package viperblockd import ( "context" + "os" "testing" "github.com/stretchr/testify/assert" @@ -19,10 +20,13 @@ func TestMountVolume_AlreadyMountedReturnsExistingExport(t *testing.T) { const volumeName = "vol-mountidempotent01" const existingURI = "nbd://127.0.0.1:10809" + // A live pid, because the guard only hands back an export it can still see + // running: an entry whose process is gone describes a dead export and is + // dropped instead, which is what stops a volume becoming unstartable. cfg.MountedVolumes = append(cfg.MountedVolumes, MountedVolume{ Name: volumeName, NBDURI: existingURI, - PID: 4242, + PID: os.Getpid(), ReadOnly: false, }) diff --git a/spinifex/services/viperblockd/nbdkit_recovery.go b/spinifex/services/viperblockd/nbdkit_recovery.go index 899bc4cb6..ac8d0134c 100644 --- a/spinifex/services/viperblockd/nbdkit_recovery.go +++ b/spinifex/services/viperblockd/nbdkit_recovery.go @@ -16,8 +16,10 @@ import ( "strconv" "strings" "syscall" + "time" "github.com/mulgadc/spinifex/spinifex/utils" + "github.com/mulgadc/viperblock/viperblock" "github.com/nats-io/nats.go" ) @@ -125,13 +127,42 @@ func corroborateNbdkit(cfg *Config, disc discoveredNbdkit) bool { return err == nil && info.Mode()&os.ModeSocket != 0 } +// defaultRecoveryAttempts is how many times recovery tries to build a +// survivor's VB before giving up on it, and defaultRecoveryBackoff is the +// pause between those attempts. +// +// Recovery runs at daemon start, when JetStream is routinely still catching up +// and a KV claim can time out on its own default. One shot there orphans a +// running guest's data path for the life of the process, which is a far worse +// outcome than waiting a few seconds. +const ( + defaultRecoveryAttempts = 4 + defaultRecoveryBackoff = 3 * time.Second +) + // rebuildMountedVolume re-derives disc's NBD URI, builds its daemon-side VB // (cfg.buildVB, mountVolume's construction) and registers the same // config/owner subscriptions a fresh mount would. Never touches the process. func rebuildMountedVolume(ctx context.Context, cfg *Config, nc *nats.Conn, disc discoveredNbdkit) (MountedVolume, error) { - vb, _, lease, err := cfg.buildVB(ctx, disc.Volume) - if err != nil { - return MountedVolume{}, fmt.Errorf("construct VB: %w", err) + attempts, backoff := cfg.recoveryBuildPolicy() + var vb *viperblock.VB + var lease *volumeLease + var err error + for attempt := 1; ; attempt++ { + vb, _, lease, err = cfg.buildVB(ctx, disc.Volume) + if err == nil { + break + } + if attempt >= attempts { + return MountedVolume{}, fmt.Errorf("construct VB after %d attempts: %w", attempt, err) + } + slog.WarnContext(ctx, "recovery: could not build VB for a surviving nbdkit, retrying", + "pid", disc.PID, "volume", disc.Volume, "attempt", attempt, "err", err) + select { + case <-ctx.Done(): + return MountedVolume{}, fmt.Errorf("construct VB: %w", ctx.Err()) + case <-time.After(backoff): + } } var nbdURI string @@ -205,6 +236,9 @@ type reapReason string const ( reapReasonUnadoptable reapReason = "unadoptable" reapReasonDuplicate reapReason = "duplicate" + // reapReasonUnregistered is an export still serving a volume the registry + // has no entry for, found when that volume is unmounted. + reapReasonUnregistered reapReason = "unregistered" ) // reapOrphanedNbdkit decides whether disc -- a corroborated nbdkit process @@ -213,35 +247,36 @@ const ( // only ever signalled once a scan of every other process on the host finds no // reference to its NBD endpoint. A referencing process, or a scan that could // not complete, both leave it running: the caller gets a loud log either way. -func reapOrphanedNbdkit(procRoot string, disc discoveredNbdkit, reason reapReason) { +// Reports whether disc was signalled. +func reapOrphanedNbdkit(procRoot string, disc discoveredNbdkit, reason reapReason) bool { if hidden, herr := procHidepidInvisible(procRoot); hidden { slog.Warn("recovery: proc mount hides other users' process entries; a live referencer could be invisible to this scan, leaving unclaimed nbdkit running", "pid", disc.PID, "volume", disc.Volume, "reason", reason, "err", herr) - return + return false } referencingPID, referenced, err := endpointReferencer(procRoot, disc) if err != nil { slog.Warn("recovery: could not scan for processes using an unclaimed nbdkit's endpoint, leaving it running", "pid", disc.PID, "volume", disc.Volume, "reason", reason, "err", err) - return + return false } if referenced { slog.Error("recovery: unclaimed nbdkit process is still referenced by a live process; left running deliberately, needs operator attention", "pid", disc.PID, "volume", disc.Volume, "reason", reason, "referencing_pid", referencingPID) - return + return false } proc, err := os.FindProcess(disc.PID) if err != nil { slog.Error("recovery: failed to locate unclaimed nbdkit process to reap", "pid", disc.PID, "volume", disc.Volume, "reason", reason, "err", err) - return + return false } if err := proc.Signal(syscall.SIGTERM); err != nil { slog.Error("recovery: failed to signal unclaimed nbdkit process", "pid", disc.PID, "volume", disc.Volume, "reason", reason, "err", err) - return + return false } if disc.Socket != "" { @@ -253,6 +288,7 @@ func reapOrphanedNbdkit(procRoot string, disc discoveredNbdkit, reason reapReaso slog.Info("recovery: reaped unclaimed nbdkit process with no live referencer", "pid", disc.PID, "volume", disc.Volume, "reason", reason) + return true } // procHidepidInvisible reports whether procRoot's mount hides another uid's @@ -358,3 +394,30 @@ func cmdlineReferencesEndpoint(cmdline []byte, disc discoveredNbdkit) bool { } return false } + +// reapUnregisteredNbdkit reaps any nbdkit this daemon owns for volumeName that +// is not in cfg.MountedVolumes, and reports whether it reaped one. +// +// The registry can be missing an entry for a live export: recovery gives up on +// a survivor whose VB it could not build, and nothing reaps it afterwards, so +// it outlives the guest and holds its socket and volume open. Unmount is the +// point the volume is going away, so anything still serving it here is a leak. +// +// Only corroborated processes are considered -- our plugin, our base dir, that +// exact volume -- and the reap itself still declines anything a live process +// references, because the guest's disk may not be torn down yet. +func reapUnregisteredNbdkit(ctx context.Context, cfg *Config, volumeName string) bool { + procRoot := cfg.procScanRoot() + var reaped bool + for _, disc := range scanNbdkitProcs(procRoot) { + if disc.Volume != volumeName || !corroborateNbdkit(cfg, disc) { + continue + } + slog.WarnContext(ctx, "ebs.unmount: found an nbdkit serving this volume with no registry entry", + "volume", volumeName, "pid", disc.PID, "socket", disc.Socket) + if reapOrphanedNbdkit(procRoot, disc, reapReasonUnregistered) { + reaped = true + } + } + return reaped +} diff --git a/spinifex/services/viperblockd/nbdkit_recovery_test.go b/spinifex/services/viperblockd/nbdkit_recovery_test.go index 1cccb9dba..dd8ccbab6 100644 --- a/spinifex/services/viperblockd/nbdkit_recovery_test.go +++ b/spinifex/services/viperblockd/nbdkit_recovery_test.go @@ -7,6 +7,7 @@ package viperblockd import ( "context" "encoding/json" + "errors" "fmt" "net" "os" @@ -681,3 +682,168 @@ func TestRecoverMountedVolumes_UnadoptableReapedAcrossRestarts(t *testing.T) { _, ok = findMountedVolume(cfg, volumeName) assert.False(t, ok) } + +// --- adoption failures must not orphan a live export --- + +// TestRebuildMountedVolume_RetriesATransientBuildFailure pins the retry. +// Recovery runs at daemon start, where a JetStream still catching up fails the +// lease claim on its own 5s default. Giving up on the first error orphaned the +// export for the life of the process, and the -efi volume -- always adopted +// after the boot volume -- was the one that hit the window. +func TestRebuildMountedVolume_RetriesATransientBuildFailure(t *testing.T) { + _, natsURL := setupEmbeddedNATS(t) + cfg := setupTestConfig(t, natsURL) + cfg.recoveryAttempts = 3 + cfg.recoveryBackoff = time.Millisecond + nc := startProviderSubjects(t, cfg, natsURL) + + var calls int + build := fileBackedConstructVB(t) + cfg.constructVB = func(ctx context.Context, volumeName string) (*viperblock.VB, int, error) { + calls++ + if calls < 3 { + return nil, 0, errors.New("context deadline exceeded") + } + return build(ctx, volumeName) + } + + disc := discoveredNbdkit{PID: 4243, Volume: "vol-transient001-efi", Socket: filepath.Join(t.TempDir(), "efi.sock")} + mv, err := rebuildMountedVolume(context.Background(), cfg, nc, disc) + require.NoError(t, err, "a build that succeeds on the third attempt must be adopted, not abandoned") + assert.Equal(t, 3, calls, "every attempt up to the first success should have been made") + assert.Equal(t, disc.Volume, mv.Name) +} + +// TestRebuildMountedVolume_GivesUpAfterTheLastAttempt keeps the retry bounded, +// so a genuinely unbuildable volume still reaches the reap path. +func TestRebuildMountedVolume_GivesUpAfterTheLastAttempt(t *testing.T) { + _, natsURL := setupEmbeddedNATS(t) + cfg := setupTestConfig(t, natsURL) + cfg.recoveryAttempts = 2 + cfg.recoveryBackoff = time.Millisecond + nc := startProviderSubjects(t, cfg, natsURL) + + var calls int + cfg.constructVB = func(context.Context, string) (*viperblock.VB, int, error) { + calls++ + return nil, 0, errors.New("permanently broken") + } + + disc := discoveredNbdkit{PID: 4244, Volume: "vol-permafail001", Socket: filepath.Join(t.TempDir(), "x.sock")} + _, err := rebuildMountedVolume(context.Background(), cfg, nc, disc) + require.Error(t, err) + assert.Contains(t, err.Error(), "after 2 attempts") + assert.Equal(t, 2, calls, "the retry must be bounded by the configured attempts") +} + +// --- unmount reaping an export the registry never claimed --- + +// unregisteredFixture stages the leak this exists for: a live, corroborated +// nbdkit for volumeName that cfg.MountedVolumes has no entry for, as recovery +// leaves behind when it cannot build the volume's VB. +func unregisteredFixture(t *testing.T, volumeName string) (*Config, int, string) { + t.Helper() + pid := spawnLongRunningProcess(t) + socket := filepath.Join(t.TempDir(), volumeName+".sock") + listenUnix(t, socket) + + procRoot := t.TempDir() + writeProcFixture(t, procRoot, pid, "nbdkit", nbdkitArgv(t.TempDir(), socket, 0, volumeName)) + + cfg := &Config{procRoot: procRoot} + cfg.BaseDir = discoveredBaseDir(t, procRoot, volumeName) + return cfg, pid, socket +} + +// discoveredBaseDir reads back the base dir the fixture's argv encodes, so the +// Config agrees with it and corroboration passes. +func discoveredBaseDir(t *testing.T, procRoot, volumeName string) string { + t.Helper() + for _, disc := range scanNbdkitProcs(procRoot) { + if disc.Volume == volumeName { + return disc.BaseDir + } + } + t.Fatalf("fixture nbdkit for %s was not discoverable", volumeName) + return "" +} + +// TestReapUnregisteredNbdkit_ReapsAnExportTheRegistryNeverClaimed is the leak +// itself: the EFI volume's nbdkit outlives its guest because recovery could +// not adopt it, and unmount is the last point anything knows it should die. +func TestReapUnregisteredNbdkit_ReapsAnExportTheRegistryNeverClaimed(t *testing.T) { + const volumeName = "vol-unreg0001-efi" + cfg, pid, socket := unregisteredFixture(t, volumeName) + + logs := captureLogs(t) + assert.True(t, reapUnregisteredNbdkit(context.Background(), cfg, volumeName), + "an export serving a volume with no registry entry must be reported as reaped") + + require.Eventually(t, func() bool { return !utils.ProcessAlive(pid) }, 2*time.Second, 20*time.Millisecond, + "the unregistered export must be signalled and exit") + _, err := os.Stat(socket) + assert.True(t, os.IsNotExist(err), "the reaped export's socket file must be removed") + assert.Contains(t, logs.String(), "no registry entry") + assert.Contains(t, logs.String(), "reason=unregistered") +} + +// TestReapUnregisteredNbdkit_LeavesAnotherVolumeAlone guards the obvious way +// this could destroy a running guest: unmounting one volume must not touch the +// export for any other, however similar the name. +func TestReapUnregisteredNbdkit_LeavesAnotherVolumeAlone(t *testing.T) { + const volumeName = "vol-unreg0002-efi" + cfg, pid, socket := unregisteredFixture(t, volumeName) + + assert.False(t, reapUnregisteredNbdkit(context.Background(), cfg, "vol-unreg0002"), + "the boot volume's unmount must not claim to have reaped the EFI volume's export") + assert.True(t, utils.ProcessAlive(pid), "an export for a different volume must be left running") + _, err := os.Stat(socket) + assert.NoError(t, err, "an export for a different volume must keep its socket") +} + +// TestReapUnregisteredNbdkit_LeavesAForeignExportAlone proves the daemon only +// reaps its own: another daemon's export for the same volume name is not ours +// to signal, so corroboration must reject it on the base dir alone. +func TestReapUnregisteredNbdkit_LeavesAForeignExportAlone(t *testing.T) { + const volumeName = "vol-unreg0003-efi" + cfg, pid, socket := unregisteredFixture(t, volumeName) + cfg.BaseDir = filepath.Join(t.TempDir(), "someone-elses-data-dir") + + assert.False(t, reapUnregisteredNbdkit(context.Background(), cfg, volumeName), + "an export for a different data dir belongs to a different daemon") + assert.True(t, utils.ProcessAlive(pid), "a foreign export must be left running") + _, err := os.Stat(socket) + assert.NoError(t, err, "a foreign export must keep its socket") +} + +// TestReapUnregisteredNbdkit_LeavesAReferencedExportAlone is the safety rule +// carried over from recovery's reap: at unmount the guest's blockdev may not +// be torn down yet, and killing the server underneath it strands QEMU. +func TestReapUnregisteredNbdkit_LeavesAReferencedExportAlone(t *testing.T) { + const volumeName = "vol-unreg0004-efi" + cfg, pid, socket := unregisteredFixture(t, volumeName) + writeReferencerFixture(t, cfg.procRoot, 919191, "driver=nbd,server.path="+socket) + + logs := captureLogs(t) + assert.False(t, reapUnregisteredNbdkit(context.Background(), cfg, volumeName), + "an export something still references must not be reported as reaped") + assert.True(t, utils.ProcessAlive(pid), "an export a live process references must be left running") + assert.Contains(t, logs.String(), "still referenced by a live process") +} + +// TestUnmountVolume_ReapsAnUnregisteredExport is the wiring: unmount is the +// only event that names the volume after its guest is gone, so it is where the +// leaked export has to be caught. NotFound stays true -- there is still no +// registry entry to seal -- and Reaped tells the caller it was not a no-op. +func TestUnmountVolume_ReapsAnUnregisteredExport(t *testing.T) { + const volumeName = "vol-unreg0005-efi" + cfg, pid, _ := unregisteredFixture(t, volumeName) + cfg.MountedVolumes = []MountedVolume{} + + resp, err := unmountVolume(context.Background(), cfg, volumeName) + require.Error(t, err, "the volume is genuinely not mounted here, reaped or not") + assert.True(t, resp.NotFound, "an unregistered volume has no entry to seal") + assert.True(t, resp.Reaped, "the export the unmount reaped must be reported") + require.Eventually(t, func() bool { return !utils.ProcessAlive(pid) }, 2*time.Second, 20*time.Millisecond, + "unmount must leave no nbdkit serving the volume it just removed") +} diff --git a/spinifex/services/viperblockd/provider_handlers.go b/spinifex/services/viperblockd/provider_handlers.go index 8edcaa63b..cde31280e 100644 --- a/spinifex/services/viperblockd/provider_handlers.go +++ b/spinifex/services/viperblockd/provider_handlers.go @@ -67,7 +67,18 @@ func registerProviderSubjects(cfg *Config, nc *nats.Conn) error { if err != nil { return fmt.Errorf("volume leases: %w", err) } - cfg.leases = leases + cfg.leases = cfg.bindLeaseFence(leases) + } + cfg.nc = nc + + // 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()) + if err != nil { + return fmt.Errorf("volume dirty markers: %w", err) + } + cfg.dirty = dirty } subs := []struct { @@ -858,6 +869,10 @@ func handleDeleteVolume(ctx context.Context, cfg *Config, nc *nats.Conn, msg *na } } + // The volume is gone, so a marker pinning it to this node pins nothing. + // Left behind it would outlive every copy of the data it describes. + cfg.purgeVolumeDirty(ctx, req.VolumeID) + slog.Info("ebs.provider.volume.delete: deleted", "volume", req.VolumeID) respondProvider(ctx, msg, ebsprovider.DeleteVolumeResponse{Versioned: ebsprovider.NewVersioned()}) } @@ -1324,14 +1339,24 @@ func mountVolume(ctx context.Context, cfg *Config, nc *nats.Conn, volumeName str // ebs.mount subject is the route production actually takes: a retried // attach racing a fresh one would otherwise start two real exports. if mv, ok := findMountedVolume(cfg, volumeName); ok { - // Access mode is fixed when nbdkit starts, so a remount asking for the - // other mode cannot be answered with the running export. - if mv.ReadOnly != readOnly { - err := fmt.Errorf("volume %s is already mounted read_only=%t on this node", volumeName, mv.ReadOnly) - return types.EBSMountResponse{Error: err.Error()}, err + // An entry whose socket is gone describes an export that no longer + // exists, and returning its URI makes the volume unstartable forever: + // every caller waits for a socket nothing will create. Dropping it and + // mounting again is safe because an absent socket cannot be serving. + if stale, why := mountEntryIsStale(mv); stale { + slog.WarnContext(ctx, "ebs.mount: dropping stale mount entry and remounting", + "volume", volumeName, "uri", mv.NBDURI, "reason", why) + releaseStaleMount(ctx, cfg, volumeName) + } else { + // Access mode is fixed when nbdkit starts, so a remount asking for + // the other mode cannot be answered with the running export. + if mv.ReadOnly != readOnly { + err := fmt.Errorf("volume %s is already mounted read_only=%t on this node", volumeName, mv.ReadOnly) + return types.EBSMountResponse{Error: err.Error()}, err + } + slog.InfoContext(ctx, "ebs.mount: already mounted, returning existing export", "volume", volumeName, "uri", mv.NBDURI) + return types.EBSMountResponse{URI: mv.NBDURI, Mounted: true}, nil } - slog.InfoContext(ctx, "ebs.mount: already mounted, returning existing export", "volume", volumeName, "uri", mv.NBDURI) - return types.EBSMountResponse{URI: mv.NBDURI, Mounted: true}, nil } // Clear any receipt left by a previous mount before anything else can @@ -1588,8 +1613,15 @@ func unmountVolume(ctx context.Context, cfg *Config, volumeName string) (types.E matched.VB.Detach() } - if err := utils.KillProcess(matched.PID); err != nil { - slog.ErrorContext(ctx, "Failed to kill nbdkit process", "pid", matched.PID, "err", err) + // The seal below rewrites the directory nbdkit writes, so a kill that + // did not take makes it a concurrent write to that directory. Fail the + // unmount instead: the entry stays mounted and a retry re-attempts + // both, which is what a failed seal already does. + if err := utils.ForceKillProcess(matched.PID, fenceKillTimeout); err != nil { + slog.ErrorContext(ctx, "ebs.unmount: nbdkit did not exit, refusing to seal underneath a live writer", + "volume", matched.Name, "pid", matched.PID, "err", err) + ebsResponse.Error = fmt.Sprintf("kill nbdkit for %s: %v", matched.Name, err) + return ebsResponse, errors.New(ebsResponse.Error) } // nbdkit is now dead, so no process writes the shared BaseDir: seal @@ -1604,13 +1636,18 @@ func unmountVolume(ctx context.Context, cfg *Config, volumeName string) (types.E if err := cfg.seal(ctx, matched.Name); err != nil { slog.ErrorContext(ctx, "ebs.unmount: failed to seal volume to predastore", "volume", matched.Name, "err", err) ebsResponse.Error = fmt.Sprintf("seal volume: %v", err) + // The un-uploaded writes are now this node's alone, and the + // lease saying so dies with the node. Record it durably. + cfg.markVolumeDirtyAfterFailedSeal(ctx, matched.Name, matched.leaseGeneration(), err.Error()) } else { slog.InfoContext(ctx, "ebs.unmount: volume sealed to predastore", "volume", matched.Name) + cfg.clearVolumeDirty(ctx, matched.Name, matched.leaseGeneration()) } } else if consumeSealReceipt(cfg.BaseDir, matched.Name) { // Healthy path: the plugin sealed to predastore and removed // its local state itself, leaving this receipt as proof. slog.InfoContext(ctx, "ebs.unmount: volume already sealed by nbdkit plugin", "volume", matched.Name) + cfg.clearVolumeDirty(ctx, matched.Name, matched.leaseGeneration()) } else { // A durable volume reached unmount with no local WAL and no // seal receipt: this node never held its state, so there is @@ -1650,10 +1687,17 @@ func unmountVolume(ctx context.Context, cfg *Config, volumeName string) (types.E } if matchIdx < 0 { + // No registry entry, but the export can still be live: recovery gives + // up on a survivor it could not adopt, and nothing else ever reaps it. + // Unmount is the point the volume is going away, so an nbdkit this + // daemon owns for this volume is a leak whatever left it behind. + reaped := reapUnregisteredNbdkit(ctx, cfg, volumeName) + ebsResponse = types.EBSUnMountResponse{ Volume: volumeName, Error: fmt.Sprintf("Volume %s not found", volumeName), NotFound: true, + Reaped: reaped, } } @@ -1773,3 +1817,64 @@ func handleUnpublishVolume(ctx context.Context, cfg *Config, msg *nats.Msg) { slog.Info("ebs.provider.volume.unpublish: unpublished", "volume", req.VolumeID, "node", cfg.NodeName) respondProvider(ctx, msg, ebsprovider.UnpublishVolumeResponse{Versioned: ebsprovider.NewVersioned()}) } + +// mountEntryIsStale reports whether a MountedVolume describes an export that is +// no longer running, and why. +// +// Only positive evidence of death counts. The entry guards against a second +// nbdkit for one volume, so treating a live export as stale would create the +// double-writer hazard it exists to prevent — an absent socket or a dead pid +// are the two conditions under which no export can be serving. +func mountEntryIsStale(mv MountedVolume) (bool, string) { + if mv.Socket != "" { + if _, err := os.Stat(mv.Socket); errors.Is(err, os.ErrNotExist) { + return true, "socket is gone" + } + } + if mv.PID > 0 && !utils.ProcessAlive(mv.PID) { + return true, "nbdkit process is gone" + } + return false, "" +} + +// releaseStaleMount drops a dead export's entry and everything it held, so the +// remount that follows starts from the same state a clean unmount would leave. +func releaseStaleMount(ctx context.Context, cfg *Config, volumeName string) { + cfg.mu.Lock() + var matched MountedVolume + found := false + for i, volume := range cfg.MountedVolumes { + if volume.Name == volumeName { + matched, found = volume, true + cfg.MountedVolumes = append(cfg.MountedVolumes[:i], cfg.MountedVolumes[i+1:]...) + break + } + } + cfg.mu.Unlock() + if !found { + return + } + + if matched.ConfigSub != nil { + if err := matched.ConfigSub.Unsubscribe(); err != nil { + slog.ErrorContext(ctx, "stale mount: unsubscribe config topic", "volume", volumeName, "err", err) + } + } + for _, sub := range matched.OwnerSubs { + if sub == nil { + continue + } + if err := sub.Unsubscribe(); err != nil { + slog.ErrorContext(ctx, "stale mount: unsubscribe owner topic", "volume", volumeName, "err", err) + } + } + if matched.VB != nil { + matched.VB.Detach() + } + if matched.Socket != "" { + if err := os.Remove(matched.Socket); err != nil && !os.IsNotExist(err) { + slog.ErrorContext(ctx, "stale mount: remove socket", "volume", volumeName, "socket", matched.Socket, "err", err) + } + } + cfg.releaseVolumeLease(ctx, matched.Lease) +} diff --git a/spinifex/services/viperblockd/stale_mount_test.go b/spinifex/services/viperblockd/stale_mount_test.go new file mode 100644 index 000000000..f7bbc9659 --- /dev/null +++ b/spinifex/services/viperblockd/stale_mount_test.go @@ -0,0 +1,66 @@ +package viperblockd + +//test:in-package — mountEntryIsStale and releaseStaleMount are unexported, and +//exporting them would widen the package API for a test rather than for a caller. + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A mount entry pointing at a socket that no longer exists describes an export +// that is gone. Returning its URI is what makes a volume permanently +// unstartable: every caller then waits for a socket nothing will create. +func TestMountEntryIsStaleWhenSocketIsGone(t *testing.T) { + socket := filepath.Join(t.TempDir(), "nbd.sock") + + stale, why := mountEntryIsStale(MountedVolume{Name: "vol-1", Socket: socket, PID: os.Getpid()}) + assert.True(t, stale, "an absent socket means the export is gone") + assert.Equal(t, "socket is gone", why) + + require.NoError(t, os.WriteFile(socket, nil, 0o600)) + stale, _ = mountEntryIsStale(MountedVolume{Name: "vol-1", Socket: socket, PID: os.Getpid()}) + assert.False(t, stale, "a live export must never be treated as stale: that is the double-writer hazard") +} + +// The pid is the second piece of positive evidence, for an export whose socket +// file outlived the process that owned it. +func TestMountEntryIsStaleWhenProcessIsGone(t *testing.T) { + socket := filepath.Join(t.TempDir(), "nbd.sock") + require.NoError(t, os.WriteFile(socket, nil, 0o600)) + + // PID 0 is "unknown", not dead, so it must not be read as evidence. + stale, _ := mountEntryIsStale(MountedVolume{Name: "vol-1", Socket: socket, PID: 0}) + assert.False(t, stale, "an unknown pid is not evidence of death") + + stale, why := mountEntryIsStale(MountedVolume{Name: "vol-1", Socket: socket, PID: deadPID(t)}) + assert.True(t, stale, "a dead nbdkit means the export is gone") + assert.Equal(t, "nbdkit process is gone", why) +} + +// releaseStaleMount must drop the entry so the remount behind it can proceed, +// and must be safe to call for a volume that is not mounted at all. +func TestReleaseStaleMountDropsTheEntry(t *testing.T) { + cfg := &Config{MountedVolumes: []MountedVolume{{Name: "vol-1"}, {Name: "vol-2"}}} + + releaseStaleMount(t.Context(), cfg, "vol-1") + require.Len(t, cfg.MountedVolumes, 1) + assert.Equal(t, "vol-2", cfg.MountedVolumes[0].Name) + + releaseStaleMount(t.Context(), cfg, "vol-absent") + assert.Len(t, cfg.MountedVolumes, 1, "releasing an unmounted volume must be a no-op") +} + +// deadPID returns a pid that has certainly exited. +func deadPID(t *testing.T) int { + t.Helper() + proc, err := os.StartProcess("/bin/true", []string{"/bin/true"}, &os.ProcAttr{}) + require.NoError(t, err) + state, err := proc.Wait() + require.NoError(t, err) + return state.Pid() +} diff --git a/spinifex/services/viperblockd/vbwire/vbwire.go b/spinifex/services/viperblockd/vbwire/vbwire.go new file mode 100644 index 000000000..1dc3e5e4d --- /dev/null +++ b/spinifex/services/viperblockd/vbwire/vbwire.go @@ -0,0 +1,134 @@ +// Package vbwire is the contract between viperblockd and everything that has +// to read what it publishes: the NATS subjects it addresses, the shapes it puts +// on them, and the KV bucket recording which node holds a volume's only current +// copy. +// +// It is a leaf on purpose. A consumer of the contract -- the daemon stopping a +// fenced guest, an operator tool listing unsealed volumes -- should not have to +// link the storage service to name what it is listening for. +package vbwire + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" +) + +// VolumeFencedSubject is where a fenced volume is announced so the daemon on +// this node can stop the guest that was using it. Node-addressed: the guest is +// local, and every other node's daemon has nothing to do about it. +func VolumeFencedSubject(node string) string { + if node == "" { + return "ebs.fenced" + } + return "ebs." + node + ".fenced" +} + +// VolumeFencedEvent announces that a node tore down a volume's export because +// the lease moved. Winner is who holds it now, for the operator reading the +// guest's stop reason. +type VolumeFencedEvent struct { + Volume string `json:"volume"` + Node string `json:"node"` + Winner string `json:"winner"` + Reason string `json:"reason"` +} + +// DirtyBucket names the volumes whose writes are not confirmed to have reached +// the backend, so the node holding them is still known after that node is gone. +// +// Deliberately no TTL. The volume lease answers "who is writing this right now" +// and expires with its holder; this answers "whose copy is ahead of the +// backend", which has to outlive the holder to be worth anything. +// +// It is a placement input, not a gate. Instance start already prefers the node +// that last ran the instance and falls back after a window, and refusing the +// fallback here would trade a rare data-loss risk for a volume that cannot run +// anywhere. +const DirtyBucket = "VIPERBLOCK_VOLUME_DIRTY" + +// DirtyRecord is what a node publishes about writes the backend may not have. +// Reason says how it got that way, so an operator reading a takeover warning +// does not have to correlate journals across nodes. +// +// Generation is the lease revision the writer held. It orders two nodes' claims +// on one volume, which is the whole reason a returning node cannot quietly +// overwrite or clear a marker that has moved on without it. +type DirtyRecord struct { + Owner string `json:"owner"` + Generation uint64 `json:"generation"` + Since time.Time `json:"since"` + Reason string `json:"reason"` +} + +// UnsealedVolume reports a volume whose writes are not confirmed to have +// reached the backend, and which node holds them. +type UnsealedVolume struct { + VolumeID string + Owner string + Since time.Time + Reason string +} + +// BindDirtyBucket opens the dirty bucket read-only for an operator tool. It does +// not create the bucket: a cluster where no volume has ever been opened has no +// bucket, and reporting that as an error would read as a fault. A nil bucket +// with a nil error is that case. +func BindDirtyBucket(ctx context.Context, nc *nats.Conn) (jetstream.KeyValue, error) { + js, err := jetstream.New(nc) + if err != nil { + return nil, fmt.Errorf("jetstream: %w", err) + } + kv, err := js.KeyValue(ctx, DirtyBucket) + if errors.Is(err, jetstream.ErrBucketNotFound) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("volume dirty bucket: %w", err) + } + return kv, nil +} + +// ListUnsealedVolumes reports every volume a node holds writes for that the +// backend may not have. +// +// Starting one elsewhere is allowed and preferred over an instance that cannot +// run, but it opens from the last checkpoint that did reach the backend. This +// is the list of volumes where that trade would cost something. +func ListUnsealedVolumes(ctx context.Context, nc *nats.Conn) ([]UnsealedVolume, error) { + kv, err := BindDirtyBucket(ctx, nc) + if err != nil || kv == nil { + return nil, err + } + keys, err := kv.Keys(ctx) + if errors.Is(err, jetstream.ErrNoKeysFound) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("list unsealed volumes: %w", err) + } + + unsealed := make([]UnsealedVolume, 0, len(keys)) + for _, key := range keys { + entry, err := kv.Get(ctx, key) + if err != nil { + continue + } + var record DirtyRecord + if err := json.Unmarshal(entry.Value(), &record); err != nil { + // Something holds writes for this volume even if the record cannot + // be read, so report it rather than hide it. + unsealed = append(unsealed, UnsealedVolume{VolumeID: key, Reason: "marker is unreadable"}) + continue + } + unsealed = append(unsealed, UnsealedVolume{ + VolumeID: key, Owner: record.Owner, Since: record.Since, Reason: record.Reason, + }) + } + return unsealed, nil +} diff --git a/spinifex/services/viperblockd/vbwire/vbwire_test.go b/spinifex/services/viperblockd/vbwire/vbwire_test.go new file mode 100644 index 000000000..234a5aa76 --- /dev/null +++ b/spinifex/services/viperblockd/vbwire/vbwire_test.go @@ -0,0 +1,17 @@ +package vbwire_test + +import ( + "testing" + + "github.com/mulgadc/spinifex/spinifex/services/viperblockd/vbwire" + "github.com/stretchr/testify/assert" +) + +// TestVolumeFencedSubject_IsNodeAddressed pins the routing. A fenced guest is on +// the node that lost the volume, so a subject without the node in it would reach +// daemons with nothing to stop and, on a queue group, miss the one that has. +func TestVolumeFencedSubject_IsNodeAddressed(t *testing.T) { + assert.Equal(t, "ebs.node-a.fenced", vbwire.VolumeFencedSubject("node-a")) + assert.Equal(t, "ebs.fenced", vbwire.VolumeFencedSubject(""), + "a single-node daemon has no node name, and still has to hear its own fences") +} diff --git a/spinifex/services/viperblockd/viperblockd.go b/spinifex/services/viperblockd/viperblockd.go index 42bf97997..05c89a483 100644 --- a/spinifex/services/viperblockd/viperblockd.go +++ b/spinifex/services/viperblockd/viperblockd.go @@ -110,6 +110,17 @@ type MountedVolume struct { // export is up and given back when this entry leaves cfg.MountedVolumes. Lease *volumeLease } + +// leaseGeneration is the epoch this export writes under, or 0 for a mount that +// predates the lease store. Zero never matches a real marker, so a clear that +// falls back to it declines rather than removing somebody else's. +func (v MountedVolume) leaseGeneration() uint64 { + if v.Lease == nil { + return 0 + } + return v.Lease.generation +} + type Config struct { ConfigPath string PluginPath string @@ -167,6 +178,12 @@ type Config struct { // select the production defaults. loadStateRetryAttempts int loadStateRetryBaseDelay time.Duration + recoveryAttempts int + recoveryBackoff time.Duration + + // procRoot is where process scans look. Empty means /proc; tests point it + // at a fabricated directory. + procRoot string // sealVolume overrides how a detached volume is sealed to predastore. // Nil means sealVolumeVB, the real seal. Tests that need a seal to FAIL @@ -186,6 +203,16 @@ type Config struct { // refuses rather than proceeding blind. leases *volumeLeases + // dirty names the node holding a volume's only current copy after a failed + // seal. The lease covers a live holder and expires with it; this covers a + // dead one and does not expire. + dirty *volumeDirty + + // nc announces a fenced volume to this node's daemon. Held here because the + // fence runs from a lease-renewal goroutine, which has no handler's + // connection to borrow. Nil in tests that never fence. + nc *nats.Conn + // ready is closed once every subscription is registered on the server. // Nil in production; tests set it to wait for the real event instead of // guessing at a sleep. @@ -239,6 +266,28 @@ func (cfg *Config) loadStateRetryPolicy() (int, time.Duration) { return attempts, baseDelay } +// recoveryBuildPolicy is how hard recovery tries to build a survivor's VB. +// Zero values select the production defaults. +func (cfg *Config) recoveryBuildPolicy() (int, time.Duration) { + attempts := cfg.recoveryAttempts + if attempts <= 0 { + attempts = defaultRecoveryAttempts + } + backoff := cfg.recoveryBackoff + if backoff <= 0 { + backoff = defaultRecoveryBackoff + } + return attempts, backoff +} + +// procScanRoot is the directory process scans walk, defaulting to /proc. +func (cfg *Config) procScanRoot() string { + if cfg.procRoot == "" { + return "/proc" + } + return cfg.procRoot +} + type Service struct { Config *Config } @@ -657,7 +706,8 @@ func launchService(cfg *Config) (err error) { if err != nil { return fmt.Errorf("volume leases: %w", err) } - cfg.leases = leases + cfg.leases = cfg.bindLeaseFence(leases) + cfg.nc = nc // Rebuild MountedVolumes from any nbdkit processes that survived a // restart before the daemon accepts a single request, so a handler can diff --git a/spinifex/services/viperblockd/viperblockd_integration_test.go b/spinifex/services/viperblockd/viperblockd_integration_test.go index 89c3854c5..01bbc85a2 100644 --- a/spinifex/services/viperblockd/viperblockd_integration_test.go +++ b/spinifex/services/viperblockd/viperblockd_integration_test.go @@ -94,6 +94,10 @@ func setupTestConfig(t *testing.T, natsURL string) *Config { Debug: true, MountedVolumes: []MountedVolume{}, NodeName: "test-node", + // One shot: the production retry exists for a JetStream still catching + // up at daemon start, and every test that fails construction does so + // deliberately and permanently. + recoveryAttempts: 1, } installTestVolumeLeases(t, cfg, natsURL) diff --git a/spinifex/services/viperblockd/volume_dirty.go b/spinifex/services/viperblockd/volume_dirty.go new file mode 100644 index 000000000..e95f508f4 --- /dev/null +++ b/spinifex/services/viperblockd/volume_dirty.go @@ -0,0 +1,238 @@ +package viperblockd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/mulgadc/spinifex/spinifex/otelsetup" + "github.com/mulgadc/spinifex/spinifex/services/viperblockd/vbwire" + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" +) + +// errDirtyMarkerSuperseded reports a write refused because the marker names a +// later generation. The caller holds a lease it no longer owns. +var errDirtyMarkerSuperseded = errors.New("dirty marker belongs to a later generation") + +// volumeDirty records and answers "which node holds this volume's only current +// copy", from a JetStream KV bucket that outlives the node. +type volumeDirty struct { + kv jetstream.KeyValue + owner string +} + +// newVolumeDirty binds the dirty bucket, creating it if this is the first node +// up. owner identifies this node in the entries it writes. +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) + } + kv, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ + Bucket: vbwire.DirtyBucket, + Description: "volumes whose last seal failed, keyed by volume, naming the node holding the current copy", + History: 1, + }) + if err != nil { + return nil, fmt.Errorf("volume dirty bucket: %w", err) + } + return &volumeDirty{kv: kv, owner: owner}, nil +} + +// 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 +// name this node as the current copy after it stopped being one. +// +// Writing at the same generation is the ordinary case — every open marks, and a +// later failed seal on the same open refines the reason. +func (d *volumeDirty) mark(ctx context.Context, volumeName string, generation uint64, reason string) error { + if !volumeLeaseKeyPattern.MatchString(volumeName) { + return fmt.Errorf("volume name %q cannot be a dirty key", volumeName) + } + payload, err := json.Marshal(vbwire.DirtyRecord{ + Owner: d.owner, + Generation: generation, + Since: time.Now().UTC(), + Reason: reason, + }) + if err != nil { + return fmt.Errorf("marshal dirty record: %w", err) + } + + entry, err := d.kv.Get(ctx, volumeName) + if errors.Is(err, jetstream.ErrKeyNotFound) { + if _, err := d.kv.Create(ctx, volumeName, payload); err != nil { + return fmt.Errorf("mark %s dirty: %w", volumeName, err) + } + return nil + } + if err != nil { + return fmt.Errorf("read dirty mark on %s: %w", volumeName, err) + } + + var current vbwire.DirtyRecord + if err := json.Unmarshal(entry.Value(), ¤t); err == nil && current.Generation > generation { + return fmt.Errorf("%w: %s holds generation %d, this node has %d", + errDirtyMarkerSuperseded, current.Owner, current.Generation, generation) + } + + // Conditioned on the revision just read, so a concurrent takeover between + // the read and the write loses this update rather than overwriting theirs. + if _, err := d.kv.Update(ctx, volumeName, payload, entry.Revision()); err != nil { + return fmt.Errorf("mark %s dirty: %w", volumeName, err) + } + return nil +} + +// clear drops the marker after a seal that reached the backend. Absent is the +// normal case, so a missing key is not an error. +// +// Only clears a marker this node wrote at this generation. A node returning +// after a takeover still has local state to seal, and sealing it says nothing +// about the copy that moved on — clearing the winner's marker there would erase +// the record that the winner's own writes are unconfirmed. +func (d *volumeDirty) clear(ctx context.Context, volumeName string, generation uint64) error { + entry, err := d.kv.Get(ctx, volumeName) + if errors.Is(err, jetstream.ErrKeyNotFound) { + return nil + } + if err != nil { + return fmt.Errorf("read dirty mark on %s: %w", volumeName, err) + } + + var current vbwire.DirtyRecord + if err := json.Unmarshal(entry.Value(), ¤t); err != nil { + return fmt.Errorf("unreadable dirty mark on %s: %w", volumeName, err) + } + if current.Owner != d.owner || current.Generation != generation { + slog.WarnContext(ctx, "not clearing a dirty marker this node does not own, the volume moved on", + "volume", volumeName, "marker_owner", current.Owner, "marker_generation", current.Generation, + "this_owner", d.owner, "this_generation", generation) + return nil + } + + if err := d.kv.Delete(ctx, volumeName, jetstream.LastRevision(entry.Revision())); err != nil && + !errors.Is(err, jetstream.ErrKeyNotFound) { + return fmt.Errorf("clear dirty mark on %s: %w", volumeName, err) + } + return nil +} + +// purge removes the marker for a volume that no longer exists. Unconditional +// because delete is permanent: there is no copy left for an owner to hold, so a +// marker naming one describes nothing. +func (d *volumeDirty) purge(ctx context.Context, volumeName string) error { + if err := d.kv.Delete(ctx, volumeName); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) { + return fmt.Errorf("purge dirty mark on %s: %w", volumeName, err) + } + return nil +} + +// holder returns the record for volumeName and whether one exists. An entry +// that cannot be read is reported as absent: refusing every mount because the +// bucket is unreadable would convert a degraded cluster into a stopped one. +func (d *volumeDirty) holder(ctx context.Context, volumeName string) (vbwire.DirtyRecord, bool) { + entry, err := d.kv.Get(ctx, volumeName) + if err != nil { + if !errors.Is(err, jetstream.ErrKeyNotFound) { + slog.Warn("volume dirty: could not read marker", "volume", volumeName, "err", err) + } + return vbwire.DirtyRecord{}, false + } + var record vbwire.DirtyRecord + if err := json.Unmarshal(entry.Value(), &record); err != nil { + slog.Warn("volume dirty: unreadable marker", "volume", volumeName, "err", err) + return vbwire.DirtyRecord{}, false + } + return record, true +} + +// markVolumeDirty records that this node holds writes for volumeName. +// +// The error is the caller's to honour on the open path. A volume opened without +// a marker is one a later takeover cannot warn about, so a node that cannot +// write the marker cannot show it is safe to write the volume. +func (cfg *Config) markVolumeDirty(ctx context.Context, volumeName string, generation uint64, reason string) error { + if cfg.dirty == nil { + return nil + } + if err := cfg.dirty.mark(ctx, volumeName, generation, reason); err != nil { + slog.ErrorContext(ctx, "could not record that this node holds unconfirmed writes", + "volume", volumeName, "generation", generation, "err", err) + return err + } + // Every open marks, so this is the routine case. The events worth a human's + // attention — a failed seal, a takeover — are logged by their own callers. + slog.DebugContext(ctx, "volume writes not yet confirmed to the backend", + "volume", volumeName, "owner", cfg.dirty.owner, "generation", generation, "reason", reason) + return nil +} + +// markVolumeDirtyAfterFailedSeal records a seal that did not reach the backend. +// Best effort, unlike the open path: the seal has already failed and the +// caller's own error is the one that matters. +func (cfg *Config) markVolumeDirtyAfterFailedSeal(ctx context.Context, volumeName string, generation uint64, reason string) { + if err := cfg.markVolumeDirty(ctx, volumeName, generation, reason); err != nil { + slog.ErrorContext(ctx, "could not record the failed seal, so a start elsewhere will not warn", + "volume", volumeName, "err", err) + } +} + +// clearVolumeDirty drops the marker after a successful seal, if this node still +// owns it at this generation. +func (cfg *Config) clearVolumeDirty(ctx context.Context, volumeName string, generation uint64) { + if cfg.dirty == nil { + return + } + if err := cfg.dirty.clear(ctx, volumeName, generation); err != nil { + slog.ErrorContext(ctx, "could not clear the dirty mark, so later starts stay marked as unconfirmed", + "volume", volumeName, "err", err) + } +} + +// purgeVolumeDirty drops the marker for a deleted volume. +func (cfg *Config) purgeVolumeDirty(ctx context.Context, volumeName string) { + if cfg.dirty == nil { + return + } + if err := cfg.dirty.purge(ctx, volumeName); err != nil { + slog.ErrorContext(ctx, "could not remove the dirty mark for a deleted volume", + "volume", volumeName, "err", err) + } +} + +// reportVolumeTakeover records that this node is opening a volume another node +// holds unsealed writes for, and takes ownership of the marker. +// +// It never refuses. Instance start forwards to the node that last ran the +// instance and only falls back here once that node has failed its window, so by +// the time this runs the alternative is not "wait for the better copy", it is +// "the instance runs nowhere". The warning is the deliverable: this is the one +// moment a human can be told which writes are about to be left behind. +// It reports whether it took the marker over, so the caller does not then +// overwrite the detail with a generic one. +func (cfg *Config) reportVolumeTakeover(ctx context.Context, volumeName string, generation uint64) (bool, error) { + if cfg.dirty == nil { + return false, nil + } + record, ok := cfg.dirty.holder(ctx, volumeName) + if !ok || record.Owner == "" || record.Owner == cfg.dirty.owner { + return false, nil + } + + 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) + + // 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. + reason := fmt.Sprintf("took over from %s, which held unsealed writes since %s (%s)", + record.Owner, record.Since.Format(time.RFC3339), record.Reason) + return true, cfg.markVolumeDirty(ctx, volumeName, generation, reason) +} diff --git a/spinifex/services/viperblockd/volume_dirty_test.go b/spinifex/services/viperblockd/volume_dirty_test.go new file mode 100644 index 000000000..546697e02 --- /dev/null +++ b/spinifex/services/viperblockd/volume_dirty_test.go @@ -0,0 +1,266 @@ +package viperblockd + +//test:in-package — the dirty marker, its bucket and acquireVolumeLease are all +//unexported, and the takeover they implement has no exported surface. + +import ( + "testing" + + "github.com/mulgadc/spinifex/spinifex/services/viperblockd/vbwire" + "github.com/nats-io/nats.go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestDirty binds a dirty-marker store for owner against natsURL. +func newTestDirty(t *testing.T, natsURL, owner string) *volumeDirty { + t.Helper() + + nc, err := nats.Connect(natsURL) + require.NoError(t, err) + t.Cleanup(nc.Close) + + dirty, err := newVolumeDirty(t.Context(), nc, owner) + require.NoError(t, err) + return dirty +} + +// TestVolumeDirty_MarkedOnOpenNotOnSealFailure is the property the marker is +// built for. A node killed mid-write never reaches its seal, so a marker written +// only when a seal fails is absent in exactly the case it exists to describe. +func TestVolumeDirty_MarkedOnOpenNotOnSealFailure(t *testing.T) { + _, natsURL := setupEmbeddedNATS(t) + + const volumeName = "vol-dirtymarkedonopen" + + cfg := &Config{ + leases: newTestLeases(t, natsURL, "node-a"), + dirty: newTestDirty(t, natsURL, "node-a"), + } + + _, err := cfg.acquireVolumeLease(t.Context(), volumeName) + require.NoError(t, err) + + record, ok := cfg.dirty.holder(t.Context(), volumeName) + require.True(t, ok, "opening a volume must record that its writes are not yet on the backend") + assert.Equal(t, "node-a", record.Owner) +} + +// TestVolumeDirty_DoesNotRefuseAnotherNode locks the availability half. Instance +// start forwards to the node that last ran the instance and only falls back once +// that node has failed its window, so refusing here would leave the instance +// unable to run anywhere rather than running with a slightly older copy. +func TestVolumeDirty_DoesNotRefuseAnotherNode(t *testing.T) { + _, natsURL := setupEmbeddedNATS(t) + + const volumeName = "vol-dirtytakeoverallowed" + + gone := newTestDirty(t, natsURL, "node-a") + require.NoError(t, gone.mark(t.Context(), volumeName, 1, "seal volume: predastore unreachable")) + + survivor := &Config{ + leases: newTestLeases(t, natsURL, "node-b"), + dirty: newTestDirty(t, natsURL, "node-b"), + } + + lease, err := survivor.acquireVolumeLease(t.Context(), volumeName) + require.NoError(t, err, "a volume must not be pinned to a node that is gone") + require.NotNil(t, lease) +} + +// TestVolumeDirty_TakeoverNamesThePreviousHolder pins the record left behind. A +// takeover silently loses whatever node-a never sealed, so the marker has to +// carry who held it and why for the operator who reads it afterwards. +func TestVolumeDirty_TakeoverNamesThePreviousHolder(t *testing.T) { + _, natsURL := setupEmbeddedNATS(t) + + const volumeName = "vol-dirtytakeoverrecorded" + + gone := newTestDirty(t, natsURL, "node-a") + require.NoError(t, gone.mark(t.Context(), volumeName, 1, "seal volume: predastore unreachable")) + + survivor := &Config{ + leases: newTestLeases(t, natsURL, "node-b"), + dirty: newTestDirty(t, natsURL, "node-b"), + } + _, err := survivor.acquireVolumeLease(t.Context(), volumeName) + require.NoError(t, err) + + record, ok := survivor.dirty.holder(t.Context(), volumeName) + require.True(t, ok) + assert.Equal(t, "node-b", record.Owner, "the node now holding the writes owns the marker") + assert.Contains(t, record.Reason, "node-a", "the takeover must name the node whose writes were left behind") + assert.Contains(t, record.Reason, "predastore unreachable", + "why the previous holder could not seal is the operator's only lead") +} + +// TestVolumeDirty_OwnerReopeningIsNotATakeover guards against a node warning +// about itself. Reopening a volume this node already holds is the ordinary +// restart path, not a lost-writes event. +func TestVolumeDirty_OwnerReopeningIsNotATakeover(t *testing.T) { + _, natsURL := setupEmbeddedNATS(t) + + const volumeName = "vol-dirtyownerreopens" + + cfg := &Config{ + leases: newTestLeases(t, natsURL, "node-a"), + dirty: newTestDirty(t, natsURL, "node-a"), + } + require.NoError(t, cfg.dirty.mark(t.Context(), volumeName, 1, "seal volume: boom")) + + _, err := cfg.acquireVolumeLease(t.Context(), volumeName) + require.NoError(t, err) + + record, ok := cfg.dirty.holder(t.Context(), volumeName) + require.True(t, ok) + assert.NotContains(t, record.Reason, "took over", + "a node reopening its own volume has taken over from nobody") +} + +// TestVolumeDirty_ClearedAfterASuccessfulSeal pins that the marker is not a +// one-way door: once the writes reach the backend the volume is clean again and +// carries no placement preference. +func TestVolumeDirty_ClearedAfterASuccessfulSeal(t *testing.T) { + _, natsURL := setupEmbeddedNATS(t) + + const volumeName = "vol-dirtyclearsonseal" + + owner := &Config{dirty: newTestDirty(t, natsURL, "node-a")} + require.NoError(t, owner.dirty.mark(t.Context(), volumeName, 7, "seal volume: boom")) + + owner.clearVolumeDirty(t.Context(), volumeName, 7) + + _, ok := owner.dirty.holder(t.Context(), volumeName) + assert.False(t, ok, "a sealed volume must leave no marker behind") +} + +// TestVolumeDirty_ClearIsIdempotent covers the ordinary path, where every +// successful unmount clears a marker that may already be gone. +func TestVolumeDirty_ClearIsIdempotent(t *testing.T) { + _, natsURL := setupEmbeddedNATS(t) + + dirty := newTestDirty(t, natsURL, "node-a") + require.NoError(t, dirty.clear(t.Context(), "vol-dirtynevermarked", 1), + "clearing an unmarked volume is the normal case, not an error") +} + +// TestVolumeDirty_ClearDeclinesAMarkerAnotherNodeOwns is the safety the +// generation buys. A node that comes back after a takeover still has local +// state to seal, and its seal says nothing about the copy that moved on — +// clearing there would erase the record that the winner's writes are unsealed. +func TestVolumeDirty_ClearDeclinesAMarkerAnotherNodeOwns(t *testing.T) { + _, natsURL := setupEmbeddedNATS(t) + + const volumeName = "vol-dirtyclearnotours" + + winner := newTestDirty(t, natsURL, "node-b") + require.NoError(t, winner.mark(t.Context(), volumeName, 9, "took over from node-a")) + + returned := &Config{dirty: newTestDirty(t, natsURL, "node-a")} + returned.clearVolumeDirty(t.Context(), volumeName, 4) + + record, ok := winner.holder(t.Context(), volumeName) + require.True(t, ok, "a returning node must not clear the marker that moved past it") + assert.Equal(t, "node-b", record.Owner) + assert.EqualValues(t, 9, record.Generation) +} + +// TestVolumeDirty_ClearDeclinesAnOlderGenerationOfTheSameNode covers the same +// node reopening a volume it already held. The stale export's clear must not +// remove the marker the newer one wrote. +func TestVolumeDirty_ClearDeclinesAnOlderGenerationOfTheSameNode(t *testing.T) { + _, natsURL := setupEmbeddedNATS(t) + + const volumeName = "vol-dirtyclearoldgen" + + owner := &Config{dirty: newTestDirty(t, natsURL, "node-a")} + require.NoError(t, owner.dirty.mark(t.Context(), volumeName, 12, "reopened")) + + owner.clearVolumeDirty(t.Context(), volumeName, 5) + + record, ok := owner.dirty.holder(t.Context(), volumeName) + require.True(t, ok, "an older export clearing would leave the live one unrecorded") + assert.EqualValues(t, 12, record.Generation) +} + +// TestVolumeDirty_MarkRefusesToOverwriteALaterGeneration stops a returning node +// renaming itself as the current copy. Placement reads this marker, so an +// overwrite here would point a later takeover at the stale node. +func TestVolumeDirty_MarkRefusesToOverwriteALaterGeneration(t *testing.T) { + _, natsURL := setupEmbeddedNATS(t) + + const volumeName = "vol-dirtymarkstale" + + winner := newTestDirty(t, natsURL, "node-b") + require.NoError(t, winner.mark(t.Context(), volumeName, 9, "took over from node-a")) + + stale := newTestDirty(t, natsURL, "node-a") + err := stale.mark(t.Context(), volumeName, 4, "seal volume: boom") + require.ErrorIs(t, err, errDirtyMarkerSuperseded) + + record, ok := winner.holder(t.Context(), volumeName) + require.True(t, ok) + assert.Equal(t, "node-b", record.Owner, "the later generation still owns the marker") +} + +// TestVolumeDirty_OpenFailsWhenTheMarkerCannotBeWritten pins the ordering the +// marker depends on. A volume opened without one is a volume whose writes +// nothing records, so a later takeover could not tell they existed — and the +// lease has to go back, or the volume is stranded on a node not writing it. +func TestVolumeDirty_OpenFailsWhenTheMarkerCannotBeWritten(t *testing.T) { + _, natsURL := setupEmbeddedNATS(t) + + const volumeName = "vol-dirtymarkunwritable" + + // A generation no lease in a fresh bucket can reach, so this node's own + // mark is refused as stale. + blocker := newTestDirty(t, natsURL, "node-a") + require.NoError(t, blocker.mark(t.Context(), volumeName, 1<<40, "held by a later generation")) + + cfg := &Config{ + leases: newTestLeases(t, natsURL, "node-a"), + dirty: newTestDirty(t, natsURL, "node-a"), + } + + _, err := cfg.acquireVolumeLease(t.Context(), volumeName) + require.Error(t, err, "a volume whose writes cannot be recorded must not be opened") + + owner, held := cfg.leases.currentOwner(t.Context(), volumeName) + assert.False(t, held && owner == "node-a", + "the lease has to be released, or the volume is locked to a node that is not writing it") +} + +// TestVolumeDirty_NoStoreDoesNotBlockMounts pins the degraded case. A daemon +// with no marker store still has the lease for live exclusion, and refusing +// every mount because the bucket is missing is a worse failure than the one +// being described. +func TestVolumeDirty_NoStoreDoesNotBlockMounts(t *testing.T) { + _, natsURL := setupEmbeddedNATS(t) + + cfg := &Config{leases: newTestLeases(t, natsURL, "node-b")} + + lease, err := cfg.acquireVolumeLease(t.Context(), "vol-dirtynostore") + require.NoError(t, err) + require.NotNil(t, lease) +} + +// TestVolumeDirty_ListReportsTheHolder covers the operator surface behind +// 'spx admin volumes unsealed', which is the only way to see a volume whose +// writes are stranded on a node that is not coming back. +func TestVolumeDirty_ListReportsTheHolder(t *testing.T) { + _, natsURL := setupEmbeddedNATS(t) + + nc, err := nats.Connect(natsURL) + require.NoError(t, err) + t.Cleanup(nc.Close) + + dirty := newTestDirty(t, natsURL, "node-a") + require.NoError(t, dirty.mark(t.Context(), "vol-dirtylisted", 1, "seal volume: predastore unreachable")) + + unsealed, err := vbwire.ListUnsealedVolumes(t.Context(), nc) + require.NoError(t, err) + require.Len(t, unsealed, 1) + assert.Equal(t, "vol-dirtylisted", unsealed[0].VolumeID) + assert.Equal(t, "node-a", unsealed[0].Owner) + assert.Contains(t, unsealed[0].Reason, "predastore unreachable") +} diff --git a/spinifex/services/viperblockd/volume_fence.go b/spinifex/services/viperblockd/volume_fence.go new file mode 100644 index 000000000..2b6dcd53c --- /dev/null +++ b/spinifex/services/viperblockd/volume_fence.go @@ -0,0 +1,200 @@ +package viperblockd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "os" + "time" + + "github.com/mulgadc/spinifex/spinifex/otelsetup" + "github.com/mulgadc/spinifex/spinifex/services/viperblockd/vbwire" + "github.com/mulgadc/spinifex/spinifex/utils" + "github.com/nats-io/nats.go/jetstream" +) + +// The fence is what makes the volume lease more than a request. Acquiring it is +// checked once, at mount; nothing rechecks it afterwards, so a node that loses +// its lease while an engine is open goes on writing into a volume another node +// now owns. Both copies then advance, and whichever reaches the backend last +// wins by arrival order rather than by ownership. +// +// Stopping the loser locally is the cheap half of that problem. It does not +// make a stale write impossible — only an epoch the backend enforces does that +// — but it closes the window from the lease TTL down to one renewal interval, +// and it costs nothing on the write path. + +// fenceKillTimeout bounds the wait for nbdkit to exit after SIGKILL. Generous +// for an uninterruptible sleep in the storage path; short enough that a fence +// that will never complete is reported rather than waited on. +const fenceKillTimeout = 30 * time.Second + +// onVolumeLeaseLost fences a volume this node can no longer show it owns. +// Called from the renewal goroutine's failure path, on its own goroutine: +// fencing releases the lease, and releasing waits for that goroutine to exit. +// +// It never tries to reclaim. Reclaiming looks attractive — an entry that merely +// aged out under JetStream pressure has no new holder, and stopping a healthy +// guest for that is availability spent on nothing — but the two errors are not +// symmetric. A false fence costs one guest a restart. A false reclaim leaves two +// nodes writing one volume. Reclaiming correctly needs a state transition that +// atomically replaces the lost in-memory lease and starts a new renewal loop, +// and that is an optimisation, not a safety property. +// +// The holder is read only to say who won, which is what the operator needs and +// not something the decision turns on. +func (cfg *Config) onVolumeLeaseLost(ctx context.Context, volumeName string, kind leaseLossKind) { + if cfg.leases == nil { + return + } + + // Read only to name the winner for the operator. No holder, or one this node + // cannot read, still fences: it cannot show it is the only writer, and + // continuing to write is the one option that can corrupt. + winner := "unknown" + if owner, held := cfg.leases.currentOwner(ctx, volumeName); held && owner != cfg.leases.owner { + winner = owner + } + + outcome := otelsetup.FenceOutcomeExpired + switch { + case kind == leaseLostStalled: + outcome = otelsetup.FenceOutcomeStalled + case winner != "unknown": + outcome = otelsetup.FenceOutcomeTaken + } + cfg.fenceVolume(ctx, volumeName, winner, outcome) +} + +// fenceVolume tears this node's export down because the volume belongs to +// somebody else now. The guest loses its disk immediately, which is the point: +// an export left up is a second writer. +// +// Deliberately does not seal. This node's copy is the stale one, and sealing it +// would publish an older state over the winner's. For the same reason the dirty +// marker is left alone — the winner has taken it over and it now names them. +func (cfg *Config) fenceVolume(ctx context.Context, volumeName, winner string, outcome otelsetup.FenceOutcome) { + cfg.mu.Lock() + var matched MountedVolume + found := false + for _, volume := range cfg.MountedVolumes { + if volume.Name == volumeName { + matched, found = volume, true + break + } + } + cfg.mu.Unlock() + + if !found { + // The export went away on its own between losing the lease and getting + // here, which is the ordinary unmount racing this path. Nothing to do. + slog.InfoContext(ctx, "volume lease lost after the export had already gone", "volume", volumeName) + return + } + + slog.ErrorContext(ctx, "fencing volume: the lease moved while this node had it open, so the guest is losing its disk", + "volume", volumeName, "previous_owner", cfg.leases.owner, "winner", winner, + "pid", matched.PID) + + if matched.ConfigSub != nil { + if err := matched.ConfigSub.Unsubscribe(); err != nil { + slog.ErrorContext(ctx, "fence: unsubscribe config topic", "volume", volumeName, "err", err) + } + } + unsubscribeOwnerSubjects(matched.Name, matched.OwnerSubs) + + // Detach before the kill so the state-tracking VB stops its background + // flushes. Killing nbdkit stops the data path; this stops the metadata one. + if matched.VB != nil { + matched.VB.Detach() + } + + // Everything below assumes the writer is gone, so nothing below may run + // until it is observed gone. A fence that has not killed nbdkit has fenced + // nothing, and reporting it as done is the failure that lets a second + // writer keep going while the cluster believes it stopped. + if err := utils.ForceKillProcess(matched.PID, fenceKillTimeout); err != nil { + slog.ErrorContext(ctx, "fence FAILED: nbdkit did not exit, so this node is still a writer on a volume it does not own", + "volume", volumeName, "pid", matched.PID, "winner", winner, "err", err) + otelsetup.RecordVolumeFence(ctx, otelsetup.FenceOutcomeKillFailed) + return + } + + cfg.mu.Lock() + for i, volume := range cfg.MountedVolumes { + if volume.Name == matched.Name { + cfg.MountedVolumes = append(cfg.MountedVolumes[:i], cfg.MountedVolumes[i+1:]...) + break + } + } + cfg.mu.Unlock() + + if matched.Socket != "" { + if err := os.Remove(matched.Socket); err != nil && !os.IsNotExist(err) { + slog.ErrorContext(ctx, "fence: could not remove nbd socket", "socket", matched.Socket, "err", err) + } + } + + // Stops the renewal goroutine and drops the local entry. The lease was + // already flagged lost, so this cannot delete the winner's key. + cfg.releaseVolumeLease(ctx, matched.Lease) + + otelsetup.RecordVolumeFence(ctx, outcome) + cfg.publishVolumeFenced(ctx, volumeName, winner) +} + +// publishVolumeFenced tells the local daemon a guest is now running against a +// disk that is gone. Best effort: the fence has already happened, and a guest +// left running with dead I/O is a worse outcome to report than to cause. +func (cfg *Config) publishVolumeFenced(ctx context.Context, volumeName, winner string) { + if cfg.nc == nil { + return + } + event := vbwire.VolumeFencedEvent{ + Volume: volumeName, + Node: cfg.leaseOwner(), + Winner: winner, + Reason: fmt.Sprintf("volume lease moved to %s while this node had the volume open", winner), + } + payload, err := json.Marshal(event) + if err != nil { + slog.ErrorContext(ctx, "fence: marshal fenced event", "volume", volumeName, "err", err) + return + } + subject := vbwire.VolumeFencedSubject(cfg.NodeName) + if err := cfg.nc.Publish(subject, payload); err != nil { + slog.ErrorContext(ctx, "fence: could not announce the fenced volume, the guest will be left with dead I/O", + "volume", volumeName, "subject", subject, "err", err) + } +} + +// currentOwner reports who holds volumeName's lease now, and whether anyone +// does. An unreadable entry is reported as unheld: the caller fences on that, +// which is the safe reading of "cannot tell". +func (l *volumeLeases) currentOwner(ctx context.Context, volumeName string) (string, bool) { + entry, err := l.kv.Get(ctx, volumeName) + if err != nil { + if !errors.Is(err, jetstream.ErrKeyNotFound) { + slog.WarnContext(ctx, "volume lease: could not read the holder after losing it", + "volume", volumeName, "err", err) + } + return "", false + } + var record volumeLeaseRecord + if err := json.Unmarshal(entry.Value(), &record); err != nil { + slog.WarnContext(ctx, "volume lease: unreadable holder record", "volume", volumeName, "err", err) + return "", false + } + return record.Owner, record.Owner != "" +} + +// bindLeaseFence points a lease store's loss callback at this Config, so a +// renewal that discovers the lease has moved reaches the fence. +func (cfg *Config) bindLeaseFence(leases *volumeLeases) *volumeLeases { + if leases != nil { + leases.onLost = cfg.onVolumeLeaseLost + } + return leases +} diff --git a/spinifex/services/viperblockd/volume_fence_test.go b/spinifex/services/viperblockd/volume_fence_test.go new file mode 100644 index 000000000..869c557dc --- /dev/null +++ b/spinifex/services/viperblockd/volume_fence_test.go @@ -0,0 +1,204 @@ +package viperblockd + +//test:in-package — the fence, the lease store and MountedVolumes are all +//unexported, and the decision the fence makes has no exported surface. + +import ( + "context" + "os/exec" + "testing" + + "github.com/mulgadc/spinifex/spinifex/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stubExport starts a real process to stand in for nbdkit. The fence must +// observe the writer gone before it does anything else, so a fixture with a +// fake PID would exercise the failure path and prove nothing about the kill. +// +// Reaped on its own goroutine as the mount path does, because kill(pid,0) +// succeeds against a zombie: an unreaped child never looks like it exited. +func stubExport(t *testing.T) int { + t.Helper() + + cmd := exec.Command("sleep", "300") + require.NoError(t, cmd.Start()) + reaped := make(chan struct{}) + go func() { defer close(reaped); _ = cmd.Wait() }() + t.Cleanup(func() { + _ = cmd.Process.Kill() + <-reaped + }) + return cmd.Process.Pid +} + +// fencedConfig builds a Config owning volumeName with one mounted entry, as a +// node that has an export up would have. +func fencedConfig(t *testing.T, natsURL, owner, volumeName string) *Config { + t.Helper() + + cfg := &Config{ + NodeName: owner, + leases: newTestLeases(t, natsURL, owner), + dirty: newTestDirty(t, natsURL, owner), + } + cfg.leases = cfg.bindLeaseFence(cfg.leases) + + lease, err := cfg.acquireVolumeLease(t.Context(), volumeName) + require.NoError(t, err) + cfg.MountedVolumes = []MountedVolume{{Name: volumeName, Lease: lease, PID: stubExport(t)}} + + // Stop the renewal goroutine before the embedded server goes away, or it + // spends the TTL surrendering a lease no test is looking at any more. + t.Cleanup(func() { cfg.releaseVolumeLease(context.Background(), lease) }) + return cfg +} + +// TestVolumeFence_TearsDownWhenAnotherNodeHoldsTheLease is the property the +// fence exists for. A node that has lost its lease is a second writer, and the +// only way to stop being one without a backend that can refuse is to give up +// the export. +func TestVolumeFence_TearsDownWhenAnotherNodeHoldsTheLease(t *testing.T) { + _, natsURL := setupEmbeddedNATS(t) + + const volumeName = "vol-fencelost" + + cfg := fencedConfig(t, natsURL, "node-a", volumeName) + pid := cfg.MountedVolumes[0].PID + + // node-b takes the volume, which is what the renewal would discover. + winner := newTestLeases(t, natsURL, "node-b") + require.NoError(t, winner.kv.Delete(t.Context(), volumeName)) + _, err := winner.acquire(t.Context(), volumeName) + require.NoError(t, err) + + cfg.onVolumeLeaseLost(t.Context(), volumeName, leaseLostToPeer) + + cfg.mu.Lock() + mounted := len(cfg.MountedVolumes) + cfg.mu.Unlock() + assert.Zero(t, mounted, "a fenced volume must not be left exported: the export is the second writer") + assert.False(t, utils.ProcessAlive(pid), "the export process has to be gone, not merely forgotten") +} + +// TestVolumeFence_FencesAnEntryNobodyHolds covers the lapsed case. An entry that +// aged out with no successor is not proof this node is safe to keep writing — +// it is proof this node cannot tell. Stopping one guest is the cheaper error +// than leaving two nodes writing one volume. +func TestVolumeFence_FencesAnEntryNobodyHolds(t *testing.T) { + _, natsURL := setupEmbeddedNATS(t) + + const volumeName = "vol-fencelapsed" + + cfg := fencedConfig(t, natsURL, "node-a", volumeName) + require.NoError(t, cfg.leases.kv.Delete(t.Context(), volumeName)) + + cfg.onVolumeLeaseLost(t.Context(), volumeName, leaseLostToPeer) + + cfg.mu.Lock() + mounted := len(cfg.MountedVolumes) + cfg.mu.Unlock() + assert.Zero(t, mounted, "a lease this node cannot prove it holds must not stay exported") +} + +// TestVolumeFence_KillFailureLeavesTheVolumeMounted pins the ordering the fence +// depends on. Releasing the lease or announcing the fence after a kill that did +// not take would tell the cluster a writer stopped that is still running, which +// is worse than not fencing at all. +func TestVolumeFence_KillFailureLeavesTheVolumeMounted(t *testing.T) { + _, natsURL := setupEmbeddedNATS(t) + + const volumeName = "vol-fencekillfails" + + cfg := fencedConfig(t, natsURL, "node-a", volumeName) + cfg.MountedVolumes[0].PID = 0 // rejected by ForceKillProcess, so the kill cannot succeed + + cfg.fenceVolume(t.Context(), volumeName, "node-b", "taken") + + cfg.mu.Lock() + mounted := len(cfg.MountedVolumes) + cfg.mu.Unlock() + require.Equal(t, 1, mounted, "a fence that could not stop the writer must not report the volume released") + + owner, held := cfg.leases.currentOwner(t.Context(), volumeName) + require.True(t, held, "the lease must not be released while this node is still exporting") + assert.Equal(t, "node-a", owner) +} + +// TestVolumeFence_DoesNotSealOnTheWayOut pins the one thing the fence must +// never do. This node's copy is the stale one, so sealing it would publish an +// older state over the winner's — the corruption the fence exists to prevent. +func TestVolumeFence_DoesNotSealOnTheWayOut(t *testing.T) { + _, natsURL := setupEmbeddedNATS(t) + + const volumeName = "vol-fencenoseal" + + sealed := false + cfg := fencedConfig(t, natsURL, "node-a", volumeName) + cfg.sealVolume = func(context.Context, string) error { sealed = true; return nil } + + cfg.fenceVolume(t.Context(), volumeName, "node-b", "taken") + + assert.False(t, sealed, "fencing must never seal: this node's copy is behind the winner's") +} + +// TestVolumeFence_LeavesTheDirtyMarkerToTheWinner covers the record an operator +// reads afterwards. The winner takes the marker over when it opens the volume, +// so a fencing node clearing it would erase the only note that writes were lost. +func TestVolumeFence_LeavesTheDirtyMarkerToTheWinner(t *testing.T) { + _, natsURL := setupEmbeddedNATS(t) + + const volumeName = "vol-fencekeepsmarker" + + cfg := fencedConfig(t, natsURL, "node-a", volumeName) + + cfg.fenceVolume(t.Context(), volumeName, "node-b", "taken") + + record, ok := cfg.dirty.holder(t.Context(), volumeName) + require.True(t, ok, "fencing must leave the marker behind for the winner to take over") + assert.Equal(t, "node-a", record.Owner) +} + +// TestVolumeFence_AlreadyUnmountedIsNotAnError covers the ordinary race, where +// a volume is unmounted normally between losing its lease and the fence running. +func TestVolumeFence_AlreadyUnmountedIsNotAnError(t *testing.T) { + _, natsURL := setupEmbeddedNATS(t) + + cfg := &Config{ + NodeName: "node-a", + leases: newTestLeases(t, natsURL, "node-a"), + dirty: newTestDirty(t, natsURL, "node-a"), + } + + cfg.fenceVolume(t.Context(), "vol-fencenotmounted", "node-b", "taken") +} + +// TestUnmountVolume_RefusesToSealUnderALiveWriter is the unmount side of the +// same ordering. The seal rewrites the directory nbdkit writes, so a kill that +// did not take makes it a concurrent write to that directory — and the seal +// would publish a state torn between two writers. The entry has to stay +// mounted so a retry re-attempts both. +func TestUnmountVolume_RefusesToSealUnderALiveWriter(t *testing.T) { + _, natsURL := setupEmbeddedNATS(t) + + const volumeName = "vol-unmountkillfails" + + sealed := false + cfg := fencedConfig(t, natsURL, "node-a", volumeName) + cfg.sealVolume = func(context.Context, string) error { sealed = true; return nil } + cfg.MountedVolumes[0].PID = 0 // rejected by ForceKillProcess, so the kill cannot succeed + + response, err := unmountVolume(t.Context(), cfg, volumeName) + + require.Error(t, err, "an unmount that could not stop the writer must not report success") + assert.Contains(t, response.Error, "kill nbdkit", + "the caller needs to know the writer is still up, not that some seal failed") + assert.False(t, response.NotFound, "the volume is mounted here; reporting it missing would read as an already-completed unmount") + assert.False(t, sealed, "sealing under a live nbdkit writes a state torn between two writers") + + cfg.mu.Lock() + mounted := len(cfg.MountedVolumes) + cfg.mu.Unlock() + assert.Equal(t, 1, mounted, "a failed unmount must leave the entry so a retry re-attempts the kill and the seal") +} diff --git a/spinifex/services/viperblockd/volume_lease.go b/spinifex/services/viperblockd/volume_lease.go index 8b72c21b0..0b3561e2d 100644 --- a/spinifex/services/viperblockd/volume_lease.go +++ b/spinifex/services/viperblockd/volume_lease.go @@ -29,6 +29,22 @@ const volumeLeaseTTL = 45 * time.Second // TTL so one missed renewal does not surrender the lease. const volumeLeaseRenewInterval = 15 * time.Second +// volumeLeaseRenewTimeout bounds one renewal attempt. Without it a JetStream +// call that hangs holds the renewal goroutine past the point where the server +// has already given the entry away, and the holder never notices. +const volumeLeaseRenewTimeout = 5 * time.Second + +// volumeLeaseValidity is how long a holder may keep writing after its last +// *confirmed* renewal. Set below volumeLeaseTTL on purpose: a server-side TTL +// is not a lease unless the holder stops before the server may re-grant, so the +// difference is the margin covering scheduling delay and request latency. +const volumeLeaseValidity = 30 * time.Second + +// volumeLeaseCheckInterval is how often validity is tested. Shorter than the +// renewal interval so a lapsed holder is fenced on its own schedule rather than +// on the next renewal that happens to come due. +const volumeLeaseCheckInterval = 5 * time.Second + // errVolumeLeaseHeld reports that another claimant owns the volume. It is the // one lease failure a caller can do something about, so it is distinguishable. var errVolumeLeaseHeld = errors.New("volume is leased by another owner") @@ -52,12 +68,32 @@ type volumeLeaseRecord struct { AcquiredAt time.Time `json:"acquired_at"` } +// leaseLossKind is why a holder stopped being able to prove it owns a volume. +// The two are not the same event and a fence reports them separately: one says +// the cluster moved the volume, the other says this node lost contact with it. +type leaseLossKind int + +const ( + // leaseLostToPeer: a conditional update was rejected, so the entry changed + // under this node and another may hold it now. + leaseLostToPeer leaseLossKind = iota + + // leaseLostStalled: renewal went unconfirmed for long enough that the server + // TTL is about to admit another owner. Nobody has necessarily taken it yet. + leaseLostStalled +) + // volumeLeases hands out cluster-wide volume leases from a JetStream KV // bucket, and remembers which ones this node holds. type volumeLeases struct { kv jetstream.KeyValue owner string + // onLost is called when this node can no longer prove it owns a volume. Nil + // leaves the loss logged and nothing else, which is what a unit test with no + // export to tear down wants. + onLost func(context.Context, string, leaseLossKind) + mu sync.Mutex held map[string]*volumeLease } @@ -98,6 +134,10 @@ type volumeLease struct { // lost records that the entry moved out from under this holder, which // means somebody else may now be writing the volume. lost bool + // confirmed is when the last conditional update was acknowledged by the + // server. An attempt still in flight does not count: only a write the + // server accepted proves the entry is still ours. + confirmed time.Time // refs counts opens on this node sharing the lease. The lease is released // when the last one lets go. refs int @@ -143,6 +183,7 @@ func (l *volumeLeases) acquire(ctx context.Context, volumeName string) (*volumeL volume: volumeName, generation: revision, revision: revision, + confirmed: time.Now(), done: make(chan struct{}), refs: 1, } @@ -249,11 +290,19 @@ func (l *volumeLeases) release(ctx context.Context, lease *volumeLease) { } // renewLoop refreshes the entry so the bucket TTL does not expire a live -// holder, and notices when the lease has been taken away. +// holder, notices when the lease has been taken away, and — the part that makes +// it a lease rather than a hint — gives the volume up when it can no longer +// prove the entry is still ours. +// +// The validity check is separate from the renewal because the two answer +// different questions. Renewal asks "can I still reach JetStream"; validity +// asks "may I still write". A holder partitioned from NATS keeps failing the +// first indefinitely while the server hands the entry to somebody else, and +// only the second stops it. func (lease *volumeLease) renewLoop(ctx context.Context) { defer close(lease.done) - ticker := time.NewTicker(volumeLeaseRenewInterval) + ticker := time.NewTicker(volumeLeaseCheckInterval) defer ticker.Stop() for { @@ -261,6 +310,13 @@ func (lease *volumeLease) renewLoop(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: + if lease.expiredLocally() { + lease.surrender(ctx) + return + } + if time.Since(lease.lastConfirmed()) < volumeLeaseRenewInterval { + continue + } if !lease.renew(ctx) { return } @@ -268,12 +324,43 @@ func (lease *volumeLease) renewLoop(ctx context.Context) { } } +// expiredLocally reports that this holder has gone longer than +// volumeLeaseValidity without a confirmed renewal, so the server may be about +// to grant the entry to somebody else. +func (lease *volumeLease) expiredLocally() bool { + return time.Since(lease.lastConfirmed()) >= volumeLeaseValidity +} + +func (lease *volumeLease) lastConfirmed() time.Time { + lease.mu.Lock() + defer lease.mu.Unlock() + return lease.confirmed +} + +// surrender gives the volume up because this holder can no longer prove it owns +// it. Marked lost so the release path cannot delete an entry that may already +// belong to another node. +func (lease *volumeLease) surrender(ctx context.Context) { + lease.mu.Lock() + lease.lost = true + since := time.Since(lease.confirmed) + lease.mu.Unlock() + + slog.Error("volume lease could not be confirmed before its TTL, surrendering the volume", + "volume", lease.volume, "generation", lease.generation, + "unconfirmed_for_ms", otelsetup.Millis(since), "ttl_ms", otelsetup.Millis(volumeLeaseTTL)) + + if onLost := lease.leases.onLost; onLost != nil { + go onLost(context.WithoutCancel(ctx), lease.volume, leaseLostStalled) + } +} + // renew rewrites the entry conditioned on the revision this holder last saw, // and reports whether the lease is still ours. // -// Losing it is logged rather than enforced: until the object store can refuse -// a stale writer's PUT, failing live guest I/O here would add a failure mode -// without stopping anything. +// Losing it hands off to onLost, which fences the export. That runs on its own +// goroutine because fencing releases the lease, and releasing waits for this +// one to exit. func (lease *volumeLease) renew(ctx context.Context) bool { record := volumeLeaseRecord{Owner: lease.leases.owner, Generation: lease.generation, AcquiredAt: time.Now().UTC()} payload, err := json.Marshal(record) @@ -286,11 +373,17 @@ func (lease *volumeLease) renew(ctx context.Context) bool { revision := lease.revision lease.mu.Unlock() + // Bounded so a hung JetStream call cannot hold this goroutine past the + // point where the server has already re-granted the entry. + ctx, cancel := context.WithTimeout(ctx, volumeLeaseRenewTimeout) + defer cancel() + renewed, err := lease.leases.kv.Update(ctx, lease.key, payload, revision) switch { case err == nil: lease.mu.Lock() lease.revision = renewed + lease.confirmed = time.Now() lease.mu.Unlock() return true case errors.Is(err, context.Canceled): @@ -302,6 +395,11 @@ func (lease *volumeLease) renew(ctx context.Context) bool { lease.lost = true lease.mu.Unlock() slog.Error("volume lease lost: another opener may hold this volume", "volume", lease.volume, "generation", lease.generation, "err", err) + if onLost := lease.leases.onLost; onLost != nil { + // WithoutCancel: release cancels this context, and the fence has + // KV reads and a teardown to finish after that. + go onLost(context.WithoutCancel(ctx), lease.volume, leaseLostToPeer) + } return false default: // A transient JetStream error is not a lost lease. Keep renewing; the @@ -328,7 +426,26 @@ func (cfg *Config) acquireVolumeLease(ctx context.Context, volumeName string) (* if cfg.leases == nil { return nil, fmt.Errorf("%w: cannot establish exclusive access to %s", errNoVolumeLeaseStore, volumeName) } - return cfg.leases.acquire(ctx, volumeName) + lease, err := cfg.leases.acquire(ctx, volumeName) + if err != nil { + return nil, err + } + + // Mark before a single write lands, not when a seal fails. A node killed + // mid-write never reaches its seal, so marking there leaves no trace of the + // case the marker exists for. A takeover writes its own richer reason. + took, err := cfg.reportVolumeTakeover(ctx, volumeName, lease.generation) + if err == nil && !took { + err = cfg.markVolumeDirty(ctx, volumeName, lease.generation, + "volume open, writes not yet confirmed to the backend") + } + if err != nil { + // Opening anyway would leave writes nothing records, so a later takeover + // could not tell this node ever held them. Give the lease back instead. + cfg.leases.release(ctx, lease) + return nil, fmt.Errorf("record unconfirmed writes for %s: %w", volumeName, err) + } + return lease, nil } // releaseVolumeLease gives up a lease taken by acquireVolumeLease. diff --git a/spinifex/services/viperblockd/volume_lease_test.go b/spinifex/services/viperblockd/volume_lease_test.go index 13a973628..b6c3ea025 100644 --- a/spinifex/services/viperblockd/volume_lease_test.go +++ b/spinifex/services/viperblockd/volume_lease_test.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "testing" + "time" "github.com/mulgadc/spinifex/spinifex/ebsprovider" "github.com/nats-io/nats.go" @@ -224,3 +225,85 @@ func TestVolumeLease_RejectsUnsafeKeys(t *testing.T) { require.Errorf(t, err, "volume name %q must not become a lease key", name) } } + +// TestVolumeLease_ValidityIsBoundedBelowTheServerTTL pins the arithmetic that +// makes this a lease rather than a hint. The holder has to stop writing before +// the server may grant the entry to somebody else, and it has to get at least +// one check in between giving up on renewal and reaching that point. +func TestVolumeLease_ValidityIsBoundedBelowTheServerTTL(t *testing.T) { + require.Less(t, volumeLeaseValidity, volumeLeaseTTL, + "a holder that stops only when the server expires the entry has already overlapped its successor") + require.Less(t, volumeLeaseRenewInterval, volumeLeaseValidity, + "a renewal that comes due after validity lapses can never confirm one in time") + require.Less(t, volumeLeaseCheckInterval, volumeLeaseValidity-volumeLeaseRenewInterval, + "validity has to be tested at least once between a missed renewal and the deadline it enforces") + require.Less(t, volumeLeaseRenewTimeout, volumeLeaseCheckInterval+volumeLeaseRenewInterval, + "an unbounded renewal holds the goroutine past the point the entry may have been re-granted") +} + +// TestVolumeLease_UnconfirmedRenewalSurrendersTheVolume is the partition case +// revision checking cannot see. A node that loses NATS while the winner keeps +// it never gets an update rejected, so nothing tells it the volume moved. It +// has to give the volume up on its own clock, and report the reason as a stall +// rather than as a peer taking it — nobody has necessarily taken it yet. +func TestVolumeLease_UnconfirmedRenewalSurrendersTheVolume(t *testing.T) { + _, natsURL := setupEmbeddedNATS(t) + leases := newTestLeases(t, natsURL, "node-a") + + const volumeName = "vol-leasestalled" + lost := make(chan leaseLossKind, 1) + leases.onLost = func(_ context.Context, volume string, kind leaseLossKind) { + assert.Equal(t, volumeName, volume) + lost <- kind + } + + lease, err := leases.acquire(t.Context(), volumeName) + require.NoError(t, err) + lease.stop() + <-lease.done + + require.False(t, lease.expiredLocally(), "a lease confirmed just now is still valid") + + // Age the last confirmation past the validity window, which is what a node + // that cannot reach JetStream looks like from inside. + lease.mu.Lock() + lease.confirmed = time.Now().Add(-volumeLeaseValidity - time.Second) + lease.mu.Unlock() + require.True(t, lease.expiredLocally(), "an unconfirmed holder must not still consider itself valid") + + lease.surrender(t.Context()) + + select { + case kind := <-lost: + assert.Equal(t, leaseLostStalled, kind, + "a stall is not a takeover: reporting it as one would name a winner that may not exist") + case <-time.After(5 * time.Second): + t.Fatal("a surrendered lease must fence its export, and nothing was called") + } + + lease.mu.Lock() + defer lease.mu.Unlock() + assert.True(t, lease.lost, "a surrendered lease must be marked lost so release cannot delete the successor's entry") +} + +// TestVolumeLease_SurrenderedLeaseIsNotDeletedOnRelease covers what happens +// after. The entry may already belong to another node, so deleting it on the +// way out would evict a live holder and hand the volume to a third opener. +func TestVolumeLease_SurrenderedLeaseIsNotDeletedOnRelease(t *testing.T) { + _, natsURL := setupEmbeddedNATS(t) + leases := newTestLeases(t, natsURL, "node-a") + + const volumeName = "vol-leasestalledrelease" + lease, err := leases.acquire(t.Context(), volumeName) + require.NoError(t, err) + + lease.mu.Lock() + lease.confirmed = time.Now().Add(-volumeLeaseValidity - time.Second) + lease.mu.Unlock() + lease.surrender(t.Context()) + + leases.release(t.Context(), lease) + + _, err = leases.kv.Get(t.Context(), volumeName) + require.NoError(t, err, "releasing a surrendered lease must leave the entry alone: it may be the successor's now") +} diff --git a/spinifex/types/ebs.go b/spinifex/types/ebs.go index a699713ad..2020ddb0b 100644 --- a/spinifex/types/ebs.go +++ b/spinifex/types/ebs.go @@ -68,6 +68,9 @@ type EBSUnMountResponse struct { // after a request that timed out client-side but completed server-side). // The caller treats this as a successful, idempotent seal. NotFound bool `json:"NotFound"` + // Reaped signals the handler found and killed an export for this volume + // that had no registry entry, which is a leak whatever left it behind. + Reaped bool `json:"Reaped,omitempty"` } type EBSSyncRequest struct { diff --git a/spinifex/utils/process.go b/spinifex/utils/process.go index fdd85a6b6..b87765298 100644 --- a/spinifex/utils/process.go +++ b/spinifex/utils/process.go @@ -1,6 +1,7 @@ package utils import ( + "errors" "fmt" "os" "runtime" @@ -66,7 +67,13 @@ func ForceKillProcess(pid int, timeout time.Duration) error { if err != nil { return err } + // A process that has already exited is the outcome asked for, not a failure. + // Callers use this to establish that a writer is gone, and one that died on + // its own is as gone as one this killed. if err := process.Signal(syscall.SIGKILL); err != nil { + if errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH) { + return nil + } return err } return WaitForProcessExit(pid, timeout) diff --git a/spinifex/utils/utils_test.go b/spinifex/utils/utils_test.go index fcd86c0b8..a66721d31 100644 --- a/spinifex/utils/utils_test.go +++ b/spinifex/utils/utils_test.go @@ -684,6 +684,20 @@ func TestForceKillProcess(t *testing.T) { assert.Error(t, ForceKillProcess(0, time.Second), "invalid pid must error") } +// TestForceKillProcess_AlreadyExitedIsSuccess pins the contract callers rely on. +// This is asked "make sure this process is gone", and one that died on its own +// is as gone as one it killed — reporting a failure there makes a caller treat +// a stopped writer as a live one. +func TestForceKillProcess_AlreadyExitedIsSuccess(t *testing.T) { + cmd := exec.Command("true") + require.NoError(t, cmd.Start()) + pid := cmd.Process.Pid + require.NoError(t, cmd.Wait()) + + assert.NoError(t, ForceKillProcess(pid, time.Second), + "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") diff --git a/spinifex/vm/devices.go b/spinifex/vm/devices.go index 428a309ad..b4b6178dd 100644 --- a/spinifex/vm/devices.go +++ b/spinifex/vm/devices.go @@ -173,8 +173,7 @@ func HotplugEBSBus(port int) string { // // werror/rerror mirror the boot drive's on-error policy (see Drive.Werror): // a data volume has no -drive-backed BlockBackend to carry it, so the qdev -// device itself sets werror=report,rerror=report to report backend ENOSPC to -// the guest instead of letting QEMU's default werror=enospc pause the VM. +// device itself sets werror=stop,rerror=stop. func VolumeBlkDeviceArgs(volumeID, nodeName, iothreadID, bus string) []string { return []string{ fmt.Sprintf("id=%s", VolumeDeviceID(volumeID)), @@ -182,8 +181,8 @@ func VolumeBlkDeviceArgs(volumeID, nodeName, iothreadID, bus string) []string { fmt.Sprintf("iothread=%s", iothreadID), fmt.Sprintf("serial=%s", VolumeSerial(volumeID)), fmt.Sprintf("bus=%s", bus), - "werror=report", - "rerror=report", + "werror=stop", + "rerror=stop", } } @@ -205,11 +204,16 @@ func VolumeBlkDeviceQMPArgs(volumeID, nodeName, iothreadID, bus string) map[stri "iothread": iothreadID, "serial": VolumeSerial(volumeID), "bus": bus, - "werror": "report", - "rerror": "report", + "werror": "stop", + "rerror": "stop", } } +// NBDReconnectDelaySeconds is how long an NBD disconnect pauses requests before +// they start failing. It covers a storage process restarting on the same socket; +// past it the error surfaces and werror=stop pauses the VM instead. +const NBDReconnectDelaySeconds = 30 + // NBDServerOpts holds the server.* fields QEMU's nbd blockdev driver needs, // broken out of a parsed NBD URI (see utils.ParseNBDURI) so both the QMP // blockdev-add nested "server" object and the command-line -blockdev @@ -244,6 +248,6 @@ func (o NBDServerOpts) CommandLineArgs() []string { // which only creates a BlockBackend with an auto-generated node-name). func VolumeBlockdev(nodeName string, server NBDServerOpts) Blockdev { opts := append([]string{"driver=nbd", fmt.Sprintf("node-name=%s", nodeName)}, server.CommandLineArgs()...) - opts = append(opts, "export=") + opts = append(opts, "export=", fmt.Sprintf("reconnect-delay=%d", NBDReconnectDelaySeconds)) return Blockdev{Value: strings.Join(opts, ",")} } diff --git a/spinifex/vm/devices_test.go b/spinifex/vm/devices_test.go index b2db3234e..7947e04d5 100644 --- a/spinifex/vm/devices_test.go +++ b/spinifex/vm/devices_test.go @@ -167,21 +167,20 @@ func TestRngDevice_MMIO_WithOptions(t *testing.T) { assert.Equal(t, "virtio-rng-device", d.Value) } -// TestVolumeBlkDevice_OnErrorReport pins the cold-boot data-volume device -// string to werror=report,rerror=report, mirroring the boot drive's existing -// on-error policy so a full backend reports ENOSPC to the guest instead of -// pausing the VM. -func TestVolumeBlkDevice_OnErrorReport(t *testing.T) { +// TestVolumeBlkDevice_OnErrorStop pins the cold-boot data-volume device string +// to werror=stop,rerror=stop. Reporting the error handed a backend outage to +// the guest as EIO, which aborts its journal; stop holds the request instead. +func TestVolumeBlkDevice_OnErrorStop(t *testing.T) { d := VolumeBlkDevice("vol-data-a", "nbd-vol-data-a", "ioth-vol-data-a", "hotplug-ebs3") - assert.Equal(t, "virtio-blk-pci,id=vdisk-vol-data-a,drive=nbd-vol-data-a,iothread=ioth-vol-data-a,serial=voldataa,bus=hotplug-ebs3,werror=report,rerror=report", d.Value) + assert.Equal(t, "virtio-blk-pci,id=vdisk-vol-data-a,drive=nbd-vol-data-a,iothread=ioth-vol-data-a,serial=voldataa,bus=hotplug-ebs3,werror=stop,rerror=stop", d.Value) } -// TestVolumeBlkDeviceQMPArgs_OnErrorReport is the hotplug counterpart: the -// QMP device_add argument map AttachVolume sends must carry the same policy. -func TestVolumeBlkDeviceQMPArgs_OnErrorReport(t *testing.T) { +// TestVolumeBlkDeviceQMPArgs_OnErrorStop is the hotplug counterpart: the QMP +// device_add argument map AttachVolume sends must carry the same policy. +func TestVolumeBlkDeviceQMPArgs_OnErrorStop(t *testing.T) { args := VolumeBlkDeviceQMPArgs("vol-data-a", "nbd-vol-data-a", "ioth-vol-data-a", "hotplug-ebs3") - assert.Equal(t, "report", args["werror"]) - assert.Equal(t, "report", args["rerror"]) + assert.Equal(t, "stop", args["werror"]) + assert.Equal(t, "stop", args["rerror"]) } // host_mtu is how a guest that never takes a DHCP lease — statically addressed, diff --git a/spinifex/vm/fence.go b/spinifex/vm/fence.go new file mode 100644 index 000000000..833998a46 --- /dev/null +++ b/spinifex/vm/fence.go @@ -0,0 +1,92 @@ +package vm + +import ( + "context" + "log/slog" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" +) + +// fenceStateReasonCode is the StateReason a fenced instance carries. Distinct +// from Server.RecoveryFailed so an operator can tell "this node could not +// recover the guest" from "another node owns this guest's disks now". +const fenceStateReasonCode = "Server.VolumeFenced" + +// FenceVolume stops the guest running against volumeID on this node, because +// the volume's lease has moved and this node is no longer its owner. +// +// It is deliberately the narrowest teardown in the package. The guest process +// is killed and its taps are removed, and nothing else: the volumes, ENIs and +// addresses all belong to whichever node holds the instance now, so releasing +// them here would tear down resources in live use elsewhere. For the same +// reason the volumes are not unmounted, which would seal this node's stale copy +// over the winner's. +// +// Returns the instance it stopped, or "" when no local guest was using the +// volume — the ordinary case, since a volume is usually fenced after its +// instance has already gone. +func (m *Manager) FenceVolume(ctx context.Context, volumeID, reason string) string { + instance := m.instanceUsingVolume(volumeID) + if instance == nil { + return "" + } + + skip := false + var observed InstanceState + m.Inspect(instance, func(v *VM) { + observed = v.Status + if v.Status == StateError || v.Status == StateShuttingDown || v.Status == StateTerminated { + skip = true + return + } + if v.Instance != nil { + v.Instance.StateReason = &ec2.StateReason{ + Code: aws.String(fenceStateReasonCode), + Message: aws.String(reason), + } + } + }) + if skip { + slog.InfoContext(ctx, "FenceVolume: instance already in a terminal state", + "instanceId", instance.ID, "volumeId", volumeID, "status", string(observed)) + return instance.ID + } + + if err := m.transitionWithPrecheck(instance, StateError); err != nil { + slog.ErrorContext(ctx, "FenceVolume transition failed", "instanceId", instance.ID, "err", err) + if m.Status(instance) != StateError { + return instance.ID + } + } + slog.ErrorContext(ctx, "Instance fenced: another node owns its volume, so the guest is being stopped here", + "instanceId", instance.ID, "volumeId", volumeID, "reason", reason) + + m.goroutineWg.Go(func() { + m.shutdownQEMU(instance) + m.cleanupTapDevices(instance) + m.Inspect(instance, func(v *VM) { v.LastNode = m.deps.NodeID }) + if err := m.writeRunningState(); err != nil { + slog.ErrorContext(ctx, "FenceVolume: failed to persist state after fencing", + "instanceId", instance.ID, "err", err) + } + }) + return instance.ID +} + +// instanceUsingVolume returns the local instance holding volumeID, or nil. The +// map is this node's own running set, so a hit means the guest is here. +func (m *Manager) instanceUsingVolume(volumeID string) *VM { + var found *VM + m.View(func(vms map[string]*VM) { + for _, v := range vms { + for _, req := range v.EBSRequests.Requests { + if req.Name == volumeID { + found = v + return + } + } + } + }) + return found +} diff --git a/spinifex/vm/fence_test.go b/spinifex/vm/fence_test.go new file mode 100644 index 000000000..b5f220298 --- /dev/null +++ b/spinifex/vm/fence_test.go @@ -0,0 +1,121 @@ +package vm + +//test:in-package — FenceVolume's decision is about unexported manager state +//(instance status, StateReason, the local VM map) with no exported surface. + +import ( + "testing" + + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/mulgadc/spinifex/spinifex/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// vmUsingVolume builds a running instance with volumeID attached, as a guest +// that viperblockd has an export open for would look. +func vmUsingVolume(id, volumeID string) *VM { + return &VM{ + ID: id, + Status: StateRunning, + Instance: &ec2.Instance{}, + EBSRequests: types.EBSRequests{Requests: []types.EBSRequest{{Name: volumeID}}}, + } +} + +// TestFenceVolume_StopsTheGuestUsingTheVolume is the property the fence exists +// for on this side. viperblockd has already torn the export down, so the guest +// is running against a disk that is gone and its writes can never land. +func TestFenceVolume_StopsTheGuestUsingTheVolume(t *testing.T) { + m := NewManagerWithDeps(Deps{NodeID: "node-a"}) + instance := vmUsingVolume("i-fenced", "vol-fenced") + m.Insert(instance) + + got := m.FenceVolume(t.Context(), "vol-fenced", "lease moved to node-b") + m.goroutineWg.Wait() + + assert.Equal(t, "i-fenced", got) + assert.Equal(t, StateError, m.Status(instance), + "a guest whose disk was taken away is not running, whatever the record says") +} + +// TestFenceVolume_RecordsWhyItStopped covers what an operator sees afterwards. +// A guest in error with no reason is indistinguishable from a crash, and the +// distinction is the whole point: another node owns this volume now. +func TestFenceVolume_RecordsWhyItStopped(t *testing.T) { + m := NewManagerWithDeps(Deps{NodeID: "node-a"}) + instance := vmUsingVolume("i-fencedreason", "vol-fencedreason") + m.Insert(instance) + + m.FenceVolume(t.Context(), "vol-fencedreason", "lease moved to node-b") + m.goroutineWg.Wait() + + require.NotNil(t, instance.Instance.StateReason) + assert.Equal(t, fenceStateReasonCode, *instance.Instance.StateReason.Code) + assert.Equal(t, "lease moved to node-b", *instance.Instance.StateReason.Message) +} + +// TestFenceVolume_RecordsTheNodeItRanOn keeps placement working after a fence. +// Start prefers the node that last ran the instance, so a fence that did not +// stamp LastNode would lose the only hint about where its state is. +func TestFenceVolume_RecordsTheNodeItRanOn(t *testing.T) { + m := NewManagerWithDeps(Deps{NodeID: "node-a"}) + instance := vmUsingVolume("i-fencedlastnode", "vol-fencedlastnode") + m.Insert(instance) + + m.FenceVolume(t.Context(), "vol-fencedlastnode", "lease moved") + m.goroutineWg.Wait() + + assert.Equal(t, "node-a", instance.LastNode) +} + +// TestFenceVolume_NoLocalGuestIsNotAnError covers the ordinary case. A volume +// is usually fenced after the instance using it has already gone, and there is +// nothing left here to stop. +func TestFenceVolume_NoLocalGuestIsNotAnError(t *testing.T) { + m := NewManagerWithDeps(Deps{NodeID: "node-a"}) + m.Insert(vmUsingVolume("i-other", "vol-somethingelse")) + + assert.Empty(t, m.FenceVolume(t.Context(), "vol-nobodyhas", "lease moved")) +} + +// TestFenceVolume_LeavesATerminalInstanceAlone stops the fence undoing a +// teardown already in flight. Driving a shutting-down instance to error would +// strand its volumes and ENIs, which is worse than the state it came from. +func TestFenceVolume_LeavesATerminalInstanceAlone(t *testing.T) { + for _, state := range []InstanceState{StateError, StateShuttingDown, StateTerminated} { + t.Run(string(state), func(t *testing.T) { + m := NewManagerWithDeps(Deps{NodeID: "node-a"}) + instance := vmUsingVolume("i-fencedterminal", "vol-fencedterminal") + instance.Status = state + m.Insert(instance) + + got := m.FenceVolume(t.Context(), "vol-fencedterminal", "lease moved") + m.goroutineWg.Wait() + + assert.Equal(t, "i-fencedterminal", got) + assert.Equal(t, state, m.Status(instance), "a terminal instance must not be re-driven") + assert.Nil(t, instance.Instance.StateReason, + "the reason belongs to the teardown already under way, not to this fence") + }) + } +} + +// TestInstanceUsingVolume_MatchesOnAnyAttachedVolume covers the lookup. A guest +// is fenced for its data disk as readily as its root, so a match on any +// attached volume has to count. +func TestInstanceUsingVolume_MatchesOnAnyAttachedVolume(t *testing.T) { + m := NewManagerWithDeps(Deps{}) + instance := &VM{ + ID: "i-multi", + Status: StateRunning, + EBSRequests: types.EBSRequests{Requests: []types.EBSRequest{ + {Name: "vol-root"}, {Name: "vol-data"}, + }}, + } + m.Insert(instance) + + assert.Equal(t, instance, m.instanceUsingVolume("vol-data")) + assert.Equal(t, instance, m.instanceUsingVolume("vol-root")) + assert.Nil(t, m.instanceUsingVolume("vol-absent")) +} diff --git a/spinifex/vm/lifecycle.go b/spinifex/vm/lifecycle.go index 98e51bb4e..8a28ab283 100644 --- a/spinifex/vm/lifecycle.go +++ b/spinifex/vm/lifecycle.go @@ -890,6 +890,19 @@ const ( // mark an instance impaired and trigger a process-liveness check (90s of // unresponsiveness). Exported so DescribeInstanceStatus uses the same gate. QMPMaxConsecutiveFailures = 3 + // qmpStatusIOError is the run state QEMU reports for a VM paused by + // werror=stop or rerror=stop. + qmpStatusIOError = "io-error" + // qmpMaxIOErrorResumes is how many cont attempts are made at heartbeat rate + // before the instance is called impaired. It is a reporting threshold, not + // a stopping one: QEMU holds the failed request for as long as the guest + // exists, so there is always something to recover. + qmpMaxIOErrorResumes = 10 + // qmpIOErrorLogEvery is the heartbeat count between log lines once the + // instance is impaired. Only the logging slows: a retry costs one replayed + // I/O, and a guest that waited minutes to find out its backend had returned + // was paying far more for it than the retry ever saved. + qmpIOErrorLogEvery = 10 ) // qmpHeartbeat polls query-status every qmpHeartbeatInterval. A dead process @@ -922,6 +935,15 @@ func (m *Manager) qmpHeartbeatPoll(instance *VM) bool { slog.Debug("QMP heartbeat", "instance", instance.ID) ctx := context.WithValue(context.Background(), noTraceKey{}, true) qmpStatus, err := queryQMPStatus(ctx, instance, qmpCommandTimeout) + + // A VM paused by werror=stop is not a failure to recover from: the guest is + // intact and its failed request is still held, so the answer is to re-submit + // it once the backend is back rather than to count it as unresponsive. + if err == nil && qmpStatus.Status == qmpStatusIOError && m.Status(instance) == StateRunning { + m.resumeAfterIOError(ctx, instance) + return true + } + if err == nil && !qmpStatus.Running && m.Status(instance) == StateRunning { err = fmt.Errorf("QMP reported non-running status %q", qmpStatus.Status) } @@ -948,6 +970,44 @@ func (m *Manager) qmpHeartbeatPoll(instance *VM) bool { return true } +// resumeAfterIOError re-submits the held request on a VM that werror=stop +// paused. cont replays the failed I/O rather than discarding it, so a backend +// that has come back leaves the guest with nothing to notice but latency. +// +// Retries run at heartbeat rate for as long as the guest exists. After +// qmpMaxIOErrorResumes the instance is reported impaired and the log line is +// throttled, but the attempts are not: a cont costs one replayed I/O, so +// retrying a backend that is still down is nearly free, while a guest still +// paused minutes after its backend returned is an outage of our own making. +func (m *Manager) resumeAfterIOError(ctx context.Context, instance *VM) { + var attempt int + m.UpdateState(instance.ID, func(v *VM) { + v.Health.IOErrorResumes++ + attempt = v.Health.IOErrorResumes + if attempt == 1 { + v.Health.IOErrorSince = time.Now() + } + if attempt == qmpMaxIOErrorResumes { + v.Health.ImpairedSince = time.Now() + } + }) + + if attempt == qmpMaxIOErrorResumes { + slog.Error("Guest still paused on I/O error; reporting impaired and slowing retries", + "instance", instance.ID, "attempts", attempt) + } + // A paused guest is polled for as long as it exists, so logging every + // attempt would bury the transition that matters in copies of itself. + if attempt <= qmpMaxIOErrorResumes || attempt%qmpIOErrorLogEvery == 0 { + slog.Warn("Guest paused on a backend I/O error, resuming", + "instance", instance.ID, "attempt", attempt) + } + if _, err := sendQMPCommandWithTimeout(ctx, instance.QMPClient, + qmp.QMPCommand{Execute: "cont"}, instance.ID, qmpCommandTimeout); err != nil { + slog.Error("Failed to resume guest after I/O error", "instance", instance.ID, "err", err) + } +} + // queryQMPStatus decodes query-status so monitor responsiveness is not // mistaken for a running guest. func queryQMPStatus(ctx context.Context, instance *VM, timeout time.Duration) (qmp.Status, error) { @@ -988,6 +1048,8 @@ func (m *Manager) recordQMPSuccess(instance *VM) { v.Health.QMPConsecutiveFailures = 0 v.Health.ImpairedSince = time.Time{} v.Health.LastQMPSuccess = time.Now() + v.Health.IOErrorResumes = 0 + v.Health.IOErrorSince = time.Time{} }) } @@ -1310,12 +1372,12 @@ func buildDrives(requests []types.EBSRequest, cpuCount int, machineType string) ID: "os", Cache: "none", - // Report backend write errors to the guest rather than letting - // QEMU's default werror=enospc pause the VM: when the storage - // pool exhausts, the guest fs then returns a clean ENOSPC to - // userspace and the VM stays reachable instead of freezing. - Werror: "report", - Rerror: "report", + // Pause the VM and hold the failed request rather than + // delivering EIO to the guest, which aborts its journal. A + // transient backend loss is then recovered with cont. + Werror: "stop", + Rerror: "stop", + ReconnectDelay: NBDReconnectDelaySeconds, } iothreadID := "ioth-os" diff --git a/spinifex/vm/lifecycle_test.go b/spinifex/vm/lifecycle_test.go index 9c3a2c2f0..60fb2c72f 100644 --- a/spinifex/vm/lifecycle_test.go +++ b/spinifex/vm/lifecycle_test.go @@ -7,6 +7,7 @@ import ( "net" "os" "path/filepath" + "sync" "syscall" "testing" "time" @@ -127,7 +128,7 @@ func TestBuildDrives(t *testing.T) { }, cpuCount: 4, wantDrives: []Drive{ - {File: "nbd:unix:/tmp/boot.sock", Format: "raw", If: "none", Media: "disk", ID: "os", Cache: "none", Werror: "report", Rerror: "report"}, + {File: "nbd:unix:/tmp/boot.sock", Format: "raw", If: "none", Media: "disk", ID: "os", Cache: "none", Werror: "stop", Rerror: "stop", ReconnectDelay: NBDReconnectDelaySeconds}, }, wantIOThreads: []IOThread{{ID: "ioth-os"}}, wantDevices: []Device{ @@ -168,10 +169,10 @@ func TestBuildDrives(t *testing.T) { cpuCount: 2, wantIOThreads: []IOThread{{ID: "ioth-vol-data-a"}}, wantBlockdevs: []Blockdev{ - {Value: "driver=nbd,node-name=nbd-vol-data-a,server.type=unix,server.path=/tmp/data-a.sock,export="}, + {Value: "driver=nbd,node-name=nbd-vol-data-a,server.type=unix,server.path=/tmp/data-a.sock,export=,reconnect-delay=30"}, }, wantDevices: []Device{ - {Value: "virtio-blk-pci,id=vdisk-vol-data-a,drive=nbd-vol-data-a,iothread=ioth-vol-data-a,serial=voldataa,bus=hotplug-ebs3,werror=report,rerror=report"}, + {Value: "virtio-blk-pci,id=vdisk-vol-data-a,drive=nbd-vol-data-a,iothread=ioth-vol-data-a,serial=voldataa,bus=hotplug-ebs3,werror=stop,rerror=stop"}, }, wantHotplugPorts: []int{3}, }, @@ -183,10 +184,10 @@ func TestBuildDrives(t *testing.T) { cpuCount: 2, wantIOThreads: []IOThread{{ID: "ioth-vol-data-b"}}, wantBlockdevs: []Blockdev{ - {Value: "driver=nbd,node-name=nbd-vol-data-b,server.type=unix,server.path=/tmp/data-b.sock,export="}, + {Value: "driver=nbd,node-name=nbd-vol-data-b,server.type=unix,server.path=/tmp/data-b.sock,export=,reconnect-delay=30"}, }, wantDevices: []Device{ - {Value: "virtio-blk-pci,id=vdisk-vol-data-b,drive=nbd-vol-data-b,iothread=ioth-vol-data-b,serial=voldatab,bus=hotplug-ebs1,werror=report,rerror=report"}, + {Value: "virtio-blk-pci,id=vdisk-vol-data-b,drive=nbd-vol-data-b,iothread=ioth-vol-data-b,serial=voldatab,bus=hotplug-ebs1,werror=stop,rerror=stop"}, }, wantHotplugPorts: []int{1}, }, @@ -202,12 +203,12 @@ func TestBuildDrives(t *testing.T) { {ID: "ioth-vol-data-d"}, }, wantBlockdevs: []Blockdev{ - {Value: "driver=nbd,node-name=nbd-vol-data-c,server.type=unix,server.path=/tmp/data-c.sock,export="}, - {Value: "driver=nbd,node-name=nbd-vol-data-d,server.type=unix,server.path=/tmp/data-d.sock,export="}, + {Value: "driver=nbd,node-name=nbd-vol-data-c,server.type=unix,server.path=/tmp/data-c.sock,export=,reconnect-delay=30"}, + {Value: "driver=nbd,node-name=nbd-vol-data-d,server.type=unix,server.path=/tmp/data-d.sock,export=,reconnect-delay=30"}, }, wantDevices: []Device{ - {Value: "virtio-blk-pci,id=vdisk-vol-data-c,drive=nbd-vol-data-c,iothread=ioth-vol-data-c,serial=voldatac,bus=hotplug-ebs1,werror=report,rerror=report"}, - {Value: "virtio-blk-pci,id=vdisk-vol-data-d,drive=nbd-vol-data-d,iothread=ioth-vol-data-d,serial=voldatad,bus=hotplug-ebs2,werror=report,rerror=report"}, + {Value: "virtio-blk-pci,id=vdisk-vol-data-c,drive=nbd-vol-data-c,iothread=ioth-vol-data-c,serial=voldatac,bus=hotplug-ebs1,werror=stop,rerror=stop"}, + {Value: "virtio-blk-pci,id=vdisk-vol-data-d,drive=nbd-vol-data-d,iothread=ioth-vol-data-d,serial=voldatad,bus=hotplug-ebs2,werror=stop,rerror=stop"}, }, wantHotplugPorts: []int{1, 2}, }, @@ -227,7 +228,7 @@ func TestBuildDrives(t *testing.T) { wantErr: "no free EBS hot-plug port for volume vol-overflow", }, { - name: "mixed boot + EFI + data: boot and EFI stay byte-identical to the legacy shape", + name: "mixed boot + EFI + data: boot and EFI keep the legacy -drive shape", requests: []types.EBSRequest{ {Name: "vol-boot", NBDURI: "nbd:unix:/tmp/boot.sock", Boot: true}, {Name: "vol-efi", NBDURI: "nbd:unix:/tmp/efi.sock", EFI: true}, @@ -235,7 +236,7 @@ func TestBuildDrives(t *testing.T) { }, cpuCount: 4, wantDrives: []Drive{ - {File: "nbd:unix:/tmp/boot.sock", Format: "raw", If: "none", Media: "disk", ID: "os", Cache: "none", Werror: "report", Rerror: "report"}, + {File: "nbd:unix:/tmp/boot.sock", Format: "raw", If: "none", Media: "disk", ID: "os", Cache: "none", Werror: "stop", Rerror: "stop", ReconnectDelay: NBDReconnectDelaySeconds}, {File: "nbd:unix:/tmp/efi.sock", Format: "raw", If: "pflash", Unit: 1}, }, wantIOThreads: []IOThread{ @@ -243,11 +244,11 @@ func TestBuildDrives(t *testing.T) { {ID: "ioth-vol-data-a"}, }, wantBlockdevs: []Blockdev{ - {Value: "driver=nbd,node-name=nbd-vol-data-a,server.type=unix,server.path=/tmp/data-a.sock,export="}, + {Value: "driver=nbd,node-name=nbd-vol-data-a,server.type=unix,server.path=/tmp/data-a.sock,export=,reconnect-delay=30"}, }, wantDevices: []Device{ {Value: "virtio-blk-pci,drive=os,iothread=ioth-os,num-queues=4,bootindex=1"}, - {Value: "virtio-blk-pci,id=vdisk-vol-data-a,drive=nbd-vol-data-a,iothread=ioth-vol-data-a,serial=voldataa,bus=hotplug-ebs3,werror=report,rerror=report"}, + {Value: "virtio-blk-pci,id=vdisk-vol-data-a,drive=nbd-vol-data-a,iothread=ioth-vol-data-a,serial=voldataa,bus=hotplug-ebs3,werror=stop,rerror=stop"}, }, wantHotplugPorts: []int{0, 0, 3}, }, @@ -1379,3 +1380,125 @@ func TestNewQMPClientWithHandshake_RetriesTransientConnect(t *testing.T) { require.NotNil(t, client) _ = client.Conn.Close() } + +// startRecordingQMPListener is startWorkingQMPListener with a tally: it counts +// the commands it was asked to execute, which is how a test says how many times +// the manager tried to resume a paused guest. +func startRecordingQMPListener(t *testing.T) (string, func(string) int) { + t.Helper() + sockPath := filepath.Join(t.TempDir(), "qmp.sock") + ln, err := net.Listen("unix", sockPath) + require.NoError(t, err) + + var mu sync.Mutex + seen := map[string]int{} + done := make(chan struct{}) + go func() { + defer close(done) + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer func() { _ = c.Close() }() + greeting := `{"QMP":{"version":{"qemu":{"major":8,"minor":0}},"capabilities":[]}}` + "\n" + if _, err := c.Write([]byte(greeting)); err != nil { + return + } + dec := json.NewDecoder(c) + for { + var cmd struct { + Execute string `json:"execute"` + } + if err := dec.Decode(&cmd); err != nil { + return + } + mu.Lock() + seen[cmd.Execute]++ + mu.Unlock() + if _, err := c.Write([]byte(`{"return":{}}` + "\n")); err != nil { + return + } + } + }(conn) + } + }() + t.Cleanup(func() { + _ = ln.Close() + <-done + }) + + return sockPath, func(execute string) int { + mu.Lock() + defer mu.Unlock() + return seen[execute] + } +} + +// pausedGuest wires a manager holding one running VM whose QMP monitor answers, +// which is the state a werror=stop pause leaves behind. +func pausedGuest(t *testing.T) (*Manager, *VM, func(string) int) { + t.Helper() + sockPath, count := startRecordingQMPListener(t) + client, err := qmp.NewQMPClient(sockPath) + require.NoError(t, err) + t.Cleanup(func() { _ = client.Conn.Close() }) + + instance := &VM{ID: "i-paused", Status: StateRunning, QMPClient: client} + m := NewManager() + m.Insert(instance) + + return m, instance, count +} + +// The first attempts run at heartbeat rate, because a backend that comes back +// quickly should return the guest quickly. +func TestResumeAfterIOErrorRetriesEveryHeartbeatAtFirst(t *testing.T) { + m, instance, count := pausedGuest(t) + + for range qmpMaxIOErrorResumes { + m.resumeAfterIOError(t.Context(), instance) + } + + assert.Equal(t, qmpMaxIOErrorResumes, count("cont"), + "every heartbeat inside the budget must re-submit the held request") + assert.Equal(t, qmpMaxIOErrorResumes, instance.Health.IOErrorResumes) + assert.False(t, instance.Health.IOErrorSince.IsZero(), "the first pause must be stamped") + assert.False(t, instance.Health.ImpairedSince.IsZero(), + "spending the budget is what DescribeInstanceStatus reports as impaired") +} + +// Past the budget the guest is reported impaired, and the retries keep running +// at heartbeat rate. They used to drop to one per ten heartbeats, which meant a +// guest could sit paused for five minutes after its backend had come back -- +// an outage of our own making, to save one replayed I/O per thirty seconds. +func TestResumeAfterIOErrorKeepsRetryingPastTheBudget(t *testing.T) { + m, instance, count := pausedGuest(t) + + const beyond = qmpMaxIOErrorResumes + 3*qmpIOErrorLogEvery + for range beyond { + m.resumeAfterIOError(t.Context(), instance) + } + + assert.Equal(t, beyond, count("cont"), + "every heartbeat must re-submit the held request, however long the outage has run") + assert.Equal(t, beyond, instance.Health.IOErrorResumes, + "the count keeps rising, so the impaired report stays anchored to it") +} + +// A guest that resumes must start from zero, or a second unrelated outage +// inherits a spent budget and is written off on its first heartbeat. +func TestQMPSuccessClearsTheIOErrorBudget(t *testing.T) { + m, instance, _ := pausedGuest(t) + + for range qmpMaxIOErrorResumes + 1 { + m.resumeAfterIOError(t.Context(), instance) + } + require.NotZero(t, instance.Health.IOErrorResumes) + + m.recordQMPSuccess(instance) + + assert.Zero(t, instance.Health.IOErrorResumes) + assert.True(t, instance.Health.IOErrorSince.IsZero()) +} diff --git a/spinifex/vm/shutdown.go b/spinifex/vm/shutdown.go index bab33cfa1..c687f5766 100644 --- a/spinifex/vm/shutdown.go +++ b/spinifex/vm/shutdown.go @@ -490,6 +490,32 @@ func (m *Manager) terminateCleanup(instance *VM) { // that is the one step whose failure means data loss. QMP, PID-file, fw_cfg // and telemetry cleanup stay best-effort and are logged only. func (m *Manager) shutdownAndUnmount(instance *VM) error { + m.shutdownQEMU(instance) + + var sealErr error + if m.deps.VolumeMounter != nil { + if err := m.deps.VolumeMounter.Unmount(context.Background(), instance); err != nil { + slog.Error("Volume unmount failed", "id", instance.ID, "err", err) + sealErr = fmt.Errorf("%w for instance %s: %w", ErrVolumeSealFailed, instance.ID, err) + } + } + + for _, fw := range instance.Config.FwCfg { + if err := os.Remove(fw.File); err != nil && !os.IsNotExist(err) { + slog.Warn("Failed to remove fw_cfg temp file", "file", fw.File, "id", instance.ID, "err", err) + } + } + + removeTelemetryArtifacts(instance) + + return sealErr +} + +// shutdownQEMU takes the guest process down and nothing else. Separated from +// shutdownAndUnmount for the fence path, which must stop the guest without +// unmounting: a fenced node's volumes belong to another node now, and an +// unmount would seal this node's stale copy over theirs. +func (m *Manager) shutdownQEMU(instance *VM) { if instance.QMPClient != nil { if _, err := sendQMPCommand(context.Background(), instance.QMPClient, qmp.QMPCommand{Execute: "system_powerdown"}, instance.ID); err != nil { slog.Warn("QMP system_powerdown failed (VM may already be stopped)", @@ -514,24 +540,6 @@ func (m *Manager) shutdownAndUnmount(instance *VM) error { } } } - - var sealErr error - if m.deps.VolumeMounter != nil { - if err := m.deps.VolumeMounter.Unmount(context.Background(), instance); err != nil { - slog.Error("Volume unmount failed", "id", instance.ID, "err", err) - sealErr = fmt.Errorf("%w for instance %s: %w", ErrVolumeSealFailed, instance.ID, err) - } - } - - for _, fw := range instance.Config.FwCfg { - if err := os.Remove(fw.File); err != nil && !os.IsNotExist(err) { - slog.Warn("Failed to remove fw_cfg temp file", "file", fw.File, "id", instance.ID, "err", err) - } - } - - removeTelemetryArtifacts(instance) - - return sealErr } // cleanupTapDevices removes the primary VPC tap, every extra ENI tap, and diff --git a/spinifex/vm/vm.go b/spinifex/vm/vm.go index ae26a41f1..49bf0e5a8 100644 --- a/spinifex/vm/vm.go +++ b/spinifex/vm/vm.go @@ -32,6 +32,12 @@ type InstanceHealthState struct { QMPConsecutiveFailures int `json:"qmp_consecutive_failures"` LastQMPSuccess time.Time `json:"last_qmp_success,omitzero"` ImpairedSince time.Time `json:"impaired_since,omitzero"` + + // IOErrorResumes counts consecutive cont attempts made against a VM paused + // by werror=stop. It clears on the first running poll, so it measures a + // backend that keeps failing rather than one that failed once. + IOErrorResumes int `json:"io_error_resumes,omitempty"` + IOErrorSince time.Time `json:"io_error_since,omitzero"` } // ExtraENI describes an additional VPC network interface attached to a VM @@ -290,15 +296,15 @@ type Drive struct { Media string `json:"media"` ID string `json:"id"` Cache string `json:"cache,omitempty"` - // Werror and Rerror set QEMU's block on-error policy. Left empty, QEMU - // applies its default werror=enospc, which pauses the whole VM on an - // out-of-space write rather than reporting it — so a guest never sees the - // backend's ENOSPC. Setting "report" delivers the error to the guest, - // letting its filesystem surface a clean out-of-space to userspace and stay - // alive. Set on the drive: with if=none the virtio-blk device inherits the - // BlockBackend's on-error policy. + // Werror and Rerror set QEMU's block on-error policy. "stop" pauses the VM + // and holds the failed request, so a transient backend loss is resumed with + // cont instead of reaching the guest as EIO and corrupting its filesystem. Werror string `json:"werror,omitempty"` Rerror string `json:"rerror,omitempty"` + // ReconnectDelay emits file.reconnect-delay, the seconds an NBD disconnect + // pauses requests before failing them. The file. prefix is required: the + // bare name reaches the format driver, which has no such option. + ReconnectDelay int `json:"reconnect_delay,omitempty"` // Unit selects the pflash slot when If=="pflash": 0 is the CODE blob, // 1 is the per-VM VARS volume. Ignored for non-pflash drives. Unit int `json:"unit,omitempty"` @@ -513,6 +519,10 @@ func (cfg *Config) Execute() (*exec.Cmd, error) { opts = append(opts, fmt.Sprintf("rerror=%s", drive.Rerror)) } + if drive.ReconnectDelay > 0 { + opts = append(opts, fmt.Sprintf("file.reconnect-delay=%d", drive.ReconnectDelay)) + } + args = append(args, "-drive", strings.Join(opts, ",")) } diff --git a/spinifex/vm/vm_test.go b/spinifex/vm/vm_test.go index 9b5799f4c..196258645 100644 --- a/spinifex/vm/vm_test.go +++ b/spinifex/vm/vm_test.go @@ -255,7 +255,7 @@ func TestExecute_Blockdev_UnixServer(t *testing.T) { } } require.Greater(t, blockdevIdx, -1, "-blockdev must be emitted") - assert.Equal(t, "driver=nbd,node-name=nbd-vol-data-a,server.type=unix,server.path=/run/spinifex/nbd/nbd-vol-data-a.sock,export=", args[blockdevIdx+1]) + assert.Equal(t, "driver=nbd,node-name=nbd-vol-data-a,server.type=unix,server.path=/run/spinifex/nbd/nbd-vol-data-a.sock,export=,reconnect-delay=30", args[blockdevIdx+1]) deviceIdx := -1 for i, a := range args { @@ -266,7 +266,7 @@ func TestExecute_Blockdev_UnixServer(t *testing.T) { } require.Greater(t, deviceIdx, -1) assert.Greater(t, deviceIdx, blockdevIdx, "-blockdev must precede the -device referencing it") - assert.Equal(t, "virtio-blk-pci,id=vdisk-vol-data-a,drive=nbd-vol-data-a,iothread=ioth-vol-data-a,serial=voldataa,bus=hotplug-ebs3,werror=report,rerror=report", args[deviceIdx+1]) + assert.Equal(t, "virtio-blk-pci,id=vdisk-vol-data-a,drive=nbd-vol-data-a,iothread=ioth-vol-data-a,serial=voldataa,bus=hotplug-ebs3,werror=stop,rerror=stop", args[deviceIdx+1]) } // TestExecute_Blockdev_InetServer asserts the TCP NBD form renders @@ -285,7 +285,7 @@ func TestExecute_Blockdev_InetServer(t *testing.T) { cmd, err := cfg.Execute() require.NoError(t, err) - assert.Equal(t, "driver=nbd,node-name=nbd-vol-data-b,server.type=inet,server.host=10.0.0.5,server.port=10809,export=", + assert.Equal(t, "driver=nbd,node-name=nbd-vol-data-b,server.type=inet,server.host=10.0.0.5,server.port=10809,export=,reconnect-delay=30", argValue(cmd.Args[1:], "-blockdev")) } diff --git a/spinifex/vm/volumes.go b/spinifex/vm/volumes.go index 16838ad76..dd524d414 100644 --- a/spinifex/vm/volumes.go +++ b/spinifex/vm/volumes.go @@ -166,11 +166,12 @@ func (m *Manager) AttachVolume(ctx context.Context, id, volumeID, device string) if _, err := sendQMPCommand(ctx, instance.QMPClient, qmp.QMPCommand{ Execute: "blockdev-add", Arguments: map[string]any{ - "node-name": nodeName, - "driver": "nbd", - "server": serverArg, - "export": "", - "read-only": false, + "node-name": nodeName, + "driver": "nbd", + "server": serverArg, + "export": "", + "read-only": false, + "reconnect-delay": NBDReconnectDelaySeconds, }, }, instance.ID); err != nil { slog.ErrorContext(ctx, "AttachVolume: QMP blockdev-add failed", "volumeId", volumeID, "err", err) diff --git a/spinifex/vpcd/imds_instance_state.go b/spinifex/vpcd/imds_instance_state.go index 0337aed57..202006e44 100644 --- a/spinifex/vpcd/imds_instance_state.go +++ b/spinifex/vpcd/imds_instance_state.go @@ -1,12 +1,14 @@ package vpcd import ( - "log/slog" + "context" + "fmt" "os" "sync" "time" "github.com/mulgadc/spinifex/spinifex/daemon" + "github.com/mulgadc/spinifex/spinifex/kvstore" "github.com/mulgadc/spinifex/spinifex/vm" "github.com/nats-io/nats.go" ) @@ -79,18 +81,19 @@ type recordLoader interface { LoadInstanceRecord(instanceID string) (*vm.InstanceRecord, error) } -// newInstanceRecordLoader opens the shared instance record space best-effort: -// IMDS still serves local hits without it, and it only backs the fallback for -// an instance that has moved off this node. Returns nil on any failure. -func newInstanceRecordLoader(nc *nats.Conn) recordLoader { +// newInstanceRecordLoader opens the shared instance record space, retrying while +// JetStream comes up. Failing here is fatal by design: without it an instance +// 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) if err != nil { - slog.Warn("vpcd: JetStream manager unavailable, IMDS instance lookup will not fall back off this node", "err", err) - return nil + return nil, fmt.Errorf("jetstream manager: %w", err) } - if err := jsm.InitKVBucket(); err != nil { - slog.Warn("vpcd: instance-state bucket unavailable, IMDS instance lookup will not fall back off this node", "err", err) - return nil + if _, err := kvstore.OpenWithRetry(ctx, daemon.InstanceStateBucket, kvstore.DefaultOpenWindow, + func(context.Context) (struct{}, error) { return struct{}{}, jsm.InitKVBucket() }, + ); err != nil { + return nil, err } - return jsm + return jsm, nil } diff --git a/spinifex/vpcd/vpcd.go b/spinifex/vpcd/vpcd.go index 0b4a80430..f7b9a9c60 100644 --- a/spinifex/vpcd/vpcd.go +++ b/spinifex/vpcd/vpcd.go @@ -539,6 +539,10 @@ func launchService(cfg *Config) error { // vpcd is the composition root for both planes IMDS straddles: it wires the // on-disk VM state reader and the shared record space, so handlers/imds // itself never needs to import the compute plane beyond vm.VM. + records, err := newInstanceRecordLoader(ctx, nc) + if err != nil { + return fmt.Errorf("open instance record space: %w", err) + } imdsSvc, err := handlers_imds.NewIMDSServiceImpl( ctx, nc, @@ -546,7 +550,7 @@ func launchService(cfg *Config) error { handlers_imds.NewNATSProfileLookup(nc), handlers_imds.NewNATSPublicKeyLookup(nc), newLocalVMStateReader(cfg.DataDir), - newInstanceRecordLoader(nc), + records, listTaps, cfg.NorthstarBaseDomain, cfg.NorthstarInternalDomain, diff --git a/tests/e2e/Makefile b/tests/e2e/Makefile index 158929d48..1200808b5 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 +SUITES_BUILD ?= cert lb eks ecs single iam ecr bedrock gpu natuplink multinode storagegrowth partialblock rds quota diskperf storagefault -.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-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-all clean e2e-build RUN_FLAG = $(if $(RUN),-run '$(RUN)',) @@ -65,6 +65,9 @@ e2e-diskperf: ; $(MAKE) e2e PKG=./diskperf/... TIMEOUT=180m # most of a 30m package timeout before the tests still running get to report. e2e-rds: ; $(MAKE) e2e PKG=./rds/... TIMEOUT=45m e2e-quota: ; $(MAKE) e2e PKG=./quota/... +# Freezes predastore, so its budget covers a deliberate outage plus the +# recovery and re-verification that follow it, twice over for the two tests. +e2e-storagefault: ; $(MAKE) e2e PKG=./storagefault/... TIMEOUT=45m e2e-all: ; $(MAKE) e2e e2e-build: diff --git a/tests/e2e/diskperf/diskperf_test.go b/tests/e2e/diskperf/diskperf_test.go index 33b320d9f..1fa273296 100644 --- a/tests/e2e/diskperf/diskperf_test.go +++ b/tests/e2e/diskperf/diskperf_test.go @@ -288,7 +288,7 @@ func ensureDiskPerfInstance(t *testing.T, fix *Fixture) (instanceID string, tgt t.Helper() instType, arch := harness.DiscoverNanoInstanceType(t, fix.Harness) ami := harness.DiscoverUbuntuAMI(t, fix.Harness, arch) - keyName, keyPath := harness.EnsureKeyPair(t, fix.Harness, fix.ArtifactDir(t)) + keyName, keyPath := harness.EnsureKeyPair(t, fix.Harness) vpc := harness.EnsureDefaultVPC(t, fix.Harness) harness.AuthorizeSSHIngress(t, fix.AWS, vpc.SGID) diff --git a/tests/e2e/gpu/ecs_gpu_test.go b/tests/e2e/gpu/ecs_gpu_test.go index d2b34f936..aad6fe60b 100644 --- a/tests/e2e/gpu/ecs_gpu_test.go +++ b/tests/e2e/gpu/ecs_gpu_test.go @@ -97,14 +97,13 @@ func discoverECSGPUNodeAMI(c *harness.AWSClient, vendor string) (amiID, reason s // in for asserting on the nvidia-smi table text directly. func TestECSGPUTaskExposure(t *testing.T) { fx := requireECSGPUFixture(t) - artifacts := harness.ArtifactDir(t, fx.Env) harness.Phase(t, "Ensuring ecsInstanceRole instance profile") ensureECSGPUInstanceProfile(t, fx.AWS) vpc := harness.EnsureDefaultVPC(t, fx.Harness) harness.AuthorizeSSHIngress(t, fx.AWS, vpc.SGID) - keyName, _ := harness.EnsureKeyPair(t, fx.Harness, artifacts) + keyName, _ := harness.EnsureKeyPair(t, fx.Harness) clusterName := fmt.Sprintf("%s-%d", ecsGPUClusterPfx, time.Now().Unix()) harness.Phase(t, "Creating ECS cluster %q", clusterName) diff --git a/tests/e2e/gpu/gpu_test.go b/tests/e2e/gpu/gpu_test.go index fe902d344..8c7718a5e 100644 --- a/tests/e2e/gpu/gpu_test.go +++ b/tests/e2e/gpu/gpu_test.go @@ -24,7 +24,7 @@ func TestGPUPassthrough(t *testing.T) { harness.Phase(t, "GPU — Passthrough: launch, device visibility, driver access with %s", fix.GPUInstanceType) vpc := harness.EnsureDefaultVPC(t, fix.Harness) - keyName, keyPath := harness.EnsureKeyPair(t, fix.Harness, fix.ArtifactDir(t)) + keyName, keyPath := harness.EnsureKeyPair(t, fix.Harness) harness.AuthorizeSSHIngress(t, fix.AWS, vpc.SGID) instanceID := launchGPUInstance(t, fix, vpc.SubnetID, []string{vpc.SGID}, keyName) @@ -72,7 +72,7 @@ func TestGPUPassthrough_Release(t *testing.T) { harness.Phase(t, "GPU — Release: GPU returns to pool after termination") vpc := harness.EnsureDefaultVPC(t, fix.Harness) - keyName, _ := harness.EnsureKeyPair(t, fix.Harness, fix.ArtifactDir(t)) + keyName, _ := harness.EnsureKeyPair(t, fix.Harness) sgIDs := []*string{aws.String(vpc.SGID)} harness.Step(t, "launching first GPU instance") diff --git a/tests/e2e/harness/ec2helpers.go b/tests/e2e/harness/ec2helpers.go index 782478a89..5a102ca2a 100644 --- a/tests/e2e/harness/ec2helpers.go +++ b/tests/e2e/harness/ec2helpers.go @@ -264,13 +264,27 @@ func AttachVolumeWait(t *testing.T, c *AWSClient, volID, instanceID, device stri // DetachVolumeWait detaches volID and blocks until it reaches "available". func DetachVolumeWait(t *testing.T, c *AWSClient, volID string) { t.Helper() - _, err := c.EC2.DetachVolume(&ec2.DetachVolumeInput{VolumeId: aws.String(volID)}) - if err != nil { - t.Fatalf("detach-volume %s: %v", volID, err) + // A volume that is already on its way to available is the outcome asked + // for, not a failure. Teardown runs this after a terminate that releases + // the volume itself, so refusing an already-released volume fails a test + // whose assertions all passed. + if _, err := c.EC2.DetachVolume(&ec2.DetachVolumeInput{VolumeId: aws.String(volID)}); err != nil { + if !isIncorrectVolumeState(err) { + t.Fatalf("detach-volume %s: %v", volID, err) + } + t.Logf("detach-volume %s: already released, waiting for available", volID) } WaitForVolumeState(t, c, volID, "available", WithPoll(500*time.Millisecond)) } +// isIncorrectVolumeState reports whether err is the refusal a detach gets for a +// volume that is not attached. It is the answer for one already released, and +// for one still mid-detach, both of which are what a teardown wanted. +func isIncorrectVolumeState(err error) bool { + var aerr awserr.Error + return asErr(err, &aerr) && aerr.Code() == "IncorrectState" +} + // RegisterVolumeTeardown force-detaches then deletes volID at test end. It does // not call t.Fatal, so a cleanup hiccup can't mask the test's real result, but a // teardown that actually leaves the volume behind fails the test — a silent leak diff --git a/tests/e2e/harness/fixtures.go b/tests/e2e/harness/fixtures.go index 066a810ae..e86f39fae 100644 --- a/tests/e2e/harness/fixtures.go +++ b/tests/e2e/harness/fixtures.go @@ -67,6 +67,10 @@ type Fixture struct { cleanups map[string]struct{} sf singleflight.Group + // keyDir holds private keys for the process. Created on first use and + // removed with the key pair it belongs to. + keyDir string + // processCleanups holds teardown callbacks for process-mode fixtures. // Close() runs them LIFO. Unused (and untouched) when parent != nil. // @@ -319,14 +323,40 @@ func randHex(nBytes int) (string, error) { // EnsureKeyPair // ---------------------------------------------------------------------------- -// EnsureKeyPair creates (or returns the cached) named EC2 key pair. The PEM -// is written to artifactsDir/.pem with 0600. Returns (keyName, pemPath). -func EnsureKeyPair(t *testing.T, fx *Fixture, artifactsDir string) (string, string) { +// keyPairDir returns the process-scoped directory private keys are written +// to, creating it on first use. +// +// Deliberately not a caller-supplied directory. The key pair lives as long as +// the fixture, so a private key kept anywhere shorter-lived goes missing while +// the registered public key is still being launched against. +func (f *Fixture) keyPairDir() (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.keyDir != "" { + return f.keyDir, nil + } + dir, err := os.MkdirTemp("", "spinifex-e2e-keys-") + if err != nil { + return "", fmt.Errorf("private key dir: %w", err) + } + f.keyDir = dir + return dir, nil +} + +// EnsureKeyPair creates (or returns the cached) named EC2 key pair. The PEM is +// written with 0600 into a directory owned by the fixture, and both are +// removed together. Returns (keyName, pemPath). +func EnsureKeyPair(t *testing.T, fx *Fixture) (string, string) { t.Helper() name := fx.resourceName("e2e-key") key := "keypair:" + name - pemPath := filepath.Join(artifactsDir, name+".pem") + dir, err := fx.keyPairDir() + if err != nil { + t.Fatalf("EnsureKeyPair: %v", err) + } + pemPath := filepath.Join(dir, name+".pem") + id, err := fx.ensureOnce(t, key, func() (string, func() error, error) { out, err := fx.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{ KeyName: aws.String(name), @@ -334,15 +364,18 @@ func EnsureKeyPair(t *testing.T, fx *Fixture, artifactsDir string) (string, stri if err != nil { return "", nil, fmt.Errorf("CreateKeyPair %s: %w", name, err) } - if err := os.MkdirAll(artifactsDir, 0o750); err != nil { - return "", nil, fmt.Errorf("mkdir %s: %w", artifactsDir, err) - } if err := os.WriteFile(pemPath, []byte(aws.StringValue(out.KeyMaterial)), 0o600); err != nil { return "", nil, fmt.Errorf("write pem %s: %w", pemPath, err) } + if err := assertOneKeyPair(fx, name); err != nil { + return "", nil, err + } fx.tagRunResources(aws.StringValue(out.KeyPairId)) return aws.StringValue(out.KeyName), func() error { _, derr := fx.EC2.DeleteKeyPair(&ec2.DeleteKeyPairInput{KeyName: aws.String(name)}) + if rerr := os.RemoveAll(dir); rerr != nil && derr == nil { + derr = rerr + } return derr }, nil }) @@ -352,6 +385,33 @@ func EnsureKeyPair(t *testing.T, fx *Fixture, artifactsDir string) (string, stri return id, pemPath } +// assertOneKeyPair fails when the cluster holds more than one key pair under +// name, which it should not be able to. +// +// A create that lands twice leaves two pairs with one name and different +// material. RunInstances then resolves the name to one of them while we hold +// the private key of the other, and the only symptom is every guest in the run +// rejecting the key at the SSH gate five minutes later, once per guest. Cheap +// to check here, and it names the cause outright. +func assertOneKeyPair(fx *Fixture, name string) error { + out, err := fx.EC2.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{ + KeyNames: []*string{aws.String(name)}, + }) + if err != nil { + return fmt.Errorf("DescribeKeyPairs %s: %w", name, err) + } + if len(out.KeyPairs) == 1 { + return nil + } + + got := make([]string, 0, len(out.KeyPairs)) + for _, kp := range out.KeyPairs { + got = append(got, fmt.Sprintf("%s/%s", aws.StringValue(kp.KeyPairId), aws.StringValue(kp.KeyFingerprint))) + } + return fmt.Errorf("key pair %s resolves to %d pairs (%s); a launch cannot be told which one it got", + name, len(out.KeyPairs), strings.Join(got, " ")) +} + // ---------------------------------------------------------------------------- // EnsureAMI // ---------------------------------------------------------------------------- @@ -606,6 +666,10 @@ func EnsureSG(t *testing.T, fx *Fixture, vpcID, namePrefix string) string { // ---------------------------------------------------------------------------- // InstanceSpec captures the inputs to RunInstances. UserData is optional. +// +// Scope names a group of callers that must not share a guest. It reaches the +// memo key and nothing else, so two identical specs with different scopes get +// an instance each — what a test needs when it damages the guest on purpose. type InstanceSpec struct { AMIID string InstanceType string @@ -613,6 +677,7 @@ type InstanceSpec struct { SubnetID string SGID string UserData string + Scope string } // EnsureInstance launches a single instance matching spec, polls to @@ -627,8 +692,8 @@ func EnsureInstance(t *testing.T, fx *Fixture, spec InstanceSpec) string { sum := sha256.Sum256([]byte(spec.UserData)) udHash = hex.EncodeToString(sum[:8]) } - key := fmt.Sprintf("instance:%s:%s:%s:%s:%s:%s", - spec.AMIID, spec.InstanceType, spec.KeyName, spec.SubnetID, spec.SGID, udHash) + key := fmt.Sprintf("instance:%s:%s:%s:%s:%s:%s:%s", + spec.AMIID, spec.InstanceType, spec.KeyName, spec.SubnetID, spec.SGID, udHash, spec.Scope) id, err := fx.ensureOnce(t, key, func() (string, func() error, error) { input := &ec2.RunInstancesInput{ ImageId: aws.String(spec.AMIID), diff --git a/tests/e2e/harness/fixtures_test.go b/tests/e2e/harness/fixtures_test.go index ae9c336bc..472b5ddf2 100644 --- a/tests/e2e/harness/fixtures_test.go +++ b/tests/e2e/harness/fixtures_test.go @@ -4,7 +4,8 @@ package harness import ( "errors" - "path/filepath" + "fmt" + "os" "strings" "sync" "sync/atomic" @@ -32,6 +33,10 @@ type fakeEC2 struct { // the concurrent-callers test deterministically. holdCreate chan struct{} + // keyPairsForName is how many pairs DescribeKeyPairs reports for a name. + // Zero means one, which is the only healthy answer. + keyPairsForName atomic.Int64 + // Volume teardown state. volState is what DescribeVolumes reports; // DetachVolume flips it to "available", modelling a node that releases the // volume once the attachment is forced off. deleteVolumeErr, when set, is @@ -43,6 +48,22 @@ type fakeEC2 struct { deleteVolumeErr error } +func (f *fakeEC2) DescribeKeyPairs(in *ec2.DescribeKeyPairsInput) (*ec2.DescribeKeyPairsOutput, error) { + n := int(f.keyPairsForName.Load()) + if n == 0 { + n = 1 + } + out := &ec2.DescribeKeyPairsOutput{} + for i := range n { + out.KeyPairs = append(out.KeyPairs, &ec2.KeyPairInfo{ + KeyName: in.KeyNames[0], + KeyPairId: aws.String(fmt.Sprintf("key-%d", i)), + KeyFingerprint: aws.String(fmt.Sprintf("fingerprint-%d", i)), + }) + } + return out, nil +} + func (f *fakeEC2) CreateVolume(*ec2.CreateVolumeInput) (*ec2.Volume, error) { return &ec2.Volume{VolumeId: aws.String("vol-fake")}, nil } @@ -122,20 +143,43 @@ func newFakeFixture(t *testing.T) (*Fixture, *fakeEC2) { } // TestEnsureKeyPair_FirstCallCreates verifies first invocation calls -// CreateKeyPair once and writes PEM to the artifact dir. +// CreateKeyPair once and writes the PEM where it says it did. func TestEnsureKeyPair_FirstCallCreates(t *testing.T) { fx, ec2c := newFakeFixture(t) - dir := t.TempDir() - name, pemPath := EnsureKeyPair(t, fx, dir) + name, pemPath := EnsureKeyPair(t, fx) if name == "" { t.Fatalf("EnsureKeyPair returned empty name") } if got := ec2c.createKeyPair.Load(); got != 1 { t.Fatalf("CreateKeyPair calls = %d, want 1", got) } - if filepath.Dir(pemPath) != dir { - t.Fatalf("pemPath dir = %s, want %s", filepath.Dir(pemPath), dir) + if _, err := os.Stat(pemPath); err != nil { + t.Fatalf("pemPath %s: %v", pemPath, err) + } +} + +// TestEnsureKeyPair_PEMOutlivesAPassingTest is the regression. The private key +// used to be written into the caller's per-test artifact directory, which is +// removed when that test passes. Every later test then received a path to a +// file that no longer existed while the key pair was still registered, so its +// guest was launched against a public key nothing held the other half of and +// SSH could never succeed. +func TestEnsureKeyPair_PEMOutlivesAPassingTest(t *testing.T) { + fx, _ := newFakeFixture(t) + + var firstPath string + t.Run("passes", func(it *testing.T) { + _ = ArtifactDir(it, &Env{ArtifactDir: it.TempDir()}) + _, firstPath = EnsureKeyPair(it, fx) + }) + + _, secondPath := EnsureKeyPair(t, fx) + if secondPath != firstPath { + t.Fatalf("second call returned %s, want the path the PEM was written to (%s)", secondPath, firstPath) + } + if _, err := os.Stat(secondPath); err != nil { + t.Fatalf("private key vanished when the first test passed: %v", err) } } @@ -143,10 +187,9 @@ func TestEnsureKeyPair_FirstCallCreates(t *testing.T) { // return the cached name without re-calling CreateKeyPair. func TestEnsureKeyPair_SecondCallCached(t *testing.T) { fx, ec2c := newFakeFixture(t) - dir := t.TempDir() - first, _ := EnsureKeyPair(t, fx, dir) - second, _ := EnsureKeyPair(t, fx, dir) + first, _ := EnsureKeyPair(t, fx) + second, _ := EnsureKeyPair(t, fx) if first != second { t.Fatalf("second call returned %q, want cached %q", second, first) } @@ -160,7 +203,6 @@ func TestEnsureKeyPair_SecondCallCached(t *testing.T) { func TestEnsureKeyPair_ConcurrentCallers(t *testing.T) { fx, ec2c := newFakeFixture(t) ec2c.holdCreate = make(chan struct{}) - dir := t.TempDir() const N = 10 var wg sync.WaitGroup @@ -171,7 +213,7 @@ func TestEnsureKeyPair_ConcurrentCallers(t *testing.T) { go func(i int) { defer wg.Done() started <- struct{}{} - n, _ := EnsureKeyPair(t, fx, dir) + n, _ := EnsureKeyPair(t, fx) names[i] = n }(i) } @@ -342,11 +384,10 @@ func TestEnsureKeyPair_CleanupRunsOnce(t *testing.T) { t.Run("inner", func(it *testing.T) { var fx *Fixture fx, ec2c = newFakeFixture(it) - dir := it.TempDir() - EnsureKeyPair(it, fx, dir) - EnsureKeyPair(it, fx, dir) - EnsureKeyPair(it, fx, dir) + EnsureKeyPair(it, fx) + EnsureKeyPair(it, fx) + EnsureKeyPair(it, fx) }) if got := ec2c.deleteKeyPair.Load(); got != 1 { @@ -390,7 +431,7 @@ func TestPollUntil_TimeoutWrapsLastErr(t *testing.T) { // //nolint:unused,deadcode // exists only to fail compile when an Ensure* signature drifts func _ensureCompileCheck(t *testing.T, fx *Fixture) { - _, _ = EnsureKeyPair(t, fx, "/tmp") + _, _ = EnsureKeyPair(t, fx) _ = EnsureAMI(t, fx, AMISource{Existing: "ami-0"}) _ = EnsureDefaultVPC(t, fx) _ = EnsureSubnet(t, fx, "vpc-0", "10.0.0.0/24", "ap-southeast-2a") @@ -400,3 +441,26 @@ func _ensureCompileCheck(t *testing.T, fx *Fixture) { _ = EnsureSnapshot(t, fx, SnapshotSpec{VolumeID: "vol-0"}) _ = EnsureNATGateway(t, fx, "subnet-0", "") } + +// TestAssertOneKeyPair_RejectsADuplicateName is the five-minute-per-guest +// failure made immediate. Two pairs under one name means RunInstances picks one +// and we hold the private key of the other, and the only other evidence is +// every guest in the run refusing the key at the SSH gate. +func TestAssertOneKeyPair_RejectsADuplicateName(t *testing.T) { + fx, ec2c := newFakeFixture(t) + + if err := assertOneKeyPair(fx, "e2e-key-abc"); err != nil { + t.Fatalf("one pair under the name must be accepted: %v", err) + } + + ec2c.keyPairsForName.Store(2) + err := assertOneKeyPair(fx, "e2e-key-abc") + if err == nil { + t.Fatal("two pairs under one name must fail: a launch cannot be told which one it got") + } + for _, want := range []string{"e2e-key-abc", "2 pairs", "fingerprint-0", "fingerprint-1"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not name %q", err, want) + } + } +} diff --git a/tests/e2e/harness/predastore.go b/tests/e2e/harness/predastore.go index a7ca58610..14a0ea6ac 100644 --- a/tests/e2e/harness/predastore.go +++ b/tests/e2e/harness/predastore.go @@ -6,11 +6,15 @@ import ( "bytes" "context" "crypto/tls" + "encoding/json" "errors" "fmt" "io" "net/http" "os" + "sort" + "strconv" + "strings" "testing" "time" @@ -173,3 +177,198 @@ func newPredastoreS3(host string) (*s3.S3, error) { } return s3.New(sess), nil } + +// servingBucketPrefix names the per-check bucket AssertPredastoreServing owns. +const servingBucketPrefix = "predastore-serving-" + +// servingHoldRounds is how many consecutive round trips have to succeed before +// the backend is called restored, and servingHoldGap is the pause between them. +// +// One is not enough. A gate can answer the round trip that arrives just after a +// thaw and then lose its blob peers under the load that follows, which is a +// backend that never recovered reported as one that did. Three rounds over +// ~20s is long enough to cross that window and short enough to stay in budget. +const ( + servingHoldRounds = 3 + servingHoldGap = 10 * time.Second +) + +// AssertPredastoreServing waits until every host round-trips an object, holds +// that result across servingHoldRounds, and fails the test if it never does +// within budget. +// +// Restoring the process a fault stopped is not the same as restoring the +// service. A backend can answer systemctl, hold its listeners and still serve +// nothing, so a fault injector that stops at the process leaves whatever runs +// next to discover the difference. +// +// The round trip writes as well as reads, deliberately. Under RS(2,1) a read +// reconstructs from a single surviving shard while a write needs two durable, +// so a read-only probe passes on a cluster that cannot serve a guest at all. +func AssertPredastoreServing(ctx context.Context, t *testing.T, hosts []string, budget time.Duration) { + t.Helper() + if len(hosts) == 0 { + return + } + + bucket := fmt.Sprintf("%s%d", servingBucketPrefix, time.Now().UnixNano()) + payload := bytes.Repeat([]byte("s"), 64<<10) + deadline := time.Now().Add(budget) + + var lastErr error + var held int + for { + lastErr = predastoreRoundTrip(ctx, hosts, bucket, payload) + switch { + case lastErr == nil: + held++ + if held >= servingHoldRounds { + Detail(t, "backendServingOn", strings.Join(hosts, ",")) + Detail(t, "backendServingHeldRounds", strconv.Itoa(held)) + AssertPredastoreReady(ctx, t, hosts) + return + } + case held > 0: + // A gate that served and then stopped is the failure this hold is + // here to catch, so the count starts again rather than decaying. + t.Logf("predastore served %d round(s) then failed, restarting the hold: %v", held, lastErr) + held = 0 + } + + if time.Now().After(deadline) || ctx.Err() != nil { + t.Errorf("predastore did not hold %d serving round trips through %v within %s (last error: %v)", + servingHoldRounds, hosts, budget, lastErr) + return + } + time.Sleep(servingHoldGap) + } +} + +// predastoreAdminPort serves /healthz and /readyz on each host's cluster plane. +const predastoreAdminPort = 8660 + +// AssertPredastoreReady checks each host's readiness probe and reports any +// failed check by name. +// +// The round trip above proves this workstation can write through a gate. It +// says nothing about which blob peers that gate can reach, and a gate one node +// short of the write floor serves a small object today and fails a guest +// tomorrow. /readyz is where that is answerable, so a host that serves it and +// reports a failed check fails here; one that does not serve it is reported and +// skipped, since the admin listener is optional configuration. +func AssertPredastoreReady(ctx context.Context, t *testing.T, hosts []string) { + t.Helper() + for _, host := range hosts { + failed, err := predastoreReadyFailures(ctx, predastoreReadyURL(host)) + if err != nil { + t.Logf("predastore readiness on %s is unavailable, skipping: %v", host, err) + continue + } + if len(failed) > 0 { + t.Errorf("predastore on %s reports failed readiness checks after the fault was cleared: %v", + host, failed) + continue + } + Detail(t, "backendReady", host) + } +} + +// predastoreReadyURL is the readiness probe on a host's cluster plane. +func predastoreReadyURL(host string) string { + return fmt.Sprintf("http://%s:%d/readyz", host, predastoreAdminPort) +} + +// predastoreReadyFailures names the checks /readyz reports as failed. An +// unready process is not itself an error here -- the failed check names are the +// finding, and an empty list from a 503 would be the probe hiding one. +func predastoreReadyFailures(ctx context.Context, url string) ([]string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var body struct { + Status string `json:"status"` + Checks map[string]string `json:"checks"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("decode %s: %w", url, err) + } + if len(body.Checks) == 0 && body.Status != "ready" { + return nil, fmt.Errorf("%s reported %q with no checks", url, body.Status) + } + + failed := make([]string, 0, len(body.Checks)) + for name, state := range body.Checks { + if state != "ok" { + failed = append(failed, name+"="+state) + } + } + sort.Strings(failed) + return failed, nil +} + +// predastoreRoundTrip puts and gets one object through every host, so a single +// unusable node fails the check whichever gate it is reached through. +func predastoreRoundTrip(ctx context.Context, hosts []string, bucket string, payload []byte) error { + first, err := newPredastoreS3(hosts[0]) + if err != nil { + return err + } + if _, err := first.CreateBucketWithContext(ctx, &s3.CreateBucketInput{ + Bucket: aws.String(bucket), + }); err != nil && !isBucketExists(err) { + return fmt.Errorf("create bucket %s on %s: %w", bucket, hosts[0], err) + } + + for _, host := range hosts { + cli, err := newPredastoreS3(host) + if err != nil { + return err + } + key := "serving/" + host + if _, err := cli.PutObjectWithContext(ctx, &s3.PutObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + Body: bytes.NewReader(payload), + }); err != nil { + return fmt.Errorf("put through %s: %w", host, err) + } + got, err := getObjectBytes(ctx, cli, bucket, key) + if err != nil { + return fmt.Errorf("get through %s: %w", host, err) + } + if !bytes.Equal(got, payload) { + return fmt.Errorf("get through %s returned %d bytes, want %d", host, len(got), len(payload)) + } + if _, err := cli.DeleteObjectWithContext(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + }); err != nil { + return fmt.Errorf("delete through %s: %w", host, err) + } + } + + if _, err := first.DeleteBucketWithContext(ctx, &s3.DeleteBucketInput{ + Bucket: aws.String(bucket), + }); err != nil { + return fmt.Errorf("delete bucket %s: %w", bucket, err) + } + return nil +} + +// isBucketExists reports whether err is the S3 error for a bucket the caller +// already owns, which a retried round trip is expected to hit. +func isBucketExists(err error) bool { + var aerr awserr.Error + if errors.As(err, &aerr) { + return aerr.Code() == s3.ErrCodeBucketAlreadyExists || + aerr.Code() == s3.ErrCodeBucketAlreadyOwnedByYou + } + return false +} diff --git a/tests/e2e/harness/predastore_test.go b/tests/e2e/harness/predastore_test.go new file mode 100644 index 000000000..9cc30801c --- /dev/null +++ b/tests/e2e/harness/predastore_test.go @@ -0,0 +1,89 @@ +//go:build e2e + +package harness + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/aws/aws-sdk-go/aws/awserr" +) + +// readyzStub serves one canned /readyz response. +func readyzStub(t *testing.T, status int, body string) string { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return srv.URL + "/readyz" +} + +// TestPredastoreReadyFailures_NamesTheUnreachableBlobNode is the check the +// backend-outage test was missing. A gate that has lost a blob peer still +// reconstructs every read, so the round trip passes; the failed check name is +// the only place the missing peer is visible. +func TestPredastoreReadyFailures_NamesTheUnreachableBlobNode(t *testing.T) { + url := readyzStub(t, http.StatusServiceUnavailable, + `{"status":"unready","checks":{"meta_leader":"ok","blob_nodes":"failed","blob_node_5":"failed","blob_node_6":"ok"}}`) + + failed, err := predastoreReadyFailures(context.Background(), url) + if err != nil { + t.Fatalf("predastoreReadyFailures: %v", err) + } + if got, want := strings.Join(failed, ","), "blob_node_5=failed,blob_nodes=failed"; got != want { + t.Errorf("failed checks = %q, want %q", got, want) + } +} + +func TestPredastoreReadyFailures_ReadyReportsNothing(t *testing.T) { + url := readyzStub(t, http.StatusOK, + `{"status":"ready","checks":{"meta_leader":"ok","blob_nodes":"ok","blob_node_5":"ok"}}`) + + failed, err := predastoreReadyFailures(context.Background(), url) + if err != nil { + t.Fatalf("predastoreReadyFailures: %v", err) + } + if len(failed) != 0 { + t.Errorf("failed checks = %v, want none", failed) + } +} + +// An unready answer carrying no checks tells us nothing about why, and +// reporting it as "no failures" would be the probe hiding the finding. +func TestPredastoreReadyFailures_UnreadyWithNoChecksIsAnError(t *testing.T) { + url := readyzStub(t, http.StatusServiceUnavailable, `{"status":"unready","checks":{}}`) + + if _, err := predastoreReadyFailures(context.Background(), url); err == nil { + t.Error("an unready answer with no checks must not read as no failures") + } +} + +// A host that serves no admin listener is a configuration state, not a +// failure, so the caller has to be able to tell the two apart. +func TestPredastoreReadyFailures_NoListenerIsAnError(t *testing.T) { + if _, err := predastoreReadyFailures(context.Background(), "http://127.0.0.1:1/readyz"); err == nil { + t.Error("a host with no admin listener must report an error, not readiness") + } +} + +// TestIsIncorrectVolumeState covers the one refusal teardown tolerates. A +// volume already on its way to available is the outcome the detach wanted, and +// failing on it fails a test whose assertions all passed. +func TestIsIncorrectVolumeState(t *testing.T) { + if !isIncorrectVolumeState(awserr.New("IncorrectState", "not attached", nil)) { + t.Error("a volume that is not attached must read as already released") + } + if isIncorrectVolumeState(awserr.New("VolumeInUse", "attached elsewhere", nil)) { + t.Error("VolumeInUse is a real failure and must not be swallowed") + } + if isIncorrectVolumeState(errors.New("connection refused")) { + t.Error("a non-AWS error must not read as already released") + } +} diff --git a/tests/e2e/harness/rds_client.go b/tests/e2e/harness/rds_client.go index 2010ae05d..679d4d31b 100644 --- a/tests/e2e/harness/rds_client.go +++ b/tests/e2e/harness/rds_client.go @@ -111,7 +111,7 @@ func RDSClientVMIn(t *testing.T, c *AWSClient, fx *Fixture, env *Env, p RDSClien // The suite-level artifact root, not the calling test's subdirectory: the key // outlives the test that first asked for the VM, and ArtifactDir prunes a // passing test's directory — taking the PEM the next caller needs with it. - keyName, keyPath := EnsureKeyPair(t, fx, env.ArtifactDir) + keyName, keyPath := EnsureKeyPair(t, fx) instanceID := EnsureInstance(t, fx, InstanceSpec{ AMIID: ami, diff --git a/tests/e2e/iam/helpers_test.go b/tests/e2e/iam/helpers_test.go index 59cd43525..d197bb44a 100644 --- a/tests/e2e/iam/helpers_test.go +++ b/tests/e2e/iam/helpers_test.go @@ -32,7 +32,7 @@ func needAMI(t *testing.T, fix *Fixture) string { // needKeyPair returns the memoized primary EC2 key pair name + PEM path. func needKeyPair(t *testing.T, fix *Fixture) (name, pemPath string) { t.Helper() - return harness.EnsureKeyPair(t, fix.Harness, fix.TmpDir) + return harness.EnsureKeyPair(t, fix.Harness) } // needInstance bootstraps the suite's primary running instance and returns diff --git a/tests/e2e/multinode/helpers_test.go b/tests/e2e/multinode/helpers_test.go index 76b9082ec..562cb9eb4 100644 --- a/tests/e2e/multinode/helpers_test.go +++ b/tests/e2e/multinode/helpers_test.go @@ -42,7 +42,7 @@ func needAMI(t *testing.T, fix *Fixture, arch string) string { // pruned by t.Cleanup as soon as whichever test first calls this one passes. func needKeyPair(t *testing.T, fix *Fixture) (name, pemPath string) { t.Helper() - return harness.EnsureKeyPair(t, fix.Harness, fix.TmpDir) + return harness.EnsureKeyPair(t, fix.Harness) } // Package-scoped trio. Whichever Test* runs first triggers the launch; every other caller of needInstanceTrio reuses the same IDs. diff --git a/tests/e2e/natuplink/natuplink_test.go b/tests/e2e/natuplink/natuplink_test.go index 59fbaf84d..b4bdd4a49 100644 --- a/tests/e2e/natuplink/natuplink_test.go +++ b/tests/e2e/natuplink/natuplink_test.go @@ -320,7 +320,7 @@ func phaseInstanceEgress(t *testing.T, fix *fixture, def harness.VPCInfo) egress instType, arch := harness.DiscoverNanoInstanceType(t, fix.harness) amiID := harness.DiscoverUbuntuAMI(t, fix.harness, arch) - keyName, _ := harness.EnsureKeyPair(t, fix.harness, fix.artifacts) + keyName, _ := harness.EnsureKeyPair(t, fix.harness) harness.Detail(t, "type", instType, "ami", amiID, "key", keyName) harness.Step(t, "run-instances (default subnet, egress-probe user-data)") diff --git a/tests/e2e/partialblock/partial_block_durability_test.go b/tests/e2e/partialblock/partial_block_durability_test.go index dc6d9da0b..f06f2d0ac 100644 --- a/tests/e2e/partialblock/partial_block_durability_test.go +++ b/tests/e2e/partialblock/partial_block_durability_test.go @@ -253,7 +253,7 @@ func ensurePartialBlockInstance(t *testing.T, fix *Fixture) (instanceID string, t.Helper() instType, arch := harness.DiscoverNanoInstanceType(t, fix.Harness) ami := harness.DiscoverUbuntuAMI(t, fix.Harness, arch) - keyName, keyPath := harness.EnsureKeyPair(t, fix.Harness, fix.ArtifactDir(t)) + keyName, keyPath := harness.EnsureKeyPair(t, fix.Harness) vpc := harness.EnsureDefaultVPC(t, fix.Harness) harness.AuthorizeSSHIngress(t, fix.AWS, vpc.SGID) diff --git a/tests/e2e/single/helpers_test.go b/tests/e2e/single/helpers_test.go index 71666bf1e..050b76114 100644 --- a/tests/e2e/single/helpers_test.go +++ b/tests/e2e/single/helpers_test.go @@ -42,7 +42,7 @@ func needAMI(t *testing.T, fix *Fixture) string { // needKeyPair returns the memoized primary EC2 key pair name + PEM path. func needKeyPair(t *testing.T, fix *Fixture) (name, pemPath string) { t.Helper() - return harness.EnsureKeyPair(t, fix.Harness, fix.TmpDir) + return harness.EnsureKeyPair(t, fix.Harness) } // customAMIName is the well-known AMI name used by CreateImage tests. diff --git a/tests/e2e/storagefault/guest_survival_test.go b/tests/e2e/storagefault/guest_survival_test.go new file mode 100644 index 000000000..9723ad715 --- /dev/null +++ b/tests/e2e/storagefault/guest_survival_test.go @@ -0,0 +1,557 @@ +//go:build e2e + +package storagefault + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/mulgadc/spinifex/tests/e2e/harness" +) + +const ( + // faultDevice is the guest-visible attach point for the workload volume. + faultDevice = "/dev/sdf" + + // faultVolumeSizeGiB sizes the scratch volume. The working set is smaller + // still: this test is about what happens to the filesystem, not about + // moving enough data to measure anything. + faultVolumeSizeGiB = 2 + + // faultMount is where the guest mounts the workload filesystem. + faultMount = "/mnt/fault" + + // verifyMiB is the region written, flushed and later re-verified. The + // verdict is taken from this and nothing else. + verifyMiB = 256 + + // loadBlocks is how many 64 KiB blocks each pass of the sustained load + // writes: 512 MiB, to a file of its own so an interrupted load write is + // never mistaken for lost data. + loadBlocks = 8192 + + // rootLoadDir is where the root-filesystem load runs. The user's home is on + // the root volume; /tmp is tmpfs on this image, so a load there would move + // no bytes on vda and the root would never see the fault at all. + rootLoadDir = "/home/ubuntu/.storagefault-load" + + // verifyFile holds the region the verdict is taken from, and the pid and log + // files track the two loads. All are on paths the guest always has. + verifyFile = faultMount + "/verify.dat" + loadPID = "/tmp/storagefault-load.pid" + loadLog = "/tmp/storagefault-load.log" + rootLoadPID = "/tmp/storagefault-rootload.pid" + rootLoadLog = "/tmp/storagefault-rootload.log" + + // sha256HexLen is the length of a sha256 digest in hex, used to reject a + // truncated or error-laden reading rather than compare it. + sha256HexLen = 64 + + // rootLoadBlocks is how many 32 KiB blocks each root-load file gets, kept + // deliberately small: the point is journal traffic on the root volume, not + // filling a nano guest's disk. + rootLoadBlocks = 256 + + // rootDevice is the guest's root block device, whose writes are the ones + // that decide whether the fault could reach the root filesystem. + rootDevice = "vda" + + // rootWriteSample is the gap between the two sectors_written readings that + // prove the root load is live. Long enough to clear a quiet moment between + // fio's own fsyncs, short enough not to eat into the pre-fault settle. + rootWriteSample = 3 * time.Second + + // freezeHold is how long predastore stays frozen. It is derived, not tuned: + // viperblock tolerates maxDrainFailures=10 consecutive drain failures, and a + // drain against a frozen backend returns at the 30s backend timeout, so the + // earliest a write can fail is ~300s. Below that the test measures the retry + // budget rather than the outage and passes for the wrong reason. + freezeHold = 420 * time.Second + + // loadSettle is how long to let the load establish real in-flight I/O before + // the fault. A freeze that lands before the first write proves nothing. + loadSettle = 20 * time.Second + + // recoverySettle is how long to wait after the thaw for the backend to + // answer again before judging the guest. + recoverySettle = 30 * time.Second + + // guestRecoveryTimeout bounds the wait for a guest paused on an I/O error + // to answer again. The resume rides the control plane's heartbeat, so this + // covers a poll period plus the guest catching up on held requests. + guestRecoveryTimeout = 3 * time.Minute +) + +// corruptionSignatures are the kernel messages that mean the guest's +// filesystem took the fault as data loss rather than as a pause. Each is the +// guest reacting to an error it should never have been shown. +var corruptionSignatures = []*regexp.Regexp{ + regexp.MustCompile(`EXT4-fs error`), + regexp.MustCompile(`Aborting journal`), + regexp.MustCompile(`Remounting filesystem read-only`), + regexp.MustCompile(`I/O error, dev \w+`), +} + +// TestGuestSurvivesBackendOutage freezes predastore under a running fio +// workload and asserts the guest comes back to an intact, still-writable +// filesystem. +// +// Expected to fail against the current engine: every volume is realized with +// werror=report, so QEMU hands the guest EIO, ext4 aborts its journal and +// remounts read-only. That failure is the reproduction. +func TestGuestSurvivesBackendOutage(t *testing.T) { + fix := requireStorageFaultFixture(t) + requireNoneFrozen(t, fix) + + instanceID, tgt := ensureFaultInstance(t, fix) + hostNode := harness.InstanceHostingNode(t, fix.Cluster, instanceID) + if hostNode == nil && len(fix.Cluster.Nodes) > 1 { + t.Fatalf("cannot identify the node hosting %s, so the freeze set cannot spare it", instanceID) + } + + az := harness.DiscoverDefaultAZ(t, fix.Harness) + volID := harness.EnsureVolume(t, fix.Harness, az, faultVolumeSizeGiB) + + before := harness.GuestDiskSet(t, tgt) + harness.AttachVolumeWait(t, fix.AWS, volID, instanceID, faultDevice) + dev := harness.WaitForNewGuestDisk(t, tgt, before, 90*time.Second) + t.Cleanup(func() { harness.DetachVolumeWait(t, fix.AWS, volID) }) + + prepareWorkloadFilesystem(t, tgt, dev) + quiesceWorkload(t, tgt) + + freezeSet, why := freezeSetFor(fix, hostNode) + harness.Detail(t, "instance", instanceID, "volume", volID, "guest_device", dev, + "host_node", nodeName(hostNode), "freeze", why) + + harness.Step(t, "writing the %d MiB verifiable region and flushing it", verifyMiB) + digest := writeVerifiableRegion(t, tgt) + + loadRuntime := loadSettle + freezeHold + recoverySettle + harness.Step(t, "starting sustained load for %s, on the volume and the root filesystem", loadRuntime) + startLoad(t, tgt, loadRuntime) + startRootLoad(t, tgt, loadRuntime) + time.Sleep(loadSettle) + if !loadRunning(t, tgt) { + t.Fatalf("the load exited before the fault was injected, so nothing was under load: %s", + loadLogTail(t, tgt)) + } + assertRootLoadIsWriting(t, tgt) + + harness.Step(t, "freezing predastore on %s for %s", nodeNames(freezeSet), freezeHold) + freezePredastore(t, fix, freezeSet) + + observeDuringOutage(t, fix, instanceID, tgt) + + harness.Step(t, "thawing predastore and waiting %s for the backend to answer", recoverySettle) + thawPredastore(t, fix, freezeSet) + awaitGuestRecovered(t, tgt) + + // Evidence is gathered from the console as well as the guest because a + // guest whose root went read-only may not answer SSH, and its silence is + // the very outcome under test. + console, consoleErr := harness.InstanceConsole(fix.AWS, instanceID) + if consoleErr != nil { + t.Logf("console unavailable (guest evidence only): %v", consoleErr) + } + guestDmesg := bestEffort(tgt, "sudo dmesg | tail -n 400") + evidence := console + "\n" + guestDmesg + + assertNoCorruption(t, evidence, instanceID, fix) + assertStillWritable(t, tgt, dev) + assertWorkloadIntegrity(t, tgt, digest) +} + +// prepareWorkloadFilesystem lays an ext4 filesystem on the scratch device and +// mounts it. errors=remount-ro is ext4's default and is left in place on +// purpose: it is the guest's own protection, and the test is about whether the +// guest is ever given a reason to invoke it. +func prepareWorkloadFilesystem(t *testing.T, tgt harness.SSHTarget, dev string) { + t.Helper() + cmd := fmt.Sprintf( + "sudo mkfs.ext4 -q -F /dev/%s && sudo mkdir -p %s && sudo mount -o errors=remount-ro /dev/%s %s && sudo chmod 777 %s", + dev, faultMount, dev, faultMount, faultMount) + if out, err := harness.GuestExecTimeout(tgt, cmd, 3*time.Minute); err != nil { + t.Fatalf("prepare workload filesystem on /dev/%s: %v\n%s", dev, err, out) + } +} + +// The workload is built from dd, sha256sum and a shell loop, all of which are +// in the base image. That is a deliberate choice over fio: this suite needs +// real in-flight I/O and a byte-exact integrity check, not a benchmark, and +// installing a package over the internet inside the guest was by some distance +// the least reliable step in the whole run. + +// loadLoop keeps real I/O in flight across the fault so it lands on a busy +// device. It rewrites one file in place, which is deliberately not verified: a +// time-bounded loop is cut off mid-write by design, and judging those blocks +// would report the interruption as corruption. +func loadLoop(runtime time.Duration) string { + return fmt.Sprintf( + "end=$(( $(date +%%s) + %d )); "+ + "while [ \"$(date +%%s)\" -lt $end ]; do "+ + "dd if=/dev/zero of=%s/load.dat bs=64k count=%d oflag=direct conv=notrunc "+ + "status=none || exit 1; done", + int(runtime.Seconds()), faultMount, loadBlocks) +} + +// rootLoadLoop drives the root filesystem so the fault lands on vda as well as +// on the attached volume. conv=fsync forces a flush after every file, which is +// the ext4 journal commit that aborts the journal when it fails — a failed read +// of a clean page is merely retried and proves much less. +func rootLoadLoop(runtime time.Duration) string { + return fmt.Sprintf( + "end=$(( $(date +%%s) + %d )); i=0; "+ + "while [ \"$(date +%%s)\" -lt $end ]; do "+ + "dd if=/dev/zero of=%s/load.$((i%%4)) bs=32k count=%d conv=fsync "+ + "status=none || exit 1; i=$((i+1)); done", + int(runtime.Seconds()), rootLoadDir, rootLoadBlocks) +} + +// startRootLoad launches the root-filesystem load detached and removes its +// files afterwards, so a nano guest's 8 GiB root is not left consumed. +func startRootLoad(t *testing.T, tgt harness.SSHTarget, runtime time.Duration) { + t.Helper() + t.Cleanup(func() { + _, _ = harness.GuestExecTimeout(tgt, "sudo rm -rf "+rootLoadDir, 60*time.Second) + }) + + cmd := fmt.Sprintf("mkdir -p %s && rm -f %s; "+ + "sudo setsid bash -c 'echo $$ > %s; %s' >%s 2>&1 %s; %s' >%s 2>&1 /dev/null; sudo pkill -x dd 2>/dev/null; true", loadPID)) + for range 15 { + if !loadRunning(t, tgt) { + break + } + time.Sleep(2 * time.Second) + } + bestEffort(tgt, fmt.Sprintf("sync; sudo umount %s 2>/dev/null; true", faultMount)) + }) +} + +// loadRunning reports whether the load loop is still alive. It tracks the loop +// rather than dd, which exits and restarts on every pass. +func loadRunning(t *testing.T, tgt harness.SSHTarget) bool { + t.Helper() + out := bestEffort(tgt, fmt.Sprintf( + "sudo kill -0 $(cat %s 2>/dev/null) 2>/dev/null && echo RUNNING || echo GONE", loadPID)) + return strings.Contains(out, "RUNNING") +} + +func loadLogTail(t *testing.T, tgt harness.SSHTarget) string { + t.Helper() + return bestEffort(tgt, "sudo tail -n 40 "+loadLog) +} + +// observeDuringOutage records what the control plane says while the backend is +// down. These are logged rather than asserted mid-flight: the assertions that +// decide the test are made after recovery, where they are not racing a +// transient. +func observeDuringOutage(t *testing.T, fix *Fixture, instanceID string, tgt harness.SSHTarget) { + t.Helper() + deadline := time.Now().Add(freezeHold) + for time.Now().Before(deadline) { + time.Sleep(15 * time.Second) + alive := "unreachable" + if _, err := harness.GuestExecTimeout(tgt, "true", 10*time.Second); err == nil { + alive = "reachable" + } + t.Logf("during outage: guest_ssh=%s instance_state=%s", alive, instanceStateOf(fix, instanceID)) + } +} + +// assertNoCorruption is the assertion this suite exists for. A guest that was +// paused for the outage shows none of these; a guest that was handed EIO shows +// several. +func assertNoCorruption(t *testing.T, evidence, instanceID string, fix *Fixture) { + t.Helper() + var hits []string + for _, re := range corruptionSignatures { + if m := re.FindString(evidence); m != "" { + hits = append(hits, m) + } + } + if len(hits) == 0 { + harness.Step(t, "no corruption signatures in the guest console or dmesg") + return + } + + harness.DumpInstanceConsole(t, fix.AWS, instanceID, fix.ArtifactDir(t), "storagefault-console") + t.Errorf("the guest was shown I/O errors and reacted to them: %s\n"+ + "This is the bug: the backend outage reached the guest as EIO instead of pausing it, "+ + "so ext4 aborted and protected itself by going read-only. The volume is now only as "+ + "consistent as the journal managed to be.", strings.Join(hits, ", ")) +} + +// assertStillWritable checks the workload filesystem is mounted rw. A guest +// that survived the outage correctly needs no remount to keep working. +func assertStillWritable(t *testing.T, tgt harness.SSHTarget, dev string) { + t.Helper() + mounts, err := harness.GuestExecTimeout(tgt, "cat /proc/mounts", 30*time.Second) + if err != nil { + t.Errorf("cannot read /proc/mounts, so the guest did not come back usable: %v", err) + return + } + for _, line := range strings.Split(mounts, "\n") { + if !strings.Contains(line, faultMount) { + continue + } + switch { + case strings.Contains(line, " ro,"), strings.HasSuffix(strings.TrimSpace(line), " ro"): + t.Errorf("%s is mounted read-only after recovery: %q", faultMount, strings.TrimSpace(line)) + default: + harness.Step(t, "%s is still mounted read-write", faultMount) + } + return + } + t.Errorf("%s is no longer mounted at all after the outage", faultMount) +} + +// assertWorkloadIntegrity re-reads the verifiable region and compares its +// digest to the one taken when it was written. This is what distinguishes "the +// guest went read-only" from "the volume lost an acknowledged write", which are +// different failures with different fixes. +// +// The cache is dropped first, so the comparison reads the volume rather than +// the page cache that has been holding the same bytes since before the fault. +func assertWorkloadIntegrity(t *testing.T, tgt harness.SSHTarget, want string) { + t.Helper() + check := fmt.Sprintf( + "sync; echo 3 | sudo tee /proc/sys/vm/drop_caches >/dev/null; "+ + "sudo sha256sum %s | cut -d' ' -f1", verifyFile) + out, err := harness.GuestExecTimeout(tgt, check, 10*time.Minute) + if err != nil { + t.Errorf("could not re-read the verifiable region after recovery: %v\n%s", err, out) + return + } + if got := strings.TrimSpace(out); got != want { + t.Errorf("the verifiable region changed across the outage — an acknowledged write did not survive:\n"+ + " wrote %s\n read %s", want, got) + return + } + harness.Step(t, "sha256 verification passed: every acknowledged write survived") +} + +// bestEffort runs a guest command and returns its output, or a marker when the +// guest cannot answer. Used for evidence gathering, where an unreachable guest +// is itself information and must not abort the run. +func bestEffort(tgt harness.SSHTarget, cmd string) string { + out, err := harness.GuestExecTimeout(tgt, cmd, 30*time.Second) + if err != nil { + return fmt.Sprintf("", err) + } + return out +} + +// awaitGuestRecovered waits for a guest that the fault paused to answer again. +// It never fails the test: whether the guest came back is what the assertions +// after it are for, and they say far more about why than a timeout here would. +func awaitGuestRecovered(t *testing.T, tgt harness.SSHTarget) { + t.Helper() + time.Sleep(recoverySettle) + if harness.TryGuestSSHReady(tgt.Host, tgt.Port, tgt.User, tgt.KeyPath, guestRecoveryTimeout) { + return + } + harness.Step(t, "guest still not answering %s after the backend came back", tgt.Host) +} + +// lastLines keeps the tail of a console log. The whole buffer is 64 KiB of +// boot messages, and what matters is what the guest said most recently. +func lastLines(s string, n int) string { + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + if len(lines) > n { + lines = lines[len(lines)-n:] + } + return strings.Join(lines, "\n") +} + +// instanceStateOf reports the EC2 state, or why it could not be read. It never +// fails the test: the control plane being unreachable during the outage is an +// observation, and the assertions that decide the run are made after recovery. +func instanceStateOf(fix *Fixture, instanceID string) string { + out, err := fix.AWS.EC2.DescribeInstances(&ec2.DescribeInstancesInput{ + InstanceIds: []*string{aws.String(instanceID)}, + }) + if err != nil { + return fmt.Sprintf("", err) + } + for _, r := range out.Reservations { + for _, inst := range r.Instances { + if inst.State != nil && inst.State.Name != nil { + return *inst.State.Name + } + } + } + return "" +} + +func nodeName(n *harness.Node) string { + if n == nil { + return "" + } + return n.Name +} + +func nodeNames(nodes []harness.Node) string { + out := make([]string, 0, len(nodes)) + for _, n := range nodes { + out = append(out, n.Name) + } + return strings.Join(out, ", ") +} + +// ensureFaultInstance launches the guest this suite drives. The smallest +// catalog entry is enough: the workload is bounded by the volume, not by the +// guest's CPU or memory. +// +// Each test gets its own guest. Every fault here is meant to hurt, and a guest +// that survived one is not a clean starting point for the next: a wedged jbd2 +// or a filesystem the kernel has given up on outlives the test that caused it, +// and the next test then fails on damage it did not do. +func ensureFaultInstance(t *testing.T, fix *Fixture) (instanceID string, tgt harness.SSHTarget) { + t.Helper() + instType, arch := harness.DiscoverNanoInstanceType(t, fix.Harness) + ami := harness.DiscoverUbuntuAMI(t, fix.Harness, arch) + keyName, keyPath := harness.EnsureKeyPair(t, fix.Harness) + vpc := harness.EnsureDefaultVPC(t, fix.Harness) + harness.AuthorizeSSHIngress(t, fix.AWS, vpc.SGID) + + instanceID = harness.EnsureInstance(t, fix.Harness, harness.InstanceSpec{ + AMIID: ami, + InstanceType: instType, + KeyName: keyName, + SubnetID: vpc.SubnetID, + SGID: vpc.SGID, + Scope: t.Name(), + }) + inst := harness.WaitForInstanceState(t, fix.AWS, instanceID, "running") + host, port := harness.InstancePublicSSHHost(t, inst) + + harness.Step(t, "waiting for guest SSH on %s:%d (instance_type=%s)", host, port, instType) + if !harness.TryGuestSSHReady(host, port, "ubuntu", keyPath, 5*time.Minute) { + // The instance is shared across this package's tests, so an unreachable + // guest is usually something an earlier test left behind. The console + // is the only evidence, and terminating it takes that away. + console, err := harness.InstanceConsole(fix.AWS, instanceID) + if err != nil { + console = fmt.Sprintf("console unavailable: %v", err) + } + t.Fatalf("guest %s SSH %s:%d not ready after 5m\nconsole tail:\n%s", + instanceID, host, port, lastLines(console, 60)) + } + return instanceID, harness.SSHTarget{User: "ubuntu", Host: host, Port: port, KeyPath: keyPath} +} + +// rootSectorsWritten reads sectors_written for the root device. In +// /proc/diskstats the device name is the third field and sectors_written the +// tenth, counted from the start of the line rather than from the name. +func rootSectorsWritten(tgt harness.SSHTarget) (int64, error) { + out, err := harness.GuestExecTimeout(tgt, + "awk '$3==\""+rootDevice+"\"{print $10}' /proc/diskstats", 30*time.Second) + if err != nil { + return 0, fmt.Errorf("read /proc/diskstats: %w (%s)", err, out) + } + field := strings.TrimSpace(out) + if field == "" { + return 0, fmt.Errorf("no %s row in /proc/diskstats:\n%s", rootDevice, + bestEffort(tgt, "cat /proc/diskstats")) + } + n, err := strconv.ParseInt(field, 10, 64) + if err != nil { + return 0, fmt.Errorf("parse sectors_written %q: %w", field, err) + } + return n, nil +} + +// assertRootLoadIsWriting confirms the root load is both running and actually +// moving bytes on the root device. A load that produced no writes would make a +// clean run mean nothing, since the fault could never have reached the root +// filesystem at all. Two samples, because a non-zero total is only the boot's +// own writes; an increase is what proves the load is live right now. +func assertRootLoadIsWriting(t *testing.T, tgt harness.SSHTarget) { + t.Helper() + out, err := harness.GuestExecTimeout(tgt, "pgrep -f 'name=rootload' >/dev/null && echo RUNNING", 30*time.Second) + if err != nil || !strings.Contains(out, "RUNNING") { + t.Fatalf("the root-filesystem load is not running, so %s is idle and the fault cannot reach it:\n%s", + rootDevice, bestEffort(tgt, "tail -n 30 ~/rootfio.log")) + } + + before, err := rootSectorsWritten(tgt) + if err != nil { + t.Fatalf("cannot confirm the root load is writing: %v", err) + } + time.Sleep(rootWriteSample) + after, err := rootSectorsWritten(tgt) + if err != nil { + t.Fatalf("cannot confirm the root load is writing: %v", err) + } + if after <= before { + t.Fatalf("%s took no writes over %s (sectors_written stayed at %d), so a clean run would prove nothing:\n%s", + rootDevice, rootWriteSample, after, bestEffort(tgt, "tail -n 30 ~/rootfio.log")) + } + harness.Step(t, "root load is writing: %s gained %d sectors (%d KiB) in %s", + rootDevice, after-before, (after-before)/2, rootWriteSample) +} diff --git a/tests/e2e/storagefault/main_test.go b/tests/e2e/storagefault/main_test.go new file mode 100644 index 000000000..3ff712b62 --- /dev/null +++ b/tests/e2e/storagefault/main_test.go @@ -0,0 +1,302 @@ +//go:build e2e + +// Package storagefault proves what a guest does when its storage backend stops +// answering, and what a stop/start does when the seal to that backend failed. +// +// The claim is about corruption, not availability. Nobody expects a guest to +// keep serving I/O while predastore is down; the question is whether it comes +// back to a filesystem that is intact and a volume whose acknowledged writes +// are all present. Those are the two assertions here, and both are expected to +// fail against the engine as it stands. +// +// The fault is SIGSTOP rather than a stop of the unit. A stopped process holds +// its TCP connections open and sends no RST, so peers see a node that is +// present and answering nothing — which is the fault that defeats code with no +// timeout, and the one a clean shutdown cannot reproduce. SIGCONT restores it +// with no state lost, so the cluster is left exactly as it was found. +// +// It is its own package rather than a case inside single/ because it must +// freeze a cluster-wide service. Everything sharing the process would see the +// same outage, so a suite that memoizes one instance across many tests cannot +// host this without making every sibling's failure ambiguous. +package storagefault + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/mulgadc/spinifex/tests/e2e/harness" +) + +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 + SSH harness.SSH +} + +func (f *Fixture) ArtifactDir(t *testing.T) string { + t.Helper() + return harness.ArtifactDir(t, f.Env) +} + +// requireStorageFaultFixture returns the package-scoped Fixture singleton, +// building it on first call. Skips when SPINIFEX_E2E is unset or the cluster +// is a size this suite has no defined freeze set for. +func requireStorageFaultFixture(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 inject the fault: %w", err) + return + } + sshCli, err := harness.NewSSH(cluster) + if err != nil { + pkgFixErr = err + return + } + awsCli := harness.NewAWSClient(t, env) + h, err := harness.NewProcessFixture(awsCli) + if err != nil { + pkgFixErr = err + return + } + harness.EnsureDefaultSGOpen(t, awsCli) + pkgFix = &Fixture{Env: env, AWS: awsCli, Harness: h, Cluster: cluster, SSH: sshCli} + }) + if pkgFixErr != nil { + t.Fatalf("storagefault fixture init: %v", pkgFixErr) + } + if pkgFix == nil { + t.Skip("SPINIFEX_E2E unset") + } + if n := len(pkgFix.Cluster.Nodes); n != 1 && n < 3 { + t.Skipf("no defined freeze set for a %d-node cluster: 1 node is RS(1,0) and 3+ leaves a floor to break, 2 is neither", n) + } + return pkgFix +} + +// --- Fault injection ------------------------------------------------------- +// +// Every signal below is sent to a PID systemd reports as the predastore unit's +// own MainPID, and only after its /proc cmdline has been confirmed. Nothing +// here matches a process by name. That is not caution for its own sake: every +// Spinifex service is the same spx binary, so `pgrep -f spx` matches NATS and +// the daemon too, and these hosts also run unrelated QEMU guests whose comm is +// indistinguishable from the platform's own. + +// predastoreUnit is the systemd unit whose MainPID is the only process this +// suite ever signals. +const predastoreUnit = "spinifex-predastore" + +// predastoreCmdline is the substring the resolved PID's cmdline must contain. +// A MainPID that does not match is a unit that has been redefined, and the +// safe response is to refuse rather than to signal an unknown process. +const predastoreCmdline = "service predastore" + +// freezeSettle is how long to wait after signalling before treating the state +// change as in effect, so an assertion cannot race the signal. +const freezeSettle = 2 * time.Second + +// backendRecoveryBudget is how long the backend has to serve object I/O again +// once a fault is cleared. Generous because reconnection and meta re-election +// both have to happen; a wedge does not clear at all, so the budget only has to +// tell the two apart. +const backendRecoveryBudget = 3 * time.Minute + +// predastorePID resolves the predastore unit's MainPID on node and confirms the +// process is the one expected. Returns 0 with no error when the unit is not +// running, which is a fact the caller decides what to do about. +func predastorePID(ctx context.Context, ssh harness.SSH, node harness.Node) (int, error) { + raw, err := ssh.Run(ctx, node, "systemctl show "+predastoreUnit+" -p MainPID --value") + if err != nil { + return 0, fmt.Errorf("resolve %s MainPID on %s: %w", predastoreUnit, node.Name, err) + } + pid, err := strconv.Atoi(strings.TrimSpace(string(raw))) + if err != nil { + return 0, fmt.Errorf("MainPID on %s was %q, not a number", node.Name, strings.TrimSpace(string(raw))) + } + if pid <= 1 { + return 0, nil + } + + cmdline, err := ssh.Run(ctx, node, fmt.Sprintf("tr '\\0' ' ' < /proc/%d/cmdline", pid)) + if err != nil { + return 0, fmt.Errorf("read cmdline of pid %d on %s: %w", pid, node.Name, err) + } + if !strings.Contains(string(cmdline), predastoreCmdline) { + return 0, fmt.Errorf("refusing to signal pid %d on %s: cmdline %q does not contain %q", + pid, node.Name, strings.TrimSpace(string(cmdline)), predastoreCmdline) + } + return pid, nil +} + +// predastoreStopped reports whether the predastore process on node is in state +// T. Used both to refuse a run that starts against a frozen cluster and to +// confirm a thaw actually took. +func predastoreStopped(ctx context.Context, ssh harness.SSH, node harness.Node) (bool, error) { + pid, err := predastorePID(ctx, ssh, node) + if err != nil || pid == 0 { + return false, err + } + state, err := ssh.Run(ctx, node, fmt.Sprintf("ps -o state= -p %d", pid)) + if err != nil { + return false, fmt.Errorf("read state of pid %d on %s: %w", pid, node.Name, err) + } + return strings.HasPrefix(strings.TrimSpace(string(state)), "T"), nil +} + +// requireNoneFrozen fails the run when any node's predastore is already +// stopped. A leftover from an aborted run would make every assertion below +// meaningless, and thawing someone else's fault injection is not this suite's +// call to make. +func requireNoneFrozen(t *testing.T, f *Fixture) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + for _, n := range f.Cluster.Nodes { + stopped, err := predastoreStopped(ctx, f.SSH, n) + if err != nil { + t.Fatalf("preflight: %v", err) + } + if stopped { + t.Fatalf("preflight: predastore on %s is already stopped — a previous run left it frozen. "+ + "Thaw it with: ssh %s 'sudo kill -CONT $(systemctl show %s -p MainPID --value)'", + n.Name, n.Addr, predastoreUnit) + } + } +} + +// freezePredastore SIGSTOPs predastore on each node and registers an +// unconditional thaw. The cleanup is registered before the first signal, so a +// failure part-way through the set still restores the nodes already frozen. +func freezePredastore(t *testing.T, f *Fixture, nodes []harness.Node) { + t.Helper() + t.Cleanup(func() { thawPredastore(t, f, nodes) }) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + for _, n := range nodes { + pid, err := predastorePID(ctx, f.SSH, n) + if err != nil { + t.Fatalf("freeze: %v", err) + } + if pid == 0 { + t.Fatalf("freeze: %s is not running on %s", predastoreUnit, n.Name) + } + if _, err := f.SSH.Run(ctx, n, fmt.Sprintf("sudo kill -STOP %d", pid)); err != nil { + t.Fatalf("freeze: SIGSTOP pid %d on %s: %v", pid, n.Name, err) + } + t.Logf("froze predastore pid %d on %s", pid, n.Name) + } + time.Sleep(freezeSettle) +} + +// thawPredastore SIGCONTs predastore on each node and confirms it left state T. +// A node that cannot be thawed fails the test even if every assertion passed: +// leaving one frozen wedges the cluster for whatever runs next. +func thawPredastore(t *testing.T, f *Fixture, nodes []harness.Node) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + for _, n := range nodes { + pid, err := predastorePID(ctx, f.SSH, n) + if err != nil || pid == 0 { + t.Errorf("thaw: cannot resolve predastore on %s (pid=%d): %v", n.Name, pid, err) + continue + } + if _, err := f.SSH.Run(ctx, n, fmt.Sprintf("sudo kill -CONT %d", pid)); err != nil { + t.Errorf("thaw: SIGCONT pid %d on %s: %v", pid, n.Name, err) + continue + } + stopped, err := predastoreStopped(ctx, f.SSH, n) + if err != nil { + t.Errorf("thaw: confirm %s: %v", n.Name, err) + continue + } + if stopped { + t.Errorf("thaw: predastore on %s is still stopped after SIGCONT", n.Name) + continue + } + t.Logf("thawed predastore pid %d on %s", pid, n.Name) + } + time.Sleep(freezeSettle) + + requireBackendServing(t, f) +} + +// requireBackendServing proves the whole cluster answers object I/O again, not +// just the nodes that were frozen: the fault reaches every node through the +// stripe, and a wedged one is reached through whichever gate is asked. +func requireBackendServing(t *testing.T, f *Fixture) { + t.Helper() + if f == nil || f.Cluster == nil { + return + } + + hosts := make([]string, 0, len(f.Cluster.Nodes)) + for _, n := range f.Cluster.Nodes { + hosts = append(hosts, n.Addr) + } + + ctx, cancel := context.WithTimeout(context.Background(), backendRecoveryBudget+time.Minute) + defer cancel() + harness.AssertPredastoreServing(ctx, t, hosts, backendRecoveryBudget) +} + +// freezeSetFor returns the nodes to freeze so the backend is definitively below +// its floor for a guest on hostNode, and a label describing why. +// +// Every node but the guest's own is frozen. On three nodes that is two of +// three: one shard is left, below DataShards=2 with degraded writes on, and +// meta raft loses quorum. Leaving the guest's own node up is deliberate — the +// local gate stays reachable, so what is being tested is the backend having no +// quorum rather than viperblock having no gate to talk to. +func freezeSetFor(f *Fixture, hostNode *harness.Node) ([]harness.Node, string) { + all := f.Cluster.Nodes + if len(all) == 1 { + return all, "the only node: RS(1,0) has no redundancy, so any freeze is total" + } + + var out []harness.Node + for _, n := range all { + if hostNode != nil && n.Index == hostNode.Index { + continue + } + out = append(out, n) + } + return out, fmt.Sprintf("%d of %d nodes, every one but the guest's own host", len(out), len(all)) +} diff --git a/tests/e2e/storagefault/nbdkit_fault_test.go b/tests/e2e/storagefault/nbdkit_fault_test.go new file mode 100644 index 000000000..9e2d51ac9 --- /dev/null +++ b/tests/e2e/storagefault/nbdkit_fault_test.go @@ -0,0 +1,333 @@ +//go:build e2e + +package storagefault + +import ( + "context" + "fmt" + "strconv" + "strings" + "testing" + "time" + + "github.com/mulgadc/spinifex/tests/e2e/harness" +) + +// nbdkitComm is the exact /proc//comm for an nbdkit process, short enough +// that Linux's 15-byte truncation never applies. +const nbdkitComm = "nbdkit" + +// nbdkitPluginSuffix identifies our own plugin in a discovered argv, so an +// nbdkit serving something else is never signalled. +const nbdkitPluginSuffix = "viperblock-plugin.so" + +// nbdkitSettle is how long to wait after signalling nbdkit before judging the +// guest, so an assertion cannot race the signal. +const nbdkitSettle = 5 * time.Second + +// qemuUser is the account the guests run as. The relaunched socket has to be +// writable by it, because that is who reconnects to it. +const qemuUser = "spinifex-daemon" + +// nbdkitUser is the account viperblockd starts nbdkit as. The relaunch matches +// it so the process keeps the same access to the volume's cache and key. +const nbdkitUser = "spinifex-viperblock" + +// nbdkitRelaunchTimeout bounds the wait for a relaunched nbdkit to rebind its +// socket. Opening a volume reads its state from the backend, so this is not +// instant. +const nbdkitRelaunchTimeout = 90 * time.Second + +// nbdkitPIDForVolume resolves the pid of the nbdkit serving exactly volumeID on +// node. It matches on three things together — the comm, our plugin, and the +// volume argument — because every other nbdkit on these hosts belongs to +// another volume or another tenant, and a name match would hit all of them. +// Returns 0 with no error when nothing serves that volume here. +func nbdkitPIDForVolume(ctx context.Context, ssh harness.SSH, node harness.Node, volumeID string) (int, error) { + // pgrep -x on the comm, then filter each candidate's own cmdline. Done on + // the far side because one round trip per pid over SSH is needlessly slow. + script := fmt.Sprintf( + `for p in $(pgrep -x %s 2>/dev/null); do `+ + `c=$(tr '\0' ' ' < /proc/$p/cmdline 2>/dev/null); `+ + `case "$c" in *%s*volume=%s*|*volume=%s*%s*) echo "$p"; esac; `+ + `done`, + nbdkitComm, nbdkitPluginSuffix, volumeID, volumeID, nbdkitPluginSuffix) + + raw, err := ssh.Run(ctx, node, script) + if err != nil { + return 0, fmt.Errorf("scan nbdkit for %s on %s: %w", volumeID, node.Name, err) + } + fields := strings.Fields(string(raw)) + if len(fields) == 0 { + return 0, nil + } + if len(fields) > 1 { + return 0, fmt.Errorf("refusing to signal: %d nbdkit processes serve %s on %s (%v)", + len(fields), volumeID, node.Name, fields) + } + pid, err := strconv.Atoi(fields[0]) + if err != nil { + return 0, fmt.Errorf("nbdkit pid for %s on %s was %q, not a number", volumeID, node.Name, fields[0]) + } + return pid, nil +} + +// requireNbdkitForVolume resolves the pid and fails the test when nothing is +// serving the volume. A test that signalled nothing would pass for the wrong +// reason, so the absence is fatal rather than skipped. +func requireNbdkitForVolume(t *testing.T, f *Fixture, node harness.Node, volumeID string) int { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + pid, err := nbdkitPIDForVolume(ctx, f.SSH, node, volumeID) + if err != nil { + t.Fatalf("cannot resolve nbdkit for %s on %s: %v", volumeID, node.Name, err) + } + if pid == 0 { + t.Fatalf("no nbdkit serves %s on %s, so there is no fault to inject", volumeID, node.Name) + } + return pid +} + +// signalNbdkit sends sig to pid, having re-confirmed the pid still belongs to +// the volume. The re-check is not paranoia: pids are reused, and the gap +// between resolving one and signalling it is exactly where a reused pid would +// land us on an unrelated process. +func signalNbdkit(ctx context.Context, f *Fixture, node harness.Node, volumeID string, pid int, sig string) error { + current, err := nbdkitPIDForVolume(ctx, f.SSH, node, volumeID) + if err != nil { + return err + } + if current != pid { + return fmt.Errorf("refusing to signal pid %d on %s: it no longer serves %s (now %d)", + pid, node.Name, volumeID, current) + } + if _, err := f.SSH.Run(ctx, node, fmt.Sprintf("sudo kill -%s %d", sig, pid)); err != nil { + return fmt.Errorf("kill -%s %d on %s: %w", sig, pid, node.Name, err) + } + return nil +} + +// freezeNbdkit SIGSTOPs the volume's nbdkit and registers the thaw before the +// signal is sent, so an abort between the two still recovers. A frozen nbdkit +// holds its NBD socket open and answers nothing, which the guest sees as a +// stall rather than an error. +func freezeNbdkit(t *testing.T, f *Fixture, node harness.Node, volumeID string, pid int) { + t.Helper() + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + // Best effort by design: on the kill variant the process is already + // gone, and a failed SIGCONT there is not a test failure. + _, _ = f.SSH.Run(ctx, node, fmt.Sprintf("sudo kill -CONT %d 2>/dev/null || true", pid)) + }) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + if err := signalNbdkit(ctx, f, node, volumeID, pid, "STOP"); err != nil { + t.Fatalf("freeze nbdkit for %s: %v", volumeID, err) + } + t.Logf("froze nbdkit pid %d serving %s on %s", pid, volumeID, node.Name) + time.Sleep(nbdkitSettle) +} + +// thawNbdkit SIGCONTs the volume's nbdkit. +func thawNbdkit(t *testing.T, f *Fixture, node harness.Node, pid int) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + if _, err := f.SSH.Run(ctx, node, fmt.Sprintf("sudo kill -CONT %d", pid)); err != nil { + t.Errorf("thaw nbdkit pid %d on %s: %v", pid, node.Name, err) + return + } + t.Logf("thawed nbdkit pid %d on %s", pid, node.Name) +} + +// nbdkitCmdline reads a running nbdkit's argv, so the process can be recreated +// exactly. Capturing it before the kill is what makes the relaunch faithful: +// the socket path carries a launch-time nonce that cannot be derived from the +// volume id. +func nbdkitCmdline(ctx context.Context, f *Fixture, node harness.Node, pid int) (string, error) { + raw, err := f.SSH.Run(ctx, node, fmt.Sprintf("sudo tr '\\0' '\\n' < /proc/%d/cmdline", pid)) + if err != nil { + return "", fmt.Errorf("read cmdline of pid %d on %s: %w", pid, node.Name, err) + } + args := strings.Fields(string(raw)) + if len(args) == 0 { + return "", fmt.Errorf("pid %d on %s has an empty cmdline", pid, node.Name) + } + return strings.Join(args, " "), nil +} + +// nbdkitSocketPath pulls the --unix argument out of a captured argv. The +// relaunch must bind the same path or QEMU has nothing to reconnect to. +func nbdkitSocketPath(cmdline string) (string, error) { + args := strings.Fields(cmdline) + for i, a := range args { + if a == "--unix" && i+1 < len(args) { + return args[i+1], nil + } + } + return "", fmt.Errorf("no --unix argument in nbdkit cmdline %q", cmdline) +} + +// killedNbdkit is what a kill leaves behind: enough to put the process back. +// envFile is a path on the node, never the credentials themselves — see +// captureNbdkitEnv. +type killedNbdkit struct { + cmdline string + socket string + envFile string +} + +// captureNbdkitEnv copies the credential variables out of a running nbdkit's +// environment into a file on the same node. viperblockd passes them this way +// rather than on the command line so they stay out of the world-readable +// /proc//cmdline, so a relaunch built from argv alone fails with +// "access_key parameter is required". +// +// The values are never read back into the test: the file is written on the +// node, consumed on the node and deleted there, so they stay exactly as +// confined as the original design intended. +func captureNbdkitEnv(ctx context.Context, f *Fixture, node harness.Node, volumeID string, pid int) (string, error) { + path := fmt.Sprintf("/tmp/nbdkit-env-%s", volumeID) + capture := fmt.Sprintf( + "sudo sh -c 'umask 077; tr \"\\0\" \"\\n\" < /proc/%d/environ | grep -E \"^VB_(ACCESS|SECRET)_KEY=\" > %s' && "+ + "sudo chown %s %s && sudo test -s %s", + pid, path, nbdkitUser, path, path) + if _, err := f.SSH.Run(ctx, node, capture); err != nil { + return "", fmt.Errorf("capture nbdkit credentials on %s: %w", node.Name, err) + } + return path, nil +} + +// killNbdkit SIGKILLs the volume's nbdkit, first capturing what is needed to +// bring it back. Nothing restarts it on its own — viperblockd re-adopts +// survivors after its own restart and never starts a process — so the volume +// stays unserved until relaunchNbdkit puts it back. +// +// The guest reaches this by a different route to a freeze: the socket closes +// rather than going quiet, so requests fail instead of blocking. +func killNbdkit(t *testing.T, f *Fixture, node harness.Node, volumeID string, pid int) killedNbdkit { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + cmdline, err := nbdkitCmdline(ctx, f, node, pid) + if err != nil { + t.Fatalf("capture nbdkit cmdline before killing it: %v", err) + } + socket, err := nbdkitSocketPath(cmdline) + if err != nil { + t.Fatalf("locate the nbd socket for %s: %v", volumeID, err) + } + envFile, err := captureNbdkitEnv(ctx, f, node, volumeID, pid) + if err != nil { + t.Fatalf("capture the environment before killing nbdkit for %s: %v", volumeID, err) + } + t.Cleanup(func() { + cleanCtx, cancelClean := context.WithTimeout(context.Background(), 30*time.Second) + defer cancelClean() + _, _ = f.SSH.Run(cleanCtx, node, fmt.Sprintf("sudo rm -f %s", envFile)) + }) + + if err := signalNbdkit(ctx, f, node, volumeID, pid, "KILL"); err != nil { + t.Fatalf("kill nbdkit for %s: %v", volumeID, err) + } + t.Logf("killed nbdkit pid %d serving %s on %s (socket %s)", pid, volumeID, node.Name, socket) + time.Sleep(nbdkitSettle) + return killedNbdkit{cmdline: cmdline, socket: socket, envFile: envFile} +} + +// reapRelaunchedNbdkit kills the relaunched server. setsid detaches it from +// the SSH session that started it, and viperblockd never learned it exists, so +// nothing else will ever reap it. +// +// Call this only once the volume is detached. It is the server behind an +// attached disk, so killing it first leaves QEMU holding the blockdev node and +// the platform's own detach then fails with "Node ... is in use". +// +// The PID is resolved from the socket path rather than the volume id: a +// legitimate viperblockd nbdkit for the same volume must not be killed, and +// only this one is bound to this path. +func reapRelaunchedNbdkit(t *testing.T, f *Fixture, node harness.Node, socket string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + out, err := f.SSH.Run(ctx, node, + fmt.Sprintf("sudo pgrep -a nbdkit | grep -F -- %q | awk '{print $1}'", socket)) + if err != nil { + t.Logf("could not look up relaunched nbdkit on %s: %v", node.Name, err) + return + } + for _, pid := range strings.Fields(string(out)) { + if _, err := f.SSH.Run(ctx, node, fmt.Sprintf("sudo kill %s", pid)); err != nil { + t.Logf("could not stop relaunched nbdkit %s on %s: %v", pid, node.Name, err) + continue + } + t.Logf("stopped relaunched nbdkit %s on %s", pid, node.Name) + } + if _, err := f.SSH.Run(ctx, node, fmt.Sprintf("sudo rm -f %s", socket)); err != nil { + t.Logf("could not remove %s on %s: %v", socket, node.Name, err) + } +} + +// relaunchNbdkit puts the volume's NBD server back on the same socket path, +// which is what turns the kill into a recoverable fault rather than a +// destructive one. The stale socket is removed first: nbdkit will not bind a +// path that already exists, and a SIGKILL leaves the file behind. +// +// It runs as the same user under setsid, because nbdkit was started with -f +// and would otherwise die with the SSH session that launched it. +func relaunchNbdkit(t *testing.T, f *Fixture, node harness.Node, volumeID string, k killedNbdkit) { + t.Helper() + // Budgeted to outlast the poll below with room for its round trips. Sharing + // a shorter context with the poll left it expired by the time the + // diagnostics ran, so a failure reported nothing at all. + ctx, cancel := context.WithTimeout(context.Background(), nbdkitRelaunchTimeout+3*time.Minute) + defer cancel() + + // Output goes to a file rather than /dev/null: an nbdkit that starts and + // exits is the interesting failure, and its reason is only on stderr. + // The credentials are sourced inside the target user's shell rather than + // placed on the command line, so they are no more exposed than viperblockd + // leaves them. Reading with `export "$line"` keeps any character in the + // value intact, which quoting into a command string would not. + log := fmt.Sprintf("/tmp/nbdkit-relaunch-%s.log", volumeID) + start := fmt.Sprintf( + "sudo rm -f %s; sudo touch %s && sudo chmod 666 %s && "+ + "sudo -u %s bash -c 'while IFS= read -r l; do export \"$l\"; done < %s; exec setsid %s' "+ + "%s 2>&1 & sleep 5; cat %s", + k.socket, log, log, nbdkitUser, k.envFile, k.cmdline, log, log) + // The early output is read here rather than only on failure: a later query + // can fail on its own and take the reason with it. + early, err := f.SSH.Run(ctx, node, start) + if err != nil { + t.Fatalf("relaunch nbdkit for %s on %s: %v", volumeID, node.Name, err) + } + if len(strings.TrimSpace(string(early))) > 0 { + t.Logf("nbdkit relaunch said: %s", strings.TrimSpace(string(early))) + } + + // QEMU reconnects to the path, not to the process, so the socket being + // back and writable by the account QEMU runs as is the whole signal. The + // chmod is viperblockd's own step, not a workaround: nbdkit binds the + // socket 0755 and a QEMU with no group-write bit can never reconnect. + ready := fmt.Sprintf("sudo test -S %s && sudo chmod 0770 %s && sudo -u %s test -w %s", + k.socket, k.socket, qemuUser, k.socket) + deadline := time.Now().Add(nbdkitRelaunchTimeout) + for time.Now().Before(deadline) { + if _, err := f.SSH.Run(ctx, node, ready); err == nil { + t.Logf("relaunched nbdkit for %s on %s, socket %s is back", volumeID, node.Name, k.socket) + return + } + time.Sleep(2 * time.Second) + } + + out, _ := f.SSH.Run(ctx, node, fmt.Sprintf( + "cat %s 2>/dev/null; echo '--- socket ---'; sudo ls -la %s 2>&1; echo '--- procs ---'; pgrep -ax nbdkit | head", + log, k.socket)) + t.Fatalf("nbdkit for %s did not rebind %s on %s within %s\ncommand: %s\n%s", + volumeID, k.socket, node.Name, nbdkitRelaunchTimeout, k.cmdline, out) +} diff --git a/tests/e2e/storagefault/nbdkit_loss_test.go b/tests/e2e/storagefault/nbdkit_loss_test.go new file mode 100644 index 000000000..dde352c16 --- /dev/null +++ b/tests/e2e/storagefault/nbdkit_loss_test.go @@ -0,0 +1,125 @@ +//go:build e2e + +package storagefault + +import ( + "testing" + "time" + + "github.com/mulgadc/spinifex/tests/e2e/harness" +) + +// nbdkitHold is how long the volume stays unserved. Unlike the predastore +// freeze this needs no retry budget outlasting: a killed nbdkit closes its +// socket and QEMU fails the request at once, and a frozen one stalls for as +// long as it is held. Short enough that both variants fit in one run. +const nbdkitHold = 60 * time.Second + +// TestGuestSurvivesNbdkitLoss removes the volume's own NBD server rather than +// the storage cluster behind it, which is a far narrower fault: one volume on +// one host, with no cluster-wide service stopped and nothing for another suite +// to trip over. +// +// The two signals are deliberately both here, because they are on opposite +// sides of the defect and running only one would prove the wrong thing: +// +// - SIGSTOP holds the NBD socket open and answers nothing. The guest blocks. +// No error is ever delivered, so ext4 has no reason to abort. +// - SIGKILL closes the socket, so requests fail rather than block. Under the +// nbd driver's default reconnect-delay of 0 they fail instantly, and under +// werror=report that EIO reaches the guest and aborts its journal. +// +// The killed variant is put back on the same socket path afterwards, because a +// volume left dead tests the fault and not the recovery. Under +// reconnect-delay plus werror=stop,rerror=stop both must become stalls that +// resume, and neither may show a corruption signature. +func TestGuestSurvivesNbdkitLoss(t *testing.T) { + cases := []struct { + name string + kill bool + }{ + {name: "Frozen", kill: false}, + {name: "Killed", kill: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fix := requireStorageFaultFixture(t) + + instanceID, tgt := ensureFaultInstance(t, fix) + hostNode := harness.InstanceHostingNode(t, fix.Cluster, instanceID) + if hostNode == nil { + t.Fatalf("cannot identify the node hosting %s, so its nbdkit cannot be found", instanceID) + } + + az := harness.DiscoverDefaultAZ(t, fix.Harness) + volID := harness.EnsureVolume(t, fix.Harness, az, faultVolumeSizeGiB) + + before := harness.GuestDiskSet(t, tgt) + harness.AttachVolumeWait(t, fix.AWS, volID, instanceID, faultDevice) + dev := harness.WaitForNewGuestDisk(t, tgt, before, 90*time.Second) + + // relaunchedSocket names an nbdkit the killed variant put back by + // hand. Reaped here rather than on its own cleanup so it happens + // after the detach: it is the server behind an attached disk, and + // killing it first leaves the detach fighting a pinned blockdev. + var relaunchedSocket string + t.Cleanup(func() { + harness.DetachVolumeWait(t, fix.AWS, volID) + if relaunchedSocket != "" { + reapRelaunchedNbdkit(t, fix, *hostNode, relaunchedSocket) + } + }) + + prepareWorkloadFilesystem(t, tgt, dev) + quiesceWorkload(t, tgt) + + pid := requireNbdkitForVolume(t, fix, *hostNode, volID) + harness.Detail(t, "instance", instanceID, "volume", volID, "guest_device", dev, + "host_node", nodeName(hostNode), "nbdkit_pid", pid) + + harness.Step(t, "writing the %d MiB verifiable region and flushing it", verifyMiB) + digest := writeVerifiableRegion(t, tgt) + + loadRuntime := loadSettle + nbdkitHold + recoverySettle + harness.Step(t, "starting sustained load for %s", loadRuntime) + startLoad(t, tgt, loadRuntime) + time.Sleep(loadSettle) + if !loadRunning(t, tgt) { + t.Fatalf("the load exited before the fault was injected, so nothing was under load: %s", + loadLogTail(t, tgt)) + } + + var killed killedNbdkit + if tc.kill { + harness.Step(t, "killing nbdkit for %s, then holding %s", volID, nbdkitHold) + killed = killNbdkit(t, fix, *hostNode, volID, pid) + } else { + harness.Step(t, "freezing nbdkit for %s for %s", volID, nbdkitHold) + freezeNbdkit(t, fix, *hostNode, volID, pid) + } + + observeDuringOutage(t, fix, instanceID, tgt) + + if tc.kill { + harness.Step(t, "relaunching nbdkit on %s, then waiting %s", killed.socket, recoverySettle) + relaunchNbdkit(t, fix, *hostNode, volID, killed) + relaunchedSocket = killed.socket + } else { + harness.Step(t, "thawing nbdkit and waiting %s", recoverySettle) + thawNbdkit(t, fix, *hostNode, pid) + } + awaitGuestRecovered(t, tgt) + + console, consoleErr := harness.InstanceConsole(fix.AWS, instanceID) + if consoleErr != nil { + t.Logf("console unavailable (guest evidence only): %v", consoleErr) + } + evidence := console + "\n" + bestEffort(tgt, "sudo dmesg | tail -n 400") + + assertNoCorruption(t, evidence, instanceID, fix) + assertStillWritable(t, tgt, dev) + assertWorkloadIntegrity(t, tgt, digest) + }) + } +} diff --git a/tests/e2e/storagefault/stop_start_durability_test.go b/tests/e2e/storagefault/stop_start_durability_test.go new file mode 100644 index 000000000..3b6c6b949 --- /dev/null +++ b/tests/e2e/storagefault/stop_start_durability_test.go @@ -0,0 +1,466 @@ +//go:build e2e + +package storagefault + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/mulgadc/spinifex/tests/e2e/harness" +) + +const ( + // patternDevice is the guest-visible attach point for the pattern volume. + patternDevice = "/dev/sdg" + + // patternVolumeSizeGiB sizes the volume holding the known pattern. + patternVolumeSizeGiB = 1 + + // patternMiB is how much of the device the pattern covers. Large enough to + // span many 4 KiB blocks and several 4 MiB chunks, small enough to write + // and re-read in seconds. + patternMiB = 64 + + // stopStartTimeout bounds a StopInstances or StartInstances round trip. + // 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 +) + +// viperblockBaseDir is where a node keeps per-volume local state. Overridable +// for a cluster laid out differently, but this is the installed default and +// what the seal receipt sits beside. +func viperblockBaseDir() string { + if v := os.Getenv("SPINIFEX_VIPERBLOCK_BASEDIR"); v != "" { + return v + } + return "/var/lib/spinifex/viperblock" +} + +// TestStopStartPreservesWritesOnSameNode proves the recovery path the whole +// dirty-volume design rests on: when a seal fails, the local WAL is kept and a +// restart on the same node recovers from it with nothing lost. +// +// Expected to pass against the current engine. It is here because the +// cross-node test below is only meaningful if this one holds — a fix that +// refused every start would make that test green while making the platform +// worse. +func TestStopStartPreservesWritesOnSameNode(t *testing.T) { + fix := requireStorageFaultFixture(t) + requireNoneFrozen(t, fix) + + instanceID, tgt := ensureFaultInstance(t, fix) + hostNode := harness.InstanceHostingNode(t, fix.Cluster, instanceID) + if hostNode == nil { + t.Fatalf("cannot identify the node hosting %s, so its local state cannot be inspected", instanceID) + } + + az := harness.DiscoverDefaultAZ(t, fix.Harness) + volID := harness.EnsureVolume(t, fix.Harness, az, patternVolumeSizeGiB) + + before := harness.GuestDiskSet(t, tgt) + harness.AttachVolumeWait(t, fix.AWS, volID, instanceID, patternDevice) + dev := harness.WaitForNewGuestDisk(t, tgt, before, 90*time.Second) + t.Cleanup(func() { harness.DetachVolumeWait(t, fix.AWS, volID) }) + + want := writePattern(t, tgt, dev) + harness.Detail(t, "instance", instanceID, "volume", volID, "host_node", hostNode.Name, "sha256", want) + + freezeSet, why := freezeSetFor(fix, hostNode) + harness.Step(t, "freezing predastore on %s (%s) so the seal cannot complete", nodeNames(freezeSet), why) + freezePredastore(t, fix, freezeSet) + + harness.Step(t, "stopping %s with the backend frozen", instanceID) + stopErr := stopInstance(t, fix, instanceID) + t.Logf("StopInstances returned: %v", errOrOK(stopErr)) + + assertLocalStateKept(t, fix, hostNode, volID) + + thawPredastore(t, fix, freezeSet) + time.Sleep(recoverySettle) + + harness.Step(t, "starting %s again on the same node", instanceID) + startInstance(t, fix, instanceID) + inst := harness.WaitForInstanceState(t, fix.AWS, instanceID, "running") + host, port := harness.InstancePublicSSHHost(t, inst) + if !harness.TryGuestSSHReady(host, port, "ubuntu", tgt.KeyPath, 5*time.Minute) { + t.Fatalf("guest did not come back after a same-node restart%s", consoleTail(fix, instanceID)) + } + tgt = harness.SSHTarget{User: "ubuntu", Host: host, Port: port, KeyPath: tgt.KeyPath} + + got := readPattern(t, tgt, dev) + if got != want { + t.Errorf("a same-node restart after a failed seal lost data: sha256 was %s, now %s.\n"+ + "This breaks the premise the dirty-volume design rests on — the local WAL is supposed "+ + "to be kept and recovered from on exactly this path.", want, got) + return + } + harness.Step(t, "same-node restart recovered every acknowledged write from local state") +} + +// TestCrossNodeStartAfterFailedSealTakesOverLoudly pins the trade the design +// makes. A volume whose seal failed has its freshest copy on one node, and +// starting it elsewhere opens an older checkpoint from the backend. +// +// Starting elsewhere is nonetheless the right outcome: instance start forwards +// to the node that last ran the instance and only falls back once that node has +// failed its window, so refusing here would leave the instance unable to run at +// all. What must never happen is the fallback being silent, so this asserts the +// takeover is logged and names the node whose writes were left behind. +func TestCrossNodeStartAfterFailedSealTakesOverLoudly(t *testing.T) { + fix := requireStorageFaultFixture(t) + if len(fix.Cluster.Nodes) < 3 { + t.Skip("a cross-node start needs somewhere else to start: single-node clusters cannot reproduce this") + } + requireNoneFrozen(t, fix) + + instanceID, tgt := ensureFaultInstance(t, fix) + hostNode := harness.InstanceHostingNode(t, fix.Cluster, instanceID) + if hostNode == nil { + t.Fatalf("cannot identify the node hosting %s", instanceID) + } + + az := harness.DiscoverDefaultAZ(t, fix.Harness) + volID := harness.EnsureVolume(t, fix.Harness, az, patternVolumeSizeGiB) + + before := harness.GuestDiskSet(t, tgt) + harness.AttachVolumeWait(t, fix.AWS, volID, instanceID, patternDevice) + dev := harness.WaitForNewGuestDisk(t, tgt, before, 90*time.Second) + t.Cleanup(func() { harness.DetachVolumeWait(t, fix.AWS, volID) }) + + want := writePattern(t, tgt, dev) + harness.Detail(t, "instance", instanceID, "volume", volID, "host_node", hostNode.Name, "sha256", want) + + freezeSet, _ := freezeSetFor(fix, hostNode) + freezePredastore(t, fix, freezeSet) + + harness.Step(t, "stopping %s with the backend frozen, so the seal fails", instanceID) + _ = stopInstance(t, fix, instanceID) + assertLocalStateKept(t, fix, hostNode, volID) + + thawPredastore(t, fix, freezeSet) + time.Sleep(recoverySettle) + + // 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. + harness.Step(t, "taking %s out so the start has to happen elsewhere", hostNode.Name) + isolateNode(t, fix, *hostNode) + + if startErr := startInstance(t, fix, instanceID); startErr != nil { + t.Fatalf("the start was refused, so the instance can run nowhere while %s is down: %v\n"+ + "Falling back to the backend checkpoint is the intended behaviour here.", hostNode.Name, startErr) + } + + inst := harness.WaitForInstanceState(t, fix.AWS, instanceID, "running") + landed := harness.InstanceHostingNode(t, fix.Cluster, instanceID) + if landed != nil && landed.Index == hostNode.Index { + t.Skipf("the instance started on %s again, so no cross-node start happened and this test proved nothing", hostNode.Name) + } + harness.Step(t, "the instance started on %s while %s was down", nodeName(landed), hostNode.Name) + + // 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) + + host, port := harness.InstancePublicSSHHost(t, inst) + if !harness.TryGuestSSHReady(host, port, "ubuntu", tgt.KeyPath, 5*time.Minute) { + t.Errorf("the cross-node start was allowed and the guest never came back: opening from the "+ + "backend checkpoint has to yield a bootable volume, not an unusable one.%s", + consoleTail(fix, instanceID)) + return + } + tgt = harness.SSHTarget{User: "ubuntu", Host: host, Port: port, KeyPath: tgt.KeyPath} + + // Reading the pattern needs sudo, which the checkpoint may predate. Losing it + // is the same accepted rollback, but only once cloud-init is ruled out: a guest + // that found no datasource is the keyless-boot bug, not a storage outcome. + if !guestSudoReady(tgt) { + ds := guestDatasource(tgt) + if strings.Contains(ds, "DataSourceNone") || ds == "" { + t.Errorf("the guest came back unable to sudo and cloud-init found no datasource, so this "+ + "is IMDS failing to answer, not the checkpoint rolling back: %q%s", ds, + consoleTail(fix, instanceID)) + return + } + harness.Step(t, "the pattern cannot be read: the root filesystem opened from a checkpoint "+ + "predating cloud-init's sudoers drop-in, the same documented cost as the pattern "+ + "changing. cloud-init did find its datasource (%s), so metadata was served.", ds) + return + } + + if got := readPattern(t, tgt, dev); got == want { + harness.Step(t, "the pattern also survived the takeover: %s had sealed enough of it", hostNode.Name) + } else { + harness.Step(t, "the pattern changed across the takeover (was %s, now %s), which is the "+ + "documented cost of starting from the backend checkpoint", want, got) + } +} + +// guestSudoReady reports whether the guest can still sudo without a password. +// A volume opened from the backend checkpoint may predate cloud-init writing +// /etc/sudoers.d/90-cloud-init-users, which no read of the device can tell apart. +func guestSudoReady(tgt harness.SSHTarget) bool { + _, err := harness.GuestExecTimeout(tgt, "sudo -n true", 60*time.Second) + return err == nil +} + +// guestDatasource returns what cloud-init recorded for this boot. result.json is +// the file that names the datasource and is world-readable; status --long is the +// fallback. This is the discriminator between a rollback and IMDS never answering. +func guestDatasource(tgt harness.SSHTarget) string { + out, err := harness.GuestExecTimeout(tgt, + "cat /run/cloud-init/result.json 2>/dev/null; cloud-init status --long 2>/dev/null", + 60*time.Second) + if err != nil { + return "" + } + return strings.Join(strings.Fields(out), " ") +} + +// assertTakeoverLogged fails unless the node that opened the volume said so. +// +// 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) { + t.Helper() + if landed == nil { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + 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" + 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 + } + journal := string(out) + if strings.TrimSpace(journal) == "" { + 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) + 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) + return + } + harness.Step(t, "%s logged the takeover and named %s", landed.Name, previous.Name) +} + +// assertLocalStateKept checks the node kept the volume's local files and wrote +// no seal receipt. Both are what a correctly failed seal leaves behind, and +// together they are the ground truth a cluster-visible marker would index. +func assertLocalStateKept(t *testing.T, fix *Fixture, node *harness.Node, volID string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + base := viperblockBaseDir() + dirCmd := fmt.Sprintf("sudo test -d %s/%s && echo PRESENT || echo ABSENT", base, volID) + dirOut, err := fix.SSH.Run(ctx, *node, dirCmd) + if err != nil { + t.Fatalf("inspect local volume state on %s: %v", node.Name, err) + } + receiptCmd := fmt.Sprintf("sudo test -f %s/%s.sealed && echo PRESENT || echo ABSENT", base, volID) + receiptOut, err := fix.SSH.Run(ctx, *node, receiptCmd) + if err != nil { + t.Fatalf("inspect seal receipt on %s: %v", node.Name, err) + } + + local := strings.TrimSpace(string(dirOut)) + receipt := strings.TrimSpace(string(receiptOut)) + harness.Detail(t, "node", node.Name, "local_state", local, "seal_receipt", receipt) + + if receipt == "PRESENT" { + t.Errorf("a seal receipt exists for %s on %s even though the backend was frozen: "+ + "the seal reported success it cannot have had", volID, node.Name) + } + if local == "ABSENT" { + t.Errorf("%s kept no local state for %s after a failed seal. The un-uploaded writes were the "+ + "only current copy and they have been deleted — this is unrecoverable data loss, not a routing bug.", + node.Name, volID) + } +} + +// writePattern fills the head of the device with a reproducible pattern, +// flushes it to the backend and returns its checksum. O_DIRECT plus an +// explicit flush is what makes the write acknowledged rather than merely +// buffered, which is the only kind this suite makes claims about. +func writePattern(t *testing.T, tgt harness.SSHTarget, dev string) string { + t.Helper() + cmd := fmt.Sprintf( + "sudo dd if=/dev/urandom of=/dev/%s bs=1M count=%d oflag=direct status=none && "+ + "sudo blockdev --flushbufs /dev/%s && sync", + dev, patternMiB, dev) + out, err := harness.GuestExecTimeout(tgt, cmd, 5*time.Minute) + if err != nil { + t.Fatalf("write pattern to /dev/%s: %v\n%s\n%s", dev, err, out, deviceGeometry(tgt, dev)) + } + return readPattern(t, tgt, dev) +} + +// readPattern re-reads the pattern region and returns its checksum. The page +// cache is dropped first so the read comes from the device rather than from +// what was written before the restart. +// +// Not O_DIRECT: this image ships uutils dd, whose O_DIRECT read path only +// handles 512-byte transfers and fails EINVAL at 4k and above. Dropping the +// cache gives the same guarantee and works under either dd. +func readPattern(t *testing.T, tgt harness.SSHTarget, dev string) string { + t.Helper() + cmd := fmt.Sprintf( + "set -o pipefail; sudo blockdev --flushbufs /dev/%[1]s && sync && "+ + "echo 3 | sudo tee /proc/sys/vm/drop_caches >/dev/null && "+ + "sudo dd if=/dev/%[1]s bs=1M count=%[2]d status=none | sha256sum | cut -d' ' -f1", + dev, patternMiB) + out, err := harness.GuestExecTimeout(tgt, cmd, 5*time.Minute) + if err != nil { + t.Fatalf("read pattern back from /dev/%s: %v\n%s\n%s", dev, err, out, deviceGeometry(tgt, dev)) + } + sum := strings.TrimSpace(out) + if len(sum) != 64 { + t.Fatalf("expected a sha256 from /dev/%s, got %q\n%s", dev, sum, deviceGeometry(tgt, dev)) + } + return sum +} + +// stopInstance stops the instance and waits for it to settle. The error is +// returned rather than fatal: whether a stop against a dead backend succeeds +// or fails is one of the things under test. +func stopInstance(t *testing.T, fix *Fixture, instanceID string) error { + t.Helper() + if _, err := fix.AWS.EC2.StopInstances(&ec2.StopInstancesInput{ + InstanceIds: []*string{aws.String(instanceID)}, + }); err != nil { + return err + } + deadline := time.Now().Add(stopStartTimeout) + for time.Now().Before(deadline) { + switch instanceStateOf(fix, instanceID) { + case "stopped": + return nil + case "error": + return fmt.Errorf("instance %s entered error state during stop", instanceID) + } + time.Sleep(10 * time.Second) + } + return fmt.Errorf("instance %s did not reach stopped within %s", instanceID, stopStartTimeout) +} + +// startInstance starts the instance, returning the API error if the start is +// refused. A refusal is the desired behaviour for a dirty volume, so this is a +// value the caller inspects rather than a failure. +func startInstance(t *testing.T, fix *Fixture, instanceID string) error { + t.Helper() + _, err := fix.AWS.EC2.StartInstances(&ec2.StartInstancesInput{ + InstanceIds: []*string{aws.String(instanceID)}, + }) + return err +} + +// isolatedUnits are the two services that have to be down for a node to look +// gone. The daemon so ec2.start.{lastNode} has no responder, and viperblock so +// nothing on this node keeps renewing the lease it holds on the volume. +// +// Not the whole target: predastore stays up so the node keeps serving its shard, +// which is what a node whose control plane died looks like from the cluster. +var isolatedUnits = []string{"spinifex-daemon", "spinifex-viperblock"} + +// leaseExpiryBudget is how long to wait for an unrenewed volume lease to age +// out of JetStream once its holder is gone. Comfortably past the 45s TTL, since +// the entry only disappears on the bucket's own schedule. +const leaseExpiryBudget = 75 * time.Second + +// isolateNode stops the control-plane services on node and restores them on +// cleanup, then waits for the volume lease the node held to expire. +// +// Waiting is the point. Without it the start elsewhere is refused by a lease +// that is simply still current, which says nothing about what happens when its +// holder is not coming back. +func isolateNode(t *testing.T, fix *Fixture, node harness.Node) { + t.Helper() + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) + defer cancel() + for _, unit := range isolatedUnits { + if _, err := fix.SSH.Run(ctx, node, "sudo systemctl start "+unit); err != nil { + t.Errorf("could not restart %s on %s — the node is left without it: %v", unit, node.Name, err) + continue + } + t.Logf("restarted %s on %s", unit, node.Name) + } + }) + + ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) + defer cancel() + for _, unit := range isolatedUnits { + if _, err := fix.SSH.Run(ctx, node, "sudo systemctl stop "+unit); err != nil { + t.Fatalf("stop %s on %s: %v", unit, node.Name, err) + } + } + + harness.Step(t, "waiting %s for %s's volume lease to age out", leaseExpiryBudget, node.Name) + time.Sleep(leaseExpiryBudget) +} + +func errOrOK(err error) string { + if err == nil { + return "success" + } + return err.Error() +} + +// consoleTail returns the guest's serial console for a restart that never +// answered SSH, formatted to append to a failure message. Whether the kernel +// booted and stalled or never got that far is only visible here, and the log is +// gone once the fixture terminates the instance. +func consoleTail(fix *Fixture, instanceID string) string { + console, err := harness.InstanceConsole(fix.AWS, instanceID) + if err != nil { + return fmt.Sprintf("\nconsole unavailable: %v", err) + } + if strings.TrimSpace(console) == "" { + return "\nconsole was empty, so the guest produced no serial output at all" + } + const tailBytes = 4096 + if len(console) > tailBytes { + console = console[len(console)-tailBytes:] + } + return "\nguest console (last " + fmt.Sprint(len(console)) + " bytes):\n" + console +} + +// deviceGeometry reports what the guest kernel thinks the device is, for a +// pattern write or read that failed. An alignment or size mismatch is the usual +// cause of an EINVAL on an O_DIRECT transfer, and neither is visible in dd's +// own message. +func deviceGeometry(tgt harness.SSHTarget, dev string) string { + cmd := fmt.Sprintf( + "for p in getsize64 getss getpbsz getiomin getioopt; do "+ + "printf '%%s=%%s ' $p $(sudo blockdev --$p /dev/%[1]s 2>&1); done; echo; "+ + "dd --version | head -1; "+ + "echo -n 'direct 512: '; sudo dd if=/dev/%[1]s bs=512 count=1 iflag=direct of=/dev/null 2>&1 | tail -1; "+ + "echo -n 'direct 4k: '; sudo dd if=/dev/%[1]s bs=4k count=1 iflag=direct of=/dev/null 2>&1 | tail -1; "+ + "echo -n 'direct 1M: '; sudo dd if=/dev/%[1]s bs=1M count=1 iflag=direct of=/dev/null 2>&1 | tail -1; "+ + "echo -n 'buffered 1M: '; sudo dd if=/dev/%[1]s bs=1M count=1 of=/dev/null 2>&1 | tail -1", + dev) + out, err := harness.GuestExecTimeout(tgt, cmd, time.Minute) + if err != nil { + return fmt.Sprintf("device geometry unavailable: %v\n%s", err, out) + } + return "device geometry:\n" + out +} diff --git a/tests/e2e/storagegrowth/image_pull_test.go b/tests/e2e/storagegrowth/image_pull_test.go index 56a5d98df..f98a2277d 100644 --- a/tests/e2e/storagegrowth/image_pull_test.go +++ b/tests/e2e/storagegrowth/image_pull_test.go @@ -568,7 +568,7 @@ func ensureImagePullInstance(t *testing.T, fix *Fixture, launch func(ami, instTy t.Helper() instType, arch := imagePullInstanceType(t, fix) ami := harness.DiscoverUbuntuAMI(t, fix.Harness, arch) - keyName, keyPath := harness.EnsureKeyPair(t, fix.Harness, fix.ArtifactDir(t)) + keyName, keyPath := harness.EnsureKeyPair(t, fix.Harness) vpcID := harness.CreateVPC(t, fix.AWS, imagePullVPCCIDR) t.Cleanup(func() { harness.DeleteVPC(t, fix.AWS, vpcID) }) diff --git a/tests/e2e/storagegrowth/storage_growth_test.go b/tests/e2e/storagegrowth/storage_growth_test.go index 6158d26ab..6d9b57fca 100644 --- a/tests/e2e/storagegrowth/storage_growth_test.go +++ b/tests/e2e/storagegrowth/storage_growth_test.go @@ -390,7 +390,7 @@ func ensureWorkloadInstance(t *testing.T, fix *Fixture) (instanceID string, tgt t.Helper() instType, arch := harness.DiscoverNanoInstanceType(t, fix.Harness) ami := harness.DiscoverUbuntuAMI(t, fix.Harness, arch) - keyName, keyPath := harness.EnsureKeyPair(t, fix.Harness, fix.ArtifactDir(t)) + keyName, keyPath := harness.EnsureKeyPair(t, fix.Harness) vpc := harness.EnsureDefaultVPC(t, fix.Harness) harness.AuthorizeSSHIngress(t, fix.AWS, vpc.SGID)