Skip to content

Commit a25cb01

Browse files
LTSCommerceclaude
andcommitted
Plan 00055: L2 LXC can test a real existing container (CW_LXC_TEST_CONTAINER)
Per review (localdev, real bl-* containers are fine to test with): add a real LXC runtime path. Set CW_LXC_TEST_CONTAINER=<name> and the LXC block uses that EXISTING system container instead of a flaky minimal-busybox throwaway — a full system container has proper coreutils/procps for the spinner and its own PID namespace for NSpid. Safety guarantees for a nominated container: - NEVER created or destroyed — only started if stopped, then restored to its prior run-state (lxc_restore stops it only if we started it; never lxc-destroy). - spinner stopped best-effort on teardown; cleanup runs on every early-return path. - the EXIT trap still only force-removes cw-test-* names, never bl-*. Robustness for a real multi-process container: - representative finding prefers one whose container_pid is a ground-truth spinner PID (a busy app process under the same container_name no longer fools the spinner-cmd assertion); adds the container_pid-in-ground-truth assertion. - safety survive-assert checks one of OUR spinner PIDs survived, not just any 'while' process. Without the env var, the block still attempts a throwaway and SKIPs cleanly if it can't generate load. shellcheck clean (all levels); qa-all green (345 files). Refs: CLAUDE/Plan/00055-container-process-watchdog Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e6e3652 commit a25cb01

2 files changed

Lines changed: 105 additions & 22 deletions

File tree

CLAUDE/Plan/00055-container-process-watchdog/acceptance.bash

Lines changed: 97 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,25 @@ best_effort_rm() {
141141
return 0
142142
}
143143

144+
# lxc_restore <name> <existing 0|1> <started_by_us 0|1>
145+
# Tear down the LXC test load safely. A throwaway container is destroyed; an
146+
# operator-nominated EXISTING container is NEVER destroyed — we stop our spinner
147+
# and restore its prior run-state (stop it only if we started it).
148+
lxc_restore() {
149+
local name="$1" existing="$2" started="$3"
150+
if [ "$existing" -eq 1 ]; then
151+
best_effort_rm "stop spinner in $name" sudo lxc-attach -n "$name" -- pkill -f 'while :; do'
152+
if [ "$started" -eq 1 ]; then
153+
best_effort_rm "lxc-stop $name (restore prior STOPPED state)" sudo lxc-stop -n "$name"
154+
echo " restored '$name' to STOPPED (we started it for the test)"
155+
else
156+
echo " left '$name' RUNNING (its prior state — we did not start it)"
157+
fi
158+
else
159+
best_effort_rm "lxc-destroy $name" sudo lxc-destroy -f -n "$name"
160+
fi
161+
}
162+
144163
# --------------------------------------------------------------------------- #
145164
# Cleanup — idempotent, runs on every exit (success OR failure). Force-removes
146165
# every cw-test-* container under each present engine, plus the throwaway config
@@ -351,24 +370,50 @@ run_oci_engine_block() {
351370
# =========================================================================== #
352371
run_lxc_block() {
353372
local engine="lxc"
354-
local burner="cw-test-burner-lxc"
373+
local burner existing=0 started_by_us=0 prior_state=""
355374
local json finding count gt_pids gt_in_pid out st tools procs
356375

357376
hdr "$engine — start burner + assert detection"
358377

359-
# Minimal system container via the portable download template. If the
360-
# template/network is unavailable this is a legitimate environmental SKIP
361-
# (not a faked pass) — we cannot exercise lxc without an image.
362-
if ! out="$(sudo lxc-create -n "$burner" -t download -- \
363-
--dist alpine --release edge --arch amd64 2>&1)"; then
364-
skip "$engine: could not create test container (template/network unavailable): $(printf '%s' "$out" | tail -n1)"
365-
return 0
366-
fi
367-
if ! out="$(sudo lxc-start -n "$burner" 2>&1)"; then
368-
fail "$engine: could not start container $burner: $out"
369-
return 0
378+
if [ -n "${CW_LXC_TEST_CONTAINER:-}" ]; then
379+
# Operator nominated an EXISTING container (e.g. a localdev system
380+
# container). We NEVER create or destroy it — start it only if stopped, and
381+
# restore its prior run-state afterwards (see lxc_restore). This is the real
382+
# runtime LXC test; a full system container also has proper coreutils/procps
383+
# for the spinner, unlike a minimal busybox throwaway.
384+
burner="$CW_LXC_TEST_CONTAINER"
385+
existing=1
386+
if ! prior_state="$(sudo lxc-info -n "$burner" -sH 2>&1)"; then
387+
fail "$engine: CW_LXC_TEST_CONTAINER='$burner' not found: $prior_state"
388+
return 0
389+
fi
390+
echo " using existing container '$burner' (prior state: $prior_state)"
391+
if [ "$prior_state" != "RUNNING" ]; then
392+
if ! out="$(sudo lxc-start -n "$burner" 2>&1)"; then
393+
fail "$engine: could not start existing container '$burner': $out"
394+
return 0
395+
fi
396+
started_by_us=1
397+
sleep 2
398+
fi
399+
pass "$engine: existing container '$burner' is running"
400+
else
401+
# No nominated container → attempt a throwaway minimal image. If the
402+
# template/network is unavailable, SKIP (env limitation, not a faked pass);
403+
# set CW_LXC_TEST_CONTAINER=<name> to exercise a real LXC container.
404+
burner="cw-test-burner-lxc"
405+
if ! out="$(sudo lxc-create -n "$burner" -t download -- \
406+
--dist alpine --release edge --arch amd64 2>&1)"; then
407+
skip "$engine: no CW_LXC_TEST_CONTAINER set and throwaway create failed (template/network unavailable): $(printf '%s' "$out" | tail -n1)"
408+
return 0
409+
fi
410+
if ! out="$(sudo lxc-start -n "$burner" 2>&1)"; then
411+
fail "$engine: could not start throwaway container $burner: $out"
412+
best_effort_rm "lxc-destroy $burner" sudo lxc-destroy -f -n "$burner"
413+
return 0
414+
fi
415+
pass "$engine: started throwaway container '$burner'"
370416
fi
371-
pass "$engine: started container '$burner'"
372417

