diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index aa5e9e8d..30a064e2 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -230,8 +230,15 @@ docker compose -f compose.yaml -f compose.demo.yaml pull docker compose -f compose.yaml -f compose.demo.yaml up -d --no-recreate db # + health wait ./migrate.sh -f compose.yaml -f compose.demo.yaml docker compose -f compose.yaml -f compose.demo.yaml up -d +docker image prune -af --filter "until=168h" # success-path hygiene (gh-#441), + builder prune ``` +The final prune runs **only after a successful `up`** (a failed upgrade leaves the +previous images untouched — they're what is still running) and keeps everything in use +plus roughly the last week of releases for instant rollback. Without it, superseded +release tags accumulate ~1.5 GB per release forever — 46 GB of dead tags filled the demo +box's disk mid-deploy on 2026-08-09, and an SD-card box hits that wall far sooner. + The `up -d --no-recreate db` step (gh-#305) is what makes a **first** boot work — before it, `migrate.sh` (which never starts anything by design) had no db to talk to on a fresh box and the launch deadlocked. On an upgrade it changes nothing: `--no-recreate` leaves a diff --git a/launch.sh b/launch.sh index 990fe121..3baf7703 100755 --- a/launch.sh +++ b/launch.sh @@ -232,6 +232,8 @@ if [ "$PINNED" = "1" ]; then plan_line "./migrate.sh ${MIGRATE_ARGS[*]}" plan_line "$(compose_display) up -d" plan_line "record COMPOSE_FILE=$(compose_file_value) in .env (gh-#309)" + plan_line "docker image prune -af --filter until=168h (success-path hygiene, gh-#441)" + plan_line "docker builder prune -af" plan_line "$(compose_display) ps" plan_profiles exit 0 @@ -293,6 +295,18 @@ if [ "$PINNED" = "1" ]; then persist_compose_file + # gh-#441: superseded release images accumulate ~1.5 GB per release and nothing else ever + # prunes them — 46 GB of dead tags filled the demo box's disk mid-deploy (2026-08-09), and an + # SD-card Pi hits that wall far sooner. Success path only: every failure above bails via + # preflight_fail before reaching here, so a failed upgrade never touches the previous images + # (they are what is still running). Best-effort — hygiene never fails a launch that already + # succeeded. `until=168h` keys on image CREATED time, so everything in use plus roughly the + # last week of releases survives for instant rollback; older tags go. The builder cache is + # pure waste on a --pinned box, which never builds (BUILD=1 + --pinned errors at parse time). + echo "==> pruning superseded images (kept: in-use + last 7 days)" + docker image prune -af --filter "until=168h" | tail -1 || true + docker builder prune -af >/dev/null 2>&1 || true + echo "==> stack status" compose ps exit 0 diff --git a/tests/GenWave.Host.Tests/Specs/Gh441_PinnedFlowPrunesSupersededImages.cs b/tests/GenWave.Host.Tests/Specs/Gh441_PinnedFlowPrunesSupersededImages.cs new file mode 100644 index 00000000..f5aa22fd --- /dev/null +++ b/tests/GenWave.Host.Tests/Specs/Gh441_PinnedFlowPrunesSupersededImages.cs @@ -0,0 +1,103 @@ +// gh-#441 — launch.sh --pinned prunes superseded release images after a healthy up. +// +// BDD specification — xUnit. Drives the real ./launch.sh via Process, --dry-run only +// (the Story201 contract: the plan is the behavior, assertable with no docker daemon). +// +// Why this exists: nothing ever pruned old `home-v*` tags — 46 GB of dead images filled the +// demo box's 75 GB disk mid-deploy on 2026-08-09 (the db PANIC'd on a full device during +// db/34). The prune is success-path-only hygiene: a FAILED deploy must leave the previous +// images untouched, because they are what is still running. These specs pin the plan shape; +// the success-path-only property is structural (every failure bails via preflight_fail before +// the prune line is reached). + +using System.Diagnostics; + +namespace GenWave.Host.Tests.Specs; + +public static class FeaturePinnedFlowPrunesSupersededImages +{ + static string RepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "GenWave.sln"))) + dir = dir.Parent; + + if (dir is null) throw new InvalidOperationException("repo root (GenWave.sln) not found"); + return dir.FullName; + } + + static (int ExitCode, string StdOut) RunLaunch(params string[] args) + { + var startInfo = new ProcessStartInfo("bash") + { + WorkingDirectory = RepoRoot(), + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + startInfo.ArgumentList.Add(Path.Combine(RepoRoot(), "launch.sh")); + foreach (var arg in args) startInfo.ArgumentList.Add(arg); + + using var process = Process.Start(startInfo) + ?? throw new InvalidOperationException("failed to start launch.sh"); + var stdOut = process.StandardOutput.ReadToEnd(); + process.StandardError.ReadToEnd(); + process.WaitForExit(); + return (process.ExitCode, stdOut); + } + + static string[] PlanLines(string stdOut) => + stdOut.Split('\n').Where(l => l.StartsWith("plan> ", StringComparison.Ordinal)).ToArray(); + + public static class ScenarioPinnedFlowPrunes + { + static readonly Lazy<(int ExitCode, string StdOut)> Run = + new(() => RunLaunch("--pinned", "--dry-run")); + + [Fact] + public static void Plan_prunes_images_with_the_retention_filter() + { + // The 7-day CREATED-time filter is the rollback-safety contract: the freshly pulled + // release and its recent predecessors survive; month-old tags go. + Assert.Contains( + PlanLines(Run.Value.StdOut), + l => l.Contains("image prune", StringComparison.Ordinal) && l.Contains("until=168h", StringComparison.Ordinal)); + } + + [Fact] + public static void Plan_prunes_the_builder_cache() + { + // A --pinned box never builds (BUILD=1 + --pinned errors at parse time); its build + // cache is pure waste. + Assert.Contains( + PlanLines(Run.Value.StdOut), + l => l.Contains("builder prune", StringComparison.Ordinal)); + } + + [Fact] + public static void Prune_is_planned_after_the_stack_is_up() + { + // Order is the safety property the plan can express: pull → migrate → up → prune. + // Pruning before `up -d` could remove the very images a rollback needs while the + // new ones are still unproven. + var lines = PlanLines(Run.Value.StdOut); + var upIndex = Array.FindIndex(lines, l => l.EndsWith("up -d", StringComparison.Ordinal)); + var pruneIndex = Array.FindIndex(lines, l => l.Contains("image prune", StringComparison.Ordinal)); + Assert.True(upIndex >= 0 && pruneIndex > upIndex, $"expected image prune after `up -d` (up at {upIndex}, prune at {pruneIndex})"); + } + } + + public static class ScenarioDevFlowDoesNotPrune + { + [Fact] + public static void Dev_plan_contains_no_prune() + { + // The dev flow builds from source — pruning there would eat build layers and + // dangling intermediates a developer is actively using. + var run = RunLaunch("--dry-run"); + Assert.DoesNotContain( + PlanLines(run.StdOut), + l => l.Contains("prune", StringComparison.Ordinal)); + } + } +}