From 5f990dd28b3ca91a3691005f8d61f5b2f7c285b4 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 11 Aug 2026 19:01:20 -0700 Subject: [PATCH] Sweep the snapshot commits recreation leaves behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every recreation commits the container to triple-c-snapshot-{id}:latest and moves that tag; the image it pointed at keeps its layers and loses its name. Nothing deleted those, so they accumulate — measured on one real host, 7 orphans holding 7.4 GB, three of them from a single day's work. `sweep_orphaned_snapshots` removes them, under two conditions that are the whole safety argument. Untagged: every image the app depends on carries a tag, so a project's live `:latest` and a migration's `pre-migration-*` rollback pin cannot match the filter at all. And labelled `triple-c.managed=true`, which `docker commit` copies from the container onto the image — the user's own dangling images are not ours to delete. Removal is unforced on top of that, so Docker refuses while any container is still built from the image, including the stopped containers of projects that are not running; those are counted and left for the next sweep. It runs after a recreation, which is when the orphan it just made becomes removable, and after a migration is accepted, which is the moment dropping the pin turns the pre-migration snapshot into an orphan. Both detached: this is housekeeping, and a full disk beats a project that will not start. Each sweep clears every orphan it finds, so recreations that predate it are cleaned up too. The label string is now a constant rather than four literals, and a test pins both filter conditions in place. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 8 + .../src/commands/migration_commands.rs | 10 ++ .../src/commands/project_commands.rs | 12 ++ app/src-tauri/src/docker/container.rs | 148 +++++++++++++++++- 4 files changed, 176 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9774d73..61359a2 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,14 @@ forces that). 4. **Stop**: Container halted (its filesystem layer and both named volumes persist) 5. **Restart**: Existing container restarted; if any `triple-c.*` label no longer matches the project's settings, the container is committed to a snapshot image, removed, and recreated from that snapshot — so installed packages survive 6. **Migrate**: The project is moved onto a newer base image without losing its volumes — see below + +Each recreation moves the `triple-c-snapshot-{projectId}:latest` tag, leaving the image it pointed +at before untagged but still on disk — multiple gigabytes per recreation. `sweep_orphaned_snapshots` +clears those after a recreation and after a migration is accepted. It only ever removes images that +are **both** untagged *and* labelled `triple-c.managed=true`, so a live snapshot tag and a +migration's `pre-migration-*` rollback pin are structurally out of reach, and removal is unforced so +Docker itself refuses while any container — including a stopped project's — is still built from the +image. 7. **Reset**: Container, snapshot image **and both named volumes** all removed, then recreated from the clean base image. `remove_project_volumes` deletes `triple-c-home-{projectId}` and `triple-c-claude-config-{projectId}`, so `~/.claude`, `~/.claude.json`, the OAuth login, installed skills, session transcripts and the scheduler's tasks are all lost. ### Base-Image Migration diff --git a/app/src-tauri/src/commands/migration_commands.rs b/app/src-tauri/src/commands/migration_commands.rs index 2b2ef2f..7b86388 100644 --- a/app/src-tauri/src/commands/migration_commands.rs +++ b/app/src-tauri/src/commands/migration_commands.rs @@ -833,6 +833,16 @@ pub async fn confirm_migration( migration_store::clear_staging(&project_id)?; migration_store::clear(&project_id)?; log::info!("Migration confirmed for project {}", project_id); + + // Dropping the pin above is what turns the pre-migration image into an + // orphan: it was the only tag holding a multi-gigabyte pre-migration + // snapshot. Accepting the update is therefore the moment to sweep, and + // waiting for the project's next recreation would leave it lying around + // indefinitely. + tauri::async_runtime::spawn(async { + crate::docker::sweep_orphaned_snapshots().await; + }); + Ok(()) } diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index b5e2dd0..a158925 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -450,6 +450,18 @@ pub async fn start_project_container( ).await?; emit_progress(&app_handle, &project_id, "Starting container..."); docker::start_container(&new_id).await?; + + // The commit above moved `:latest` and orphaned the image it + // used to point at; the container holding that image open was + // removed a few lines up, so now is when Docker will actually + // let it go. Detached because this is housekeeping and the + // project is already running — and it sweeps every orphan, not + // just this one, so recreations that happened before the sweep + // existed are cleaned up too. + tauri::async_runtime::spawn(async { + docker::sweep_orphaned_snapshots().await; + }); + new_id } else { emit_progress(&app_handle, &project_id, "Starting container..."); diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index f6698f0..09d0211 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -211,6 +211,12 @@ pub const SECRET_ENV_KEYS: &[&str] = &[ ]; /// Env var name prefixes Triple-C manages itself; users cannot set these by hand. +/// The label every container Triple-C creates carries — and, because +/// `docker commit` copies a container's labels onto the image, every snapshot it +/// commits. [`sweep_orphaned_snapshots`] treats it as the mark of provenance, +/// which is what keeps the sweep away from the user's own images. +const LABEL_MANAGED: &str = "triple-c.managed"; + const RESERVED_ENV_PREFIXES: &[&str] = &["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"]; /// Exact env var names Triple-C manages itself. Not covered by @@ -1355,7 +1361,7 @@ pub async fn create_container( } let mut labels = HashMap::new(); - labels.insert("triple-c.managed".to_string(), "true".to_string()); + labels.insert(LABEL_MANAGED.to_string(), "true".to_string()); labels.insert("triple-c.project-id".to_string(), project.id.clone()); labels.insert("triple-c.project-name".to_string(), project.name.clone()); labels.insert("triple-c.backend".to_string(), format!("{:?}", project.backend)); @@ -1703,6 +1709,128 @@ fn env_holds_a_secret(env: &[String]) -> bool { }) } +/// Outcome of [`sweep_orphaned_snapshots`]. +#[derive(Debug, Default, Clone, serde::Serialize)] +pub struct SnapshotSweepReport { + /// Image ids that were removed. + pub removed: Vec, + /// Bytes the removed images accounted for, as Docker reported them. A + /// shared-layer estimate, not a disk-usage measurement. + pub reclaimed_bytes: i64, + /// Orphans Docker refused to delete because a container is still built + /// from them. Normal, not a failure — the next sweep gets them. + pub in_use: usize, + /// Orphans that could not be removed for any other reason, with the error. + pub failed: Vec<(String, String)>, + /// Set when the engine could not be reached or listed at all. + pub unavailable: Option, +} + +/// The filter every sweep runs under. Extracted so a test can hold the two +/// conditions in place: **dangling** and **labelled as ours**. Losing either +/// one turns a snapshot sweep into a prune of the user's whole image store. +fn orphan_sweep_filters() -> HashMap> { + HashMap::from([ + ("dangling".to_string(), vec!["true".to_string()]), + ( + "label".to_string(), + vec![format!("{}=true", LABEL_MANAGED)], + ), + ]) +} + +/// Remove the untagged snapshot commits left behind by recreation. +/// +/// Every recreation commits the container to `triple-c-snapshot-{id}:latest` +/// and moves that tag; the image the tag pointed at before keeps its layers and +/// loses its name. Nothing else deletes those, so a project that has been +/// recreated a dozen times leaves a dozen multi-gigabyte orphans behind. +/// +/// Two conditions, and the safety of this whole function rests on them: +/// +/// * **Dangling** — untagged. Every image the app relies on carries a tag: +/// `triple-c-snapshot-{id}:latest` is what a project is rebuilt from, and a +/// migration's `pre-migration-*` pin is the only copy of a rollback target. +/// Neither can ever match this filter, so neither can be swept. +/// * **`triple-c.managed=true`** — only images Triple-C itself committed. +/// `docker commit` copies the container's labels onto the image, which is what +/// makes the label a reliable mark of provenance. The user's own dangling +/// images are none of our business. +/// +/// Removal is not forced, so Docker refuses (409) while any container is still +/// built from the image — including the stopped containers of projects that are +/// not running. That refusal is the third safety net and it is the daemon's, +/// not ours; those orphans are simply counted and left for a later sweep. +/// +/// Never fails the caller: this is housekeeping, and a full disk is a better +/// outcome than a project that will not start. +pub async fn sweep_orphaned_snapshots() -> SnapshotSweepReport { + use bollard::image::ListImagesOptions; + + let mut report = SnapshotSweepReport::default(); + + let docker = match get_docker() { + Ok(d) => d, + Err(e) => { + report.unavailable = Some(e); + return report; + } + }; + + let images = match docker + .list_images(Some(ListImagesOptions { + all: false, + filters: orphan_sweep_filters(), + ..Default::default() + })) + .await + { + Ok(images) => images, + Err(e) => { + report.unavailable = Some(format!("Could not list orphaned snapshots: {}", e)); + return report; + } + }; + + for summary in images { + match docker + .remove_image( + &summary.id, + Some(RemoveImageOptions { + force: false, + noprune: false, + }), + None, + ) + .await + { + Ok(_) => { + report.reclaimed_bytes += summary.size; + report.removed.push(summary.id); + } + Err(bollard::errors::Error::DockerResponseServerError { + status_code: 409, .. + }) => { + report.in_use += 1; + } + Err(e) => { + report.failed.push((summary.id, e.to_string())); + } + } + } + + if !report.removed.is_empty() || report.in_use > 0 { + log::info!( + "Snapshot sweep: removed {} orphan(s) ({:.2} GB), {} still in use by a container", + report.removed.len(), + report.reclaimed_bytes as f64 / 1_073_741_824.0, + report.in_use + ); + } + + report +} + /// Outcome of [`scrub_secrets_from_snapshots`], so callers can tell the user /// what actually happened rather than guessing. #[derive(Debug, Default, Clone, serde::Serialize)] @@ -2366,7 +2494,7 @@ pub async fn list_sibling_containers() -> Result, String> .into_iter() .filter(|c| { if let Some(labels) = &c.labels { - !labels.contains_key("triple-c.managed") + !labels.contains_key(LABEL_MANAGED) } else { true } @@ -2487,6 +2615,22 @@ mod tests { assert_eq!(fp, ""); } + #[test] + fn the_orphan_sweep_only_ever_looks_at_our_own_untagged_images() { + // Both conditions are load-bearing. Without `dangling` the sweep would + // match `triple-c-snapshot-{id}:latest` — what every project is rebuilt + // from — and a migration's `pre-migration-*` pin, which is the only copy + // of a rollback target. Without the label it would match every dangling + // image on the user's machine. + let filters = orphan_sweep_filters(); + assert_eq!(filters.get("dangling"), Some(&vec!["true".to_string()])); + assert_eq!( + filters.get("label"), + Some(&vec!["triple-c.managed=true".to_string()]) + ); + assert_eq!(filters.len(), 2, "an extra filter widens or narrows the sweep"); + } + #[test] fn the_custom_env_fingerprint_never_carries_the_value() { // It goes into `triple-c.custom-env-fingerprint`, which `docker inspect`