Skip to main content

Runbooks

R01 — Run Engine & Cancellation

Hands-on validation a human performs before a wave merges.

docs/runbooks/R01-run-engine-and-cancellation.md
On this page

Wave: W1 · Time: ~30 minutes · Cluster needed: no (a real cluster makes step 5 more convincing)

This runbook proves the four properties Wave 1 exists for. Each was broken in v1, and each is the kind of thing that only really convinces on real hardware:

  1. A cancelled run dies, and takes its children with it.
  2. Killing the process mid-run leaves a coherent record, not a phantom.
  3. Conflicting operations are refused immediately, naming the holder.
  4. No output is ever lost, including across a disconnect.

Preconditions

  • bin/labctl built (make cli-build).
  • A scratch SNOWOPS_HOME so this runbook never touches your real history:
$ export SNOWOPS_HOME=/tmp/snowops-r01
$ mkdir -p "$SNOWOPS_HOME"
  • pgrep and pkill available (both ship with macOS and Linux).

Teardown is step 8. Nothing here touches a cluster unless you choose the optional variant in step 5.


1. 🔍 The database is created where you expect

$ ./bin/labctl runs list

Expect: No runs recorded yet.

$ ls -la "$SNOWOPS_HOME"

Expect: snowops.db plus WAL sidecar files (-wal, -shm). Those sidecars are how a reader proceeds while a write is in flight.

Failure signature — a permissions error: SNOWOPS_HOME points somewhere unwritable. Pick another directory.


2. The automated cancellation suite passes on your machine

The process-group behaviour is OS-specific — signal delivery differs between macOS and Linux — so it must be exercised where you actually work.

$ go test -race -run 'TestExecCancellation' ./internal/toolchain/ -v

Expect: all subtests PASS, notably:

--- PASS: TestExecCancellation/kills_the_whole_process_group,_not_just_the_direct_child
--- PASS: TestExecCancellation/SIGKILLs_a_process_that_ignores_SIGTERM

That second one matters: a script that traps SIGTERM must still die after the grace period. A tool that can be made unkillable by a badly-written script is not one you can trust with a cluster.

$ go test -race ./internal/run/ ./internal/store/

Expect: both ok.


3. ⚠️ Cancel a long-running operation and confirm nothing survives

This is the headline check. We need a real long-running child process.

Create a scratch script inside the repo (the resolver only runs scripts inside a content root, which is itself the subject of step 7):

$ mkdir -p scenarios/r01-scratch
$ cat > scenarios/r01-scratch/long.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
echo "##snowops:step:phase-one"
echo "starting work"
# A grandchild, the way helm spawns its own children.
sleep 300 &
echo "spawned child $!"
wait
EOF
$ chmod +x scenarios/r01-scratch/long.sh

🔍 Before starting, note what is already running so you can tell new from old:

$ pgrep -fl 'sleep 300' ; echo "(nothing above = clean start)"

Now run it. The runs command reads the same database the engine writes, so open a second terminal (remember to export SNOWOPS_HOME there too).

Note: wiring lab/platform onto the engine is W3. Until then, drive the engine from this repository's own test harness:

$ go test -race -run 'TestCancel/cancels_a_running_run' ./internal/run/ -v

Expect: PASS, and the run reaching cancelled with cancelled by user in its transcript.

Then confirm the real-process behaviour directly:

$ go test -race -count=1 -run 'TestExecCancellation/kills_the_whole' ./internal/toolchain/ -v
$ pgrep -fl 'sleep 60' ; echo "exit=$?"

Expect: no matching processes (exit=1 from pgrep means "none found"). A surviving process here is a blocking finding — it is precisely the v1 bug this wave exists to fix.


4. Killing labctl mid-run leaves a coherent record

$ go test -race -count=1 -run 'TestRecoveryOnStart' ./internal/run/ -v

Expect: PASS. The test simulates a process that died with a run in flight, reopens the database, and asserts four things — verify the assertions read the way you would expect by skimming internal/run/engine_test.go:TestRecoveryOnStart:

  • the run is cancelled, not still running,
  • its error explains the interruption,
  • its partial log survived,
  • its lock was released (otherwise that lab is wedged forever).

🔍 Reason about the failure mode: if recovery did not run, what would happen the next time you tried to operate on that lab? (Answer: a permanent lock conflict with a run that no longer exists.) Note whether the released-lock assertion is present.


5. Conflicting operations are refused, not raced

$ go test -race -count=1 -run 'TestLockConflict' ./internal/run/ -v

Expect: all subtests PASS. The first one asserts the refusal arrives in under 100ms and that its message names the holding run and how to cancel it.

🔍 Read the message format in internal/run/engine.go (LockConflictError.Error). Judge it as a user: if you hit this at 3am, does it tell you what is happening and what to do? Note any wording you would change.


6. No output is lost, including across a disconnect

$ go test -race -count=1 -run 'TestReadLogs_CursorResume' ./internal/store/ -v

Expect: PASS. It writes 100 lines, reads them back in pages of 7 — "disconnecting" between each — and fails on any gap or duplicate.

$ go test -race -count=1 -run 'TestSubscribe/a_slow_subscriber' ./internal/run/ -v

Expect: PASS. A subscriber that never drains its channel must not cost a single persisted line. This is the v1 bug where a non-blocking channel send dropped log lines silently.


7. Scripts cannot escape their content root

$ go test -race -count=1 -run 'TestResolverContainment' ./internal/toolchain/ -v

Expect: all subtests PASS, including the symlink case — a link that sits inside the root but points outside it must be refused, since checking only the cleaned path would let it through.

🔍 Confirm by hand that a traversal is refused:

$ ./bin/labctl runs logs ../../../etc/passwd

Expect: a clear error, not a file dump.


8. labctl runs is usable

$ ./bin/labctl runs list --help
$ ./bin/labctl runs logs --help
$ ./bin/labctl runs cancel --help

Expect: each explains itself without needing the source. Note anything unclear — the CLI reference is generated from these.

$ ./bin/labctl runs cancel run_does_not_exist

Expect: an error naming the ID and suggesting labctl runs list.


9. labctl doctor diagnoses a broken environment

Covered fully by R02, but confirm one thing here: doctor must work even when the environment is too broken for anything else to start.

$ PATH=/nonexistent ./bin/labctl doctor ; echo "exit=$?"

Expect: the table renders, every tool is reported missing, and the exit is non-zero. A crash or a config-loading error is a finding — a diagnostic that needs a healthy environment is useless.


10. Teardown

$ rm -rf scenarios/r01-scratch
$ rm -rf "$SNOWOPS_HOME"
$ unset SNOWOPS_HOME
$ pgrep -fl 'sleep 300' ; echo "(nothing above = clean)"
$ git status --short          # should show no leftover scratch files

Results

# Step Pass / Fail Notes
1 Database created at SNOWOPS_HOME
2 Cancellation suite passes on this OS
3 No process survives cancellation
4 Interrupted run recovered, log intact, lock released
5 Lock conflict refused immediately and clearly
6 No log lines lost across disconnect or slow reader
7 Script traversal refused
8 labctl runs is self-explanatory
9 doctor works in a broken environment

Environment: OS + version ______ · Go ______ · Arch ______

Steps 3, 4 and 6 are blocking — they are Wave 1's reason for existing. Report failures as issues labelled runbook-finding, titled R01 step N: ….

The incident field notes

One real Kubernetes failure a week — the symptom, the commands that found it, and the fix. Written from actual lab runs, not from memory.

You'll get the Kubernetes Incident Response Field Guide, plus occasional emails about new scenarios, posts and paid offerings such as courses and workshops. Unsubscribe any time.