feat(eks): control-plane disaster recovery — etcd snapshot + restore-snapshot (#503)

* fix(eks): write etcd-snapshot.env at CP launch so snapshots actually run

* test(eks): cover resolveSystemPredastoreURL and buildEKSServiceDeps

Diff-coverage gate for 852f61d9 was unmet (48.8% < 60%) because the
mgmt-bridge branches and EKSServiceDeps assembly had no direct test.

* feat(eks): restore-snapshot DR path (fresh CP + cluster-reset restore + NLB re-point)

* fix(eks): make restore-snapshot fail-safe (validate snapshot, unwind on partial failure, honest status)

* fix(eks): sign etcd-snapshot uploads with deployment region (was 403ing)

* fix(eks): apply recovery directive on DR-seed first boot + single-node replacement host

* fix(daemon): log NATS handler failures at Error (were only on the trace span)
This commit is contained in:
Josh Siviour
2026-07-10 22:14:58 +10:00
committed by GitHub
parent 6879a3425a
commit d663760dec
28 changed files with 1358 additions and 119 deletions
+13 -6
View File
@@ -156,9 +156,10 @@ func emitAddonsTSV(out io.Writer, body []byte) error {
// recoveryDirective mirrors the gateway's RecoveryDirective wire shape; duplicated
// here to keep this tiny VM binary free of the handler package's dependency graph.
type recoveryDirective struct {
Epoch int64 `json:"epoch"`
Action string `json:"action"`
Snapshot string `json:"snapshot"`
Epoch int64 `json:"epoch"`
Action string `json:"action"`
Snapshot string `json:"snapshot"`
SnapshotRequired bool `json:"snapshotRequired"`
}
type internalRecoveryResponse struct {
@@ -166,8 +167,10 @@ type internalRecoveryResponse struct {
}
// emitRecoveryTSV decodes the recovery directive and writes a single
// epoch\taction\tsnapshot line the on-VM k3s-recovery agent consumes without a
// JSON parser. An unset action defaults to "none" (steady state).
// epoch\taction\tsnapshot\tsnapshotRequired line the on-VM k3s-recovery agent
// consumes without a JSON parser. An unset action defaults to "none" (steady
// state); snapshotRequired is 1 when the snapshot MUST restore (fresh DR seed
// aborts boot on fetch failure) else 0.
func emitRecoveryTSV(out io.Writer, body []byte) error {
var resp internalRecoveryResponse
if err := json.Unmarshal(body, &resp); err != nil {
@@ -177,7 +180,11 @@ func emitRecoveryTSV(out io.Writer, body []byte) error {
if d.Action == "" {
d.Action = "none"
}
if _, err := fmt.Fprintf(out, "%d\t%s\t%s\n", d.Epoch, d.Action, d.Snapshot); err != nil {
required := "0"
if d.SnapshotRequired {
required = "1"
}
if _, err := fmt.Fprintf(out, "%d\t%s\t%s\t%s\n", d.Epoch, d.Action, d.Snapshot, required); err != nil {
return err
}
return nil
+77
View File
@@ -0,0 +1,77 @@
package cmd
import (
"context"
"fmt"
"os"
"time"
"github.com/mulgadc/spinifex/spinifex/admin"
handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
"github.com/spf13/cobra"
)
var adminEksCmd = &cobra.Command{
Use: "eks",
Short: "Manage EKS control-plane disaster recovery",
}
var adminEksRestoreSnapshotCmd = &cobra.Command{
Use: "restore-snapshot",
Short: "Rebuild a single-CP cluster's control plane from an etcd snapshot",
Long: `restore-snapshot drives the single-CP total-loss DR path: it launches a fresh
control-plane VM as a cluster-init seed, directs its boot-time recovery agent to
run k3s server --cluster-reset (restoring the given etcd snapshot, or the latest
one found in predastore when --snapshot is omitted), and re-points the cluster
NLB target groups at the new CP.
Only single-CP clusters are supported. An HA cluster has a potentially
surviving etcd quorum and should be recovered via quorum reformation instead
of this destructive single-node rebuild.`,
Run: runAdminEksRestoreSnapshot,
}
func init() {
adminCmd.AddCommand(adminEksCmd)
adminEksCmd.AddCommand(adminEksRestoreSnapshotCmd)
adminEksRestoreSnapshotCmd.Flags().String("cluster", "", "Cluster name to restore (required)")
adminEksRestoreSnapshotCmd.Flags().String("snapshot", "", "Etcd snapshot key to restore; defaults to the latest snapshot found in predastore")
adminEksRestoreSnapshotCmd.Flags().String("account", "", "Account ID that owns the cluster; defaults to the bootstrap account")
_ = adminEksRestoreSnapshotCmd.MarkFlagRequired("cluster")
}
func runAdminEksRestoreSnapshot(cmd *cobra.Command, _ []string) {
clusterName, _ := cmd.Flags().GetString("cluster")
snapshot, _ := cmd.Flags().GetString("snapshot")
accountID, _ := cmd.Flags().GetString("account")
if accountID == "" {
accountID = admin.DefaultAccountID()
}
_, nc, err := loadConfigAndConnect()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
defer nc.Close()
svc := handlers_eks.NewNATSEKSService(nc)
// The server side launches a VM (30-60s) then fences the old CP with retries,
// so the RPC needs generous headroom over a bare launch.
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second)
defer cancel()
fmt.Printf("Restoring control plane for cluster %q (account %s)...\n", clusterName, accountID)
out, err := svc.RestoreSnapshot(ctx, &handlers_eks.RestoreSnapshotInput{
ClusterName: clusterName,
Snapshot: snapshot,
}, accountID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Printf("New control plane %s launched (snapshot %s)\n", out.NewInstanceID, out.Snapshot)
fmt.Printf("Status: %s\n", out.Status)
}
+5
View File
@@ -171,6 +171,11 @@ read.
| `spx admin gpu mig enable` | `--profile <name>` (required, e.g. `1g.10gb`), `--gpu <pci-addr>` (optional, default: all MIG-capable GPUs) | Checks no GPU instances running (NATS) → discovers MIG-capable GPUs via `gpu.Discover()` (filtered by `--gpu` if set) → enables MIG mode on each target (`gpu.EnableMIGMode`) → lists available profiles (`gpu.ListProfiles`) and validates requested profile name → destroys any existing instances (`gpu.DestroyAllInstances`) → creates new instances filling GPU capacity (`gpu.CreateInstances`) → writes `mig_profile` to `spinifex.toml` via `admin.SetMIGProfile` → sends SIGHUP to `spinifex-daemon`. Must be run directly on the target host. |
| `spx admin gpu mig disable` | `--gpu <pci-addr>` (optional, default: all MIG-capable GPUs) | Checks no GPU instances running (NATS) → discovers MIG-capable GPUs via `gpu.Discover()` (filtered by `--gpu` if set) → destroys all GPU instances (`gpu.DestroyAllInstances`) → disables MIG mode (`gpu.DisableMIGMode`) → clears `mig_profile` in `spinifex.toml` via `admin.SetMIGProfile` → sends SIGHUP to `spinifex-daemon`. Must be run directly on the target host. |
### EKS Control-Plane Disaster Recovery
| Command | Flags | Description |
|---------|-------|-------------|
| `spx admin eks restore-snapshot` | `--cluster` (required), `--snapshot` (optional, defaults to the latest snapshot in predastore), `--account` (optional, defaults to the bootstrap account) | Single-CP total-loss DR path (fail-safe): validates the snapshot exists in predastore BEFORE any mutation (a typo'd/missing key hard-fails, never resets into an empty datastore) → launches a fresh control-plane VM as a cluster-init seed (replaying the persisted create-time launch template) → sets a required-snapshot `RecoveryDirective` (`cluster-reset`) so the boot-time recovery agent aborts rather than resets-into-empty if it cannot fetch the snapshot → persists the replacement in cluster meta BEFORE re-pointing the NLB (so an NLB failure is convergeable by the reconciler, returned as a provisional status, not a hard error) → re-points the cluster NLB's apiserver and konnectivity target groups from the old CP's ENI to the new one → fences the old CP with retries, failing loudly if it cannot be confirmed terminated (split-brain guard). Any failure before the meta commit unwinds the fresh CP (terminate + clear directive) so a re-run does not stack a second resetting control plane. The returned status is provisional — success means the sequence completed, not that etcd is restored and serving; verify cluster health. HA clusters (a spread with a potentially surviving quorum) are rejected — recover those via quorum reformation instead. |
## AWS-Compatible API
+20 -7
View File
@@ -48,14 +48,26 @@ if [ -z "${ROLE}" ] && [ -f "${AGENT_ENVFILE}" ]; then
ROLE="agent"
fi
case "${ROLE}" in
server | server-join | agent) ;;
*)
die "SPINIFEX_K3S_ROLE missing or invalid: '${ROLE}'"
;;
esac
# Written before the rc-service calls below: mulga-eks-k3s-recovery, started
# inline for server/server-join, reads ROLE_FILE at the top of its own logic
# and must find it already in place, not empty.
mkdir -p "$(dirname "${ROLE_FILE}")"
printf '%s\n' "${ROLE}" > "${ROLE_FILE}"
case "${ROLE}" in
server)
log "configuring server role"
rc-update add eks-token-webhook default
# Pre-k3s etcd recovery: added to the runlevel so it runs before k3s on
# every subsequent boot, but not started now — a fresh cluster has no
# directive and the role file it guards on is written at the end of this
# script. It first acts on the reboot the reconciler triggers.
# Pre-k3s etcd recovery: enabled for later boots and started inline
# now too, so a restore-snapshot DR seed's directive applies before
# k3s starts on this first boot. A plain create has no directive, so it's a no-op.
rc-update add mulga-eks-k3s-recovery default
rc-update add k3s default
# konnectivity-server fronts apiserver egress; every server replica runs
@@ -69,6 +81,7 @@ case "${ROLE}" in
# (mulga-siv-231.7); server-join nodes do not run it.
rc-update add mulga-eks-addon-sync default
rc-service eks-token-webhook start
rc-service mulga-eks-k3s-recovery start
rc-service k3s start
rc-service konnectivity-server start
rc-service k3s-first-boot start
@@ -79,12 +92,14 @@ case "${ROLE}" in
log "configuring server-join role"
rc-update add eks-token-webhook default
# Pre-k3s etcd recovery (see server role): server-join members are the
# wipe-rejoin followers of a cluster-reset, so they need it too.
# wipe-rejoin followers of a cluster-reset, so they need it too, and for
# the same first-boot reason must be started here, not deferred.
rc-update add mulga-eks-k3s-recovery default
rc-update add k3s default
rc-update add konnectivity-server default
rc-update add mulga-eks-state-report default
rc-service eks-token-webhook start
rc-service mulga-eks-k3s-recovery start
rc-service k3s start
rc-service konnectivity-server start
rc-service mulga-eks-state-report start
@@ -99,7 +114,5 @@ case "${ROLE}" in
;;
esac
mkdir -p "$(dirname "${ROLE_FILE}")"
printf '%s\n' "${ROLE}" > "${ROLE_FILE}"
rc-update del eks-node-role default 2>/dev/null || true
log "role '${ROLE}' configured"
@@ -65,8 +65,11 @@ check "server: enables k3s-first-boot" "yes" "$(grep -q 'rc-update add k3s-first
check "server: starts k3s" "yes" "$(grep -q 'rc-service k3s start' "${CALLS}" && echo yes || echo no)"
check "server: enables konnectivity-server" "yes" "$(grep -q 'rc-update add konnectivity-server default' "${CALLS}" && echo yes || echo no)"
check "server: enables k3s-recovery" "yes" "$(grep -q 'rc-update add mulga-eks-k3s-recovery default' "${CALLS}" && echo yes || echo no)"
# Recovery is added to the runlevel (pre-k3s on later boots) but not started now.
check "server: does not start k3s-recovery" "no" "$(grep -q 'rc-service mulga-eks-k3s-recovery' "${CALLS}" && echo yes || echo no)"
# Recovery is added to the runlevel (so it also runs pre-k3s on later boots)
# AND started inline now — a restore-snapshot DR seed's directive must apply
# on this very first boot, before k3s starts.
check "server: starts k3s-recovery" "yes" "$(grep -q 'rc-service mulga-eks-k3s-recovery start' "${CALLS}" && echo yes || echo no)"
check "server: starts k3s-recovery before k3s" "yes" "$(awk '/rc-service mulga-eks-k3s-recovery start/{r=NR} /rc-service k3s start/{k=NR} END{print (r>0 && k>0 && r<k) ? "yes" : "no"}' "${CALLS}")"
check "server: no k3s-agent" "no" "$(grep -q 'k3s-agent' "${CALLS}" && echo yes || echo no)"
check "server: self-disables" "yes" "$(grep -q 'rc-update del eks-node-role default' "${CALLS}" && echo yes || echo no)"
@@ -81,6 +84,7 @@ check "server-join: enables k3s" "yes" "$(grep -q 'rc-update add k3s default' "$
check "server-join: enables state-report" "yes" "$(grep -q 'rc-update add mulga-eks-state-report default' "${CALLS}" && echo yes || echo no)"
check "server-join: enables konnectivity-server" "yes" "$(grep -q 'rc-update add konnectivity-server default' "${CALLS}" && echo yes || echo no)"
check "server-join: enables k3s-recovery" "yes" "$(grep -q 'rc-update add mulga-eks-k3s-recovery default' "${CALLS}" && echo yes || echo no)"
check "server-join: starts k3s-recovery" "yes" "$(grep -q 'rc-service mulga-eks-k3s-recovery start' "${CALLS}" && echo yes || echo no)"
# Join servers must NOT re-publish bootstrap; the first server already did.
check "server-join: no k3s-first-boot" "no" "$(grep -q 'k3s-first-boot' "${CALLS}" && echo yes || echo no)"
check "server-join: no k3s-agent" "no" "$(grep -q 'k3s-agent' "${CALLS}" && echo yes || echo no)"
+10
View File
@@ -28,6 +28,16 @@ depend() {
}
start_pre() {
# mulga-eks-k3s-recovery drops this marker when a required-snapshot restore
# could not fetch its etcd snapshot. Refuse to start: a fresh DR seed with no
# snapshot would cluster-init an EMPTY datastore (silent total data loss). The
# marker is in /run tmpfs, cleared on reboot, so recovery retries the fetch
# next boot and k3s starts once the snapshot restores.
if [ -f /run/spinifex-eks/k3s-restore-block ]; then
eerror "restore-snapshot could not fetch the required etcd snapshot; refusing to start k3s on an empty datastore (see mulga-eks-k3s-recovery log)"
return 1
fi
# config.yaml is written by k3s-first-boot from the skeleton + cloud-init
# data. Refuse to start if neither exists.
if [ ! -f /etc/rancher/k3s/config.yaml ]; then
@@ -66,14 +66,24 @@ fi
#
# Note: --aws-sigv4 requires curl >= 7.75 (Alpine 3.21 ships 8.x).
URL="${SPINIFEX_PREDASTORE_ENDPOINT%/}/${BUCKET}/${KEY}"
curl -fsSL \
# Capture curl's own exit: piping straight into logger masks it behind logger's
# (near-always 0), so a failed upload would log "uploaded" and delete the only
# copy — snapshots would silently never land. Keep the local snapshot and exit
# non-zero on failure so cron surfaces it and the next run still has data on disk.
if UPLOAD_OUT=$(curl -fsSL \
--aws-sigv4 "aws:amz:${AWS_REGION:-au-mel-1}:s3" \
--user "${SPINIFEX_PREDASTORE_AKID}:${SPINIFEX_PREDASTORE_SECRET}" \
-H "x-amz-content-sha256: UNSIGNED-PAYLOAD" \
--upload-file "${SNAPSHOT_FILE}" \
"${URL}" 2>&1 | logger -t mulga-eks-etcd-snapshot
# Remove the local snapshot to bound disk usage; the canonical copy lives in
# predastore. Restore path (`spx admin eks restore-snapshot`) pulls from there.
rm -f "${SNAPSHOT_FILE}"
logger -t mulga-eks-etcd-snapshot "uploaded ${KEY}"
"${URL}" 2>&1); then
[ -n "${UPLOAD_OUT}" ] && printf '%s\n' "${UPLOAD_OUT}" | logger -t mulga-eks-etcd-snapshot
# Remove the local snapshot only after a confirmed upload to bound disk usage;
# the canonical copy lives in predastore (`spx admin eks restore-snapshot`).
rm -f "${SNAPSHOT_FILE}"
logger -t mulga-eks-etcd-snapshot "uploaded ${KEY}"
else
UPLOAD_RC=$?
[ -n "${UPLOAD_OUT}" ] && printf '%s\n' "${UPLOAD_OUT}" | logger -t mulga-eks-etcd-snapshot
logger -t mulga-eks-etcd-snapshot "ERROR: upload of ${KEY} failed (curl rc=${UPLOAD_RC}); keeping local snapshot ${SNAPSHOT_FILE}"
exit 1
fi
@@ -29,10 +29,13 @@ done
:
EOF
# curl stub: record the full argument line (carries --upload-file + URL).
# curl stub: record the full argument line (carries --upload-file + URL). Exits
# non-zero when CURL_FAIL=1 so the harness can exercise the failed-upload path.
cat > "${STUBBIN}/curl" <<'EOF'
#!/bin/sh
echo "curl $*" >> "${CURL_CALLS}"
[ "${CURL_FAIL:-0}" = "1" ] && { echo "curl: (7) connection refused" >&2; exit 7; }
exit 0
EOF
# logger stub: swallow.
@@ -102,6 +105,24 @@ run server "${WORK}/15min/mulga-eks-etcd-snapshot"
[ -s "${WORK}/curl.calls" ] && fail "no-env: must not upload" || pass "no-env: no upload without creds"
ENVFILE="${ENVFILE_SAVE}"
# --- Case 5: upload FAILS -> non-zero exit + local snapshot KEPT (not masked) ---
# Set up like run() but expect a non-zero exit and assert the snapshot survives.
K3S_CALLS="${WORK}/k3s.calls"; CURL_CALLS="${WORK}/curl.calls"
SNAPSHOT_DIR="${WORK}/snapshots"; ROLE_FILE="${WORK}/role"; K3S_BIN=k3s
: > "${K3S_CALLS}"; : > "${CURL_CALLS}"; rm -rf "${SNAPSHOT_DIR}"
printf '%s' server > "${ROLE_FILE}"
export K3S_CALLS CURL_CALLS SNAPSHOT_DIR ROLE_FILE K3S_BIN ENVFILE
export TIER=frequent CURL_FAIL=1
if sh "${WORK}/15min/mulga-eks-etcd-snapshot" </dev/null; then
fail "upload-fail: must exit non-zero when curl fails"
else
pass "upload-fail: non-zero exit surfaces the failure"
fi
ls "${SNAPSHOT_DIR}"/etcd-frequent-* >/dev/null 2>&1 \
&& pass "upload-fail: local snapshot kept for the next run" \
|| fail "upload-fail: local snapshot deleted despite failed upload"
unset TIER CURL_FAIL
if [ "${FAILS}" -eq 0 ]; then
echo "PASS: all mulga-eks-etcd-snapshot cases"
exit 0
@@ -7,10 +7,10 @@ set -eu
# but embedded etcd never reformed quorum after a simultaneous restart).
#
# The reconciler cannot push into the guest, so recovery is pull-based: this
# fetches a per-member directive epoch<TAB>action<TAB>snapshot through the AWS
# gateway (eks-gateway-fetch -resource recovery), epoch-guarded so a directive
# applies exactly once. Ordered `before k3s` so the etcd datastore is mutated
# while the apiserver is stopped. Actions:
# fetches a per-member directive epoch<TAB>action<TAB>snapshot<TAB>required
# through the AWS gateway (eks-gateway-fetch -resource recovery), epoch-guarded
# so a directive applies exactly once. Ordered `before k3s` so the etcd datastore
# is mutated while the apiserver is stopped. Actions:
# none steady state — nothing to do
# cluster-reset seed member: `k3s server --cluster-reset` reforms a
# single-member etcd from this node's intact data (optionally
@@ -18,6 +18,13 @@ set -eu
# wipe-rejoin follower: delete the local etcd datastore so k3s re-joins the
# reset seed's quorum fresh on the next k3s start
#
# required=1 marks a snapshot that MUST restore (fresh DR seed via
# `spx admin eks restore-snapshot`): a fresh seed has no intact local etcd, so a
# cluster-reset without the snapshot would boot an EMPTY cluster. On a fetch
# failure this drops BLOCK_MARKER and aborts boot; k3s.initd refuses to start on
# the marker, so k3s never resets into an empty datastore. The fetch is retried
# on the next boot (BLOCK_MARKER lives in /run tmpfs, cleared on reboot).
#
# Env knobs default to production paths; overridable so the logic is unit-testable
# without root, IMDS, a gateway, or a real k3s.
@@ -25,6 +32,7 @@ ROLE_FILE=${ROLE_FILE:-/etc/spinifex-eks/role}
ENVFILE=${ENVFILE:-/etc/spinifex-eks/first-boot.env}
SNAPSHOT_ENVFILE=${SNAPSHOT_ENVFILE:-/etc/spinifex-eks/etcd-snapshot.env}
EPOCH_FILE=${EPOCH_FILE:-/var/lib/spinifex-eks/recovery.epoch}
BLOCK_MARKER=${BLOCK_MARKER:-/run/spinifex-eks/k3s-restore-block}
K3S_BIN=${K3S_BIN:-/usr/local/bin/k3s}
K3S_CONFIG=${K3S_CONFIG:-/etc/rancher/k3s/config.yaml}
ETCD_DIR=${ETCD_DIR:-/var/lib/rancher/k3s/server/db/etcd}
@@ -72,12 +80,26 @@ fetch_snapshot() {
[ -n "${SPINIFEX_PREDASTORE_SECRET:-}" ] || return 1
_url="${SPINIFEX_PREDASTORE_ENDPOINT%/}/eks-backups-system/${EKS_ACCOUNT_ID}/${EKS_CLUSTER_NAME}/${_name}"
mkdir -p "${SNAPSHOT_DIR}"
curl -fsSL \
# Capture curl's own exit — piping straight into logger would mask it behind
# logger's (near-always 0), so a failed download would look like success and
# the caller would wrongly cluster-reset from local etcd.
if _out=$(curl -fsSL \
--aws-sigv4 "aws:amz:${AWS_REGION:-au-mel-1}:s3" \
--user "${SPINIFEX_PREDASTORE_AKID}:${SPINIFEX_PREDASTORE_SECRET}" \
-H "x-amz-content-sha256: UNSIGNED-PAYLOAD" \
-o "${SNAPSHOT_DIR}/${_name}" \
"${_url}" 2>&1 | logger -t "${LOGTAG}"
"${_url}" 2>&1); then
[ -n "${_out}" ] && printf '%s\n' "${_out}" | logger -t "${LOGTAG}"
return 0
else
# Capture rc as the first else statement: $? read after `fi` would be the
# if's own 0, re-masking the failure we are here to surface.
_rc=$?
[ -n "${_out}" ] && printf '%s\n' "${_out}" | logger -t "${LOGTAG}"
rm -f "${SNAPSHOT_DIR}/${_name}"
log "snapshot ${_name} download failed (curl rc=${_rc})"
return 1
fi
}
# Instance ID from IMDS keys the per-member directive. IMDSv2 token if enforced,
@@ -109,8 +131,11 @@ fi
epoch=$(printf '%s' "${line}" | cut -f1)
action=$(printf '%s' "${line}" | cut -f2)
snapshot=$(printf '%s' "${line}" | cut -f3)
snapshot_required=$(printf '%s' "${line}" | cut -f4)
[ -n "${action}" ] || action=none
case "${epoch}" in '' | *[!0-9]*) epoch=0 ;; esac
# Absent 4th field (older gateway) reads as not-required, preserving HA-reform semantics.
case "${snapshot_required}" in 1) snapshot_required=1 ;; *) snapshot_required=0 ;; esac
if [ "${action}" = "none" ]; then
log "directive action=none (epoch ${epoch}); nothing to do"
@@ -136,6 +161,13 @@ case "${action}" in
if fetch_snapshot "${snapshot}"; then
restore_arg="--cluster-reset-restore-path=${SNAPSHOT_DIR}/${snapshot}"
log "cluster-reset restoring snapshot ${snapshot}"
elif [ "${snapshot_required}" = "1" ]; then
# Fresh DR seed: no intact local etcd, so a reset without the snapshot
# would boot an EMPTY cluster. Block k3s and abort; retry next boot.
log "ERROR: required snapshot ${snapshot} could not be fetched; aborting boot to avoid resetting into an EMPTY datastore (retries next boot)"
mkdir -p "$(dirname "${BLOCK_MARKER}")"
: > "${BLOCK_MARKER}"
exit 1
else
log "WARN: snapshot ${snapshot} fetch failed; cluster-reset from local etcd only"
fi
@@ -24,7 +24,9 @@ printf '%s\n' "${DIRECTIVE_LINE}"
EOF
# curl stub: answers the IMDS token PUT + instance-id GET, and materialises a
# snapshot file for any `-o <path>` download. Records every call.
# snapshot file for any `-o <path>` download. Records every call. A snapshot
# download (an `-o` arg) fails with a non-zero exit when CURL_SNAP_FAIL=1, so the
# harness can exercise the fetch-failure path.
cat > "${STUBBIN}/curl" <<'EOF'
#!/bin/sh
echo "curl $*" >> "${CURL_CALLS}"
@@ -36,7 +38,10 @@ for a in "$@"; do
done
prev=""
for a in "$@"; do
[ "$prev" = "-o" ] && echo snapdata > "$a"
if [ "$prev" = "-o" ]; then
[ "${CURL_SNAP_FAIL:-0}" = "1" ] && { echo "curl: (22) 404" >&2; exit 22; }
echo snapdata > "$a"
fi
prev="$a"
done
exit 0
@@ -80,7 +85,7 @@ pass() { echo "ok: $*"; }
# run <role> <directive-line>: reset capture files + state, then run the script
# with every path knob pointed into the temp dir.
run() {
_setup_run() {
_role=$1
DIRECTIVE_LINE=$2
K3S_CALLS="${WORK}/k3s.calls"
@@ -89,13 +94,29 @@ run() {
EPOCH_FILE="${WORK}/recovery.epoch"
ETCD_DIR="${WORK}/etcd"
SNAPSHOT_DIR="${WORK}/snapshots"
BLOCK_MARKER="${WORK}/run/k3s-restore-block"
: > "${K3S_CALLS}"
: > "${CURL_CALLS}"
rm -f "${BLOCK_MARKER}"
printf '%s' "${_role}" > "${ROLE_FILE}"
export DIRECTIVE_LINE K3S_CALLS CURL_CALLS ROLE_FILE EPOCH_FILE ETCD_DIR SNAPSHOT_DIR
export DIRECTIVE_LINE K3S_CALLS CURL_CALLS ROLE_FILE EPOCH_FILE ETCD_DIR SNAPSHOT_DIR BLOCK_MARKER
export ENVFILE SNAPSHOT_ENVFILE
export K3S_BIN=k3s FETCH_BIN=eks-gateway-fetch
sh "${SCRIPT}" </dev/null || fail "role=${_role}: non-zero exit"
}
# run <role> <directive-line>: expects a zero exit (the steady-state path).
run() {
_setup_run "$1" "$2"
sh "${SCRIPT}" </dev/null || fail "role=${1}: non-zero exit"
}
# run_abort <role> <directive-line>: expects a NON-zero exit (the required-snapshot
# fetch-failure abort). CURL_SNAP_FAIL must be set by the caller.
run_abort() {
_setup_run "$1" "$2"
if sh "${SCRIPT}" </dev/null; then
fail "role=${1}: expected non-zero exit (required-snapshot abort)"
fi
}
TAB=$(printf '\t')
@@ -149,6 +170,36 @@ grep -q 'aws-sigv4' "${WORK}/curl.calls" \
[ -f "${WORK}/snapshots/etcd-daily-20260706T000000Z.snap" ] \
&& pass "snapshot: fetched into snapshot dir" || fail "snapshot: file not materialised"
# --- Case 7: REQUIRED snapshot, fetch OK -> restore-path + no block marker ---
rm -f "${WORK}/recovery.epoch"
CURL_SNAP_FAIL=0; export CURL_SNAP_FAIL
run server "5${TAB}cluster-reset${TAB}etcd-frequent-20260709T010000Z.snap${TAB}1"
grep -q 'cluster-reset-restore-path=.*etcd-frequent-20260709T010000Z.snap' "${WORK}/k3s.calls" \
&& pass "required-ok: restore-path passed" || fail "required-ok: no restore-path: $(cat "${WORK}/k3s.calls")"
[ -f "${WORK}/run/k3s-restore-block" ] && fail "required-ok: must not block k3s on success" || pass "required-ok: no block marker"
# --- Case 8: REQUIRED snapshot, fetch FAILS -> abort: block marker, no k3s, no epoch ---
rm -f "${WORK}/recovery.epoch"
CURL_SNAP_FAIL=1; export CURL_SNAP_FAIL
run_abort server "6${TAB}cluster-reset${TAB}etcd-frequent-20260709T010000Z.snap${TAB}1"
[ -f "${WORK}/run/k3s-restore-block" ] \
&& pass "required-fail: block marker dropped to stop k3s" || fail "required-fail: no block marker"
[ -s "${WORK}/k3s.calls" ] && fail "required-fail: must NOT cluster-reset into empty datastore" || pass "required-fail: k3s not reset"
[ -f "${WORK}/recovery.epoch" ] && fail "required-fail: epoch must not record (retry next boot)" || pass "required-fail: epoch not recorded"
# --- Case 9: NON-required snapshot, fetch FAILS -> local reset, no block, epoch recorded ---
rm -f "${WORK}/recovery.epoch"
CURL_SNAP_FAIL=1; export CURL_SNAP_FAIL
run server "7${TAB}cluster-reset${TAB}etcd-frequent-20260709T010000Z.snap${TAB}0"
grep -q 'server --config .* --cluster-reset' "${WORK}/k3s.calls" \
&& pass "optional-fail: falls back to local cluster-reset" || fail "optional-fail: no reset: $(cat "${WORK}/k3s.calls")"
grep -q 'cluster-reset-restore-path' "${WORK}/k3s.calls" \
&& fail "optional-fail: must not pass a restore-path for a failed fetch" || pass "optional-fail: local-data reset only"
[ -f "${WORK}/run/k3s-restore-block" ] && fail "optional-fail: must not block k3s (HA-reform semantics)" || pass "optional-fail: no block marker"
[ "$(cat "${WORK}/recovery.epoch" 2>/dev/null)" = 7 ] \
&& pass "optional-fail: epoch recorded" || fail "optional-fail: epoch not recorded"
CURL_SNAP_FAIL=0; export CURL_SNAP_FAIL
if [ "${FAILS}" -eq 0 ]; then
echo "PASS: all mulga-eks-k3s-recovery cases"
exit 0
+1
View File
@@ -975,6 +975,7 @@ func (d *Daemon) subscribeAll() error {
natsSub{"eks.ListStagedAddonManifests", d.handleEKSListStagedAddonManifests, "spinifex-workers"},
natsSub{"eks.GetRecoveryDirective", d.handleEKSGetRecoveryDirective, "spinifex-workers"},
natsSub{"eks.SetRecoveryDirective", d.handleEKSSetRecoveryDirective, "spinifex-workers"},
natsSub{"eks.RestoreSnapshot", d.handleEKSRestoreSnapshot, "spinifex-workers"},
natsSub{"eks.AssociateIdentityProviderConfig", d.handleEKSAssociateIdentityProviderConfig, "spinifex-workers"},
natsSub{"eks.DescribeIdentityProviderConfig", d.handleEKSDescribeIdentityProviderConfig, "spinifex-workers"},
natsSub{"eks.ListIdentityProviderConfigs", d.handleEKSListIdentityProviderConfigs, "spinifex-workers"},
+4
View File
@@ -84,6 +84,10 @@ func handleNATSRequest[I any, O any](msg *nats.Msg, serviceFn func(context.Conte
}
output, err := serviceFn(ctx, input, accountID)
if err != nil {
// The error was otherwise only recorded on the OTel span, invisible
// without a trace backend. Log at Error so handler failures show up
// in the journal too.
slog.ErrorContext(ctx, "handleNATSRequest: service call failed", "subject", msg.Subject, "err", err)
utils.MarkSpanError(span, err)
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
return
+4
View File
@@ -130,6 +130,10 @@ func (d *Daemon) handleEKSSetRecoveryDirective(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.SetRecoveryDirective)
}
func (d *Daemon) handleEKSRestoreSnapshot(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.RestoreSnapshot)
}
// --- OIDC identity-provider configs ---
func (d *Daemon) handleEKSAssociateIdentityProviderConfig(msg *nats.Msg) {
+54 -27
View File
@@ -8,6 +8,7 @@ import (
handlers_ec2_placementgroup "github.com/mulgadc/spinifex/spinifex/handlers/ec2/placementgroup"
handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
"github.com/mulgadc/spinifex/spinifex/objectstore"
"log/slog"
)
@@ -74,33 +75,37 @@ func (d *Daemon) buildEKSServiceDeps() handlers_eks.EKSServiceDeps {
}
deps := handlers_eks.EKSServiceDeps{
Config: d.config,
NATSConn: d.natsConn,
MasterKey: masterKey,
GatewayBaseURL: d.resolveGatewayBaseURL(),
Region: d.config.Region,
HolderID: d.node,
ClusterSize: clusterSize,
InternalSuffix: internalSuffix,
SystemGatewayURL: d.resolveSystemGatewayBaseURL(),
SystemAccessKey: d.config.Predastore.AccessKey,
SystemSecretKey: d.config.Predastore.SecretKey,
GatewayCACert: gatewayCA,
VPCSG: d.vpcService,
VPCK3s: d.vpcService,
VPCSubnet: &daemonEKSSubnetResolver{d: d},
NLB: d.elbv2Service,
Instance: d,
Image: d.imageService,
EIP: d.eipService,
IGW: d.igwService,
Worker: d,
VPCMgr: d.vpcService,
NATGW: d.natGatewayService,
RouteTable: d.routeTableService,
PlacementGroup: handlers_ec2_placementgroup.NewNATSPlacementGroupService(d.natsConn),
Scheduler: handlers_eks.NewNATSHostScheduler(d.natsConn),
CPControl: d.newEKSCPControl(),
Config: d.config,
NATSConn: d.natsConn,
MasterKey: masterKey,
GatewayBaseURL: d.resolveGatewayBaseURL(),
Region: d.config.Region,
HolderID: d.node,
ClusterSize: clusterSize,
InternalSuffix: internalSuffix,
SystemGatewayURL: d.resolveSystemGatewayBaseURL(),
SystemAccessKey: d.config.Predastore.AccessKey,
SystemSecretKey: d.config.Predastore.SecretKey,
GatewayCACert: gatewayCA,
SystemPredastoreURL: d.resolveSystemPredastoreURL(),
SnapshotStore: objectstore.NewS3ObjectStoreFromConfig(
d.config.Predastore.Host, d.config.Predastore.Region,
d.config.Predastore.AccessKey, d.config.Predastore.SecretKey),
VPCSG: d.vpcService,
VPCK3s: d.vpcService,
VPCSubnet: &daemonEKSSubnetResolver{d: d},
NLB: d.elbv2Service,
Instance: d,
Image: d.imageService,
EIP: d.eipService,
IGW: d.igwService,
Worker: d,
VPCMgr: d.vpcService,
NATGW: d.natGatewayService,
RouteTable: d.routeTableService,
PlacementGroup: handlers_ec2_placementgroup.NewNATSPlacementGroupService(d.natsConn),
Scheduler: handlers_eks.NewNATSHostScheduler(d.natsConn),
CPControl: d.newEKSCPControl(),
}
// A KV-backed IAM service (sharing the gateway's buckets over NATS) lets EKS
@@ -176,3 +181,25 @@ func (d *Daemon) resolveSystemGatewayBaseURL() string {
}
return "https://" + net.JoinHostPort(d.mgmtBridgeIP, gatewayPort)
}
// resolveSystemPredastoreURL derives the predastore URL a system instance (the
// K3s server VM) fetches/PUTs etcd snapshots to. Same mgmt-bridge-only
// reachability constraint as resolveSystemGatewayBaseURL: config.Predastore.Host
// is rewritten to 127.0.0.1 for the local daemon, which a guest cannot dial, so
// the bridge IP is substituted with the configured port. Falls back to the
// configured Predastore.Host (scheme-prefixed) when no mgmt bridge exists.
func (d *Daemon) resolveSystemPredastoreURL() string {
predastorePort := "8443"
if d.config.Predastore.Host != "" {
if _, port, splitErr := net.SplitHostPort(d.config.Predastore.Host); splitErr == nil && port != "" {
predastorePort = port
}
}
if d.mgmtBridgeIP == "" {
if d.config.Predastore.Host == "" {
return ""
}
return "https://" + d.config.Predastore.Host
}
return "https://" + net.JoinHostPort(d.mgmtBridgeIP, predastorePort)
}
+71
View File
@@ -84,3 +84,74 @@ func TestResolveGatewayHost(t *testing.T) {
})
}
}
// TestResolveSystemPredastoreURL covers the mgmt-bridge-present,
// mgmt-bridge-absent, and no-config branches: a guest system VM (the K3s
// server) can only reach predastore via the mgmt bridge, so the local
// loopback-rewritten Predastore.Host must never leak into the URL handed to
// a guest.
func TestResolveSystemPredastoreURL(t *testing.T) {
tests := []struct {
name string
predastoreHost string
mgmtBridge string
want string
}{
{
name: "mgmt_bridge_present_uses_bridge_ip_and_configured_port",
predastoreHost: "127.0.0.1:9443",
mgmtBridge: "10.15.8.1",
want: "https://10.15.8.1:9443",
},
{
name: "mgmt_bridge_present_defaults_port_when_host_has_none",
predastoreHost: "predastore-host-no-port",
mgmtBridge: "10.15.8.1",
want: "https://10.15.8.1:8443",
},
{
name: "no_mgmt_bridge_falls_back_to_configured_host",
predastoreHost: "predastore.internal:8443",
want: "https://predastore.internal:8443",
},
{
name: "no_mgmt_bridge_no_host_returns_empty",
want: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
d := &Daemon{
mgmtBridgeIP: tc.mgmtBridge,
config: &config.Config{
Predastore: config.PredastoreConfig{Host: tc.predastoreHost},
},
}
assert.Equal(t, tc.want, d.resolveSystemPredastoreURL())
})
}
}
// TestBuildEKSServiceDeps pins that the mgmt-bridge-derived predastore URL and
// a non-nil snapshot object store actually reach EKSServiceDeps — RestoreSnapshot
// silently can't resolve "latest snapshot" without SnapshotStore, and CP launch
// silently can't write etcd-snapshot.env without SystemPredastoreURL.
func TestBuildEKSServiceDeps(t *testing.T) {
d := &Daemon{
mgmtBridgeIP: "10.15.8.1",
config: &config.Config{
Predastore: config.PredastoreConfig{
Host: "127.0.0.1:8443",
AccessKey: "AKIAPREDASTORE",
SecretKey: "pred-s3cr3t",
Region: "us-east-1",
},
},
}
deps := d.buildEKSServiceDeps()
assert.Equal(t, "https://10.15.8.1:8443", deps.SystemPredastoreURL)
assert.NotNil(t, deps.SnapshotStore)
}
+3 -3
View File
@@ -684,7 +684,7 @@ func (r *ClusterReconciler) maybeReformEtcdQuorum(ctx context.Context, meta *Clu
// consuming an attempt (stopping without the cluster-reset directive would just
// reproduce the wedge); a follower failure logs and continues (that member stays
// wedged, retried next attempt).
if _, err := StoreRecoveryDirective(r.acctKV, r.clusterName, seed.InstanceID, RecoveryActionClusterReset, ""); err != nil {
if _, err := StoreRecoveryDirective(r.acctKV, r.clusterName, seed.InstanceID, RecoveryActionClusterReset, "", false); err != nil {
slog.Warn("ClusterReconciler: set cluster-reset directive failed",
"cluster", r.clusterName, "seed", seed.InstanceID, "err", err)
return
@@ -692,7 +692,7 @@ func (r *ClusterReconciler) maybeReformEtcdQuorum(ctx context.Context, meta *Clu
r.resetAttempts++
r.resetIssued = true
for _, n := range members[1:] {
if _, err := StoreRecoveryDirective(r.acctKV, r.clusterName, n.InstanceID, RecoveryActionWipeRejoin, ""); err != nil {
if _, err := StoreRecoveryDirective(r.acctKV, r.clusterName, n.InstanceID, RecoveryActionWipeRejoin, "", false); err != nil {
slog.Warn("ClusterReconciler: set wipe-rejoin directive failed",
"cluster", r.clusterName, "instanceId", n.InstanceID, "err", err)
}
@@ -727,7 +727,7 @@ func (r *ClusterReconciler) maybeReformEtcdQuorum(ctx context.Context, meta *Clu
// still prevents re-application of the already-applied directive.
func (r *ClusterReconciler) clearRecoveryDirectives(meta *ClusterMeta) {
for _, n := range cpMemberNodes(meta) {
if _, err := StoreRecoveryDirective(r.acctKV, r.clusterName, n.InstanceID, RecoveryActionNone, ""); err != nil {
if _, err := StoreRecoveryDirective(r.acctKV, r.clusterName, n.InstanceID, RecoveryActionNone, "", false); err != nil {
slog.Warn("ClusterReconciler: clear recovery directive failed",
"cluster", r.clusterName, "instanceId", n.InstanceID, "err", err)
}
+25 -24
View File
@@ -73,30 +73,31 @@ func newEKSServiceFixture(t *testing.T) *eksServiceFixture {
rt := newFakeRouteTableProvisioner()
svc, err := NewEKSServiceImpl(EKSServiceDeps{
NATSConn: nc,
MasterKey: bootstrapTestMasterKey,
GatewayBaseURL: "https://gw.local:9999",
Region: "us-east-1",
HolderID: "node-1",
SystemGatewayURL: "https://gw.local:9999",
SystemAccessKey: "AKIAEXAMPLE",
SystemSecretKey: "s3cr3t-key",
GatewayCACert: "-----BEGIN CERTIFICATE-----\nFAKECA\n-----END CERTIFICATE-----\n",
VPCSG: sg,
VPCK3s: vpc,
VPCSubnet: fakeSubnetResolver{},
IAM: newFakeEnsurer(),
NLB: nlb,
Instance: inst,
Image: ami,
EIP: eip,
Worker: worker,
VPCMgr: vpcMgr,
IGW: igw,
NATGW: ngw,
RouteTable: rt,
PlacementGroup: &fakePlacer{},
Scheduler: &fakeHostScheduler{},
NATSConn: nc,
MasterKey: bootstrapTestMasterKey,
GatewayBaseURL: "https://gw.local:9999",
Region: "us-east-1",
HolderID: "node-1",
SystemGatewayURL: "https://gw.local:9999",
SystemAccessKey: "AKIAEXAMPLE",
SystemSecretKey: "s3cr3t-key",
SystemPredastoreURL: "https://10.15.8.1:8443",
GatewayCACert: "-----BEGIN CERTIFICATE-----\nFAKECA\n-----END CERTIFICATE-----\n",
VPCSG: sg,
VPCK3s: vpc,
VPCSubnet: fakeSubnetResolver{},
IAM: newFakeEnsurer(),
NLB: nlb,
Instance: inst,
Image: ami,
EIP: eip,
Worker: worker,
VPCMgr: vpcMgr,
IGW: igw,
NATGW: ngw,
RouteTable: rt,
PlacementGroup: &fakePlacer{},
Scheduler: &fakeHostScheduler{},
})
require.NoError(t, err)
// Tight Ready-gating knobs so nodegroup launches resolve in tests without the
+69 -1
View File
@@ -162,6 +162,8 @@ func (s *EKSServiceImpl) ProvisionReplacementCP(ctx context.Context, req Replace
in.AccessKey = s.deps.SystemAccessKey
in.SecretKey = s.deps.SystemSecretKey
}
in.PredastoreAccessKey = s.deps.SystemAccessKey
in.PredastoreSecretKey = s.deps.SystemSecretKey
instanceType := in.InstanceType
if instanceType == "" {
@@ -182,6 +184,65 @@ func (s *EKSServiceImpl) ProvisionReplacementCP(ctx context.Context, req Replace
return controlPlaneNode(target, out), nil
}
// FreshCPRequest carries everything ProvisionFreshControlPlane needs to launch a
// brand-new single-node control plane from a persisted template — the no-survivor
// case, where there is no live quorum member to join.
type FreshCPRequest struct {
AccountID string
ClusterName string
Template *K3sServerInput
ExcludeHosts []string
}
// ProvisionFreshControlPlane launches a single control-plane VM as a fresh
// cluster-init seed (ServerURL empty), replaying the create-time template with
// rotating creds re-derived. It is the single-node analogue of
// ProvisionReplacementCP for the case where the entire control plane (VM +
// volume) is gone and there is no surviving etcd quorum to join — the caller
// (restore-snapshot) drives the etcd restore via a RecoveryDirective set after
// launch, not via any state this function knows about.
func (s *EKSServiceImpl) ProvisionFreshControlPlane(ctx context.Context, req FreshCPRequest) (ControlPlaneNode, error) {
if req.Template == nil {
return ControlPlaneNode{}, errors.New("eks: ProvisionFreshControlPlane nil template")
}
in := *req.Template
in.ServerURL = ""
in.KonnServerCount = 1
in.PrunePeerIP = ""
sysAcct := admin.SystemAccountID()
in.IamInstanceProfileArn = ""
in.AccessKey = ""
in.SecretKey = ""
if profileARN := s.ensureCPInstanceProfile(sysAcct); profileARN != "" {
in.IamInstanceProfileArn = profileARN
} else {
in.AccessKey = s.deps.SystemAccessKey
in.SecretKey = s.deps.SystemSecretKey
}
in.PredastoreAccessKey = s.deps.SystemAccessKey
in.PredastoreSecretKey = s.deps.SystemSecretKey
instanceType := in.InstanceType
if instanceType == "" {
instanceType = defaultK3sServerInstanceType
}
target, err := s.pickReplacementHost(ctx, instanceType, req.ExcludeHosts)
if err != nil {
return ControlPlaneNode{}, err
}
in.TargetNodeID = target
out, err := LaunchK3sServerVM(ctx, s.deps.VPCK3s, s.deps.Instance, s.deps.Image, in)
if err != nil {
return ControlPlaneNode{}, err
}
slog.Info("ProvisionFreshControlPlane: fresh control plane launched",
"cluster", req.ClusterName, "instanceId", out.InstanceID, "host", target)
return controlPlaneNode(target, out), nil
}
// pickReplacementHost returns a schedulable host for the given instance type that
// is not already holding a live control-plane member, preserving one-member-per-host
// spread. Errors when every schedulable host is excluded (host permanently down),
@@ -191,11 +252,18 @@ func (s *EKSServiceImpl) pickReplacementHost(ctx context.Context, instanceType s
for _, h := range exclude {
ex[h] = true
}
for _, h := range s.deps.Scheduler.SchedulableHosts(ctx, instanceType) {
hosts := s.deps.Scheduler.SchedulableHosts(ctx, instanceType)
for _, h := range hosts {
if !ex[h] {
return h, nil
}
}
// On a single-node deployment the sole schedulable host is the old CP's
// own host. placeControlPlane already supports a <2-host single-CP
// topology, so fall back to it when that host is the only one available.
if len(hosts) == 1 {
return hosts[0], nil
}
return "", fmt.Errorf("eks: no schedulable host for replacement control plane (excluding %d live-member host(s))", len(exclude))
}
@@ -583,3 +583,75 @@ func TestSpecForSystemType_RejectsNonSystem(t *testing.T) {
_, _, ok = instancetypes.SpecForSystemType("sys.nonexistent")
assert.False(t, ok, "unknown system size has no spec")
}
func TestProvisionFreshControlPlane_ClusterInitNoJoin(t *testing.T) {
sched := &fakeHostScheduler{hosts: []string{"node-a", "node-b"}}
inst := &seqK3sInst{}
svc := newPlacerService(sched, &fakePlacer{}, &seqK3sVPC{}, inst)
// IAM is unwired here, so the launch re-derives static system creds from
// deps (as ProvisionReplacementCP/CreateCluster do).
svc.deps.SystemAccessKey = "AKIATEST"
svc.deps.SystemSecretKey = "secret"
tmpl := validK3sInput()
node, err := svc.ProvisionFreshControlPlane(context.Background(), FreshCPRequest{
ClusterName: "alpha",
Template: &tmpl,
ExcludeHosts: []string{"node-a"},
})
require.NoError(t, err)
assert.Equal(t, "node-b", node.NodeID, "placed on the one non-excluded host")
assert.Equal(t, "i-node-b", node.InstanceID)
assert.NotEmpty(t, node.ENIID, "fresh CP carries a fresh ENI")
ud := inst.userData["node-b"]
assert.Contains(t, ud, "cluster-init: true", "fresh CP seeds a new single-member etcd, not a join")
assert.NotContains(t, ud, "server: https://", "must not join a (nonexistent) survivor")
assert.Contains(t, ud, "EKS_ACCESS_KEY=AKIATEST", "re-derives system creds like ProvisionReplacementCP")
assert.Contains(t, ud, "SPINIFEX_PREDASTORE_AKID=AKIATEST", "re-derives predastore creds too")
}
func TestProvisionFreshControlPlane_NoFreeHostErrors(t *testing.T) {
// Two hosts, both excluded: genuinely nothing schedulable. A single
// excluded host is covered separately since that falls back to it
// (single-node topology).
sched := &fakeHostScheduler{hosts: []string{"node-a", "node-b"}}
svc := newPlacerService(sched, &fakePlacer{}, &seqK3sVPC{}, &seqK3sInst{})
svc.deps.SystemAccessKey = "AKIATEST"
svc.deps.SystemSecretKey = "secret"
tmpl := validK3sInput()
_, err := svc.ProvisionFreshControlPlane(context.Background(), FreshCPRequest{
ClusterName: "alpha",
Template: &tmpl,
ExcludeHosts: []string{"node-a", "node-b"},
})
require.Error(t, err)
assert.Contains(t, err.Error(), "no schedulable host")
}
func TestProvisionFreshControlPlane_SingleHostFallsBack(t *testing.T) {
// Single-node deployment: the only schedulable host is also the excluded
// (old CP) host. pickReplacementHost falls back to it rather than erroring.
sched := &fakeHostScheduler{hosts: []string{"node-a"}}
svc := newPlacerService(sched, &fakePlacer{}, &seqK3sVPC{}, &seqK3sInst{})
svc.deps.SystemAccessKey = "AKIATEST"
svc.deps.SystemSecretKey = "secret"
tmpl := validK3sInput()
node, err := svc.ProvisionFreshControlPlane(context.Background(), FreshCPRequest{
ClusterName: "alpha",
Template: &tmpl,
ExcludeHosts: []string{"node-a"},
})
require.NoError(t, err)
assert.Equal(t, "node-a", node.NodeID)
}
func TestProvisionFreshControlPlane_NilTemplateRejected(t *testing.T) {
svc := newPlacerService(&fakeHostScheduler{}, &fakePlacer{}, &seqK3sVPC{}, &seqK3sInst{})
_, err := svc.ProvisionFreshControlPlane(context.Background(), FreshCPRequest{ClusterName: "alpha"})
require.Error(t, err, "nil template rejected")
}
+30
View File
@@ -67,6 +67,10 @@ const (
// k3sFirstBootEnvPath is the env file k3s-first-boot.sh sources at boot.
k3sFirstBootEnvPath = "/etc/spinifex-eks/first-boot.env"
// k3sSnapshotEnvPath is the env file mulga-eks-etcd-snapshot.sh (cron) and
// mulga-eks-k3s-recovery.sh (boot-time restore) source for predastore creds.
k3sSnapshotEnvPath = "/etc/spinifex-eks/etcd-snapshot.env"
// agentEnvPath is the env file the k3s-agent OpenRC service sources for
// K3S_URL/K3S_TOKEN/K3S_NODE_NAME/K3S_NODE_LABEL.
agentEnvPath = "/etc/spinifex-eks/agent.env"
@@ -170,6 +174,13 @@ type K3sServerInput struct {
// EKS_KONNECTIVITY_SERVER_COUNT so the konnectivity-server advertises
// --server-count=N and every agent holds a tunnel to every replica (HA-correct).
KonnServerCount int `json:"konnServerCount,omitempty"`
// PredastoreEndpoint/PredastoreAccessKey/PredastoreSecretKey are the
// mgmt-bridge-reachable predastore SigV4 creds baked into etcd-snapshot.env.
// The guest snapshot cron and boot-time recovery script have no IMDS-role
// support, so these are always static (unlike AccessKey/SecretKey above).
PredastoreEndpoint string `json:"predastoreEndpoint,omitempty"`
PredastoreAccessKey string `json:"predastoreAccessKey,omitempty"`
PredastoreSecretKey string `json:"predastoreSecretKey,omitempty"`
}
// K3sServerOutput carries identifiers to persist in ClusterMeta and register with the NLB.
@@ -447,6 +458,12 @@ func validateK3sServerInput(in K3sServerInput) error {
return errors.New("eks: K3sServerInput AccessKey and SecretKey must both be set or both empty")
case strings.TrimSpace(in.GatewayCACert) == "":
return errors.New("eks: K3sServerInput empty GatewayCACert")
case in.PredastoreEndpoint == "":
return errors.New("eks: K3sServerInput empty PredastoreEndpoint")
case in.PredastoreAccessKey == "":
return errors.New("eks: K3sServerInput empty PredastoreAccessKey")
case in.PredastoreSecretKey == "":
return errors.New("eks: K3sServerInput empty PredastoreSecretKey")
case in.ServerURL != "" && in.JoinToken == "":
return errors.New("eks: K3sServerInput join server (ServerURL set) requires JoinToken")
}
@@ -638,9 +655,22 @@ func buildK3sUserData(in K3sServerInput) string {
)
k3sConfig := strings.Join(configLines, "\n")
snapshotEnvBody := strings.Join([]string{
"EKS_ACCOUNT_ID=" + in.ClusterAccountID,
"EKS_CLUSTER_NAME=" + in.ClusterName,
"SPINIFEX_PREDASTORE_ENDPOINT=" + in.PredastoreEndpoint,
"SPINIFEX_PREDASTORE_AKID=" + in.PredastoreAccessKey,
"SPINIFEX_PREDASTORE_SECRET=" + in.PredastoreSecretKey,
// Without AWS_REGION the snapshot SigV4 PUT signs with the fallback
// region and predastore rejects it with 403.
"AWS_REGION=" + in.Region,
}, "\n")
files := []userDataFile{
// 0600: may contain a system SigV4 secret key (static-cred mode).
{Path: k3sFirstBootEnvPath, Perms: "0600", Body: envBody},
// 0600: carries predastore SigV4 static creds for the snapshot cron + boot-time recovery.
{Path: k3sSnapshotEnvPath, Perms: "0600", Body: snapshotEnvBody},
{Path: k3sOIDCSigningKeyPath, Perms: "0600", Body: strings.TrimRight(in.OIDCPrivateKeyPEM, "\n")},
{Path: k3sOIDCPublicKeyPath, Perms: "0644", Body: strings.TrimRight(in.OIDCPublicKeyPEM, "\n")},
{Path: k3sConfigPath, Perms: "0644", Body: k3sConfig},
@@ -178,6 +178,10 @@ func validK3sInput() K3sServerInput {
SecretKey: "s3cr3t-key",
GatewayCACert: "-----BEGIN CERTIFICATE-----\nFAKECA\n-----END CERTIFICATE-----\n",
JoinToken: "clustertok-deadbeef",
PredastoreEndpoint: "https://10.15.8.1:8443",
PredastoreAccessKey: "AKIAPREDASTORE",
PredastoreSecretKey: "pred-s3cr3t-key",
}
}
@@ -205,6 +209,9 @@ func TestLaunchK3sServerVM_EmptyInputsRejected(t *testing.T) {
{"empty AccessKey", mk(func(in *K3sServerInput) { in.AccessKey = "" })},
{"empty SecretKey", mk(func(in *K3sServerInput) { in.SecretKey = "" })},
{"empty GatewayCACert", mk(func(in *K3sServerInput) { in.GatewayCACert = " \n" })},
{"empty PredastoreEndpoint", mk(func(in *K3sServerInput) { in.PredastoreEndpoint = "" })},
{"empty PredastoreAccessKey", mk(func(in *K3sServerInput) { in.PredastoreAccessKey = "" })},
{"empty PredastoreSecretKey", mk(func(in *K3sServerInput) { in.PredastoreSecretKey = "" })},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -239,6 +246,16 @@ func TestBuildK3sUserData_StaticCredsBakeKeys(t *testing.T) {
assert.Contains(t, ud, "EKS_SECRET_KEY=s3cr3t-key")
}
func TestBuildK3sUserData_WritesEtcdSnapshotEnv(t *testing.T) {
ud := buildK3sUserData(validK3sInput())
require.Contains(t, ud, "path: "+k3sSnapshotEnvPath)
assert.Contains(t, ud, "EKS_ACCOUNT_ID=111122223333")
assert.Contains(t, ud, "EKS_CLUSTER_NAME=alpha")
assert.Contains(t, ud, "SPINIFEX_PREDASTORE_ENDPOINT=https://10.15.8.1:8443")
assert.Contains(t, ud, "SPINIFEX_PREDASTORE_AKID=AKIAPREDASTORE")
assert.Contains(t, ud, "SPINIFEX_PREDASTORE_SECRET=pred-s3cr3t-key")
}
func TestBuildK3sUserData_ProfileModeOmitsKeys(t *testing.T) {
in := validK3sInput()
in.AccessKey = ""
+16 -10
View File
@@ -30,10 +30,15 @@ const (
// RecoveryDirective is the per-member recovery instruction. Epoch increases on
// each set so the guest applies a directive at most once: it records the
// last-applied epoch and ignores an equal-or-older one across reboots.
// SnapshotRequired marks a snapshot the guest MUST restore (fresh DR seed): if
// the fetch fails the guest aborts boot rather than cluster-reset into an empty
// datastore. It stays false for the HA-reform path, whose seed cluster-resets
// from intact local etcd when no snapshot is present.
type RecoveryDirective struct {
Epoch int64 `json:"epoch"`
Action RecoveryAction `json:"action"`
Snapshot string `json:"snapshot,omitempty"`
Epoch int64 `json:"epoch"`
Action RecoveryAction `json:"action"`
Snapshot string `json:"snapshot,omitempty"`
SnapshotRequired bool `json:"snapshotRequired,omitempty"`
}
// GetRecoveryDirectiveInput names the cluster + member whose directive to read.
@@ -50,10 +55,11 @@ type GetRecoveryDirectiveOutput struct {
// SetRecoveryDirectiveInput sets a member's directive; Epoch is assigned by the
// store (previous+1), so callers supply only the action + optional snapshot key.
type SetRecoveryDirectiveInput struct {
ClusterName string `json:"clusterName"`
InstanceID string `json:"instanceId"`
Action RecoveryAction `json:"action"`
Snapshot string `json:"snapshot,omitempty"`
ClusterName string `json:"clusterName"`
InstanceID string `json:"instanceId"`
Action RecoveryAction `json:"action"`
Snapshot string `json:"snapshot,omitempty"`
SnapshotRequired bool `json:"snapshotRequired,omitempty"`
}
// SetRecoveryDirectiveOutput returns the stored directive incl. the assigned epoch.
@@ -83,12 +89,12 @@ func LoadRecoveryDirective(acctKV nats.KeyValue, cluster, instanceID string) (Re
// StoreRecoveryDirective writes a member's directive with epoch = previous+1 and
// returns the stored value. Used by the reconciler escalation and the operator CLI.
func StoreRecoveryDirective(acctKV nats.KeyValue, cluster, instanceID string, action RecoveryAction, snapshot string) (RecoveryDirective, error) {
func StoreRecoveryDirective(acctKV nats.KeyValue, cluster, instanceID string, action RecoveryAction, snapshot string, snapshotRequired bool) (RecoveryDirective, error) {
prev, err := LoadRecoveryDirective(acctKV, cluster, instanceID)
if err != nil {
return RecoveryDirective{}, err
}
next := RecoveryDirective{Epoch: prev.Epoch + 1, Action: action, Snapshot: snapshot}
next := RecoveryDirective{Epoch: prev.Epoch + 1, Action: action, Snapshot: snapshot, SnapshotRequired: snapshotRequired}
data, err := json.Marshal(&next)
if err != nil {
return RecoveryDirective{}, fmt.Errorf("marshal recovery directive: %w", err)
@@ -130,7 +136,7 @@ func (s *EKSServiceImpl) SetRecoveryDirective(_ context.Context, input *SetRecov
if err != nil {
return nil, err
}
d, err := StoreRecoveryDirective(acctKV, input.ClusterName, input.InstanceID, input.Action, input.Snapshot)
d, err := StoreRecoveryDirective(acctKV, input.ClusterName, input.InstanceID, input.Action, input.Snapshot, input.SnapshotRequired)
if err != nil {
return nil, err
}
@@ -26,11 +26,11 @@ func TestStoreRecoveryDirective_EpochIncrements(t *testing.T) {
acctKV, err := GetOrCreateAccountBucket(js, testAccountID, 1)
require.NoError(t, err)
first, err := StoreRecoveryDirective(acctKV, "alpha", "i-0", RecoveryActionClusterReset, "")
first, err := StoreRecoveryDirective(acctKV, "alpha", "i-0", RecoveryActionClusterReset, "", false)
require.NoError(t, err)
assert.Equal(t, int64(1), first.Epoch, "first set starts at epoch 1")
second, err := StoreRecoveryDirective(acctKV, "alpha", "i-0", RecoveryActionWipeRejoin, "snap.snap")
second, err := StoreRecoveryDirective(acctKV, "alpha", "i-0", RecoveryActionWipeRejoin, "snap.snap", false)
require.NoError(t, err)
assert.Equal(t, int64(2), second.Epoch, "each set advances the epoch")
@@ -41,6 +41,21 @@ func TestStoreRecoveryDirective_EpochIncrements(t *testing.T) {
assert.Equal(t, int64(2), got.Epoch)
}
func TestStoreRecoveryDirective_SnapshotRequiredRoundTrips(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
acctKV, err := GetOrCreateAccountBucket(js, testAccountID, 1)
require.NoError(t, err)
stored, err := StoreRecoveryDirective(acctKV, "alpha", "i-seed", RecoveryActionClusterReset, "etcd-frequent-20260709T010000Z.snap", true)
require.NoError(t, err)
assert.True(t, stored.SnapshotRequired, "a required snapshot is recorded so the guest aborts on fetch failure")
got, err := LoadRecoveryDirective(acctKV, "alpha", "i-seed")
require.NoError(t, err)
assert.True(t, got.SnapshotRequired, "SnapshotRequired survives the KV round-trip")
assert.Equal(t, "etcd-frequent-20260709T010000Z.snap", got.Snapshot)
}
func TestSetRecoveryDirective_RejectsBadInput(t *testing.T) {
s := &EKSServiceImpl{}
+351
View File
@@ -0,0 +1,351 @@
package handlers_eks
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/mulgadc/spinifex/spinifex/admin"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/objectstore"
"github.com/nats-io/nats.go"
)
// eksBackupsBucket is the predastore bucket the guest etcd-snapshot cron PUTs
// to and this restore path lists/reads from. Keys are
// {accountID}/{clusterName}/etcd-{tier}-{timestamp}.snap.
const eksBackupsBucket = "eks-backups-system"
// Old-CP fence retry budget. A wedged-but-alive old CP left running while the
// new one takes over is split-brain (two live etcds), so terminate is retried
// before the restore is declared complete.
const (
restoreFenceAttempts = 3
restoreFenceDelay = 2 * time.Second
)
// RestoreSnapshotInput names the cluster to restore and, optionally, the exact
// snapshot key (basename) to apply. Empty Snapshot resolves the latest
// frequent-tier snapshot, falling back to the latest of any tier.
type RestoreSnapshotInput struct {
ClusterName string `json:"clusterName"`
Snapshot string `json:"snapshot,omitempty"`
}
// RestoreSnapshotOutput reports the fresh control-plane instance and the
// snapshot key its boot-time recovery agent will apply. Status is deliberately
// provisional: a successful return means the new CP was launched, its restore
// directive queued, meta re-pointed and the old CP fenced — NOT that etcd is
// restored and the apiserver is serving. That is observed asynchronously by the
// reconciler, so the caller must verify cluster health before relying on it.
type RestoreSnapshotOutput struct {
ClusterName string `json:"clusterName"`
NewInstanceID string `json:"newInstanceId"`
Snapshot string `json:"snapshot"`
Status string `json:"status"`
}
// etcdSnapshotKey is a parsed "etcd-{tier}-{timestamp}.snap" object basename.
type etcdSnapshotKey struct {
tier string
timestamp string
basename string
}
// parseEtcdSnapshotKey parses a snapshot object basename (not the full S3 key)
// into tier + timestamp. The timestamp (YYYYMMDDTHHMMSSZ) is fixed-width, so
// lexicographic comparison of the timestamp alone is chronological — tier names
// vary in length, so comparing the whole basename would not be.
func parseEtcdSnapshotKey(basename string) (etcdSnapshotKey, bool) {
const prefix, suffix = "etcd-", ".snap"
if !strings.HasPrefix(basename, prefix) || !strings.HasSuffix(basename, suffix) {
return etcdSnapshotKey{}, false
}
mid := strings.TrimSuffix(strings.TrimPrefix(basename, prefix), suffix)
i := strings.LastIndex(mid, "-")
if i <= 0 || i == len(mid)-1 {
return etcdSnapshotKey{}, false
}
return etcdSnapshotKey{tier: mid[:i], timestamp: mid[i+1:], basename: basename}, true
}
// resolveLatestSnapshot lists the cluster's snapshot prefix and returns the
// newest object's basename, preferring the frequent tier (tightest RPO) and
// falling back to the newest snapshot of any tier.
func resolveLatestSnapshot(ctx context.Context, store objectstore.ObjectStore, accountID, clusterName string) (string, error) {
if store == nil {
return "", errors.New("eks: no snapshot store configured; pass --snapshot explicitly")
}
prefix := accountID + "/" + clusterName + "/"
out, err := store.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
Bucket: aws.String(eksBackupsBucket),
Prefix: aws.String(prefix),
})
if err != nil {
return "", fmt.Errorf("list etcd snapshots: %w", err)
}
var bestFrequent, bestAny etcdSnapshotKey
haveFrequent, haveAny := false, false
for _, obj := range out.Contents {
base := strings.TrimPrefix(aws.StringValue(obj.Key), prefix)
parsed, ok := parseEtcdSnapshotKey(base)
if !ok {
continue
}
if !haveAny || parsed.timestamp > bestAny.timestamp {
bestAny, haveAny = parsed, true
}
if parsed.tier == "frequent" && (!haveFrequent || parsed.timestamp > bestFrequent.timestamp) {
bestFrequent, haveFrequent = parsed, true
}
}
switch {
case haveFrequent:
return bestFrequent.basename, nil
case haveAny:
return bestAny.basename, nil
default:
return "", fmt.Errorf("eks: no etcd snapshots found under %s/%s", eksBackupsBucket, prefix)
}
}
// validateSnapshotExists HEADs the snapshot object before any mutation. An
// explicit --snapshot that is typo'd or expired would otherwise be handed to the
// guest verbatim, its fetch fail, and — without the required-snapshot guard — a
// fresh seed cluster-reset into an EMPTY datastore reported as success. A nil
// store cannot verify, so it is a hard error rather than an optimistic pass.
func validateSnapshotExists(ctx context.Context, store objectstore.ObjectStore, accountID, clusterName, basename string) error {
if store == nil {
return errors.New("eks: no snapshot store configured; cannot verify the snapshot exists before a destructive restore")
}
key := accountID + "/" + clusterName + "/" + basename
if _, err := store.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(eksBackupsBucket),
Key: aws.String(key),
}); err != nil {
if objectstore.IsNoSuchKeyError(err) {
return fmt.Errorf("eks: snapshot %q not found under %s/%s/%s/; refusing to restore (a cluster-reset with no snapshot boots an EMPTY cluster)",
basename, eksBackupsBucket, accountID, clusterName)
}
return fmt.Errorf("eks: verify snapshot %q exists: %w", basename, err)
}
return nil
}
// replaceControlPlaneForRestore overwrites meta's single control-plane member
// with the freshly provisioned node, mirroring the create-path scalar fields
// (service_impl.go CreateCluster). restore-snapshot only ever targets a
// single-CP cluster (isHAControlPlane guards the caller), so this replaces
// wholesale rather than matching a dead instance ID like SwapControlPlaneMember.
func replaceControlPlaneForRestore(kv nats.KeyValue, name string, newNode ControlPlaneNode) error {
return casUpdateMeta(kv, name, func(m *ClusterMeta) bool {
m.ControlPlaneNodes = []ControlPlaneNode{newNode}
m.ControlPlaneInstanceID = newNode.InstanceID
m.ControlPlaneENIID = newNode.ENIID
m.ControlPlaneENIIP = newNode.ENIIP
m.ControlPlaneMgmtIP = newNode.MgmtIP
return true
})
}
// unwindFreshCP rolls back a just-launched fresh CP when a step before the meta
// commit fails. It supersedes the recovery directive with a no-op (so a VM that
// boots before terminate lands does not destructively reset) then terminates the
// VM. Both are best-effort and logged: leaving an orphan boot a resetting CP,
// which the operator would then compound by re-running restore into a second one.
func (s *EKSServiceImpl) unwindFreshCP(ctx context.Context, accountID, clusterName string, node ControlPlaneNode) {
if _, err := s.SetRecoveryDirective(ctx, &SetRecoveryDirectiveInput{
ClusterName: clusterName,
InstanceID: node.InstanceID,
Action: RecoveryActionNone,
}, accountID); err != nil {
slog.WarnContext(ctx, "RestoreSnapshot: clear directive during unwind failed; guest may still reset if it boots",
"cluster", clusterName, "instanceId", node.InstanceID, "err", err)
}
if err := TerminateK3sServerVM(ctx, s.deps.VPCK3s, s.deps.Instance, admin.SystemAccountID(), node.InstanceID, node.ENIID); err != nil {
slog.WarnContext(ctx, "RestoreSnapshot: terminate fresh CP during unwind failed; may orphan a resetting control plane",
"cluster", clusterName, "instanceId", node.InstanceID, "err", err)
}
}
// confirmOldCPTerminated fences the old control plane: it retries terminate until
// the instance is confirmed gone (TerminateK3sServerVM treats an already-absent
// instance as success, the primary DR case). A persistent failure means a
// wedged-but-alive old CP that would run its etcd alongside the new one —
// split-brain — so the caller must surface it loudly rather than declare success.
func (s *EKSServiceImpl) confirmOldCPTerminated(ctx context.Context, accountID string, node ControlPlaneNode, attempts int, delay time.Duration) error {
if node.InstanceID == "" && node.ENIID == "" {
return nil
}
var lastErr error
for i := range attempts {
if i > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
}
if err := TerminateK3sServerVM(ctx, s.deps.VPCK3s, s.deps.Instance, accountID, node.InstanceID, node.ENIID); err != nil {
lastErr = err
slog.WarnContext(ctx, "RestoreSnapshot: old control plane terminate attempt failed",
"instanceId", node.InstanceID, "attempt", i+1, "err", err)
continue
}
return nil
}
return lastErr
}
// RestoreSnapshot drives the single-CP total-loss DR path: it validates the
// snapshot exists, launches a fresh control-plane VM as a cluster-init seed,
// directs its boot-time recovery agent to `k3s server --cluster-reset` restoring
// that snapshot, persists the new CP into meta, re-points the cluster NLB target
// groups, and fences the old CP. HA clusters are out of scope — they have a
// potentially surviving quorum and should be recovered via quorum reformation,
// not a destructive single-node rebuild. The returned status is provisional:
// success means the sequence completed, not that etcd is restored and serving.
func (s *EKSServiceImpl) RestoreSnapshot(ctx context.Context, input *RestoreSnapshotInput, accountID string) (*RestoreSnapshotOutput, error) {
if input == nil || input.ClusterName == "" {
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
acctKV, err := s.acctKVForCluster(accountID, input.ClusterName)
if err != nil {
return nil, err
}
meta, err := GetClusterMeta(acctKV, input.ClusterName)
if err != nil {
if errors.Is(err, ErrClusterNotFound) {
return nil, errors.New(awserrors.ErrorEKSResourceNotFound)
}
return nil, err
}
if isHAControlPlane(meta) {
return nil, errors.New("eks: restore-snapshot only supports a single control-plane cluster; " +
"this cluster runs an HA spread with a potentially surviving quorum — use quorum reformation instead")
}
if meta.ControlPlaneTemplate == nil {
return nil, errors.New("eks: cluster has no persisted control-plane template; cannot restore")
}
// Resolve + validate the snapshot BEFORE any mutation. resolveLatestSnapshot
// already guarantees existence (it lists then picks); an explicit key is HEADed
// so a typo/expired key hard-fails here rather than booting an empty cluster.
snapshot := input.Snapshot
if snapshot == "" {
snapshot, err = resolveLatestSnapshot(ctx, s.deps.SnapshotStore, accountID, input.ClusterName)
if err != nil {
return nil, err
}
} else if verr := validateSnapshotExists(ctx, s.deps.SnapshotStore, accountID, input.ClusterName, snapshot); verr != nil {
return nil, verr
}
var oldNode ControlPlaneNode
if oldNodes := controlPlaneTeardownNodes(meta); len(oldNodes) > 0 {
oldNode = oldNodes[0]
}
var excludeHosts []string
if oldNode.NodeID != "" {
excludeHosts = []string{oldNode.NodeID}
}
newNode, err := s.ProvisionFreshControlPlane(ctx, FreshCPRequest{
AccountID: accountID,
ClusterName: input.ClusterName,
Template: meta.ControlPlaneTemplate,
ExcludeHosts: excludeHosts,
})
if err != nil {
return nil, fmt.Errorf("eks: provision fresh control plane: %w", err)
}
// Wire the recovery directive immediately after launch: the guest applies it
// before k3s starts on this very first boot, and it is keyed by InstanceID,
// which only exists once launched. SnapshotRequired makes the guest abort
// (not reset into an empty datastore) if it cannot fetch the snapshot.
// Any failure up to and including the meta commit unwinds the fresh CP, so a
// re-run does not stack a second resetting control plane.
if _, derr := s.SetRecoveryDirective(ctx, &SetRecoveryDirectiveInput{
ClusterName: input.ClusterName,
InstanceID: newNode.InstanceID,
Action: RecoveryActionClusterReset,
Snapshot: snapshot,
SnapshotRequired: true,
}, accountID); derr != nil {
s.unwindFreshCP(ctx, accountID, input.ClusterName, newNode)
return nil, fmt.Errorf("eks: set recovery directive: %w", derr)
}
// Persist the new CP into meta BEFORE re-pointing the NLB. Once committed the
// new CP is canonical, so a later NLB failure is convergeable by the reconciler
// (provisional success) rather than a bare error implying nothing happened.
if perr := replaceControlPlaneForRestore(acctKV, input.ClusterName, newNode); perr != nil {
s.unwindFreshCP(ctx, accountID, input.ClusterName, newNode)
return nil, fmt.Errorf("eks: persist replacement control plane: %w", perr)
}
sysAcct := admin.SystemAccountID()
// Re-point the cluster NLB: deregister the old (likely already-dead) CP,
// register the new one, on both the apiserver and konnectivity TGs. Deregister
// is best-effort (the old target is likely already unhealthy/absent). A failed
// register leaves meta already naming the new CP, so the reconciler converges —
// surfaced as a provisional status, not an error.
if oldNode.ENIIP != "" {
if meta.NLBTargetGroupArn != "" {
if derr := DeregisterClusterTarget(ctx, s.deps.NLB, sysAcct, meta.NLBTargetGroupArn, oldNode.ENIIP, k3sAPIServerPort); derr != nil {
slog.WarnContext(ctx, "RestoreSnapshot: deregister old apiserver target failed", "cluster", input.ClusterName, "err", derr)
}
}
if meta.KonnTargetGroupArn != "" {
if derr := DeregisterClusterTarget(ctx, s.deps.NLB, sysAcct, meta.KonnTargetGroupArn, oldNode.ENIIP, konnectivityAgentPort); derr != nil {
slog.WarnContext(ctx, "RestoreSnapshot: deregister old konnectivity target failed", "cluster", input.ClusterName, "err", derr)
}
}
}
nlbComplete := true
if meta.NLBTargetGroupArn != "" {
if rerr := RegisterClusterTarget(ctx, s.deps.NLB, sysAcct, meta.NLBTargetGroupArn, newNode.ENIIP, k3sAPIServerPort); rerr != nil {
nlbComplete = false
slog.WarnContext(ctx, "RestoreSnapshot: register new apiserver target failed; reconciler will converge",
"cluster", input.ClusterName, "err", rerr)
}
}
if meta.KonnTargetGroupArn != "" {
if rerr := RegisterClusterTarget(ctx, s.deps.NLB, sysAcct, meta.KonnTargetGroupArn, newNode.ENIIP, konnectivityAgentPort); rerr != nil {
nlbComplete = false
slog.WarnContext(ctx, "RestoreSnapshot: register new konnectivity target failed; reconciler will converge",
"cluster", input.ClusterName, "err", rerr)
}
}
// Fence the old CP before declaring success: a wedged-but-alive old CP running
// its etcd next to the new one is split-brain. Terminate is retried; if the old
// instance cannot be confirmed gone, fail loudly (the new CP is up and canonical
// — the operator must fence the old one manually, NOT re-run restore).
if ferr := s.confirmOldCPTerminated(ctx, sysAcct, oldNode, restoreFenceAttempts, restoreFenceDelay); ferr != nil {
return nil, fmt.Errorf("eks: restore of %q brought up new control plane %s but the old control plane %s could not be confirmed terminated after %d attempts (split-brain risk) — fence it manually, do NOT re-run restore: %w",
input.ClusterName, newNode.InstanceID, oldNode.InstanceID, restoreFenceAttempts, ferr)
}
status := fmt.Sprintf("restore initiated: new control plane %s is booting and will restore etcd from snapshot %s — verify cluster health before relying on it",
newNode.InstanceID, snapshot)
if !nlbComplete {
status = fmt.Sprintf("control plane %s restored and recorded, but NLB re-point is incomplete; the reconciler will converge — verify cluster health",
newNode.InstanceID)
}
slog.InfoContext(ctx, "RestoreSnapshot: control-plane restore sequence complete",
"cluster", input.ClusterName, "newInstanceId", newNode.InstanceID, "snapshot", snapshot, "nlbComplete", nlbComplete)
return &RestoreSnapshotOutput{
ClusterName: input.ClusterName,
NewInstanceID: newNode.InstanceID,
Snapshot: snapshot,
Status: status,
}, nil
}
@@ -0,0 +1,317 @@
package handlers_eks
import (
"context"
"errors"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/mulgadc/spinifex/spinifex/objectstore"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseEtcdSnapshotKey(t *testing.T) {
cases := []struct {
name string
basename string
wantOK bool
wantTier string
wantTS string
}{
{"frequent tier", "etcd-frequent-20260709T010000Z.snap", true, "frequent", "20260709T010000Z"},
{"daily tier", "etcd-daily-20260601T000000Z.snap", true, "daily", "20260601T000000Z"},
{"missing prefix", "frequent-20260709T010000Z.snap", false, "", ""},
{"missing suffix", "etcd-frequent-20260709T010000Z.tar", false, "", ""},
{"no tier separator", "etcd-20260709T010000Z.snap", false, "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
parsed, ok := parseEtcdSnapshotKey(tc.basename)
require.Equal(t, tc.wantOK, ok)
if tc.wantOK {
assert.Equal(t, tc.wantTier, parsed.tier)
assert.Equal(t, tc.wantTS, parsed.timestamp)
}
})
}
}
func seedSnapshot(t *testing.T, store objectstore.ObjectStore, accountID, cluster, basename string) {
t.Helper()
key := accountID + "/" + cluster + "/" + basename
_, err := store.PutObject(context.Background(), &s3.PutObjectInput{
Bucket: aws.String(eksBackupsBucket),
Key: aws.String(key),
Body: strings.NewReader("fake-snapshot-data"),
})
require.NoError(t, err)
}
func TestResolveLatestSnapshot_PrefersFrequentTier(t *testing.T) {
store := objectstore.NewMemoryObjectStore()
seedSnapshot(t, store, testAccountID, "alpha", "etcd-daily-20260709T230000Z.snap")
seedSnapshot(t, store, testAccountID, "alpha", "etcd-frequent-20260709T010000Z.snap")
seedSnapshot(t, store, testAccountID, "alpha", "etcd-frequent-20260709T020000Z.snap")
// A different cluster's snapshot must not be considered.
seedSnapshot(t, store, testAccountID, "beta", "etcd-frequent-20260710T230000Z.snap")
got, err := resolveLatestSnapshot(context.Background(), store, testAccountID, "alpha")
require.NoError(t, err)
assert.Equal(t, "etcd-frequent-20260709T020000Z.snap", got, "newest frequent-tier snapshot wins even though a newer daily exists")
}
func TestResolveLatestSnapshot_FallsBackToAnyTierWhenNoFrequent(t *testing.T) {
store := objectstore.NewMemoryObjectStore()
seedSnapshot(t, store, testAccountID, "alpha", "etcd-daily-20260601T000000Z.snap")
seedSnapshot(t, store, testAccountID, "alpha", "etcd-daily-20260602T000000Z.snap")
got, err := resolveLatestSnapshot(context.Background(), store, testAccountID, "alpha")
require.NoError(t, err)
assert.Equal(t, "etcd-daily-20260602T000000Z.snap", got)
}
func TestResolveLatestSnapshot_NoStoreConfigured(t *testing.T) {
_, err := resolveLatestSnapshot(context.Background(), nil, testAccountID, "alpha")
require.Error(t, err)
}
func TestResolveLatestSnapshot_NoSnapshotsFound(t *testing.T) {
store := objectstore.NewMemoryObjectStore()
_, err := resolveLatestSnapshot(context.Background(), store, testAccountID, "alpha")
require.Error(t, err)
}
// restoreSnapshotFixtureMeta builds a single-CP ClusterMeta with everything
// RestoreSnapshot needs: a persisted launch template and NLB target-group ARNs.
func restoreSnapshotFixtureMeta(name string) *ClusterMeta {
tmpl := validK3sInput()
tmpl.AccessKey = ""
tmpl.SecretKey = ""
tmpl.PredastoreAccessKey = ""
tmpl.PredastoreSecretKey = ""
meta := sampleClusterMeta(name)
meta.NLBTargetGroupArn = "arn:aws:elasticloadbalancing:us-east-1:111122223333:targetgroup/" + ClusterTargetGroupName(name) + "/tg-001"
meta.KonnTargetGroupArn = "arn:aws:elasticloadbalancing:us-east-1:111122223333:targetgroup/" + ClusterTargetGroupName(name) + "-konn/tg-002"
meta.ControlPlaneTemplate = &tmpl
meta.ControlPlaneNodes = []ControlPlaneNode{{
NodeID: "node-old",
InstanceID: "i-old111",
ENIID: "eni-old111",
ENIIP: "10.0.1.9",
MgmtIP: "10.255.0.5",
}}
meta.ControlPlaneInstanceID = "i-old111"
meta.ControlPlaneENIID = "eni-old111"
meta.ControlPlaneENIIP = "10.0.1.9"
return meta
}
func TestRestoreSnapshot_HappyPathLaunchesDirectsAndRepoints(t *testing.T) {
f := newEKSServiceFixture(t)
f.svc.deps.Scheduler = &fakeHostScheduler{hosts: []string{"node-new"}}
store := objectstore.NewMemoryObjectStore()
f.svc.deps.SnapshotStore = store
seedSnapshot(t, store, testAccountID, "alpha", "etcd-frequent-20260709T010000Z.snap")
meta := restoreSnapshotFixtureMeta("alpha")
require.NoError(t, PutClusterMeta(f.kv, meta))
out, err := f.svc.RestoreSnapshot(context.Background(), &RestoreSnapshotInput{ClusterName: "alpha"}, testAccountID)
require.NoError(t, err)
assert.Equal(t, "etcd-frequent-20260709T010000Z.snap", out.Snapshot, "empty --snapshot resolves the latest one")
assert.NotEmpty(t, out.NewInstanceID)
// The recovery directive must be wired for the new instance.
dirOut, err := f.svc.GetRecoveryDirective(context.Background(),
&GetRecoveryDirectiveInput{ClusterName: "alpha", InstanceID: out.NewInstanceID}, testAccountID)
require.NoError(t, err)
assert.Equal(t, RecoveryActionClusterReset, dirOut.Directive.Action)
assert.Equal(t, "etcd-frequent-20260709T010000Z.snap", dirOut.Directive.Snapshot)
assert.True(t, dirOut.Directive.SnapshotRequired, "fresh DR seed must require the snapshot (abort, not reset-into-empty)")
assert.NotEmpty(t, out.Status, "success carries a provisional status, never a bare 'restored'")
// NLB re-pointed: old CP deregistered, new CP registered, on both TGs.
require.Len(t, f.nlb.deregisterCalls, 2, "apiserver + konnectivity TGs")
require.Len(t, f.nlb.registerCalls, 2)
for _, dc := range f.nlb.deregisterCalls {
require.Len(t, dc.Targets, 1)
assert.Equal(t, "10.0.1.9", aws.StringValue(dc.Targets[0].Id), "deregisters the OLD CP's ENI IP")
}
for _, rc := range f.nlb.registerCalls {
require.Len(t, rc.Targets, 1)
assert.NotEqual(t, "10.0.1.9", aws.StringValue(rc.Targets[0].Id), "registers the NEW CP's ENI IP, not the old one")
}
// Cluster meta reflects the replacement.
got, err := GetClusterMeta(f.kv, "alpha")
require.NoError(t, err)
assert.Equal(t, out.NewInstanceID, got.ControlPlaneInstanceID)
require.Len(t, got.ControlPlaneNodes, 1)
assert.Equal(t, out.NewInstanceID, got.ControlPlaneNodes[0].InstanceID)
// The old CP is torn down best-effort.
assert.Contains(t, f.inst.terminateCalls, "i-old111")
}
func TestRestoreSnapshot_ExplicitSnapshotUsedVerbatim(t *testing.T) {
f := newEKSServiceFixture(t)
f.svc.deps.Scheduler = &fakeHostScheduler{hosts: []string{"node-new"}}
store := objectstore.NewMemoryObjectStore()
f.svc.deps.SnapshotStore = store
// A different (newer) snapshot exists; the explicit one must be used verbatim.
seedSnapshot(t, store, testAccountID, "alpha", "etcd-frequent-20260710T010000Z.snap")
seedSnapshot(t, store, testAccountID, "alpha", "etcd-daily-20260601T000000Z.snap")
meta := restoreSnapshotFixtureMeta("alpha")
require.NoError(t, PutClusterMeta(f.kv, meta))
out, err := f.svc.RestoreSnapshot(context.Background(),
&RestoreSnapshotInput{ClusterName: "alpha", Snapshot: "etcd-daily-20260601T000000Z.snap"}, testAccountID)
require.NoError(t, err)
assert.Equal(t, "etcd-daily-20260601T000000Z.snap", out.Snapshot, "explicit snapshot is used verbatim, no latest-resolution")
// The directive must mark the snapshot as required so the guest aborts on a
// fetch failure rather than resetting into an empty datastore.
dirOut, err := f.svc.GetRecoveryDirective(context.Background(),
&GetRecoveryDirectiveInput{ClusterName: "alpha", InstanceID: out.NewInstanceID}, testAccountID)
require.NoError(t, err)
assert.True(t, dirOut.Directive.SnapshotRequired)
assert.NotEmpty(t, out.Status, "a provisional status is always returned")
}
func TestRestoreSnapshot_ExplicitSnapshotMissingHardFailsBeforeLaunch(t *testing.T) {
f := newEKSServiceFixture(t)
f.svc.deps.Scheduler = &fakeHostScheduler{hosts: []string{"node-new"}}
store := objectstore.NewMemoryObjectStore()
f.svc.deps.SnapshotStore = store
// Only an unrelated snapshot exists — the requested key is absent.
seedSnapshot(t, store, testAccountID, "alpha", "etcd-frequent-20260709T010000Z.snap")
meta := restoreSnapshotFixtureMeta("alpha")
require.NoError(t, PutClusterMeta(f.kv, meta))
_, err := f.svc.RestoreSnapshot(context.Background(),
&RestoreSnapshotInput{ClusterName: "alpha", Snapshot: "etcd-daily-20260601T000000Z.snap"}, testAccountID)
require.Error(t, err)
assert.Contains(t, err.Error(), "not found")
assert.Empty(t, f.inst.launchCalls, "a missing explicit snapshot must hard-fail before launching anything")
}
func TestRestoreSnapshot_ExplicitSnapshotWithNoStoreHardFails(t *testing.T) {
f := newEKSServiceFixture(t)
f.svc.deps.Scheduler = &fakeHostScheduler{hosts: []string{"node-new"}}
f.svc.deps.SnapshotStore = nil
meta := restoreSnapshotFixtureMeta("alpha")
require.NoError(t, PutClusterMeta(f.kv, meta))
_, err := f.svc.RestoreSnapshot(context.Background(),
&RestoreSnapshotInput{ClusterName: "alpha", Snapshot: "etcd-daily-20260601T000000Z.snap"}, testAccountID)
require.Error(t, err)
assert.Empty(t, f.inst.launchCalls, "cannot verify snapshot exists without a store — must not launch")
}
func TestRestoreSnapshot_HARejected(t *testing.T) {
f := newEKSServiceFixture(t)
meta := restoreSnapshotFixtureMeta("alpha")
meta.ControlPlaneSpreadGroup = "eks-cp-111122223333-alpha"
meta.ControlPlaneNodes = append(meta.ControlPlaneNodes, ControlPlaneNode{NodeID: "node-2", InstanceID: "i-cp2"})
require.NoError(t, PutClusterMeta(f.kv, meta))
_, err := f.svc.RestoreSnapshot(context.Background(),
&RestoreSnapshotInput{ClusterName: "alpha", Snapshot: "etcd-daily-20260601T000000Z.snap"}, testAccountID)
require.Error(t, err)
assert.Contains(t, err.Error(), "HA spread")
assert.Empty(t, f.inst.launchCalls, "must not launch anything for an HA cluster")
}
func TestRestoreSnapshot_NoTemplateRejected(t *testing.T) {
f := newEKSServiceFixture(t)
meta := restoreSnapshotFixtureMeta("alpha")
meta.ControlPlaneTemplate = nil
require.NoError(t, PutClusterMeta(f.kv, meta))
_, err := f.svc.RestoreSnapshot(context.Background(),
&RestoreSnapshotInput{ClusterName: "alpha", Snapshot: "etcd-daily-20260601T000000Z.snap"}, testAccountID)
require.Error(t, err)
assert.Contains(t, err.Error(), "template")
}
func TestRestoreSnapshot_ClusterNotFound(t *testing.T) {
f := newEKSServiceFixture(t)
_, err := f.svc.RestoreSnapshot(context.Background(),
&RestoreSnapshotInput{ClusterName: "ghost", Snapshot: "etcd-daily-20260601T000000Z.snap"}, testAccountID)
require.Error(t, err)
}
func TestRestoreSnapshot_NLBFailureIsProvisionalNotError(t *testing.T) {
f := newEKSServiceFixture(t)
f.svc.deps.Scheduler = &fakeHostScheduler{hosts: []string{"node-new"}}
store := objectstore.NewMemoryObjectStore()
f.svc.deps.SnapshotStore = store
seedSnapshot(t, store, testAccountID, "alpha", "etcd-frequent-20260709T010000Z.snap")
// NLB register fails after meta is persisted: the new CP is already canonical.
f.nlb.registerErr = errors.New("TargetGroupNotFound")
meta := restoreSnapshotFixtureMeta("alpha")
require.NoError(t, PutClusterMeta(f.kv, meta))
out, err := f.svc.RestoreSnapshot(context.Background(), &RestoreSnapshotInput{ClusterName: "alpha"}, testAccountID)
require.NoError(t, err, "an NLB re-point failure after meta commit is provisional, not a hard error")
assert.Contains(t, out.Status, "NLB re-point is incomplete")
// Meta was persisted BEFORE the NLB step, so it already names the new CP and
// the reconciler can converge the target group.
got, err := GetClusterMeta(f.kv, "alpha")
require.NoError(t, err)
assert.Equal(t, out.NewInstanceID, got.ControlPlaneInstanceID, "meta persisted before NLB re-point")
// The new CP must NOT be torn down on an NLB failure (it is canonical now).
assert.NotContains(t, f.inst.terminateCalls, out.NewInstanceID)
}
func TestRestoreSnapshot_FenceFailureIsLoudError(t *testing.T) {
f := newEKSServiceFixture(t)
// The old CP cannot be terminated (still alive) — split-brain risk.
f.inst.terminateErr = errors.New("InstanceStillRunning")
oldNode := ControlPlaneNode{NodeID: "node-old", InstanceID: "i-old111", ENIID: "eni-old111", ENIIP: "10.0.1.9"}
// Drive the fence helper directly with no delay so the retry budget is exercised fast.
err := f.svc.confirmOldCPTerminated(context.Background(), testAccountID, oldNode, 3, 0)
require.Error(t, err, "a persistently un-terminable old CP is a fence failure")
require.Len(t, f.inst.terminateCalls, 3, "terminate is retried up to the attempt budget")
}
func TestConfirmOldCPTerminated_NoOldNodeSucceeds(t *testing.T) {
f := newEKSServiceFixture(t)
err := f.svc.confirmOldCPTerminated(context.Background(), testAccountID, ControlPlaneNode{}, 3, 0)
require.NoError(t, err, "no old CP to fence is trivially confirmed")
assert.Empty(t, f.inst.terminateCalls)
}
func TestUnwindFreshCP_TerminatesAndClearsDirective(t *testing.T) {
f := newEKSServiceFixture(t)
meta := restoreSnapshotFixtureMeta("alpha")
require.NoError(t, PutClusterMeta(f.kv, meta))
// Pretend a directive was already set for the fresh CP (as the real flow does
// just before a meta-commit failure), then unwind.
node := ControlPlaneNode{NodeID: "node-new", InstanceID: "i-new999", ENIID: "eni-new999", ENIIP: "10.0.1.42"}
_, err := f.svc.SetRecoveryDirective(context.Background(), &SetRecoveryDirectiveInput{
ClusterName: "alpha", InstanceID: node.InstanceID,
Action: RecoveryActionClusterReset, Snapshot: "etcd-frequent-20260709T010000Z.snap", SnapshotRequired: true,
}, testAccountID)
require.NoError(t, err)
f.svc.unwindFreshCP(context.Background(), testAccountID, "alpha", node)
// The fresh CP is terminated so it cannot boot and destructively reset.
assert.Contains(t, f.inst.terminateCalls, "i-new999")
// The directive is superseded with a no-op so a VM that boots first does not reset.
dirOut, err := f.svc.GetRecoveryDirective(context.Background(),
&GetRecoveryDirectiveInput{ClusterName: "alpha", InstanceID: node.InstanceID}, testAccountID)
require.NoError(t, err)
assert.Equal(t, RecoveryActionNone, dirOut.Directive.Action, "directive cleared to none on unwind")
}
+5
View File
@@ -54,6 +54,11 @@ type EKSService interface {
// reconciler / operator CLI set it to drive an etcd cluster-reset recovery.
GetRecoveryDirective(ctx context.Context, input *GetRecoveryDirectiveInput, accountID string) (*GetRecoveryDirectiveOutput, error)
SetRecoveryDirective(ctx context.Context, input *SetRecoveryDirectiveInput, accountID string) (*SetRecoveryDirectiveOutput, error)
// RestoreSnapshot is an internal control-plane method (not an AWS-SDK
// action): the operator CLI (spx admin eks restore-snapshot) drives the
// single-CP total-loss DR path — fresh CP launch, recovery-directive wiring,
// and NLB re-point.
RestoreSnapshot(ctx context.Context, input *RestoreSnapshotInput, accountID string) (*RestoreSnapshotOutput, error)
// OIDC identity-provider configs.
AssociateIdentityProviderConfig(ctx context.Context, input *eks.AssociateIdentityProviderConfigInput, accountID string) (*eks.AssociateIdentityProviderConfigOutput, error)
+34 -18
View File
@@ -24,6 +24,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/config"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
"github.com/mulgadc/spinifex/spinifex/handlers/sysinstance"
"github.com/mulgadc/spinifex/spinifex/objectstore"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
)
@@ -60,6 +61,16 @@ type EKSServiceDeps struct {
SystemSecretKey string
GatewayCACert string
// SystemPredastoreURL is the mgmt-bridge-reachable predastore endpoint baked
// into the CP VM's etcd-snapshot.env (SystemAccessKey/SystemSecretKey double
// as the predastore SigV4 creds — same system credential used for the gateway).
SystemPredastoreURL string
// SnapshotStore lists/reads etcd snapshots from the eks-backups-system bucket
// for restore-snapshot's latest-snapshot resolution. Nil disables that lookup
// (callers must pass --snapshot explicitly).
SnapshotStore objectstore.ObjectStore
VPCSG sgProvisioner
VPCK3s k3sVPCProvisioner
VPCSubnet SubnetVPCResolver
@@ -707,24 +718,27 @@ func (s *EKSServiceImpl) launchClusterInfra(ctx context.Context, lc clusterLaunc
elbSubnets := dedupSubnetsByAZ(ctx, s.deps.VPCSubnet, accountID, lc.subnetIDs)
serverIn := K3sServerInput{
AccountID: sysAcct,
ClusterAccountID: accountID,
ClusterName: name,
Region: region,
SubnetID: cpRefs.PrivateSubnetIDs[0],
VpcID: meta.ResourcesVpcConfig.VpcId,
ELBSubnetIDs: elbSubnets,
ControlPlaneSGID: cpSG,
NLBDNS: nlb.DNSName,
EndpointIP: nlb.FrontendIP,
PrivateEndpointIP: meta.PrivateEndpointIP,
OIDCIssuer: oidcIssuer,
OIDCPrivateKeyPEM: privPEM,
OIDCPublicKeyPEM: pubPEM,
GatewayURL: s.deps.SystemGatewayURL,
AddonGatewayURL: s.deps.GatewayBaseURL,
GatewayCACert: s.deps.GatewayCACert,
JoinToken: joinToken,
AccountID: sysAcct,
ClusterAccountID: accountID,
ClusterName: name,
Region: region,
SubnetID: cpRefs.PrivateSubnetIDs[0],
VpcID: meta.ResourcesVpcConfig.VpcId,
ELBSubnetIDs: elbSubnets,
ControlPlaneSGID: cpSG,
NLBDNS: nlb.DNSName,
EndpointIP: nlb.FrontendIP,
PrivateEndpointIP: meta.PrivateEndpointIP,
OIDCIssuer: oidcIssuer,
OIDCPrivateKeyPEM: privPEM,
OIDCPublicKeyPEM: pubPEM,
GatewayURL: s.deps.SystemGatewayURL,
AddonGatewayURL: s.deps.GatewayBaseURL,
GatewayCACert: s.deps.GatewayCACert,
JoinToken: joinToken,
PredastoreEndpoint: s.deps.SystemPredastoreURL,
PredastoreAccessKey: s.deps.SystemAccessKey,
PredastoreSecretKey: s.deps.SystemSecretKey,
}
// Prefer IMDS instance-role creds: attach a system instance profile so the
@@ -767,6 +781,8 @@ func (s *EKSServiceImpl) launchClusterInfra(ctx context.Context, lc clusterLaunc
tmpl.AccessKey = ""
tmpl.SecretKey = ""
tmpl.IamInstanceProfileArn = ""
tmpl.PredastoreAccessKey = ""
tmpl.PredastoreSecretKey = ""
meta.ControlPlaneTemplate = &tmpl
// Persist the CP VM + ENI + spread-group refs now, before any further fallible
+4
View File
@@ -153,6 +153,10 @@ func (s *NATSEKSService) SetRecoveryDirective(ctx context.Context, input *SetRec
return utils.NATSRequest[SetRecoveryDirectiveOutput](ctx, s.natsConn, "eks.SetRecoveryDirective", input, defaultTimeout, accountID)
}
func (s *NATSEKSService) RestoreSnapshot(ctx context.Context, input *RestoreSnapshotInput, accountID string) (*RestoreSnapshotOutput, error) {
return utils.NATSRequest[RestoreSnapshotOutput](ctx, s.natsConn, "eks.RestoreSnapshot", input, defaultTimeout, accountID)
}
// --- OIDC identity-provider configs ---
func (s *NATSEKSService) AssociateIdentityProviderConfig(ctx context.Context, input *eks.AssociateIdentityProviderConfigInput, accountID string) (*eks.AssociateIdentityProviderConfigOutput, error) {