Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions launch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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));
}
}
}
Loading