373418
# Launch a bounded CPU spinner. Background the WHOLE lxc-attach on the HOST side
374419
# so the spinner runs in the FOREGROUND inside the container (held alive by
@@ -399,7 +444,12 @@ run_lxc_block() {
399444
# - the podman + docker L2 blocks above prove the engine-agnostic runtime
400445
# (scan, attribution, NSpid, the safety survive-assert, allowlist, DBus).
401446
# We deliberately do NOT burn CPU inside real project containers to force it.
402-
skip "$engine: no throwaway LXC burner here — LXC cgroup attribution is covered by L1 unit fixtures + the engine-agnostic runtime by the podman/docker L2 blocks (not faking a pass)"
447+
# Could not generate in-container CPU load → SKIP (not a watchdog defect):
448+
# the LXC-specific logic is covered by L1 unit fixtures and the
449+
# engine-agnostic runtime by the podman/docker L2 blocks. Use a full system
450+
# container (CW_LXC_TEST_CONTAINER=<name>) if a minimal throwaway lacks the
451+
# tooling to spin.
452+
skip "$engine: could not generate in-container CPU load (no spinner PIDs) — LXC cgroup attribution covered by L1 unit fixtures + engine-agnostic runtime by the podman/docker L2 blocks; set CW_LXC_TEST_CONTAINER=<name> for a real system container"
403453
if ! st="$(sudo lxc-info -n "$burner" -sH 2>&1)"; then st="(lxc-info failed: $st)"; fi
404454
if ! tools="$(sudo lxc-attach -n "$burner" -- sh -c 'command -v timeout pgrep ps busybox' 2>&1)"; then
405455
tools="(tool probe failed: $tools)"
@@ -412,14 +462,15 @@ run_lxc_block() {
412462
echo " processes :"
413463
printf '%s\n' "$procs"
414464
} >&2
415-
best_effort_rm "lxc-destroy $burner" sudo lxc-destroy -f -n "$burner"
465+
lxc_restore "$burner" "$existing" "$started_by_us"
416466
return 0
417467
fi
418468
gt_in_pid="$(printf '%s\n' "$gt_pids" | head -n1)"
419469
pass "$engine: ground-truth in-container spinner PID(s): $(printf '%s' "$gt_pids" | tr '\n' ' ') (lowest $gt_in_pid)"
420470

421471
if ! json="$(scan_json)"; then
422472
fail "$engine: scan --once --json failed"
473+
lxc_restore "$burner" "$existing" "$started_by_us"
423474
return 0
424475
fi
425476

@@ -428,6 +479,7 @@ run_lxc_block() {
428479
assert_ge "$engine: at least one finding for $burner" "$count" "1"
429480
if [ "$count" -lt 1 ]; then
430481
printf '%s' "$json" | jq '.findings' >&2
482+
lxc_restore "$burner" "$existing" "$started_by_us"
431483
return 0
432484
fi
433485

@@ -436,29 +488,52 @@ run_lxc_block() {
436488
'[.findings[] | select(.container_name == $n) | select(.engine != "lxc")] | length')"
437489
assert_eq "$engine: every $burner finding attributed to lxc" "0" "$mis"
438490

439-
# Representative finding (first for the burner — they are all the same shape).
440-
finding="$(printf '%s' "$json" | jq -c --arg n "$burner" '[.findings[] | select(.container_name == $n)] | .[0]')"
491+
# Representative finding: prefer one of OUR spinner PIDs (a real system
492+
# container may also have other busy app processes flagged under the same
493+
# container_name, so a blind .[0] could pick one of those and fail the
494+
# spinner-cmd assertion). Fall back to the first finding.
495+
local gt_json
496+
gt_json="$(printf '%s\n' "$gt_pids" | jq -R 'select(length > 0) | tonumber' | jq -s '.')"
497+
finding="$(printf '%s' "$json" | jq -c --arg n "$burner" --argjson gt "$gt_json" \
498+
'[.findings[] | select(.container_name == $n)]
499+
| (map(select(.container_pid as $p | $gt | index($p))) + .) | .[0]')"
441500
assert_eq "$engine: engine field" "lxc" "$(printf '%s' "$finding" | jq -r '.engine')"
442501
assert_eq "$engine: container_name" "$burner" "$(printf '%s' "$finding" | jq -r '.container_name')"
443502
assert_contains "$engine: cmd contains the spinner" "$(printf '%s' "$finding" | jq -r '.cmd')" "while"
444503
assert_ge "$engine: age_s >= 1" "$(printf '%s' "$finding" | jq -r '.age_s')" "1"
445504
assert_ge "$engine: cpu_pct high" "$(printf '%s' "$finding" | jq -r '.cpu_pct | floor')" "5"
446505

506+
# The representative finding's container_pid must be a real in-container PID.
507+
local cpid
508+
cpid="$(printf '%s' "$finding" | jq -r '.container_pid')"
509+
if printf '%s\n' "$gt_pids" | grep -qx "$cpid"; then
510+
pass "$engine: container_pid ($cpid) matches a ground-truth in-container PID"
511+
else
512+
fail "$engine: container_pid ($cpid) not in ground-truth set [$(printf '%s' "$gt_pids" | tr '\n' ' ')]"
513+
fi
514+
447515
local hint
448516
hint="$(printf '%s' "$finding" | jq -r '.exec_hint')"
449517
assert_contains "$engine: exec_hint is engine-correct" "$hint" "lxc-attach"
450518
assert_contains "$engine: exec_hint names the container" "$hint" "$burner"
451519

452520
hdr "$engine — SAFETY: burner survives the scan (reporting-only)"
453-
local still
454-
if still="$(sudo lxc-attach -n "$burner" -- pgrep -f 'while' 2>&1)" && [ -n "$still" ]; then
521+
local still survived=0 p
522+
still="$(sudo lxc-attach -n "$burner" -- pgrep -f 'while' 2>&1)"
523+
# Confirm at least one of OUR ground-truth spinner PIDs is still alive — not
524+
# merely some other 'while' process a real system container might also run.
525+
while IFS= read -r p; do
526+
[ -n "$p" ] || continue
527+
if printf '%s\n' "$still" | grep -qx "$p"; then survived=1; break; fi
528+
done <<< "$gt_pids"
529+
if [ "$survived" -eq 1 ]; then
455530
pass "$engine: spinner STILL ALIVE after scan — tool did not terminate it"
456531
else
457-
fail "$engine: spinner is GONE after scan — reporting-only tool must NOT terminate findings! ($still)"
532+
fail "$engine: our spinner PID(s) GONE after scan — a reporting-only tool must NOT terminate findings! (still-running 'while' procs: ${still:-none})"
458533
fi
459534

460-
best_effort_rm "lxc-destroy $burner" sudo lxc-destroy -f -n "$burner"
461-
pass "$engine: container destroyed"
535+
lxc_restore "$burner" "$existing" "$started_by_us"
536+
pass "$engine: LXC test load cleaned up (container preserved if pre-existing)"
462537
}
463538

464539
# =========================================================================== #

CLAUDE/Plan/00055-container-process-watchdog/testing.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,14 @@ podman run -d --rm --name cw-test-burner <small-image> \
167167
(Use a couple of background spinners to ensure it reads as CPU-pinned; `timeout`
168168
caps the lifetime; an `EXIT` trap force-removes the container even on failure.)
169169

170+
**LXC note**: a *minimal busybox* throwaway often lacks the tooling to spin
171+
reliably, so the LXC block **SKIPs** (not FAILs) when it cannot generate load —
172+
the LXC-specific logic is already covered by the L1 fixtures and the runtime by
173+
the podman/docker blocks. To exercise a **real** LXC container, point the block at
174+
an existing one: `CW_LXC_TEST_CONTAINER=<name> ./acceptance.bash`. That container
175+
is **never created or destroyed** — it is started only if stopped and restored to
176+
its prior run-state afterwards.
177+
170178
**Per available engine** (gated by the §1.6 presence probe — podman always;
171179
docker if the daemon/group is present; lxc if `lxc-*` + sudo are present):
172180

0 commit comments

Comments
 (0)