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`