Skip to content

Commit d07b4d3

Browse files
authored
recovery drill (#297)
* fix(nexus): start serving before Keeper-based recovery completes Nexus blocked in InitializeBackingStoreFromKeepers, an infinite retry loop, before ever starting its mTLS listener. When every Keeper lost its shard, that deadlocked the break-the-glass flow: the operator restore endpoint is the documented way out of exactly that state, but an un-initialized Nexus was not listening, so `spike operator restore` had nothing to talk to. The routing layer already anticipated serving in this state (route/base exempts the operator recover/restore emergency routes from root-key validation and answers everything else with "not ready"), so the listener never coming up was purely a boot ordering bug. Run the Keeper-based recovery in a background goroutine so main can start the server immediately. Additionally, the recovery loop now stands down as soon as a root key is present, so a successful operator restore hands over to the periodic shard sync instead of polling the Keepers forever. Found and verified live by the new recovery drill (hack/bare-metal/drill/recovery-drill.sh): before the fix, the drill's shard feeds could never reach Nexus; after it, two of three shards restore the system and a pre-crash secret reads back. Spec: TBD Signed-off-by: Volkan Özçelik <volkan.ozcelik@broadcom.com> * feat(bare-metal): add a scripted live recovery/restore drill `make drill-recovery` proves the break-the-glass runbook end to end against a running dev environment: it writes a marker secret, exports recovery shards via `spike operator recover`, kills Nexus and all Keepers (losing the in-memory shards), restarts Nexus alone, feeds the shards back through `spike operator restore` over stdin, and verifies the pre-crash secret reads back. The Pilot's SPIRE entry is rotated through the recover and restore roles along the way, and a trap reverts it to superuser on every exit path. The drill retries the transient conditions it meets in practice: SVID rotation lag after entry updates (access_unauthorized) and Nexus still coming up (comms errors). Pilot output is captured with the streams merged, matching the other harness scripts, because cobra's Print family writes to stderr by default. The drill's first runs exposed the Nexus boot-order deadlock fixed in the previous commit, which is exactly the class of regression it exists to catch. TASKS.md: closes the Phase 1 recovery/restore task and both related Phase 3 items. Spec: TBD Signed-off-by: Volkan Özçelik <volkan.ozcelik@broadcom.com> --------- Signed-off-by: Volkan Özçelik <volkan.ozcelik@broadcom.com>
1 parent 9a2d22f commit d07b4d3

6 files changed

Lines changed: 341 additions & 13 deletions

File tree

.context/TASKS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ the name-based policy work.
3636

3737
### Phase 1: Correctness & Broken Things `#priority:high`
3838
- [x] Fix broken CI integration test #source:jira.xml #added:2026-07-14 #done:2026-07-16 (stale: CI has been green for several weeks; closed on user confirmation)
39-
- [ ] Fix broken recovery/restore flow #source:jira.xml #added:2026-07-14 (code review 2026-07-16: no live breakage found; awaiting the scripted drill in Phase 3 to confirm and close)
39+
- [x] Fix broken recovery/restore flow #source:jira.xml #added:2026-07-14 #done:2026-07-16 (the drill exposed a real boot-order deadlock: an un-initialized Nexus never started its listener, so the emergency restore route was unreachable when every Keeper died; fixed by recovering from Keepers in the background, and the recovery loop now stands down once an operator restore supplies the root key. Verified live by make drill-recovery)
4040
- [x] Fix `spike cipher` stream mode (broken; owner: Murat); JSON mode fix unblocks encryption-as-a-service demo/docs #source:jira.xml #added:2026-07-14 #done:2026-07-15 (stale: both cipher streaming and file modes verified passing via the make start checks on 2026-07-15)
4141
- [ ] Retry sqlite operations with exponential backoff on transient locks (all of `app/nexus/internal/state/persist`) → ideas/research-db-resilience.md #source:jira.xml #added:2026-07-14
4242
- [ ] Bound the Bootstrap keeper-wait loop with a configurable timeout/max-attempts instead of looping forever → ideas/research-db-resilience.md #source:jira.xml #added:2026-07-14
@@ -49,8 +49,8 @@ the name-based policy work.
4949
- [ ] Make `make test` concurrent again (currently serialized by env setup) → ideas/research-cli-testing.md #source:jira.xml #added:2026-07-14
5050
- [ ] Add integration tests: root key cached/recovered/not-re-initialized; secret & policy CRUD; Pilot denies when Nexus uninitialized / warns when unreachable → ideas/research-cli-testing.md #source:jira.xml #added:2026-07-14
5151
- [ ] Raise CLI command coverage to 60%+ via unit + HTTP-mock tests; fix `t.Skip()`ed tests; DI-refactor `sendShardsToKeepers` → ideas/research-cli-testing.md #source:jira.xml #added:2026-07-14
52-
- [ ] `start.sh` should exercise recovery/restore and encryption/decryption #source:jira.xml #added:2026-07-14
53-
- [ ] Scripted live recovery/restore drill: once `make start` completes cleanly, run `spike operator recover`, kill Nexus and the Keepers, restart Nexus alone, feed the shards back via `spike operator restore` (scriptable via stdin since fix/operator-restore), and verify a pre-crash secret reads back. Rationale: the 2026-07-16 code review found no live breakage (shard-index fidelity intact end to end; guards use exact SPIFFE role matching, unaffected by the policy-name migration), so only a drill can prove the Phase 1 "recovery/restore is broken" claim stale and close both tasks. Needs the recover/restore role entries (spire-server-entry-recover-register.sh / -restore-register.sh), which make start does not register by default. #added:2026-07-16
52+
- [x] `start.sh` should exercise recovery/restore and encryption/decryption #source:jira.xml #added:2026-07-14 #done:2026-07-16 (encryption/decryption checks live in start.sh since the policy-validation rework; recovery/restore is exercised by make drill-recovery, kept as a separate second-terminal script deliberately so the crash simulation never runs inside the normal startup path)
53+
- [x] Scripted live recovery/restore drill: once `make start` completes cleanly, run `spike operator recover`, kill Nexus and the Keepers, restart Nexus alone, feed the shards back via `spike operator restore` (scriptable via stdin since fix/operator-restore), and verify a pre-crash secret reads back. Rationale: the 2026-07-16 code review found no live breakage (shard-index fidelity intact end to end; guards use exact SPIFFE role matching, unaffected by the policy-name migration), so only a drill can prove the Phase 1 "recovery/restore is broken" claim stale and close both tasks. Needs the recover/restore role entries (spire-server-entry-recover-register.sh / -restore-register.sh), which make start does not register by default. #added:2026-07-16 #done:2026-07-16 (implemented as hack/bare-metal/drill/recovery-drill.sh behind make drill-recovery; the drill first exposed the Nexus boot-order deadlock, then passed end to end once it was fixed)
5454

5555
### Phase 4: Policy & Secrets `#priority:medium`
5656
- [ ] Add a `list` permission type; scope `spike secret list` to the caller's allowed path patterns → ideas/research-list-permission.md #source:jira.xml #added:2026-07-14

app/nexus/internal/initialization/initialization.go

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ import (
1717
// backend store type. The function handles three initialization modes:
1818
//
1919
// 1. SPIKE-Keeper-based initialization (SQLite and Lite backends):
20-
// - Initializes the backing store from SPIKE Keeper instances
20+
// - Initializes the backing store from SPIKE Keeper instances in the
21+
// background, so the caller can start serving immediately
2122
// - Starts a background goroutine for periodic shard synchronization
23+
// once the backing store is initialized
2224
//
2325
// 2. In-memory initialization (Memory backend):
2426
// - Initializes an empty in-memory backing store without the root key
@@ -46,16 +48,21 @@ func Initialize(source *workloadapi.X509Source) {
4648

4749
if requireBackingStoreToBootstrap := env.BackendStoreTypeVal() == env.Sqlite ||
4850
env.BackendStoreTypeVal() == env.Lite; requireBackingStoreToBootstrap {
49-
// Initialize the backing store from SPIKE Keeper instances.
50-
// This is only required when the SPIKE Nexus needs bootstrapping.
51-
// For modes where bootstrapping is not required (such as in-memory mode),
52-
// SPIKE Nexus should be initialized internally.
53-
recovery.InitializeBackingStoreFromKeepers(source)
51+
// Initialize the backing store from SPIKE Keeper instances in the
52+
// background so the caller can start the mTLS listener right away.
53+
// While the root key is missing, the router only exposes the
54+
// operator recover/restore emergency routes (see route/base), so
55+
// serving early is safe. It is also required: a break-the-glass
56+
// restore feeds shards through this very listener when every
57+
// SPIKE Keeper has lost its shard. Blocking here would deadlock
58+
// that flow, because SPIKE Nexus would never start listening.
59+
go func() {
60+
recovery.InitializeBackingStoreFromKeepers(source)
5461

55-
// Lazy evaluation in a loop:
56-
// If bootstrapping is successful, start a background process to
57-
// periodically sync shards.
58-
go recovery.SendShardsPeriodically(source)
62+
// If bootstrapping is successful, start a background process
63+
// to periodically sync shards.
64+
go recovery.SendShardsPeriodically(source)
65+
}()
5966

6067
return
6168
}

app/nexus/internal/initialization/recovery/recovery.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,16 @@ func InitializeBackingStoreFromKeepers(source *workloadapi.X509Source) {
7676
_, err := retry.Forever(ctx, func() (bool, *sdkErrors.SDKError) {
7777
log.Debug(fName, "message", "retry attempt", "time", time.Now().String())
7878

79+
// The operator may have restored the root key through the
80+
// emergency restore route while this loop was retrying. In that
81+
// case initialization already happened; stop polling the SPIKE
82+
// Keepers and let the periodic shard sync take over.
83+
if !state.RootKeyZero() {
84+
log.Info(fName,
85+
"message", "root key already present; skipping recovery")
86+
return true, nil
87+
}
88+
7989
// Early check: avoid unnecessary function call if the source is nil
8090
if source == nil {
8191
warnErr := *sdkErrors.ErrSPIFFENilX509Source.Clone()
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
2+
// \\\\\ Copyright 2024-present SPIKE contributors.
3+
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
4+
5+
package recovery
6+
7+
import (
8+
"testing"
9+
"time"
10+
11+
"github.com/spiffe/spike-sdk-go/crypto"
12+
"github.com/spiffe/spike-sdk-go/security/mem"
13+
14+
state "github.com/spiffe/spike/app/nexus/internal/state/base"
15+
)
16+
17+
// TestInitializeBackingStoreFromKeepers_RootKeyAlreadyPresent verifies
18+
// that the Keeper recovery loop stands down promptly when the root key
19+
// is already present, which is what happens when an operator restores
20+
// the system through the emergency restore route while the loop is
21+
// still retrying. Before the standdown check, this call would retry
22+
// forever (the nil source alone never stops it), so the test guards
23+
// the boot path that keeps the restore flow deadlock-free.
24+
func TestInitializeBackingStoreFromKeepers_RootKeyAlreadyPresent(
25+
t *testing.T,
26+
) {
27+
rk := &[crypto.AES256KeySize]byte{}
28+
for i := range rk {
29+
rk[i] = byte(i + 1)
30+
}
31+
state.SetRootKey(rk)
32+
33+
// SetRootKey rejects zero keys by design, so zero the key directly
34+
// under the lock, the same way the state package's own tests reset it.
35+
t.Cleanup(func() {
36+
state.LockRootKey()
37+
defer state.UnlockRootKey()
38+
mem.ClearRawBytes(state.RootKeyNoLock())
39+
})
40+
41+
done := make(chan struct{})
42+
go func() {
43+
InitializeBackingStoreFromKeepers(nil)
44+
close(done)
45+
}()
46+
47+
select {
48+
case <-done:
49+
// The loop noticed the root key and returned.
50+
case <-time.After(5 * time.Second):
51+
t.Fatal("InitializeBackingStoreFromKeepers did not stand down" +
52+
" although the root key is present")
53+
}
54+
}
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
#!/usr/bin/env bash
2+
3+
# \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
4+
# \\\\\ Copyright 2024-present SPIKE contributors.
5+
# \\\\\\\ SPDX-License-Identifier: Apache-2.0
6+
7+
# Live recovery/restore drill for the bare-metal dev environment.
8+
#
9+
# Run this from the repository root, in a second terminal, after
10+
# `make start` has completed cleanly (SPIRE server and agent, three
11+
# Keepers, and Nexus with a persistent backend are all running). Run it
12+
# from the same shell environment as `make start` so a restarted Nexus
13+
# inherits the same configuration.
14+
#
15+
# The drill proves the break-the-glass runbook end to end
16+
# (see https://spike.ist/operations/recovery/):
17+
#
18+
# 1. Writes a marker secret while the system is healthy.
19+
# 2. Exports recovery shards via `spike operator recover`.
20+
# 3. Simulates a crash: kills Nexus and all Keepers (the Keepers lose
21+
# their in-memory shards, so auto-recovery is impossible).
22+
# 4. Restarts Nexus alone and feeds the shards back one by one via
23+
# `spike operator restore`, using its non-interactive stdin mode.
24+
# 5. Verifies the marker secret reads back after the restore.
25+
#
26+
# The Pilot's SPIRE entry is rotated through the recover and restore
27+
# roles along the way; a trap reverts it to the superuser role on every
28+
# exit path. The Keepers stay down when the drill ends: run `make kill`
29+
# and `make start` afterward to return to a pristine environment.
30+
31+
set -u
32+
33+
DRILL_MARKER_PATH="drill/recovery-marker"
34+
DRILL_MARKER_VALUE="drill-$(date +%s)-$$"
35+
RECOVERY_DIR="${SPIKE_PILOT_RECOVERY_DIR:-$HOME/.spike/recover}"
36+
NEXUS_LOG="$(mktemp -t spike-drill-nexus.XXXXXX.log)"
37+
38+
say() {
39+
echo ""
40+
echo "drill: $*"
41+
}
42+
43+
fail() {
44+
echo "" >&2
45+
echo "drill: FAIL: $*" >&2
46+
exit 1
47+
}
48+
49+
# Returns the Pilot's current role by asking the SPIRE server which of
50+
# the known role SPIFFE IDs has a registration entry.
51+
current_pilot_role() {
52+
for role in superuser recover restore; do
53+
if spire-server entry show \
54+
-spiffeID "spiffe://spike.ist/spike/pilot/role/$role" 2>/dev/null |
55+
grep -q "Entry ID"; then
56+
echo "$role"
57+
return 0
58+
fi
59+
done
60+
echo "unknown"
61+
}
62+
63+
# Reverts the Pilot's entry to the superuser role no matter which role
64+
# the drill died in. Runs on every exit path via the trap below.
65+
ensure_superuser_role() {
66+
case "$(current_pilot_role)" in
67+
recover)
68+
say "reverting the Pilot entry from recover to superuser..."
69+
./hack/bare-metal/entry/spire-server-entry-recover-revert.sh
70+
;;
71+
restore)
72+
say "reverting the Pilot entry from restore to superuser..."
73+
./hack/bare-metal/entry/spire-server-entry-restore-revert.sh
74+
;;
75+
superuser)
76+
:
77+
;;
78+
*)
79+
echo "drill: WARNING: could not determine the Pilot role;" >&2
80+
echo "drill: restore it manually with" >&2
81+
echo "drill: ./hack/bare-metal/entry/spire-server-entry-su-register.sh" >&2
82+
;;
83+
esac
84+
}
85+
86+
trap ensure_superuser_role EXIT
87+
88+
# Retries a command until it succeeds or the attempts run out.
89+
retry() {
90+
local attempts="$1"
91+
local delay="$2"
92+
shift 2
93+
94+
local i
95+
for ((i = 1; i <= attempts; i++)); do
96+
if "$@"; then
97+
return 0
98+
fi
99+
sleep "$delay"
100+
done
101+
return 1
102+
}
103+
104+
# --- Preflight ------------------------------------------------------------
105+
106+
[ -x ./bin/spike ] || fail "run this from the repository root" \
107+
"(./bin/spike not found; did make start build the binaries?)"
108+
109+
for b in spike spire-server; do
110+
command -v "$b" >/dev/null 2>&1 || fail "'$b' is not on PATH"
111+
done
112+
113+
command -v spike | xargs test "$(pwd)/bin/spike" -ef ||
114+
fail "PATH resolves 'spike' outside $(pwd)/bin"
115+
116+
pgrep -x nexus >/dev/null || fail "Nexus is not running (run make start)"
117+
pgrep -x keeper >/dev/null || fail "no Keepers running (run make start)"
118+
pgrep -x spire-server >/dev/null || fail "SPIRE server is not running"
119+
120+
if [ "${SPIKE_NEXUS_BACKEND_STORE:-}" = "memory" ]; then
121+
fail "the drill needs a persistent backend;" \
122+
"unset SPIKE_NEXUS_BACKEND_STORE"
123+
fi
124+
125+
if [ "$(current_pilot_role)" != "superuser" ]; then
126+
fail "the Pilot entry is not in the superuser role;" \
127+
"revert it before running the drill"
128+
fi
129+
130+
# --- 1. Write a marker secret while healthy -------------------------------
131+
132+
say "writing the marker secret ($DRILL_MARKER_PATH)..."
133+
spike secret put "$DRILL_MARKER_PATH" value="$DRILL_MARKER_VALUE" ||
134+
fail "could not write the marker secret"
135+
136+
# Note: the Pilot prints its output on stderr (cobra's Print* default),
137+
# so merge the streams before grepping, like the other harness scripts.
138+
spike secret get "$DRILL_MARKER_PATH" 2>&1 |
139+
grep -q "$DRILL_MARKER_VALUE" ||
140+
fail "could not read the marker secret back while healthy"
141+
142+
# --- 2. Export recovery shards ---------------------------------------------
143+
144+
say "rotating the Pilot entry to the recover role..."
145+
./hack/bare-metal/entry/spire-server-entry-recover-register.sh ||
146+
fail "could not register the recover role"
147+
148+
recover_once() {
149+
spike operator recover 2>&1 | grep -q "recovery directory"
150+
}
151+
152+
say "exporting recovery shards (retrying while the SVID rotates)..."
153+
retry 5 3 recover_once || fail "spike operator recover did not succeed"
154+
155+
shard_count=$(ls "$RECOVERY_DIR"/spike.recovery.*.txt 2>/dev/null | wc -l)
156+
[ "$shard_count" -ge 2 ] ||
157+
fail "expected at least 2 shard files in $RECOVERY_DIR," \
158+
"found $shard_count"
159+
say "exported $shard_count shards to $RECOVERY_DIR"
160+
161+
say "rotating the Pilot entry back to superuser..."
162+
./hack/bare-metal/entry/spire-server-entry-recover-revert.sh ||
163+
fail "could not revert the recover role"
164+
165+
# --- 3. Simulate the crash --------------------------------------------------
166+
167+
say "simulating a crash: killing Nexus and all Keepers..."
168+
pkill -x nexus
169+
pkill -x keeper
170+
171+
crash_complete() {
172+
! pgrep -x nexus >/dev/null && ! pgrep -x keeper >/dev/null
173+
}
174+
retry 10 1 crash_complete || fail "Nexus/Keeper processes did not exit"
175+
say "Nexus and Keepers are down; Keeper shards are lost"
176+
177+
# --- 4. Restart Nexus alone and restore ------------------------------------
178+
179+
say "restarting Nexus alone (log: $NEXUS_LOG)..."
180+
nohup ./hack/bare-metal/startup/start-nexus.sh \
181+
>"$NEXUS_LOG" 2>&1 &
182+
183+
say "rotating the Pilot entry to the restore role..."
184+
./hack/bare-metal/entry/spire-server-entry-restore-register.sh ||
185+
fail "could not register the restore role"
186+
187+
restored=0
188+
for shard_file in "$RECOVERY_DIR"/spike.recovery.*.txt; do
189+
say "feeding $(basename "$shard_file")..."
190+
191+
feed_shard() {
192+
output=$(spike operator restore <"$shard_file" 2>&1)
193+
# Two conditions are transient and worth retrying: Nexus is still
194+
# starting (comms errors), and the SPIRE agent has not yet rotated
195+
# the Pilot SVID to the restore role (access_unauthorized).
196+
if echo "$output" | grep -q "Failed to communicate"; then
197+
return 1
198+
fi
199+
if echo "$output" | grep -q "access_unauthorized"; then
200+
return 1
201+
fi
202+
return 0
203+
}
204+
205+
retry 20 2 feed_shard ||
206+
fail "could not feed $(basename "$shard_file"):" \
207+
"Nexus unreachable or the restore SVID never arrived"
208+
209+
echo "$output"
210+
if echo "$output" | grep -q "restored and ready"; then
211+
restored=1
212+
break
213+
fi
214+
done
215+
216+
[ "$restored" -eq 1 ] ||
217+
fail "fed all shards but Nexus did not report itself restored"
218+
219+
say "rotating the Pilot entry back to superuser..."
220+
./hack/bare-metal/entry/spire-server-entry-restore-revert.sh ||
221+
fail "could not revert the restore role"
222+
223+
# --- 5. Verify the pre-crash secret ----------------------------------------
224+
225+
verify_marker() {
226+
spike secret get "$DRILL_MARKER_PATH" 2>&1 |
227+
grep -q "$DRILL_MARKER_VALUE"
228+
}
229+
230+
say "verifying the pre-crash marker secret..."
231+
retry 10 2 verify_marker ||
232+
fail "the marker secret did not read back after the restore"
233+
234+
# --- Cleanup ----------------------------------------------------------------
235+
236+
say "cleaning up: deleting the marker secret and the shard files..."
237+
spike secret delete "$DRILL_MARKER_PATH" >/dev/null 2>&1
238+
rm -f "$RECOVERY_DIR"/spike.recovery.*.txt
239+
240+
say "PASS: recovery/restore drill completed successfully."
241+
echo ""
242+
echo " A secret written before the crash survived the loss of Nexus"
243+
echo " and every Keeper, and was readable after a shard-based restore."
244+
echo ""
245+
echo " Note: the Keepers are still down. Run 'make kill' and then"
246+
echo " 'make start' to return to a pristine environment."
247+
echo ""

makefiles/BareMetal.mk

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@ build:
3838
build-spire:
3939
./hack/bare-metal/build/build-spire.sh
4040

41+
.PHONY: drill-recovery
42+
# Live recovery/restore drill: proves the break-the-glass runbook end
43+
# to end. Run it in a second terminal after `make start` has completed
44+
# cleanly. The drill kills Nexus and the Keepers, restores Nexus from
45+
# operator recovery shards, and verifies that a pre-crash secret reads
46+
# back. The Keepers stay down afterward; run `make kill` and then
47+
# `make start` to reset the environment.
48+
drill-recovery:
49+
./hack/bare-metal/drill/recovery-drill.sh
50+
4151
# Registry an entry to the SPIRE server for the demo app.
4252
demo-register-entry:
4353
./examples/consume-secrets/demo-register-entry.sh

0 commit comments

Comments
 (0)