From d42b7413371983c291f3372086046b6afef8b477 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 9 Aug 2026 18:19:12 -0700 Subject: [PATCH] Migrate a project onto a new base image without losing its volumes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Projects were pinned to the image they were first created from. Both create paths preferred triple-c-snapshot-:latest whenever it existed, and container_needs_recreation compared the container's live image against the triple-c.image label — which create_container wrote from the same image it created from. A tautology that could never fire. The only escape was Reset, which calls remove_project_volumes and destroys the login, skills and transcripts. Measured consequences on this host: real projects are missing socat (so the auth bridge cannot tunnel) and bubblewrap (so sandbox mode does not work), plus Mission Control and triple-c-sso-refresh, and sit 61 packages behind the base including ca-certificates, openssl and curl. Detection. create_container now writes triple-c.base-image-id (the image ID, not RepoDigests, which local-built and custom images do not have) and triple-c.create-image. container_needs_recreation takes the expected create-image and compares against the latter, so the check means something. base-image-id is deliberately NOT compared: a base bump would otherwise silently recreate from the snapshot, consuming the "you should migrate" signal without migrating. Staleness is surfaced, never acted on automatically. Migration keeps the volumes. /home/claude and ~/.claude are volumes and the image's copy is seed-only — permanently masked after first mount — so the login, ~/.claude.json, skills, transcripts, scheduler tasks, SSH keys, cargo, uv, ruff and Claude Code itself re-attach untouched. Only root-level state is rebuilt: apt packages are replayed against the new base rather than copied, so no stale libc is dragged forward, and /usr/local, /opt and the non-bind-mounted parts of /workspace are copied verbatim with tar --skip-old-files so they can never clobber a newer base binary. docker diff is not used: on a snapshot-derived container it reports only changes since the last commit. Raw image-vs-image diffing is filtered through dpkg ownership because it otherwise lies — 8,677 raw path differences on a real project reduced to 2 genuinely user-authored files, both loose /workspace-root files. Crash safety. snapshot:latest keeps pointing at the old image until the final commit, so any crash before it self-heals on next start. Later crashes are caught by reconcile_project_statuses. The rollback pin is a docker tag: 0.057s and 0 bytes. Rollback restores the system layer only — volumes are never touched — and the UI says so rather than implying a time machine. Fixes an infinite recreation loop shipped with the MCP removal. docker commit propagates labels to the image, so a container created from a snapshot inherited its non-empty triple-c.mcp-fingerprint and the one-shot shim recreated it again on every start, forever. Lineage labels are now always written explicitly. Documents the second, separate bug this uncovered: Dockerfile changes under /home/claude never reach an existing project, migration or not, because the volume masks them. Anything that must stay upgradable belongs in /usr/local/bin or /opt, or must be seeded by entrypoint.sh. 145 Rust tests, 227 frontend tests, both builds clean. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 57 + .../src/commands/migration_commands.rs | 1387 ++++++++++++++++ app/src-tauri/src/commands/mod.rs | 1 + .../src/commands/project_commands.rs | 126 +- app/src-tauri/src/docker/container.rs | 147 +- app/src-tauri/src/docker/exec.rs | 83 +- app/src-tauri/src/docker/migration.rs | 1419 +++++++++++++++++ app/src-tauri/src/docker/mod.rs | 3 + app/src-tauri/src/lib.rs | 6 + app/src-tauri/src/models/migration.rs | 250 +++ app/src-tauri/src/models/mod.rs | 2 + app/src-tauri/src/storage/migration_store.rs | 118 ++ app/src-tauri/src/storage/mod.rs | 1 + .../projects/MigrateContainerModal.test.tsx | 241 +++ .../projects/MigrateContainerModal.tsx | 310 ++++ .../projects/MigrationReportCard.tsx | 190 +++ .../home/ContainerMigrationBanner.test.tsx | 306 ++++ .../home/ContainerMigrationBanner.tsx | 232 +++ .../components/projects/home/OverviewTab.tsx | 18 + .../components/projects/home/ProjectHome.tsx | 36 + app/src/components/projects/migrationCopy.ts | 80 + app/src/hooks/useContainerMigration.test.tsx | 262 +++ app/src/hooks/useContainerMigration.ts | 293 ++++ app/src/lib/tauri-commands.ts | 43 +- app/src/lib/types.ts | 141 ++ container/entrypoint.sh | 10 + 26 files changed, 5704 insertions(+), 58 deletions(-) create mode 100644 app/src-tauri/src/commands/migration_commands.rs create mode 100644 app/src-tauri/src/docker/migration.rs create mode 100644 app/src-tauri/src/models/migration.rs create mode 100644 app/src-tauri/src/storage/migration_store.rs create mode 100644 app/src/components/projects/MigrateContainerModal.test.tsx create mode 100644 app/src/components/projects/MigrateContainerModal.tsx create mode 100644 app/src/components/projects/MigrationReportCard.tsx create mode 100644 app/src/components/projects/home/ContainerMigrationBanner.test.tsx create mode 100644 app/src/components/projects/home/ContainerMigrationBanner.tsx create mode 100644 app/src/components/projects/migrationCopy.ts create mode 100644 app/src/hooks/useContainerMigration.test.tsx create mode 100644 app/src/hooks/useContainerMigration.ts diff --git a/CLAUDE.md b/CLAUDE.md index 203509d..02fb523 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,6 +115,9 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li Binds `0.0.0.0` — unlike STT — because *project containers*, not the host process, consume it; it therefore **always** sets a LiteLLM `master_key`, since LiteLLM without one accepts any key. + - `migration.rs` — Base-image migration: manifest capture via throwaway containers, the pure + delta computation (dpkg-ownership filter, bind-mount exclusion, verbatim-copy set), and the + crash-recovery state machine. See "Base-image migration" below. - `legacy_cleanup.rs` — One-release migration shim removing leftovers from the deleted MCP feature (containers labelled `triple-c.mcp-server`, `triple-c-net-*` networks). Deletable once users have migrated. @@ -131,6 +134,23 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li - **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, Claude Code settings.json injection, then `sleep infinity` - **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations +**`/home/claude` in the image is seed-only.** It is the mount point of the named volume +`triple-c-home-{projectId}`, so after a project's *first* start the image's copy of that directory +is masked permanently and can never be updated again. A change you make under `/home/claude` in +the `Dockerfile` or in `entrypoint.sh`'s "copy this into the home dir" style reaches **new +projects only** — existing ones will never see it, with or without a base-image migration. + +So: **anything that must stay upgradable belongs in `/usr/local/bin` or `/opt`, or must be seeded +by `entrypoint.sh` at runtime** (i.e. written on every start, from a source outside the home +volume, the way `CLAUDE_INSTRUCTIONS` → `~/.claude/CLAUDE.md` and the Mission Control skill copy +already are). Putting it in the image's `/home/claude` and expecting an image update to deliver it +is the mistake. + +The flip side is the useful half of the same fact: Claude Code itself (`~/.local/bin`), cargo, uv, +ruff, the OAuth login, `~/.claude.json`, skills, transcripts, scheduler tasks and SSH keys all +re-attach for free when a container is recreated from a *different* image — which is what makes +base-image migration cheap. + ### Container Lifecycle Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation. @@ -141,6 +161,33 @@ Containers use a **stop/start** model (not create/destroy). Installed packages p intentional (Reset exists to get back to a clean base image), but do not describe Reset as preserving credentials. +### Base-image migration (`docker/migration.rs`, `commands/migration_commands.rs`) + +A container is created from `triple-c-snapshot-{projectId}:latest` whenever that image exists, and +every recreation re-commits it — so without an explicit act, a project stays on the base image it +was first built from **forever** and never picks up a new `socat`, a new `/usr/local/bin` shim or a +security update. Migration is the non-destructive way out; Reset is the destructive one. + +- **Staleness is a surfaced signal, not an automatic trigger.** `triple-c.base-image-id` records + the lineage but is deliberately **not** compared in `container_needs_recreation` — see the long + comment there. Comparing it would recreate every project *from its own snapshot* on the next base + bump: churn on the old base, and it would consume the "you should migrate" signal without + migrating. `get_container_staleness` surfaces it; `migrate_project_to_base` acts on it. +- **A missing lineage label means "unknown, probe instead", never "stale".** +- **`:latest` keeps pointing at the old lineage until the final commit.** That is what makes every + crash before that point self-heal — `start_project_container` just recreates from the old + snapshot. After the container swap, the new container's `triple-c.migration-state=in-progress` + label plus the persisted state file let `reconcile_project_statuses` offer resume or rollback. +- **Rollback restores the system layer only.** The volumes are never touched at any point, so work + done in `$HOME` during a migrated session survives a rollback. Say so in any UI copy. +- **`/etc` is never copied**, only reported: the snapshot lineage has + `/etc/apt/sources.list.d/nodesource.sources` where the current base has `nodesource.list`, and + having both breaks every `apt-get update` on a duplicate source. Verified, not theoretical. +- **`docker diff` is useless here** — on a snapshot-derived container it reports only changes since + the last commit. Migration diffs two filesystem manifests instead, filtered through dpkg + ownership and presence-in-the-new-base. Measured on a real project, that turns 8,677 raw path + differences into 2 genuinely user-authored ones. + ### Authentication Per-project, independently configured: @@ -182,6 +229,16 @@ Anthropic and Bedrock deliberately keep Claude Code's own defaults. environment or configuration, you must also write a corresponding `triple-c.*` label at creation and compare it there, or the change will silently not take effect until some unrelated setting forces a rebuild. Never put a secret in a label; labels are readable via `docker inspect`. + (`triple-c.base-image-id` is the one deliberate exception — it is written but not compared; the + reasoning is in the comment beside the check.) +- **Always write a `triple-c.*` label explicitly, even when the value is empty.** Docker merges an + image's labels into a container's at creation, and `docker commit` copies container labels onto + the snapshot image — so a label stamped once rides that snapshot into *every* future container + forever. Verified on this host, and it is not hypothetical: `triple-c.mcp-fingerprint` has not + been written by any code since the MCP feature was removed, yet a snapshot image was found still + carrying a non-empty one, which made its one-shot recreation shim recreate that project on every + single start. Writing the key explicitly overrides the inherited value — the same defence + `MANAGED_AUTH_KEYS` applies to env vars. - **New model fields need an explicit serde default when the correct default isn't the zero value.** `#[serde(default)]` on a `bool` yields `false`; follow the `default_full_permissions` pattern in `models/project.rs` for anything that should default to true. diff --git a/app/src-tauri/src/commands/migration_commands.rs b/app/src-tauri/src/commands/migration_commands.rs new file mode 100644 index 0000000..695f1bf --- /dev/null +++ b/app/src-tauri/src/commands/migration_commands.rs @@ -0,0 +1,1387 @@ +//! Container **base-image migration** — the IPC surface and the sequence. +//! +//! Moves a project off its own snapshot lineage and onto the current base image +//! **without touching either named volume**, which is the whole point: Reset +//! already gets you onto a clean base, and it takes `~/.claude`, the OAuth +//! credential, installed skills and every session transcript with it. +//! +//! # The sequence, and why it is crash-safe by construction +//! +//! ```text +//! pre-flight (nothing destructive) +//! resolve base ─ manifest(container|snapshot) ─ manifest(base) ─ deltas +//! network check (apt-get update in a throwaway base container) +//! disk check (df inside that same container — NOT a host statvfs) +//! +//! migrate +//! persist migration_state ─ stop auth bridge / browser view / exec sessions +//! stage verbatim payload to a host tar ← container still running +//! stop container +//! commit_container_snapshot → :latest, still OLD lineage +//! tag :latest → :pre-migration- ← the rollback pin, free +//! remove container +//! create from BASE (labelled migration-state=in-progress) ─ start +//! replay apt ─ replay npm -g ─ restore payload ─ probe +//! commit_container_snapshot → :latest, NEW lineage +//! phase = awaiting-confirmation +//! ``` +//! +//! `triple-c-snapshot-:latest` keeps pointing at the **old lineage** until +//! that final commit. Everything before it therefore self-heals: whatever the +//! app was doing when it died, `start_project_container` finds no container (or +//! the old one) and recreates from the old snapshot exactly as it always has. +//! +//! For the window *after* the container swap, the new container carries +//! `triple-c.migration-state=in-progress`. `reconcile_project_statuses` — +//! which already exists and already runs at startup — pairs that label with the +//! persisted state file and offers resume or rollback. See +//! [`crate::docker::migration::decide_recovery`] for the two-signal truth table. +//! +//! # One deliberate reordering +//! +//! The design memo puts payload staging after the container stop. `docker exec` +//! requires a running container, so staging happens immediately *before* the +//! stop instead. It is a read-only `tar` to a host file, so the crash-safety +//! argument is unchanged — and it now provably runs before the commit, which +//! means the pre-migration image contains everything that was staged. +//! +//! # What rollback does and does not restore +//! +//! Rollback puts the **system layer** back: the container is recreated from the +//! `:pre-migration-` image. The named volumes were never touched by the +//! migration at all, so anything written to `$HOME` during the migrated +//! session — a new login, new skills, new transcripts — survives the rollback. +//! Every report says so in words. + +use tauri::State; + +use crate::commands::project_commands::{create_container_for_project, emit_progress}; +use crate::docker; +use crate::docker::migration::{self as mig, Recovery}; +use crate::models::{ + ContainerStaleness, MigrationOptions, MigrationPhase, MigrationPlan, MigrationReport, + MigrationState, PackageFailure, Project, ProjectStatus, MIGRATION_PHASE_AWAITING, + MIGRATION_PHASE_INTERRUPTED, +}; +use crate::storage::migration_store; +use crate::AppState; + +// ───────────────────────────────────────────────────────────────────────────── +// Staleness +// ───────────────────────────────────────────────────────────────────────────── + +/// Report how far behind the current base image a project's container is, and +/// what migrating it would actually carry across. +/// +/// Read-only. Runs two filesystem probes (~3 s each) and is therefore meant to +/// be called on demand, not polled. +#[tauri::command] +pub async fn get_container_staleness( + project_id: String, + state: State<'_, AppState>, +) -> Result { + let project = state + .projects_store + .get(&project_id) + .ok_or_else(|| format!("Project {} not found", project_id))?; + let settings = state.settings_store.get(); + let base_image = crate::models::container_config::resolve_image_name( + &settings.image_source, + &settings.custom_image_name, + ); + let snapshot_image = docker::get_snapshot_image_name(&project); + + let mut out = ContainerStaleness::default(); + out.current_base_image_id = mig::image_id(&base_image).await.unwrap_or(None); + out.snapshot_created_at = mig::image_created(&snapshot_image).await; + + // Lineage, most authoritative source first: the live container's label, + // then the snapshot image's. Both are written by `create_container` and + // propagated onto the snapshot by `docker commit`. + let container_id = docker::find_existing_container(&project).await.unwrap_or(None); + let recorded = match &container_id { + Some(id) => container_label(id, mig::LABEL_BASE_IMAGE_ID).await, + None => None, + } + .or_else(|| None); + let recorded = match recorded { + Some(v) => Some(v), + None => mig::image_labels(&snapshot_image) + .await + .get(mig::LABEL_BASE_IMAGE_ID) + .cloned(), + } + .filter(|v| !v.is_empty()); + + out.base_image_id = recorded.clone(); + out.known = recorded.is_some(); + // An unknown lineage is "probe instead", never a claim of staleness. + out.stale = match (&recorded, &out.current_base_image_id) { + (Some(a), Some(b)) => a != b, + _ => false, + }; + + // ── Probes ─────────────────────────────────────────────────────────── + let running = match &container_id { + Some(id) => docker::is_container_running(id).await.unwrap_or(false), + None => false, + }; + let from_manifest = if running { + mig::manifest_from_container(container_id.as_ref().unwrap()).await + } else if docker::image_exists(&snapshot_image).await.unwrap_or(false) { + mig::manifest_from_image(&snapshot_image).await + } else { + Err("This project has no container or snapshot image yet, so there is nothing to compare against the base image.".to_string()) + }; + + let (from_manifest, base_manifest) = match from_manifest { + Ok(f) => match mig::manifest_from_image(&base_image).await { + Ok(b) => (f, b), + Err(e) => { + out.probe_error = Some(e); + return Ok(out); + } + }, + Err(e) => { + out.probe_error = Some(e); + return Ok(out); + } + }; + + let (missing_paths, missing_features) = mig::missing_features(&from_manifest, &base_manifest); + out.missing_paths = missing_paths; + out.missing_features = missing_features; + out.apt_delta = mig::set_delta(&from_manifest.apt_manual, &base_manifest.apt_manual); + out.npm_global_delta = mig::set_delta(&from_manifest.npm_global, &base_manifest.npm_global); + out.verbatim_paths = mig::compute_verbatim_paths( + &from_manifest, + &base_manifest, + &mig::bind_mount_exclusions(&project.paths), + ); + out.outdated_package_count = mig::outdated_package_count(&from_manifest, &base_manifest); + + let (etc_only_container, etc_only_base) = mig::etc_deltas(&from_manifest, &base_manifest); + if !etc_only_container.is_empty() || !etc_only_base.is_empty() { + // Reported, never copied — /etc is the base's to own. Copying the + // snapshot's `nodesource.sources` onto a base that ships + // `nodesource.list` would break every apt-get update on a duplicate + // source, which is exactly the failure this logging exists to explain. + log::info!( + "Project {}: /etc differs from the base ({} only in the container, {} only in the base) — not copied by design", + project_id, + etc_only_container.len(), + etc_only_base.len() + ); + } + + Ok(out) +} + +async fn container_label(container_id: &str, label: &str) -> Option { + let docker = docker::get_docker().ok()?; + let info = docker.inspect_container(container_id, None).await.ok()?; + info.config + .and_then(|c| c.labels) + .and_then(|l| l.get(label).cloned()) + .filter(|v| !v.is_empty()) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Migrate +// ───────────────────────────────────────────────────────────────────────────── + +/// Project ids with a migration running **in this process right now**. +/// +/// Two things need it. `reconcile_project_statuses` is callable from the +/// frontend at any time, not only at startup, and a live migration looks +/// exactly like a crashed one from the outside (state file says `in-progress`, +/// container carries the label) — without this guard a reconcile mid-run would +/// rewrite the phase to `interrupted` underneath a migration that is fine. +/// It also makes a second concurrent `migrate_project_to_base` for the same +/// project impossible. +static ACTIVE_MIGRATIONS: std::sync::OnceLock< + std::sync::Mutex>, +> = std::sync::OnceLock::new(); + +fn active_migrations() -> &'static std::sync::Mutex> { + ACTIVE_MIGRATIONS.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new())) +} + +fn is_migrating(project_id: &str) -> bool { + active_migrations() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .contains(project_id) +} + +/// RAII marker: removes the project from [`ACTIVE_MIGRATIONS`] however the +/// migration ends, including an early `?`. +struct ActiveGuard(String); + +impl ActiveGuard { + /// `None` when a migration is already running for this project. + fn acquire(project_id: &str) -> Option { + let mut set = active_migrations() + .lock() + .unwrap_or_else(|e| e.into_inner()); + if !set.insert(project_id.to_string()) { + return None; + } + Some(Self(project_id.to_string())) + } +} + +impl Drop for ActiveGuard { + fn drop(&mut self) { + active_migrations() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&self.0); + } +} + +/// Move a project's container onto the current base image. +/// +/// Volumes are never touched. Returns a [`MigrationReport`]; the project is +/// left in `awaiting-confirmation` so the user can try the container out and +/// then either [`confirm_migration`] or [`rollback_migration`]. +#[tauri::command] +pub async fn migrate_project_to_base( + project_id: String, + options: MigrationOptions, + app_handle: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result { + let Some(_guard) = ActiveGuard::acquire(&project_id) else { + return Ok(MigrationReport::failed_preflight( + "A migration is already running for this project.", + )); + }; + + let existing = migration_store::load(&project_id)?; + match existing.as_ref().map(|s| s.phase.as_str()) { + Some(crate::models::MIGRATION_PHASE_IN_PROGRESS) => { + // Only reachable if the app died mid-migration and reconcile has + // not run yet; treat it exactly like `interrupted`. + resume_migration(project_id, existing.unwrap(), app_handle, state).await + } + Some(MIGRATION_PHASE_INTERRUPTED) => { + resume_migration(project_id, existing.unwrap(), app_handle, state).await + } + Some(MIGRATION_PHASE_AWAITING) => Ok(MigrationReport::failed_preflight( + "This project already has a finished migration waiting for a decision. Confirm it or roll it back first.", + )), + Some(_) | None => fresh_migration(project_id, options, app_handle, state).await, + } +} + +async fn fresh_migration( + project_id: String, + options: MigrationOptions, + app_handle: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result { + let mut project = state + .projects_store + .get(&project_id) + .ok_or_else(|| format!("Project {} not found", project_id))?; + crate::commands::project_commands::load_secrets_for_project(&mut project); + let settings = state.settings_store.get(); + let base_image = crate::models::container_config::resolve_image_name( + &settings.image_source, + &settings.custom_image_name, + ); + let snapshot_image = docker::get_snapshot_image_name(&project); + + // ── Pre-flight: nothing below here is destructive ──────────────────── + emit_progress(&app_handle, &project_id, "Checking the base image..."); + let base_id = match mig::image_id(&base_image).await? { + Some(id) => id, + None => { + return Ok(MigrationReport::failed_preflight(format!( + "The base image '{}' is not present. Pull or build it before migrating.", + base_image + ))) + } + }; + + let container_id = match docker::find_existing_container(&project).await? { + Some(id) => id, + None => { + return Ok(MigrationReport::failed_preflight( + "This project has no container yet. Start it once, then migrate.", + )) + } + }; + + // Both the manifest and the payload come from the *running* container, not + // from the snapshot image: the image can lag by everything installed since + // the last commit, and a verbatim set computed from a stale manifest would + // silently fail to carry that work across. + let was_running = docker::is_container_running(&container_id).await.unwrap_or(false); + if !was_running { + emit_progress(&app_handle, &project_id, "Starting the container to read its state..."); + docker::start_container(&container_id).await?; + } + + emit_progress(&app_handle, &project_id, "Inspecting the current container..."); + let from_manifest = mig::manifest_from_container(&container_id).await?; + emit_progress(&app_handle, &project_id, "Inspecting the base image..."); + let base_manifest = mig::manifest_from_image(&base_image).await?; + + let bind_targets = mig::bind_mount_exclusions(&project.paths); + let verbatim = mig::compute_verbatim_paths(&from_manifest, &base_manifest, &bind_targets); + let apt_delta = mig::set_delta(&from_manifest.apt_manual, &base_manifest.apt_manual); + let npm_delta = mig::set_delta(&from_manifest.npm_global, &base_manifest.npm_global); + let (missing_paths, _) = mig::missing_features(&from_manifest, &base_manifest); + let payload_bytes = mig::verbatim_payload_bytes(&from_manifest, &verbatim); + + emit_progress(&app_handle, &project_id, "Checking network and disk..."); + let env = mig::preflight_environment(&base_image).await?; + if options.replay_packages && !apt_delta.is_empty() && !env.network_ok { + return Ok(MigrationReport::failed_preflight(format!( + "Cannot reach the package mirrors from a container ({}), so the {} package(s) this project added could not be reinstalled. Nothing was changed. Retry when the network is back, or migrate with package replay turned off.", + env.network_detail, + apt_delta.len() + ))); + } + let required = payload_bytes + .saturating_mul(2) + .saturating_add(mig::DISK_HEADROOM_BYTES); + if env.available_bytes > 0 && env.available_bytes < required { + return Ok(MigrationReport::failed_preflight(format!( + "Not enough room on Docker's storage: {} available, about {} needed. Nothing was changed.", + human_bytes(env.available_bytes), + human_bytes(required) + ))); + } + + // ── Everything from here is recorded before it happens ─────────────── + let mut mstate = MigrationState::new( + mig::image_id(&snapshot_image).await.unwrap_or(None), + Some(base_id), + options, + ); + mstate.plan = Some(MigrationPlan { + apt_packages: apt_delta.clone(), + npm_packages: npm_delta.clone(), + verbatim_paths: verbatim.clone(), + missing_paths, + }); + migration_store::save(&project_id, &mstate)?; + + // Quiesce: every host-side attachment points at a container that is about + // to stop existing. + emit_progress(&app_handle, &project_id, "Closing sessions..."); + state.auth_bridge.stop(&project_id).await; + crate::browser_view::manager().stop(&project_id).await; + state.exec_manager.close_sessions_for_container(&container_id).await; + + // Stage the payload while the container is still up (docker exec needs it). + if options.copy_paths && !verbatim.is_empty() { + emit_progress( + &app_handle, + &project_id, + &format!("Saving {} item(s) from /usr/local, /opt, /srv and /workspace...", verbatim.len()), + ); + match stage_payload(&container_id, &project_id, &verbatim).await { + Ok(path) => { + mstate.staging_path = Some(path); + migration_store::save(&project_id, &mstate)?; + } + Err(e) => { + // Nothing destructive has happened yet, so bail cleanly. + let _ = migration_store::clear(&project_id); + let _ = migration_store::clear_staging(&project_id); + return Ok(MigrationReport::failed_preflight(format!( + "Could not save the files that would be carried across: {}. Nothing was changed.", + e + ))); + } + } + } + + emit_progress(&app_handle, &project_id, "Stopping the container..."); + let _ = state + .projects_store + .update_status(&project_id, ProjectStatus::Stopping); + let _ = docker::stop_container(&container_id).await; + + emit_progress(&app_handle, &project_id, "Saving the current image..."); + docker::commit_container_snapshot(&container_id, &project).await?; + + // The rollback pin. `docker tag` of a 5.49 GB image was measured at 0.036 s + // and 0 bytes, so this is free to take and only costs disk if it is kept. + let (repo, _) = mig::split_image_ref(&snapshot_image); + let tag = mig::rollback_tag(&chrono::Utc::now()); + let rollback_ref = format!("{}:{}", repo, tag); + if let Err(e) = mig::tag_image(&snapshot_image, &repo, &tag).await { + log::warn!("Could not create the rollback tag: {} — continuing", e); + } else { + mstate.rollback_image = Some(rollback_ref); + migration_store::save(&project_id, &mstate)?; + } + + emit_progress(&app_handle, &project_id, "Recreating on the new base image..."); + docker::remove_container(&container_id).await?; + + let docker_socket = settings + .docker_socket_path + .clone() + .unwrap_or_else(default_docker_socket); + let new_id = match create_container_for_project( + &project, + &settings, + &docker_socket, + settings.global_aws.aws_config_path.as_deref(), + &base_image, + &base_image, + docker::CreateExtras { + extra_labels: &[(mig::LABEL_MIGRATION_STATE, mig::MIGRATION_LABEL_IN_PROGRESS)], + }, + ) + .await + { + Ok(id) => id, + Err(e) => { + // The old container is gone but `:latest` still holds the old + // lineage, so putting it back is a plain recreate. + let report = auto_rollback(&project, &settings, &mstate, &app_handle, &state, &e).await; + return Ok(report); + } + }; + let _ = state + .projects_store + .set_container_id(&project_id, Some(new_id.clone())); + + if let Err(e) = docker::start_container(&new_id).await { + let _ = docker::remove_container(&new_id).await; + let report = auto_rollback(&project, &settings, &mstate, &app_handle, &state, &e).await; + return Ok(report); + } + + finish_migration(project, mstate, new_id, app_handle, state).await +} + +async fn resume_migration( + project_id: String, + mstate: MigrationState, + app_handle: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result { + let mut project = state + .projects_store + .get(&project_id) + .ok_or_else(|| format!("Project {} not found", project_id))?; + crate::commands::project_commands::load_secrets_for_project(&mut project); + + let container_id = match docker::find_existing_container(&project).await? { + Some(id) => id, + None => { + // The swap never landed. `:latest` is still the old lineage, so the + // next ordinary start puts the project back exactly as it was. + let _ = migration_store::clear(&project_id); + let _ = migration_store::clear_staging(&project_id); + return Ok(MigrationReport::failed_preflight( + "The interrupted migration never replaced the container, so there was nothing to resume. The project is unchanged — start it as usual.", + )); + } + }; + if !docker::is_container_running(&container_id).await.unwrap_or(false) { + emit_progress(&app_handle, &project_id, "Starting the migrated container..."); + docker::start_container(&container_id).await?; + } + emit_progress(&app_handle, &project_id, "Resuming the interrupted migration..."); + finish_migration(project, mstate, container_id, app_handle, state).await +} + +/// Replay, restore, probe and commit. Shared by a fresh migration and a resume, +/// so both take exactly the same path from the container swap onward. +async fn finish_migration( + project: Project, + mut mstate: MigrationState, + container_id: String, + app_handle: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result { + let project_id = project.id.clone(); + let plan = mstate.plan.clone().unwrap_or_default(); + let options = mstate.options; + + let mut packages_requested: Vec = Vec::new(); + let mut packages_installed: Vec = Vec::new(); + let mut packages_failed: Vec = Vec::new(); + let mut paths_copied: Vec = Vec::new(); + let mut notes: Vec = Vec::new(); + + if options.replay_packages { + packages_requested.extend(plan.apt_packages.iter().cloned()); + packages_requested.extend(plan.npm_packages.iter().cloned()); + + if !plan.apt_packages.is_empty() { + emit_progress( + &app_handle, + &project_id, + &format!("Reinstalling {} apt package(s)...", plan.apt_packages.len()), + ); + let (ok, failed) = replay_apt(&container_id, &plan.apt_packages).await; + packages_installed.extend(ok); + packages_failed.extend(failed); + } + if !plan.npm_packages.is_empty() { + emit_progress( + &app_handle, + &project_id, + &format!("Reinstalling {} global npm package(s)...", plan.npm_packages.len()), + ); + let (ok, failed) = replay_npm(&container_id, &plan.npm_packages).await; + packages_installed.extend(ok); + packages_failed.extend(failed); + } + } + + if options.copy_paths { + if let Some(ref staging) = mstate.staging_path { + if std::path::Path::new(staging).exists() { + emit_progress(&app_handle, &project_id, "Restoring saved files..."); + match restore_payload(&container_id, staging).await { + Ok(()) => paths_copied = plan.verbatim_paths.clone(), + Err(e) => notes.push(format!("Some files could not be restored: {}", e)), + } + } + } + } + + // Probe the new container so the report states what it *actually* gained, + // rather than what the base was expected to provide. + emit_progress(&app_handle, &project_id, "Verifying the new container..."); + let mut features_restored: Vec = Vec::new(); + match mig::manifest_from_container(&container_id).await { + Ok(after) => { + for path in &plan.missing_paths { + if after.features.contains(path) { + if let Some((_, label)) = + mig::FEATURE_PROBES.iter().find(|(p, _)| p == path) + { + features_restored.push((*label).to_string()); + } + } + } + } + Err(e) => notes.push(format!("Could not verify the new container: {}", e)), + } + + emit_progress(&app_handle, &project_id, "Saving the migrated image..."); + if let Err(e) = docker::commit_container_snapshot(&container_id, &project).await { + // `:latest` still points at the old lineage, so an ordinary start would + // quietly undo the migration. Leave the record in place and let the + // user decide. + mstate.phase = MIGRATION_PHASE_INTERRUPTED.to_string(); + let report = MigrationReport { + phase: MigrationPhase::Failed, + packages_requested, + packages_installed, + packages_failed, + paths_copied, + features_restored, + rollback_available: mstate.rollback_image.is_some(), + message: format!( + "The container is running on the new base image, but saving it failed: {}. Nothing was lost — your home directory and Claude config live in volumes that were never touched — but the migration is not finished. Resume it, or roll back.", + e + ), + }; + mstate.report = Some(report.clone()); + let _ = migration_store::save(&project_id, &mstate); + return Ok(report); + } + + // The staged tar has done its job and can be several GB. + let _ = migration_store::clear_staging(&project_id); + mstate.staging_path = None; + + // `keep_rollback` is the disk trade: snapshots share almost nothing with the + // current base (3 of 31 layers measured), so a retained rollback holds + // roughly a whole snapshot — 3.8 to 12.3 GB on real projects. Off means the + // pin is dropped the moment the migration is known to have worked. + let mut rollback_available = mstate.rollback_image.is_some(); + if !options.keep_rollback { + if let Some(ref reference) = mstate.rollback_image.clone() { + match mig::untag_image(reference).await { + Ok(()) => { + mstate.rollback_image = None; + rollback_available = false; + } + Err(e) => log::warn!("Could not drop the rollback tag {}: {}", reference, e), + } + } + } + + let phase = if packages_failed.is_empty() && notes.is_empty() { + MigrationPhase::Succeeded + } else { + MigrationPhase::Partial + }; + let report = MigrationReport { + message: summarize( + phase, + &packages_installed, + &packages_failed, + &paths_copied, + &features_restored, + ¬es, + rollback_available, + ), + phase, + packages_requested, + packages_installed, + packages_failed, + paths_copied, + features_restored, + rollback_available, + }; + + mstate.phase = MIGRATION_PHASE_AWAITING.to_string(); + mstate.report = Some(report.clone()); + migration_store::save(&project_id, &mstate)?; + + let _ = state + .projects_store + .update_status(&project_id, ProjectStatus::Running); + emit_progress(&app_handle, &project_id, "Migration finished."); + Ok(report) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Confirm / rollback / state +// ───────────────────────────────────────────────────────────────────────────── + +/// Accept a finished migration: drop the rollback tag and the staged payload, +/// and clear the migration record. Idempotent. +#[tauri::command] +pub async fn confirm_migration( + project_id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let _ = &state; + let Some(mstate) = migration_store::load(&project_id)? else { + return Ok(()); + }; + if let Some(ref reference) = mstate.rollback_image { + if let Err(e) = mig::untag_image(reference).await { + log::warn!("Could not drop the rollback tag {}: {}", reference, e); + } + } + migration_store::clear_staging(&project_id)?; + migration_store::clear(&project_id)?; + log::info!("Migration confirmed for project {}", project_id); + Ok(()) +} + +/// Undo a migration: put the container back on its pre-migration image. +/// +/// Restores the **system layer only**. Both named volumes were untouched +/// throughout, so anything written to `$HOME` while the migrated container was +/// running — logins, skills, transcripts, scheduler tasks — survives. +#[tauri::command] +pub async fn rollback_migration( + project_id: String, + app_handle: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let mut project = state + .projects_store + .get(&project_id) + .ok_or_else(|| format!("Project {} not found", project_id))?; + crate::commands::project_commands::load_secrets_for_project(&mut project); + let settings = state.settings_store.get(); + + let Some(mstate) = migration_store::load(&project_id)? else { + return Err("There is no migration to roll back for this project.".to_string()); + }; + let Some(rollback_ref) = mstate.rollback_image.clone() else { + return Err( + "This migration kept no rollback image, so it cannot be undone. Nothing was changed." + .to_string(), + ); + }; + + emit_progress(&app_handle, &project_id, "Rolling back..."); + state.auth_bridge.stop(&project_id).await; + crate::browser_view::manager().stop(&project_id).await; + if let Some(id) = docker::find_existing_container(&project).await? { + state.exec_manager.close_sessions_for_container(&id).await; + let _ = docker::stop_container(&id).await; + docker::remove_container(&id).await?; + } + + let snapshot_image = docker::get_snapshot_image_name(&project); + let (repo, tag) = mig::split_image_ref(&snapshot_image); + mig::tag_image(&rollback_ref, &repo, &tag).await?; + + let base_image = crate::models::container_config::resolve_image_name( + &settings.image_source, + &settings.custom_image_name, + ); + let docker_socket = settings + .docker_socket_path + .clone() + .unwrap_or_else(default_docker_socket); + let new_id = create_container_for_project( + &project, + &settings, + &docker_socket, + settings.global_aws.aws_config_path.as_deref(), + &snapshot_image, + &base_image, + docker::CreateExtras::default(), + ) + .await?; + docker::start_container(&new_id).await?; + state + .projects_store + .set_container_id(&project_id, Some(new_id))?; + state + .projects_store + .update_status(&project_id, ProjectStatus::Running)?; + + // The rollback image is now `:latest` again; the extra tag is redundant. + let _ = mig::untag_image(&rollback_ref).await; + migration_store::clear_staging(&project_id)?; + migration_store::clear(&project_id)?; + emit_progress( + &app_handle, + &project_id, + "Rolled back. Your home directory and Claude config were never touched.", + ); + Ok(()) +} + +/// The persisted migration record, if one exists. +#[tauri::command] +pub async fn get_migration_state( + project_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let _ = &state; + migration_store::load(&project_id) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Crash recovery, called from reconcile_project_statuses +// ───────────────────────────────────────────────────────────────────────────── + +/// Reconcile one project's persisted migration record against reality. +/// +/// Called from `reconcile_project_statuses`, which already runs at startup. +/// Self-healing cases are cleaned up silently; anything needing a decision is +/// left in place with its phase normalised to `interrupted` so the UI can offer +/// resume or rollback. +pub async fn reconcile_migration(project: &Project, app_handle: &tauri::AppHandle) { + // A migration running right now is indistinguishable from a crashed one + // from the outside; only this process knows the difference. + if is_migrating(&project.id) { + return; + } + let state = match migration_store::load(&project.id) { + Ok(Some(s)) => s, + Ok(None) => return, + Err(e) => { + log::warn!("Could not read migration state for {}: {}", project.id, e); + return; + } + }; + + let labelled = match docker::find_existing_container(project).await { + Ok(Some(id)) => container_label(&id, mig::LABEL_MIGRATION_STATE).await.as_deref() + == Some(mig::MIGRATION_LABEL_IN_PROGRESS), + _ => false, + }; + + match mig::decide_recovery(Some(state.phase.as_str()), labelled) { + Recovery::None => {} + Recovery::SelfHeal => { + log::info!( + "Project '{}' ({}) has a migration record from before the container was replaced — the snapshot still holds the old image, so it self-heals", + project.name, + project.id + ); + if let Some(ref reference) = state.rollback_image { + let _ = mig::untag_image(reference).await; + } + let _ = migration_store::clear_staging(&project.id); + let _ = migration_store::clear(&project.id); + } + Recovery::OfferResumeOrRollback => { + if state.phase != MIGRATION_PHASE_INTERRUPTED { + let mut s = state.clone(); + s.phase = MIGRATION_PHASE_INTERRUPTED.to_string(); + let _ = migration_store::save(&project.id, &s); + } + log::warn!( + "Project '{}' ({}) has an unfinished base-image migration — offering resume or rollback", + project.name, + project.id + ); + emit_progress( + app_handle, + &project.id, + "An unfinished base-image migration was found. Resume it or roll it back.", + ); + } + Recovery::OfferConfirmOrRollback => { + log::info!( + "Project '{}' ({}) has a finished migration awaiting confirmation", + project.name, + project.id + ); + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Internals +// ───────────────────────────────────────────────────────────────────────────── + +fn default_docker_socket() -> String { + if cfg!(target_os = "windows") { + "//./pipe/docker_engine".to_string() + } else { + "/var/run/docker.sock".to_string() + } +} + +fn human_bytes(n: u64) -> String { + const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; + let mut v = n as f64; + let mut i = 0; + while v >= 1024.0 && i < UNITS.len() - 1 { + v /= 1024.0; + i += 1; + } + if i == 0 { + format!("{} {}", n, UNITS[0]) + } else { + format!("{:.1} {}", v, UNITS[i]) + } +} + +/// Tar the verbatim set out of the running container into a host file. +/// +/// Mirrors `download_container_backup`'s stream-an-exec's-stdout-to-a-file +/// pattern. The member list goes in via a file rather than argv, because a +/// large `/usr/local/lib` tree can produce more paths than `execve` will take. +async fn stage_payload( + container_id: &str, + project_id: &str, + verbatim: &[String], +) -> Result { + use bollard::container::LogOutput; + use futures_util::StreamExt; + use tokio::io::AsyncWriteExt; + + let members = mig::tar_member_names(verbatim); + if members.is_empty() { + return Err("nothing to stage".to_string()); + } + let list = format!("{}\n", members.join("\n")); + let list_path = docker::exec::upload_bytes_to_container( + container_id, + "/tmp", + "triple-c-migrate-include.txt", + list.as_bytes(), + 0o600, + ) + .await?; + + let host_path = migration_store::staging_path(project_id)?; + let host_path_str = host_path.to_string_lossy().to_string(); + + // `--ignore-failed-read` keeps a file that vanishes mid-walk from aborting + // the archive. `--numeric-owner` because uid/gid are remapped to the host + // user at runtime and names may not resolve in the new base. + let cmd = vec![ + "tar".to_string(), + "-cf".to_string(), + "-".to_string(), + "--numeric-owner".to_string(), + "--ignore-failed-read".to_string(), + "-C".to_string(), + "/".to_string(), + "-T".to_string(), + list_path, + ]; + let exec = docker::exec::create_attached_exec_as(container_id, cmd, false, "root", "/").await?; + let mut output = exec.output; + + let file = tokio::fs::File::create(&host_path) + .await + .map_err(|e| format!("Failed to create the staging file: {}", e))?; + let mut writer = tokio::io::BufWriter::new(file); + let mut total: u64 = 0; + let mut stderr_text = String::new(); + let mut stream_err: Option = None; + + while let Some(msg) = output.next().await { + match msg { + Ok(LogOutput::StdOut { message }) => { + if let Err(e) = writer.write_all(&message).await { + stream_err = Some(format!("Failed to write the staging file: {}", e)); + break; + } + total += message.len() as u64; + } + Ok(LogOutput::StdErr { message }) => { + stderr_text.push_str(&String::from_utf8_lossy(&message)); + } + Ok(_) => {} + Err(e) => { + stream_err = Some(format!("Staging stream error: {}", e)); + break; + } + } + } + if stream_err.is_none() { + if let Err(e) = writer.flush().await { + stream_err = Some(format!("Failed to finalize the staging file: {}", e)); + } + } + drop(writer); + + // A tar that aborts mid-stream still emits bytes, so a non-zero exit has to + // beat `total > 0`. + let exit_code = docker::exec::wait_for_exec_exit(&exec.exec_id).await; + if stream_err.is_none() && exit_code.is_some_and(|c| c != 0) { + stream_err = Some(format!( + "tar failed (exit {}){}", + exit_code.unwrap_or(-1), + if stderr_text.trim().is_empty() { + String::new() + } else { + format!(": {}", stderr_text.trim()) + } + )); + } + if stream_err.is_none() && total == 0 { + stream_err = Some("tar produced no data".to_string()); + } + + if let Some(err) = stream_err { + let _ = tokio::fs::remove_file(&host_path).await; + return Err(err); + } + + log::info!( + "Staged {} bytes of migration payload for project {} to {}", + total, + project_id, + host_path_str + ); + Ok(host_path_str) +} + +/// Stream a staged payload back into the new container. +/// +/// `--skip-old-files` is the never-clobber guarantee: a copied file can never +/// replace a newer binary the base already ships. (GNU tar's `--keep-old-files` +/// refuses just as firmly but reports every pre-existing file as an error and +/// exits non-zero, which would make the ordinary, expected outcome +/// indistinguishable from a real failure. The payload is built to exclude +/// anything already present in the base, so collisions should be rare either +/// way.) +async fn restore_payload(container_id: &str, host_path: &str) -> Result<(), String> { + use bollard::container::LogOutput; + use futures_util::StreamExt; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let cmd = vec![ + "tar".to_string(), + "-xf".to_string(), + "-".to_string(), + "--skip-old-files".to_string(), + "--numeric-owner".to_string(), + "-p".to_string(), + "-C".to_string(), + "/".to_string(), + ]; + let exec = docker::exec::create_attached_exec_as(container_id, cmd, false, "root", "/").await?; + let mut input = exec.input; + let mut output = exec.output; + + let mut file = tokio::fs::File::open(host_path) + .await + .map_err(|e| format!("Failed to open the staged payload: {}", e))?; + + // Drain stderr concurrently: a big payload can fill the exec's output pipe + // and deadlock the write below if nothing is reading. + let drain = tokio::spawn(async move { + let mut text = String::new(); + while let Some(msg) = output.next().await { + match msg { + Ok(LogOutput::StdErr { message }) | Ok(LogOutput::StdOut { message }) => { + text.push_str(&String::from_utf8_lossy(&message)) + } + Ok(_) => {} + Err(_) => break, + } + } + text + }); + + let mut buf = vec![0u8; 256 * 1024]; + loop { + let n = file + .read(&mut buf) + .await + .map_err(|e| format!("Failed to read the staged payload: {}", e))?; + if n == 0 { + break; + } + input + .write_all(&buf[..n]) + .await + .map_err(|e| format!("Failed to send the payload into the container: {}", e))?; + } + input + .shutdown() + .await + .map_err(|e| format!("Failed to close the payload stream: {}", e))?; + drop(input); + + let stderr_text = drain.await.unwrap_or_default(); + let exit_code = docker::exec::wait_for_exec_exit(&exec.exec_id).await; + if exit_code.is_some_and(|c| c != 0) { + return Err(format!( + "tar exited {}{}", + exit_code.unwrap_or(-1), + if stderr_text.trim().is_empty() { + String::new() + } else { + format!(": {}", stderr_text.trim()) + } + )); + } + Ok(()) +} + +/// Replay apt packages: one transaction, then per-package on failure. +/// +/// A single unavailable package must never cost the whole migration, which is +/// why the bulk failure falls through to a loop instead of aborting. Replaying +/// eight packages onto the current base was measured at 69.8 s, exit 0. +async fn replay_apt(container_id: &str, packages: &[String]) -> (Vec, Vec) { + let update = format!( + "DEBIAN_FRONTEND=noninteractive apt-get -o Acquire::Retries=3 update" + ); + let _ = run_root(container_id, &update).await; + + let bulk = format!( + "DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends {}", + packages + .iter() + .map(|p| shell_quote(p)) + .collect::>() + .join(" ") + ); + match run_root(container_id, &bulk).await { + Ok((_, 0)) => return (packages.to_vec(), Vec::new()), + Ok((out, code)) => log::warn!( + "Bulk apt replay failed (exit {}), falling back to one package at a time: {}", + code, + tail(&out) + ), + Err(e) => log::warn!("Bulk apt replay could not run ({}), falling back", e), + } + + let mut ok = Vec::new(); + let mut failed = Vec::new(); + for pkg in packages { + let cmd = format!( + "DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends {}", + shell_quote(pkg) + ); + match run_root(container_id, &cmd).await { + Ok((_, 0)) => ok.push(pkg.clone()), + Ok((out, code)) => failed.push(PackageFailure { + name: pkg.clone(), + reason: format!("apt-get exited {}: {}", code, tail(&out)), + }), + Err(e) => failed.push(PackageFailure { + name: pkg.clone(), + reason: e, + }), + } + } + (ok, failed) +} + +/// Replay global npm packages. npm's prefix in this image is `/usr`, so these +/// live in the container's writable layer and really are lost on an image swap. +async fn replay_npm(container_id: &str, packages: &[String]) -> (Vec, Vec) { + let bulk = format!( + "npm install -g --no-fund --no-audit {}", + packages + .iter() + .map(|p| shell_quote(p)) + .collect::>() + .join(" ") + ); + match run_root(container_id, &bulk).await { + Ok((_, 0)) => return (packages.to_vec(), Vec::new()), + Ok((out, code)) => log::warn!( + "Bulk npm replay failed (exit {}), falling back to one package at a time: {}", + code, + tail(&out) + ), + Err(e) => log::warn!("Bulk npm replay could not run ({}), falling back", e), + } + + let mut ok = Vec::new(); + let mut failed = Vec::new(); + for pkg in packages { + let cmd = format!("npm install -g --no-fund --no-audit {}", shell_quote(pkg)); + match run_root(container_id, &cmd).await { + Ok((_, 0)) => ok.push(pkg.clone()), + Ok((out, code)) => failed.push(PackageFailure { + name: pkg.clone(), + reason: format!("npm exited {}: {}", code, tail(&out)), + }), + Err(e) => failed.push(PackageFailure { + name: pkg.clone(), + reason: e, + }), + } + } + (ok, failed) +} + +async fn run_root(container_id: &str, script: &str) -> Result<(String, i64), String> { + docker::exec::exec_oneshot_as( + container_id, + "root", + vec!["/bin/sh".to_string(), "-c".to_string(), script.to_string()], + Vec::new(), + ) + .await +} + +fn shell_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', r#"'\''"#)) +} + +fn tail(s: &str) -> String { + let t = s.trim(); + let start = t.len().saturating_sub(400); + t[start..].to_string() +} + +/// Put the container back after a failure that happened *after* removal but +/// *before* the migration could finish. `:latest` still holds the old lineage +/// at that point, so this is an ordinary recreate. +async fn auto_rollback( + project: &Project, + settings: &crate::models::AppSettings, + mstate: &MigrationState, + app_handle: &tauri::AppHandle, + state: &State<'_, AppState>, + cause: &str, +) -> MigrationReport { + emit_progress( + app_handle, + &project.id, + "Migration failed — putting the previous container back...", + ); + let snapshot_image = docker::get_snapshot_image_name(project); + let base_image = crate::models::container_config::resolve_image_name( + &settings.image_source, + &settings.custom_image_name, + ); + let docker_socket = settings + .docker_socket_path + .clone() + .unwrap_or_else(default_docker_socket); + + let mut restored = false; + match create_container_for_project( + project, + settings, + &docker_socket, + settings.global_aws.aws_config_path.as_deref(), + &snapshot_image, + &base_image, + docker::CreateExtras::default(), + ) + .await + { + Ok(id) => { + if let Err(e) = docker::start_container(&id).await { + log::error!("Rollback container would not start: {}", e); + } else { + restored = true; + } + let _ = state + .projects_store + .set_container_id(&project.id, Some(id)); + let _ = state + .projects_store + .update_status(&project.id, ProjectStatus::Running); + } + Err(e) => log::error!("Could not recreate the previous container: {}", e), + } + + if let Some(ref reference) = mstate.rollback_image { + let _ = mig::untag_image(reference).await; + } + let _ = migration_store::clear_staging(&project.id); + let _ = migration_store::clear(&project.id); + + MigrationReport { + phase: MigrationPhase::RolledBack, + packages_requested: Vec::new(), + packages_installed: Vec::new(), + packages_failed: Vec::new(), + paths_copied: Vec::new(), + features_restored: Vec::new(), + rollback_available: false, + message: if restored { + format!( + "The migration failed ({}) and the previous container has been put back. Your home directory and Claude config were never touched.", + cause + ) + } else { + format!( + "The migration failed ({}) and the previous container could not be restarted automatically. Its image is intact — start the project again to recreate it. Your home directory and Claude config were never touched.", + cause + ) + }, + } +} + +fn summarize( + phase: MigrationPhase, + installed: &[String], + failed: &[PackageFailure], + copied: &[String], + features: &[String], + notes: &[String], + rollback_available: bool, +) -> String { + let mut parts: Vec = Vec::new(); + parts.push(match phase { + MigrationPhase::Succeeded => "This project now runs on the current base image.".to_string(), + _ => "This project now runs on the current base image, with some gaps.".to_string(), + }); + if !features.is_empty() { + parts.push(format!("Gained: {}.", features.join(", "))); + } + if !installed.is_empty() { + parts.push(format!("Reinstalled {} package(s).", installed.len())); + } + if !copied.is_empty() { + parts.push(format!("Carried across {} item(s).", copied.len())); + } + if !failed.is_empty() { + let names: Vec<&str> = failed.iter().map(|f| f.name.as_str()).take(5).collect(); + parts.push(format!( + "{} package(s) could not be reinstalled ({}{}).", + failed.len(), + names.join(", "), + if failed.len() > names.len() { ", …" } else { "" } + )); + } + for note in notes { + parts.push(note.clone()); + } + parts.push( + "Your home directory and Claude config live in volumes that were never touched, so the login, skills, transcripts and scheduled tasks are exactly as they were." + .to_string(), + ); + parts.push(if rollback_available { + "Roll back at any time until you confirm.".to_string() + } else { + "No rollback image was kept, so this cannot be undone.".to_string() + }); + parts.join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn byte_sizes_read_the_way_a_disk_warning_should() { + assert_eq!(human_bytes(512), "512 B"); + assert_eq!(human_bytes(2 * 1024), "2.0 KiB"); + assert_eq!(human_bytes(5_368_709_120), "5.0 GiB"); + } + + #[test] + fn package_names_cannot_break_out_of_the_replay_shell_command() { + assert_eq!(shell_quote("socat"), "'socat'"); + assert_eq!(shell_quote("a'; rm -rf /"), r#"'a'\''; rm -rf /'"#); + } + + #[test] + fn the_summary_always_says_the_volumes_were_untouched() { + let msg = summarize( + MigrationPhase::Succeeded, + &["socat".to_string()], + &[], + &[], + &["Auth bridge tunnel (socat)".to_string()], + &[], + true, + ); + assert!(msg.contains("never touched")); + assert!(msg.contains("Auth bridge tunnel (socat)")); + assert!(msg.contains("Roll back at any time")); + + let msg = summarize( + MigrationPhase::Partial, + &[], + &[PackageFailure { + name: "obsolete-pkg".to_string(), + reason: "not found".to_string(), + }], + &[], + &[], + &[], + false, + ); + assert!(msg.contains("obsolete-pkg")); + assert!(msg.contains("cannot be undone")); + assert!(msg.contains("never touched")); + } + + #[test] + fn a_live_migration_is_distinguishable_from_a_crashed_one() { + // The whole point: reconcile cannot tell them apart from the outside, + // so an in-process marker is the only thing that can. + let id = "guard-test-project"; + assert!(!is_migrating(id)); + { + let g = ActiveGuard::acquire(id).expect("first acquire must succeed"); + assert!(is_migrating(id)); + assert!( + ActiveGuard::acquire(id).is_none(), + "a second concurrent migration must be refused" + ); + drop(g); + } + assert!(!is_migrating(id), "the guard must release on drop"); + // …including when the migration bailed out through an early return. + fn early_return(id: &str) -> Option<()> { + let _g = ActiveGuard::acquire(id)?; + None + } + assert!(early_return(id).is_none()); + assert!(!is_migrating(id)); + } + + #[test] + fn a_preflight_failure_reports_no_rollback_because_nothing_was_touched() { + let r = MigrationReport::failed_preflight("nope"); + assert_eq!(r.phase, MigrationPhase::Failed); + assert!(!r.rollback_available); + assert!(r.packages_requested.is_empty()); + } +} diff --git a/app/src-tauri/src/commands/mod.rs b/app/src-tauri/src/commands/mod.rs index 61a01d2..bd2ecfc 100644 --- a/app/src-tauri/src/commands/mod.rs +++ b/app/src-tauri/src/commands/mod.rs @@ -7,6 +7,7 @@ pub mod gateway_commands; pub mod help_commands; pub mod inspect_commands; pub mod install_helper_commands; +pub mod migration_commands; pub mod project_commands; pub mod settings_commands; pub mod stt_commands; diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index 1a7ee8d..4d41728 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -2,11 +2,11 @@ use tauri::{Emitter, State}; use crate::commands::aws_commands; use crate::docker; -use crate::models::{container_config, Backend, BedrockAuthMethod, Project, ProjectPath, ProjectStatus}; +use crate::models::{container_config, AppSettings, Backend, BedrockAuthMethod, Project, ProjectPath, ProjectStatus}; use crate::storage::secure; use crate::AppState; -fn emit_progress(app_handle: &tauri::AppHandle, project_id: &str, message: &str) { +pub(crate) fn emit_progress(app_handle: &tauri::AppHandle, project_id: &str, message: &str) { let _ = app_handle.emit( "container-progress", serde_json::json!({ @@ -43,8 +43,49 @@ fn store_secrets_for_project(project: &Project) -> Result<(), String> { Ok(()) } +/// Create the project's container, threading every global setting through. +/// +/// Exists so that the two ordinary create paths below and base-image migration +/// cannot drift apart — a container created by a migration must be +/// indistinguishable from one created by a normal start, or the next +/// `container_needs_recreation` would immediately throw it away. +/// +/// `create_image` is what to create *from* (the snapshot or the base); +/// `base_image_name` is the configured base, which `create_container` needs in +/// order to tell those two apart when it stamps the lineage labels. +pub(crate) async fn create_container_for_project( + project: &Project, + settings: &AppSettings, + docker_socket: &str, + aws_config_path: Option<&str>, + create_image: &str, + base_image_name: &str, + extras: docker::CreateExtras<'_>, +) -> Result { + docker::create_container( + project, + docker_socket, + create_image, + base_image_name, + extras, + aws_config_path, + &settings.global_aws, + &settings.global_ollama, + &settings.global_llamacpp, + &settings.global_openai_compatible, + settings.global_claude_instructions.as_deref(), + &settings.global_custom_env_vars, + settings.timezone.as_deref(), + settings.global_claude_code_settings.as_ref(), + settings.default_ssh_key_path.as_deref(), + settings.default_git_user_name.as_deref(), + settings.default_git_user_email.as_deref(), + ) + .await +} + /// Populate secret fields on a project struct from the OS keychain. -fn load_secrets_for_project(project: &mut Project) { +pub(crate) fn load_secrets_for_project(project: &mut Project) { project.git_token = secure::get_project_secret(&project.id, "git-token") .unwrap_or(None); if let Some(ref mut bedrock) = project.bedrock_config { @@ -317,11 +358,26 @@ pub async fn start_project_container( // AWS config path from global settings let aws_config_path = settings.global_aws.aws_config_path.clone(); + // What we would create this container from *right now*: the project's + // snapshot when one exists, else the configured base. This is the value + // `container_needs_recreation` compares against the container's + // `triple-c.create-image` label — the check that replaced the old + // tautological one. It is resolved *before* the commit below, so it + // describes the pre-commit world the existing container was born into. + let snapshot_image = docker::get_snapshot_image_name(&project); + let expected_create_image = + if docker::image_exists(&snapshot_image).await.unwrap_or(false) { + snapshot_image.clone() + } else { + image_name.clone() + }; + let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? { // Check if config changed — if so, snapshot + recreate let needs_recreate = docker::container_needs_recreation( &existing_id, &project, + &expected_create_image, &settings.global_aws, &settings.global_ollama, &settings.global_llamacpp, @@ -352,30 +408,24 @@ pub async fn start_project_container( docker::remove_legacy_mcp_containers(&project.id).await; docker::remove_legacy_project_network(&project.id).await; - // Create from snapshot image (preserves system-level changes) - let snapshot_image = docker::get_snapshot_image_name(&project); + // Create from snapshot image (preserves system-level changes). + // Re-resolved after the commit above: when no snapshot existed + // before, one does now, and creating from the base instead + // would throw away the state that was just saved. let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) { - snapshot_image + snapshot_image.clone() } else { image_name.clone() }; - let new_id = docker::create_container( + let new_id = create_container_for_project( &project, + &settings, &docker_socket, - &create_image, aws_config_path.as_deref(), - &settings.global_aws, - &settings.global_ollama, - &settings.global_llamacpp, - &settings.global_openai_compatible, - settings.global_claude_instructions.as_deref(), - &settings.global_custom_env_vars, - settings.timezone.as_deref(), - settings.global_claude_code_settings.as_ref(), - settings.default_ssh_key_path.as_deref(), - settings.default_git_user_name.as_deref(), - settings.default_git_user_email.as_deref(), + &create_image, + &image_name, + docker::CreateExtras::default(), ).await?; emit_progress(&app_handle, &project_id, "Starting container..."); docker::start_container(&new_id).await?; @@ -389,31 +439,20 @@ pub async fn start_project_container( // Container doesn't exist (first start, or Docker pruned it). // Check for a snapshot image first — it preserves system-level // changes (apt/pip/npm installs) from the previous session. - let snapshot_image = docker::get_snapshot_image_name(&project); - let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) { + if expected_create_image == snapshot_image { log::info!("Creating container from snapshot image for project {}", project.id); - snapshot_image - } else { - image_name.clone() - }; + } + let create_image = expected_create_image.clone(); emit_progress(&app_handle, &project_id, "Creating container..."); - let new_id = docker::create_container( + let new_id = create_container_for_project( &project, + &settings, &docker_socket, - &create_image, aws_config_path.as_deref(), - &settings.global_aws, - &settings.global_ollama, - &settings.global_llamacpp, - &settings.global_openai_compatible, - settings.global_claude_instructions.as_deref(), - &settings.global_custom_env_vars, - settings.timezone.as_deref(), - settings.global_claude_code_settings.as_ref(), - settings.default_ssh_key_path.as_deref(), - settings.default_git_user_name.as_deref(), - settings.default_git_user_email.as_deref(), + &create_image, + &image_name, + docker::CreateExtras::default(), ).await?; emit_progress(&app_handle, &project_id, "Starting container..."); docker::start_container(&new_id).await?; @@ -530,6 +569,13 @@ pub async fn rebuild_project_container( /// Called by the frontend after Docker is confirmed available. Projects /// marked as Running whose containers are no longer running get reset /// to Stopped. +/// +/// This is also where an interrupted **base-image migration** is picked up. +/// It runs at startup, which is exactly when a migration that died with the app +/// needs to be noticed — see +/// [`crate::commands::migration_commands::reconcile_migration`]. The migration +/// pass runs over *every* project, not just the Running ones, because a project +/// whose container was removed mid-migration reports Stopped. #[tauri::command] pub async fn reconcile_project_statuses( app_handle: tauri::AppHandle, @@ -537,6 +583,10 @@ pub async fn reconcile_project_statuses( ) -> Result, String> { let projects = state.projects_store.list(); + for project in &projects { + crate::commands::migration_commands::reconcile_migration(project, &app_handle).await; + } + for project in &projects { if project.status != ProjectStatus::Running && project.status != ProjectStatus::Error { continue; diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 6fb0c03..c444f2e 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -700,10 +700,55 @@ pub async fn find_existing_container(project: &Project) -> Result Ok(None) } +/// Extra creation inputs that only base-image migration cares about, kept in +/// one struct so `create_container`'s already-long parameter list does not grow +/// two more positional arguments that every ordinary call site would have to +/// pass as `None`-ish placeholders. +#[derive(Debug, Clone, Copy, Default)] +pub struct CreateExtras<'a> { + /// Extra labels merged in last, overriding anything computed here. + /// Migration uses this to stamp `triple-c.migration-state=in-progress`. + pub extra_labels: &'a [(&'a str, &'a str)], +} + +/// Resolve the value for the `triple-c.base-image-id` label. +/// +/// This is the **image ID**, not a `RepoDigests` entry: a locally built image +/// (`triple-c:latest`) and any custom image have no repo digest at all, so a +/// digest-based lineage would be blank for exactly the users most likely to +/// change their base. +/// +/// Two cases: +/// * creating **from the base** — the base's own current `.Id`; +/// * creating **from the project's snapshot** — carry forward whatever lineage +/// the snapshot image already records, because a snapshot is a commit of a +/// container that itself descended from some base. Committing propagates +/// container labels onto the image (verified), which is what makes the +/// carry-forward chain hold across every recreation. +/// +/// An empty string means "unknown" — a snapshot that predates this label. It is +/// deliberately *not* the same as "stale"; see [`crate::models::ContainerStaleness::known`]. +async fn resolve_base_image_id(image_name: &str, base_image_name: &str) -> String { + if image_name == base_image_name { + return super::migration::image_id(base_image_name) + .await + .ok() + .flatten() + .unwrap_or_default(); + } + super::migration::image_labels(image_name) + .await + .get(super::migration::LABEL_BASE_IMAGE_ID) + .cloned() + .unwrap_or_default() +} + pub async fn create_container( project: &Project, docker_socket_path: &str, image_name: &str, + base_image_name: &str, + extras: CreateExtras<'_>, aws_config_path: Option<&str>, global_aws: &GlobalAwsSettings, global_ollama: &GlobalOllamaSettings, @@ -1232,6 +1277,51 @@ pub async fn create_container( labels.insert("triple-c.claude-token-version".to_string(), shared_claude.as_ref().map(|(_, v)| v.clone()).unwrap_or_default()); + // ── Base-image lineage ─────────────────────────────────────────────────── + // `triple-c.create-image` is what this container was actually created + // from — the snapshot when one exists, otherwise the configured base. It is + // what `container_needs_recreation` compares against; the older + // `triple-c.image` label recorded the same thing but was compared against + // the container's *own* image, which is where it came from, so that check + // was a tautology and never fired. `triple-c.image` is still written for + // continuity with existing containers but is no longer compared. + // + // `triple-c.base-image-id` records the lineage — see `resolve_base_image_id`. + // + // All three (plus the migration marker) are written **unconditionally**, + // even when empty. Docker merges an image's labels into a container's at + // creation, and `docker commit` copies container labels onto the snapshot + // image, so a value stamped once would otherwise ride the snapshot into + // every future container forever. Writing the key explicitly overrides the + // inherited one — the same defence MANAGED_AUTH_KEYS applies to env. + labels.insert( + super::migration::LABEL_CREATE_IMAGE.to_string(), + image_name.to_string(), + ); + labels.insert( + super::migration::LABEL_BASE_IMAGE_ID.to_string(), + resolve_base_image_id(image_name, base_image_name).await, + ); + labels.insert( + super::migration::LABEL_MIGRATION_STATE.to_string(), + String::new(), + ); + // Same defence, applied to the legacy MCP shim — and here it fixes a real, + // observed bug rather than pre-empting one. `container_needs_recreation` + // recreates any container carrying a non-empty `triple-c.mcp-fingerprint`, + // but nothing has written that label since the MCP feature was removed. It + // survives only by *inheritance* from a snapshot image committed by an + // older build (one such image was found on this host with a non-empty + // value), and every recreation re-commits it — so the shim can never + // terminate and the project is recreated on every single start. Writing it + // explicitly empty makes the shim fire exactly once, which is what it was + // always meant to do. + labels.insert("triple-c.mcp-fingerprint".to_string(), String::new()); + + for (key, value) in extras.extra_labels { + labels.insert((*key).to_string(), (*value).to_string()); + } + let host_config = HostConfig { mounts: Some(mounts), port_bindings: if port_bindings.is_empty() { None } else { Some(port_bindings) }, @@ -1506,6 +1596,7 @@ pub async fn remove_project_volumes(project: &Project) -> Result<(), String> { pub async fn container_needs_recreation( container_id: &str, project: &Project, + expected_create_image: &str, global_aws: &GlobalAwsSettings, global_ollama: &GlobalOllamaSettings, global_llamacpp: &GlobalLlamaCppSettings, @@ -1614,25 +1705,49 @@ pub async fn container_needs_recreation( return Ok(true); } - // ── Image ──────────────────────────────────────────────────────────── - // The image label is set at creation time; if the user changed the - // configured image we need to recreate. We only compare when the - // label exists (containers created before this change won't have it). - if let Some(container_image) = get_label("triple-c.image") { - // The caller doesn't pass the image name, but we can read the - // container's actual image from Docker inspect. - let actual_image = info - .config - .as_ref() - .and_then(|c| c.image.as_ref()); - if let Some(actual) = actual_image { - if *actual != container_image { - log::info!("Image mismatch (actual={:?}, label={:?})", actual, container_image); - return Ok(true); - } + // ── Create image ───────────────────────────────────────────────────── + // What this container was created from, against what we would create it + // from *now* — the caller resolves that (snapshot-if-it-exists, else the + // configured base) and passes it in as `expected_create_image`, preserving + // exactly today's semantics. + // + // This replaces a check that compared the container's actual image against + // the `triple-c.image` label. `create_container` wrote that label from the + // very image it created from, so the two could never differ: it was a + // tautology that never once fired, and it is the reason a project stayed + // pinned to its own snapshot lineage forever. + // + // A missing `triple-c.create-image` label means the container predates this + // fix — unknown, so leave it alone rather than churn every existing + // container on first launch after an update. + if let Some(container_create_image) = get_label(crate::docker::migration::LABEL_CREATE_IMAGE) { + if container_create_image != expected_create_image { + log::info!( + "Create-image mismatch (container={:?}, expected={:?})", + container_create_image, + expected_create_image + ); + return Ok(true); } } + // ── Base image id: deliberately NOT compared here ──────────────────── + // This departs from the CLAUDE.md rule that new container state gets a + // label and a comparison, and the departure is the point. + // + // `triple-c.base-image-id` records which base a container's lineage + // descends from. Comparing it here would mean that publishing a new base + // image silently recreates every project on next start — and, because + // `expected_create_image` is the snapshot whenever one exists, it would + // recreate them *from their own snapshot*: pure churn, on the old base, + // with no benefit. Worse, it would consume the very signal ("this project + // is behind the base") that is supposed to prompt the user, without + // actually migrating anything. + // + // Staleness is therefore a *surfaced* signal gating an explicit user + // action — `get_container_staleness` / `migrate_project_to_base` — not an + // automatic recreation trigger. + // ── Timezone ───────────────────────────────────────────────────────── let expected_tz = timezone.unwrap_or(""); let container_tz = get_label("triple-c.timezone").unwrap_or_default(); diff --git a/app/src-tauri/src/docker/exec.rs b/app/src-tauri/src/docker/exec.rs index dfbd66f..309b3dd 100644 --- a/app/src-tauri/src/docker/exec.rs +++ b/app/src-tauri/src/docker/exec.rs @@ -37,6 +37,22 @@ pub async fn create_attached_exec( container_id: &str, cmd: Vec, tty: bool, +) -> Result { + create_attached_exec_as(container_id, cmd, tty, "claude", "/workspace").await +} + +/// [`create_attached_exec`] with the user and working directory spelled out. +/// +/// Only base-image migration needs this: replaying `apt` and unpacking a +/// payload tar at `/` have to run as **root**, and every other caller wants the +/// `claude` / `/workspace` defaults that [`create_attached_exec`] supplies. It +/// stays the single place an attached exec is opened. +pub async fn create_attached_exec_as( + container_id: &str, + cmd: Vec, + tty: bool, + user: &str, + working_dir: &str, ) -> Result { let docker = get_docker()?; @@ -49,8 +65,8 @@ pub async fn create_attached_exec( attach_stderr: Some(true), tty: Some(tty), cmd: Some(cmd), - user: Some("claude".to_string()), - working_dir: Some("/workspace".to_string()), + user: Some(user.to_string()), + working_dir: Some(working_dir.to_string()), ..Default::default() }, ) @@ -371,6 +387,51 @@ pub async fn upload_host_file_to_container( Ok(format!("/tmp/{}", dest_name)) } +/// Write `data` into the container at `/` with `mode`. +/// +/// For small, generated files — migration uses it for the `tar -T` include +/// list, which can be too long to pass as argv. Anything large should be +/// streamed through an attached exec's stdin instead, since this buffers the +/// whole payload in memory twice (once raw, once tarred). +pub async fn upload_bytes_to_container( + container_id: &str, + dest_dir: &str, + file_name: &str, + data: &[u8], + mode: u32, +) -> Result { + let docker = get_docker()?; + + let mut tar_buf = Vec::with_capacity(data.len() + 1024); + { + let mut builder = tar::Builder::new(&mut tar_buf); + let mut header = tar::Header::new_gnu(); + header.set_size(data.len() as u64); + header.set_mode(mode); + header.set_cksum(); + builder + .append_data(&mut header, file_name, data) + .map_err(|e| format!("Failed to create tar entry: {}", e))?; + builder + .finish() + .map_err(|e| format!("Failed to finalize tar: {}", e))?; + } + + docker + .upload_to_container( + container_id, + Some(UploadToContainerOptions { + path: dest_dir.to_string(), + ..Default::default() + }), + tar_buf.into(), + ) + .await + .map_err(|e| format!("Failed to upload file to container: {}", e))?; + + Ok(format!("{}/{}", dest_dir.trim_end_matches('/'), file_name)) +} + /// Run a one-shot (non-interactive) exec command in a container and collect stdout. pub async fn exec_oneshot(container_id: &str, cmd: Vec) -> Result { exec_oneshot_env(container_id, cmd, Vec::new()).await @@ -400,6 +461,22 @@ pub async fn exec_oneshot_env_status( container_id: &str, cmd: Vec, env: Vec, +) -> Result<(String, i64), String> { + exec_oneshot_as(container_id, "claude", cmd, env).await +} + +/// [`exec_oneshot_env_status`] with the user spelled out. +/// +/// Base-image migration is the only caller that needs anything but `claude`: +/// `apt-get`, `npm -g` and the payload unpack all run as **root**. Note that +/// the container does grant `claude` passwordless sudo, but going through +/// `sudo` would put the whole command in `ps` output and add a second failure +/// mode to interpret, so the exec is simply created as root. +pub async fn exec_oneshot_as( + container_id: &str, + user: &str, + cmd: Vec, + env: Vec, ) -> Result<(String, i64), String> { let docker = get_docker()?; @@ -411,7 +488,7 @@ pub async fn exec_oneshot_env_status( attach_stderr: Some(true), cmd: Some(cmd), env: if env.is_empty() { None } else { Some(env) }, - user: Some("claude".to_string()), + user: Some(user.to_string()), ..Default::default() }, ) diff --git a/app/src-tauri/src/docker/migration.rs b/app/src-tauri/src/docker/migration.rs new file mode 100644 index 0000000..c9d8688 --- /dev/null +++ b/app/src-tauri/src/docker/migration.rs @@ -0,0 +1,1419 @@ +//! Container **base-image migration** — the Docker-level machinery. +//! +//! The orchestration (which Tauri command does what, in which order) lives in +//! [`crate::commands::migration_commands`]. This module holds the two things +//! that benefit from being separate: the *pure* delta computation, which is +//! fully unit-tested below, and the small set of Docker operations migration +//! needs that nothing else in the app does. +//! +//! # Why this is a diff of two image manifests and not `docker diff` +//! +//! `docker diff` reports changes since the container's **last commit**. Every +//! Triple-C project container is created from its own snapshot image and +//! re-committed on each recreation, so `docker diff` on one reports only what +//! happened since the most recent commit — measured on a real project: 2,533 +//! entries, almost all of them `/tmp` churn, and none of the actual +//! divergence from the base. It is the wrong tool here and is not used. +//! +//! # Why the diff is filtered through dpkg ownership +//! +//! Raw path diffing lies. On a real project, 11,088 paths differed between the +//! snapshot and the current base and approximately **zero** were user-authored: +//! the rest were the base's *own* AWS CLI and pnpm trees at different versions. +//! Two filters make the set honest: +//! +//! 1. **dpkg ownership** — anything listed in `/var/lib/dpkg/info/*.list` in +//! either image belongs to a package, not to the user. +//! 2. **presence in the new base** — if the current base already ships a path, +//! the base's copy wins by definition (that is the point of migrating), so +//! it is never carried across. This is also what makes the extraction's +//! never-clobber guarantee cheap: the payload does not even contain the +//! conflicting files. +//! +//! `pip3 list` is likewise a liar on Ubuntu — its apparent extras are +//! `dist-packages` installed by apt — so Python packages are covered by the apt +//! delta rather than by a pip diff. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +use bollard::container::{ + Config, CreateContainerOptions, LogOutput, LogsOptions, RemoveContainerOptions, + StartContainerOptions, WaitContainerOptions, +}; +use bollard::image::TagImageOptions; +use bollard::models::HostConfig; +use futures_util::StreamExt; + +use super::client::get_docker; +use crate::models::ProjectPath; + +// ───────────────────────────────────────────────────────────────────────────── +// Policy constants +// ───────────────────────────────────────────────────────────────────────────── + +/// Roots whose *non-package, not-in-the-base* contents are carried across +/// verbatim. +/// +/// `/usr/local` is narrowed to the four directories that hold executables and +/// data rather than configuration, per the migration design. `/workspace` is +/// here because loose files at the workspace root live in the container's +/// writable layer — they are not on any bind mount and are genuinely lost today +/// when a container is recreated from a different image. +pub const COPY_ROOTS: &[&str] = &[ + "/usr/local/bin", + "/usr/local/sbin", + "/usr/local/lib", + "/usr/local/share", + "/opt", + "/srv", + "/workspace", +]; + +/// Subtrees never copied even though they sit under a [`COPY_ROOTS`] entry. +/// +/// Both are the *base image's own* content, shipped by the Dockerfile. Copying +/// them forward would pin the new base to the old base's version of them, which +/// is the exact failure migration exists to fix. (The presence-in-base filter +/// would already catch them; naming them is cheap insurance against a base that +/// relocates one.) +pub const COPY_EXCLUSIONS: &[&str] = &["/usr/local/aws-cli", "/opt/mission-control"]; + +/// Roots the filesystem manifest walks. Wider than [`COPY_ROOTS`] so the +/// manifest stays useful for debugging; [`compute_verbatim_paths`] applies the +/// narrower policy. +pub const MANIFEST_ROOTS: &[&str] = &["/usr/local", "/opt", "/srv", "/workspace"]; + +/// Base-image capabilities worth telling the user they are missing, as +/// `(path, human label)`. +/// +/// A feature is only ever reported as missing when the **current base actually +/// ships it** and the container does not, so this table needs no maintenance +/// when a capability is dropped from the image — it simply stops appearing. +pub const FEATURE_PROBES: &[(&str, &str)] = &[ + ("/usr/bin/socat", "Auth bridge tunnel (socat)"), + ("/usr/bin/bwrap", "Sandbox mode (bubblewrap)"), + ("/usr/bin/cron", "Cron daemon (scheduled tasks)"), + ("/usr/bin/jq", "JSON tooling (jq)"), + ("/usr/bin/rg", "Fast search (ripgrep)"), + ("/usr/bin/gh", "GitHub CLI"), + ("/usr/bin/git", "git"), + ("/usr/bin/docker", "Docker CLI"), + ("/usr/bin/node", "Node.js"), + ("/usr/bin/python3", "Python 3"), + ("/usr/local/bin/triple-c-open", "Host browser URL relay"), + ("/usr/local/bin/osc52-clipboard", "Clipboard bridge (OSC 52)"), + ("/usr/local/bin/audio-shim", "Voice mode audio capture"), + ("/usr/local/bin/triple-c-scheduler", "Scheduled tasks"), + ("/usr/local/bin/triple-c-task-runner", "Scheduled task runner"), + ("/usr/local/bin/triple-c-sso-refresh", "AWS SSO auto-refresh"), + ("/opt/mission-control", "Mission Control (Flight Control)"), +]; + +/// Headroom demanded on Docker's storage backend on top of the measured +/// payload, so a migration cannot be the thing that fills the disk. The new +/// snapshot commit is a delta layer over the base (the base itself is already +/// on disk), and a 524 MB commit was measured at 25.6 s — 2 GiB is a generous +/// ceiling for that plus the replayed packages. +pub const DISK_HEADROOM_BYTES: u64 = 2 * 1024 * 1024 * 1024; + +/// Label carrying the image ID of the base a container's lineage descends from. +pub const LABEL_BASE_IMAGE_ID: &str = "triple-c.base-image-id"; +/// Label carrying the image this container was actually created from. +pub const LABEL_CREATE_IMAGE: &str = "triple-c.create-image"; +/// Label stamped on a container created *by* a migration, so a crash between +/// the container swap and the final commit is recognisable on restart. +pub const LABEL_MIGRATION_STATE: &str = "triple-c.migration-state"; +/// Value of [`LABEL_MIGRATION_STATE`] while a migration is unfinished. +pub const MIGRATION_LABEL_IN_PROGRESS: &str = "in-progress"; + +// ───────────────────────────────────────────────────────────────────────────── +// Manifests +// ───────────────────────────────────────────────────────────────────────────── + +/// One entry from the filesystem walk. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManifestEntry { + /// `find`'s `%y`: `f` regular, `d` directory, `l` symlink, … + pub kind: char, + pub size: u64, + pub path: String, +} + +impl ManifestEntry { + pub fn is_dir(&self) -> bool { + self.kind == 'd' + } +} + +/// Everything one probe run learned about an image or a running container. +#[derive(Debug, Clone, Default)] +pub struct Manifest { + /// Filesystem walk of [`MANIFEST_ROOTS`]. + pub paths: Vec, + /// Paths under those roots that `dpkg` owns. + pub dpkg_owned: BTreeSet, + /// `apt-mark showmanual`. + pub apt_manual: BTreeSet, + /// Globally installed npm package names (scoped names kept intact). + pub npm_global: BTreeSet, + /// Which [`FEATURE_PROBES`] paths exist. + pub features: BTreeSet, + /// Filesystem walk of `/etc`. + pub etc_paths: BTreeSet, + /// `package -> version` for every installed dpkg package. + pub dpkg_versions: BTreeMap, +} + +impl Manifest { + /// Index of the filesystem walk, for O(log n) presence tests. + fn path_set(&self) -> BTreeSet<&str> { + self.paths.iter().map(|e| e.path.as_str()).collect() + } +} + +/// The shell program run inside a throwaway container (or, when the project is +/// running, inside the container itself) to produce a [`Manifest`]. +/// +/// Sections are separated by sentinel lines so one exec answers every question; +/// on a 5.49 GB image the whole thing takes about three seconds. Every command +/// is failure-tolerant (`2>/dev/null`, no `set -e`) because a missing `npm` or +/// an unreadable directory must degrade one section, not the run. +pub fn manifest_script() -> String { + let feature_paths = FEATURE_PROBES + .iter() + .map(|(p, _)| shell_single_quote(p)) + .collect::>() + .join(" "); + let roots = MANIFEST_ROOTS + .iter() + .map(|p| shell_single_quote(p)) + .collect::>() + .join(" "); + // The dpkg grep is anchored to the manifest roots so the section stays a + // few hundred kB instead of the ~40 MB a full ownership dump would be. + let dpkg_filter = MANIFEST_ROOTS + .iter() + .map(|r| r.trim_start_matches('/')) + .collect::>() + .join("|"); + format!( + r#" +echo '###PATHS' +find {roots} -xdev -printf '%y\t%s\t%p\n' 2>/dev/null +echo '###DPKG' +cat /var/lib/dpkg/info/*.list 2>/dev/null | grep -E '^/({dpkg_filter})(/|$)' +echo '###APT' +apt-mark showmanual 2>/dev/null +echo '###NPM' +npm ls -g --depth=0 --parseable 2>/dev/null +echo '###FEATURES' +for p in {feature_paths}; do + if [ -e "$p" ]; then echo "$p"; fi +done +echo '###ETC' +find /etc -xdev -printf '%y\t%s\t%p\n' 2>/dev/null +echo '###PKGVER' +dpkg-query -W -f='${{Package}}\t${{Version}}\n' 2>/dev/null +echo '###END' +exit 0 +"# + ) +} + +/// Parse the output of [`manifest_script`]. +/// +/// Unknown sections and malformed lines are skipped rather than failing: the +/// probe runs against images this build has never seen, and one odd line must +/// not cost the whole manifest. +pub fn parse_manifest(raw: &str) -> Manifest { + let mut m = Manifest::default(); + let mut section = ""; + for line in raw.lines() { + let line = line.strip_suffix('\r').unwrap_or(line); + if let Some(name) = line.strip_prefix("###") { + section = match name { + "PATHS" | "DPKG" | "APT" | "NPM" | "FEATURES" | "ETC" | "PKGVER" | "END" => name, + _ => "", + }; + continue; + } + if line.is_empty() { + continue; + } + match section { + "PATHS" => { + if let Some(entry) = parse_find_line(line) { + m.paths.push(entry); + } + } + "DPKG" => { + m.dpkg_owned.insert(line.to_string()); + } + "APT" => { + m.apt_manual.insert(line.trim().to_string()); + } + "NPM" => { + if let Some(name) = npm_package_from_path(line) { + m.npm_global.insert(name); + } + } + "FEATURES" => { + m.features.insert(line.to_string()); + } + "ETC" => { + if let Some(entry) = parse_find_line(line) { + m.etc_paths.insert(entry.path); + } + } + "PKGVER" => { + if let Some((pkg, ver)) = line.split_once('\t') { + m.dpkg_versions.insert(pkg.to_string(), ver.to_string()); + } + } + _ => {} + } + } + m +} + +fn parse_find_line(line: &str) -> Option { + let mut parts = line.splitn(3, '\t'); + let kind = parts.next()?.chars().next()?; + let size = parts.next()?.parse::().ok()?; + let path = parts.next()?; + if !path.starts_with('/') { + return None; + } + Some(ManifestEntry { + kind, + size, + path: path.to_string(), + }) +} + +/// `/usr/lib/node_modules/@scope/pkg` → `@scope/pkg`. +/// +/// `npm ls -g --parseable` prints the prefix directory on its first line and +/// one path per installed package after it; splitting on the *last* +/// `/node_modules/` is what keeps scoped names intact. +fn npm_package_from_path(line: &str) -> Option { + let idx = line.rfind("/node_modules/")?; + let name = line[idx + "/node_modules/".len()..].trim(); + if name.is_empty() { + return None; + } + Some(name.to_string()) +} + +/// Quote a string for safe interpolation into a single-quoted shell word. +fn shell_single_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', r#"'\''"#)) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Pure delta computation +// ───────────────────────────────────────────────────────────────────────────── + +/// Set difference, sorted. Used for both the apt and the `npm -g` delta. +pub fn set_delta(from: &BTreeSet, base: &BTreeSet) -> Vec { + from.difference(base).cloned().collect() +} + +/// The `/workspace/` targets a project's bind mounts occupy. +/// +/// Everything under one of these belongs to the host filesystem and must never +/// be staged: it is not lost by a container swap, and copying a whole mounted +/// repository into a tar would be both pointless and enormous. Computed from +/// `project.paths` rather than hardcoded, because the mount names are +/// user-chosen. +pub fn bind_mount_exclusions(paths: &[ProjectPath]) -> Vec { + let mut out: Vec = paths + .iter() + .map(|p| format!("/workspace/{}", p.mount_name)) + .collect(); + out.sort(); + out.dedup(); + out +} + +/// Whether `path` is `root` itself or lives beneath it. +pub fn is_under(path: &str, root: &str) -> bool { + path == root || path.starts_with(&format!("{}/", root)) +} + +/// Drop every path that already has an ancestor in the set. +/// +/// Turns "one entry per file" into "one entry per newly-added subtree", which +/// is what makes both the reported list and the `tar -T` include list small +/// when someone has installed something large into `/usr/local/lib`. +pub fn prune_to_roots(paths: &BTreeSet) -> Vec { + let mut kept: Vec = Vec::new(); + // BTreeSet iterates lexicographically, so a parent is always visited before + // any of its children ("/a" < "/a/b"), and checking only the last kept + // entry is not enough — a sibling can intervene. Check all kept roots, but + // short-circuit on the common case. + for p in paths { + if kept.iter().any(|k| is_under(p, k) && k != p) { + continue; + } + kept.push(p.clone()); + } + kept +} + +/// The set of paths a migration would carry across verbatim. +/// +/// A path qualifies when **all** of: +/// * it lives under a [`COPY_ROOTS`] entry, +/// * it is not under a [`COPY_EXCLUSIONS`] entry or a bind-mount target, +/// * neither image's dpkg database owns it, +/// * the current base image does not already have it. +/// +/// The result is then pruned to subtree roots. An empty result means the copy +/// step is skipped entirely. +pub fn compute_verbatim_paths( + from: &Manifest, + base: &Manifest, + bind_targets: &[String], +) -> Vec { + let base_paths = base.path_set(); + let mut candidates: BTreeSet = BTreeSet::new(); + + for entry in &from.paths { + let p = entry.path.as_str(); + if !COPY_ROOTS.iter().any(|r| is_under(p, r)) { + continue; + } + // A copy root itself is a container for new content, never new content. + if COPY_ROOTS.contains(&p) { + continue; + } + if COPY_EXCLUSIONS.iter().any(|x| is_under(p, x)) { + continue; + } + if bind_targets.iter().any(|t| is_under(p, t)) { + continue; + } + if from.dpkg_owned.contains(p) || base.dpkg_owned.contains(p) { + continue; + } + if base_paths.contains(p) { + continue; + } + candidates.insert(entry.path.clone()); + } + + let dirs: BTreeSet<&str> = from + .paths + .iter() + .filter(|e| e.is_dir()) + .map(|e| e.path.as_str()) + .collect(); + + prune_to_roots(&candidates) + .into_iter() + // Drop empty directory trees. Measured on a real project, these were + // three of the five hits: `/usr/local/share/{fonts,sgml,xml}`, which a + // package's postinst creates and dpkg does not own, so no other filter + // catches them. They carry nothing, and replaying the packages that + // made them recreates them anyway. + .filter(|p| { + !dirs.contains(p.as_str()) + || from + .paths + .iter() + .any(|e| !e.is_dir() && is_under(&e.path, p)) + }) + .collect() +} + +/// Total on-disk size of a verbatim set, for the pre-flight disk estimate. +pub fn verbatim_payload_bytes(from: &Manifest, verbatim: &[String]) -> u64 { + from.paths + .iter() + .filter(|e| !e.is_dir()) + .filter(|e| verbatim.iter().any(|root| is_under(&e.path, root))) + .map(|e| e.size) + .sum() +} + +/// Base-image capabilities the container does not have, as +/// `(concrete paths, human labels)`. +/// +/// Only paths the base actually ships are considered, so this can never +/// recommend migrating to gain something the new base does not have either. +pub fn missing_features(from: &Manifest, base: &Manifest) -> (Vec, Vec) { + let mut paths = Vec::new(); + let mut labels = Vec::new(); + for (path, label) in FEATURE_PROBES { + if base.features.contains(*path) && !from.features.contains(*path) { + paths.push((*path).to_string()); + labels.push((*label).to_string()); + } + } + (paths, labels) +} + +/// How many dpkg packages the current base carries at a version the container +/// does not have — either a different version, or a package the container is +/// missing entirely. +/// +/// A rough drift measure, deliberately not a claim that every one is *newer*: +/// comparing Debian version strings properly needs `dpkg --compare-versions`, +/// and the number exists to answer "is this container far behind?", which +/// inequality answers just as well. +pub fn outdated_package_count(from: &Manifest, base: &Manifest) -> u32 { + base.dpkg_versions + .iter() + .filter(|(pkg, base_ver)| from.dpkg_versions.get(*pkg) != Some(*base_ver)) + .count() as u32 +} + +/// `/etc` paths the base has that the container does not, and vice versa. +/// +/// **Reported, never copied.** The snapshot lineage carries +/// `/etc/apt/sources.list.d/nodesource.sources` where the current base has +/// `nodesource.list`; copying `/etc` wholesale would leave both in place and +/// every `apt-get update` would fail on a duplicate-source conflict. Since +/// `/etc` is also where the base's own configuration lives, the base's copy is +/// always the right one. +pub fn etc_deltas(from: &Manifest, base: &Manifest) -> (Vec, Vec) { + let only_in_container: Vec = from + .etc_paths + .difference(&base.etc_paths) + .cloned() + .collect(); + let only_in_base: Vec = base + .etc_paths + .difference(&from.etc_paths) + .cloned() + .collect(); + (only_in_container, only_in_base) +} + +/// The `tar` member names for a verbatim set: absolute paths made relative to +/// `/`, so the archive extracts with `-C /`. +pub fn tar_member_names(verbatim: &[String]) -> Vec { + verbatim + .iter() + .map(|p| p.trim_start_matches('/').to_string()) + .filter(|p| !p.is_empty()) + .collect() +} + +// ───────────────────────────────────────────────────────────────────────────── +// Crash-recovery state machine +// ───────────────────────────────────────────────────────────────────────────── + +/// What to do about a migration state found on startup. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Recovery { + /// Nothing was in flight. + None, + /// The crash happened before the container was swapped. `:latest` still + /// points at the old lineage and `start_project_container` will recreate + /// from it unaided, so the only work is to clear the record. + SelfHeal, + /// The container was swapped but the migration never finished. The user + /// must choose: resume, or roll back. + OfferResumeOrRollback, + /// The migration finished. The user must choose: confirm, or roll back. + OfferConfirmOrRollback, +} + +/// Decide the recovery action from the two independent signals. +/// +/// The host-side state file says a migration was in flight; the container's +/// `triple-c.migration-state` label says whether the *swap* actually happened. +/// Neither alone is sufficient: +/// +/// * state file but no labelled container → the crash predates the swap +/// (or the swapped container never got created), and everything self-heals. +/// * labelled container but no state file → a stale label from a migration that +/// was already confirmed; the label rides the final commit into the snapshot +/// image, so it can outlive its migration. It must not trigger anything. +/// +/// `phase` is [`crate::models::MigrationState::phase`]. +pub fn decide_recovery(phase: Option<&str>, container_has_in_progress_label: bool) -> Recovery { + use crate::models::{ + MIGRATION_PHASE_AWAITING, MIGRATION_PHASE_INTERRUPTED, MIGRATION_PHASE_IN_PROGRESS, + }; + match phase { + None => Recovery::None, + Some(MIGRATION_PHASE_AWAITING) => Recovery::OfferConfirmOrRollback, + Some(MIGRATION_PHASE_IN_PROGRESS) | Some(MIGRATION_PHASE_INTERRUPTED) => { + if container_has_in_progress_label { + Recovery::OfferResumeOrRollback + } else { + Recovery::SelfHeal + } + } + // An unrecognised phase is a record we cannot reason about. Treat it + // like a finished migration awaiting a decision rather than silently + // discarding it: the destructive option must always be the user's. + Some(_) => Recovery::OfferConfirmOrRollback, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Docker operations +// ───────────────────────────────────────────────────────────────────────────── + +/// A throwaway container's stdout plus its exit code. +pub struct ThrowawayResult { + pub stdout: String, + pub stderr: String, + pub exit_code: i64, +} + +/// Run a shell program in a short-lived container off `image` and collect its +/// output. +/// +/// The image's `ENTRYPOINT` is overridden — the Triple-C image's entrypoint +/// ends in `sleep infinity`, so leaving it in place would hang forever. The +/// container is removed on every path, including failure. +pub async fn run_throwaway(image: &str, script: &str) -> Result { + let docker = get_docker()?; + + let config = Config { + image: Some(image.to_string()), + entrypoint: Some(vec!["/bin/sh".to_string()]), + cmd: Some(vec!["-c".to_string(), script.to_string()]), + user: Some("root".to_string()), + working_dir: Some("/".to_string()), + tty: Some(false), + host_config: Some(HostConfig { + // No mounts on purpose: this must observe the *image*, not the + // project's volumes, which are exactly the state migration does + // not need to move. + auto_remove: Some(false), + ..Default::default() + }), + ..Default::default() + }; + + let created = docker + .create_container( + None::>, + config, + ) + .await + .map_err(|e| format!("Failed to create probe container for {}: {}", image, e))?; + let id = created.id; + + let result = run_throwaway_inner(&id).await; + + if let Err(e) = docker + .remove_container( + &id, + Some(RemoveContainerOptions { + force: true, + v: true, + ..Default::default() + }), + ) + .await + { + log::warn!("Failed to remove probe container {}: {}", id, e); + } + + result +} + +async fn run_throwaway_inner(id: &str) -> Result { + let docker = get_docker()?; + + docker + .start_container(id, None::>) + .await + .map_err(|e| format!("Failed to start probe container: {}", e))?; + + let mut wait = docker.wait_container( + id, + Some(WaitContainerOptions { + condition: "not-running", + }), + ); + let mut exit_code: i64 = -1; + while let Some(msg) = wait.next().await { + match msg { + Ok(r) => exit_code = r.status_code, + // A non-zero exit is delivered as an Err by bollard; the status + // code is still what we want, and the logs below carry the detail. + Err(bollard::errors::Error::DockerContainerWaitError { code, .. }) => exit_code = code, + Err(e) => return Err(format!("Probe container wait failed: {}", e)), + } + } + + let mut logs = docker.logs( + id, + Some(LogsOptions:: { + stdout: true, + stderr: true, + follow: false, + ..Default::default() + }), + ); + let mut stdout = String::new(); + let mut stderr = String::new(); + while let Some(chunk) = logs.next().await { + match chunk { + Ok(LogOutput::StdOut { message }) => { + stdout.push_str(&String::from_utf8_lossy(&message)) + } + Ok(LogOutput::StdErr { message }) => { + stderr.push_str(&String::from_utf8_lossy(&message)) + } + Ok(other) => stdout.push_str(&String::from_utf8_lossy(&other.into_bytes())), + Err(e) => return Err(format!("Probe container log stream failed: {}", e)), + } + } + + Ok(ThrowawayResult { + stdout, + stderr, + exit_code, + }) +} + +/// Capture a [`Manifest`] from an image, via a throwaway container. +pub async fn manifest_from_image(image: &str) -> Result { + let out = run_throwaway(image, &manifest_script()).await?; + if !out.stdout.contains("###END") { + return Err(format!( + "Probe of image {} did not complete (exit {}){}", + image, + out.exit_code, + if out.stderr.trim().is_empty() { + String::new() + } else { + format!(": {}", out.stderr.trim()) + } + )); + } + Ok(parse_manifest(&out.stdout)) +} + +/// Capture a [`Manifest`] from a *running* container. +/// +/// Preferred over [`manifest_from_image`] for the "from" side whenever the +/// project is up: the snapshot image can lag the container by everything +/// installed since the last commit, and a verbatim set computed from a stale +/// manifest would silently fail to carry that work across. +pub async fn manifest_from_container(container_id: &str) -> Result { + let (out, code) = super::exec::exec_oneshot_as( + container_id, + "root", + vec!["/bin/sh".to_string(), "-c".to_string(), manifest_script()], + Vec::new(), + ) + .await?; + if !out.contains("###END") { + return Err(format!( + "Probe of the running container did not complete (exit {})", + code + )); + } + Ok(parse_manifest(&out)) +} + +/// The image ID (`sha256:…`) of a local image, or `None` if it is not present. +/// +/// Deliberately the **ID**, not a repo digest: locally built images and custom +/// images have no `RepoDigests` entry at all, so a digest-based identity would +/// silently be empty for exactly the users most likely to change their base. +pub async fn image_id(image: &str) -> Result, String> { + let docker = get_docker()?; + match docker.inspect_image(image).await { + Ok(info) => Ok(info.id.filter(|s| !s.is_empty())), + Err(bollard::errors::Error::DockerResponseServerError { + status_code: 404, .. + }) => Ok(None), + Err(e) => Err(format!("Failed to inspect image {}: {}", image, e)), + } +} + +/// An image's labels, or an empty map when it does not exist. +pub async fn image_labels(image: &str) -> HashMap { + let docker = match get_docker() { + Ok(d) => d, + Err(_) => return HashMap::new(), + }; + match docker.inspect_image(image).await { + Ok(info) => info + .config + .and_then(|c| c.labels) + .unwrap_or_default(), + Err(_) => HashMap::new(), + } +} + +/// An image's `Created` timestamp, if it exists. +pub async fn image_created(image: &str) -> Option { + let docker = get_docker().ok()?; + docker.inspect_image(image).await.ok().and_then(|i| i.created) +} + +/// Point a second tag at an existing image. +/// +/// Free in both time and space — a 5.49 GB image was measured at 0.036 s and +/// 0 bytes — which is what makes keeping a rollback pin the default-safe +/// choice. (The *image* it pins is not free: snapshots share only 3 of 31 +/// layers with the current base, so a retained rollback holds roughly its full +/// size on disk. That is the trade `MigrationOptions::keep_rollback` exposes.) +pub async fn tag_image(source: &str, repo: &str, tag: &str) -> Result<(), String> { + let docker = get_docker()?; + docker + .tag_image(source, Some(TagImageOptions { repo, tag })) + .await + .map_err(|e| format!("Failed to tag {} as {}:{}: {}", source, repo, tag, e)) +} + +/// Remove an image tag. Missing is success — a rollback tag that is already +/// gone is the state the caller wanted. +pub async fn untag_image(reference: &str) -> Result<(), String> { + let docker = get_docker()?; + match docker + .remove_image( + reference, + Some(bollard::image::RemoveImageOptions { + force: false, + noprune: false, + }), + None, + ) + .await + { + Ok(_) => Ok(()), + Err(bollard::errors::Error::DockerResponseServerError { + status_code: 404, .. + }) => Ok(()), + Err(e) => Err(format!("Failed to remove image tag {}: {}", reference, e)), + } +} + +/// A pre-migration rollback tag for a project's snapshot repo. +pub fn rollback_tag(now: &chrono::DateTime) -> String { + format!("pre-migration-{}", now.format("%Y%m%d-%H%M%S")) +} + +/// Split `repo:tag` into its parts, defaulting the tag to `latest`. +pub fn split_image_ref(image: &str) -> (String, String) { + match image.rsplit_once(':') { + // A colon in the *registry host* part is a port, not a tag. + Some((repo, tag)) if !tag.contains('/') => (repo.to_string(), tag.to_string()), + _ => (image.to_string(), "latest".to_string()), + } +} + +/// Pre-flight environment checks, run against the **new base** in a throwaway +/// container before anything destructive happens. +pub struct PreflightEnvironment { + /// `apt-get update` succeeded, so package replay has a chance. + pub network_ok: bool, + pub network_detail: String, + /// Bytes available on Docker's storage backend. + /// + /// Measured with `df` **inside a container**, not with a host `statvfs`: + /// on Windows the Docker root lives inside the WSL2 VM and is not a path + /// the Tauri process can stat at all. + pub available_bytes: u64, +} + +/// Run the network and disk pre-flight checks in one throwaway container. +pub async fn preflight_environment(base_image: &str) -> Result { + let script = r#" +echo '###DF' +df -P / | tail -n 1 +echo '###NET' +if apt-get -o Acquire::Retries=2 update >/dev/null 2>&1; then + echo ok +else + echo failed +fi +echo '###END' +exit 0 +"#; + let out = run_throwaway(base_image, script).await?; + if !out.stdout.contains("###END") { + return Err(format!( + "Pre-flight probe of {} did not complete (exit {}){}", + base_image, + out.exit_code, + if out.stderr.trim().is_empty() { + String::new() + } else { + format!(": {}", out.stderr.trim()) + } + )); + } + Ok(parse_preflight(&out.stdout)) +} + +/// Parse the pre-flight probe output. `df -P` reports 1024-byte blocks. +pub fn parse_preflight(raw: &str) -> PreflightEnvironment { + let mut section = ""; + let mut available_bytes = 0u64; + let mut network_ok = false; + let mut network_detail = "not checked".to_string(); + for line in raw.lines() { + if let Some(name) = line.strip_prefix("###") { + section = name; + continue; + } + match section { + "DF" => { + // Filesystem 1024-blocks Used Available Capacity Mounted-on + let cols: Vec<&str> = line.split_whitespace().collect(); + if cols.len() >= 4 { + if let Ok(kb) = cols[cols.len() - 3].parse::() { + available_bytes = kb.saturating_mul(1024); + } + } + } + "NET" => { + if line.trim() == "ok" { + network_ok = true; + network_detail = "apt-get update succeeded".to_string(); + } else if line.trim() == "failed" { + network_ok = false; + network_detail = + "apt-get update failed — package replay will be skipped".to_string(); + } + } + _ => {} + } + } + PreflightEnvironment { + network_ok, + network_detail, + available_bytes, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{ + MIGRATION_PHASE_AWAITING, MIGRATION_PHASE_INTERRUPTED, MIGRATION_PHASE_IN_PROGRESS, + }; + + fn manifest(paths: &[(char, u64, &str)], dpkg: &[&str], base_features: &[&str]) -> Manifest { + Manifest { + paths: paths + .iter() + .map(|(k, s, p)| ManifestEntry { + kind: *k, + size: *s, + path: p.to_string(), + }) + .collect(), + dpkg_owned: dpkg.iter().map(|s| s.to_string()).collect(), + features: base_features.iter().map(|s| s.to_string()).collect(), + ..Default::default() + } + } + + fn strs(v: &[&str]) -> BTreeSet { + v.iter().map(|s| s.to_string()).collect() + } + + // ── Manifest parsing ──────────────────────────────────────────────────── + + #[test] + fn the_manifest_parser_reads_every_section() { + let raw = "###PATHS\n\ + d\t4096\t/usr/local/bin\n\ + f\t128\t/usr/local/bin/mytool\n\ + ###DPKG\n\ + /usr/local/lib/pkgfile\n\ + ###APT\n\ + socat\n\ + postgresql-client\n\ + ###NPM\n\ + /usr/lib/node_modules\n\ + /usr/lib/node_modules/pnpm\n\ + /usr/lib/node_modules/@scope/tool\n\ + ###FEATURES\n\ + /usr/bin/socat\n\ + ###ETC\n\ + f\t10\t/etc/hosts\n\ + ###PKGVER\n\ + curl\t8.5.0-2ubuntu10.6\n\ + ###END\n"; + let m = parse_manifest(raw); + assert_eq!(m.paths.len(), 2); + assert_eq!(m.paths[1].size, 128); + assert!(m.dpkg_owned.contains("/usr/local/lib/pkgfile")); + assert_eq!(m.apt_manual, strs(&["socat", "postgresql-client"])); + // The prefix line has no `/node_modules/` segment and is dropped; + // the scoped name survives intact. + assert_eq!(m.npm_global, strs(&["pnpm", "@scope/tool"])); + assert!(m.features.contains("/usr/bin/socat")); + assert!(m.etc_paths.contains("/etc/hosts")); + assert_eq!( + m.dpkg_versions.get("curl").map(String::as_str), + Some("8.5.0-2ubuntu10.6") + ); + } + + #[test] + fn malformed_manifest_lines_are_skipped_not_fatal() { + let raw = "###PATHS\ngarbage\nf\tnotanumber\t/x\nf\t1\trelative/path\nf\t2\t/ok\n###END\n"; + let m = parse_manifest(raw); + assert_eq!(m.paths.len(), 1); + assert_eq!(m.paths[0].path, "/ok"); + } + + #[test] + fn unknown_sections_do_not_leak_into_the_previous_one() { + let raw = "###APT\nsocat\n###SOMETHINGNEW\nnoise\nmore-noise\n###END\n"; + let m = parse_manifest(raw); + assert_eq!(m.apt_manual, strs(&["socat"])); + } + + // ── Deltas ────────────────────────────────────────────────────────────── + + #[test] + fn the_apt_delta_is_the_containers_manual_set_minus_the_bases() { + let from = strs(&["socat", "postgresql-client", "redis-tools", "curl"]); + let base = strs(&["socat", "curl", "git"]); + assert_eq!( + set_delta(&from, &base), + vec!["postgresql-client".to_string(), "redis-tools".to_string()] + ); + // A base that gained packages does not produce a negative delta. + assert!(set_delta(&base, &from).contains(&"git".to_string())); + } + + #[test] + fn an_identical_package_set_produces_no_delta() { + let s = strs(&["a", "b"]); + assert!(set_delta(&s, &s).is_empty()); + } + + #[test] + fn outdated_packages_count_version_differences_and_absences() { + let mut from = Manifest::default(); + from.dpkg_versions.insert("curl".into(), "8.5.0-1".into()); + from.dpkg_versions.insert("git".into(), "2.43.0".into()); + from.dpkg_versions.insert("gone".into(), "1.0".into()); + let mut base = Manifest::default(); + base.dpkg_versions.insert("curl".into(), "8.5.0-2".into()); // newer + base.dpkg_versions.insert("git".into(), "2.43.0".into()); // same + base.dpkg_versions.insert("brandnew".into(), "1.0".into()); // absent + // curl differs + brandnew is missing = 2. `gone` is only in the + // container and is not drift against the base. + assert_eq!(outdated_package_count(&from, &base), 2); + } + + // ── dpkg ownership filter ─────────────────────────────────────────────── + + #[test] + fn dpkg_owned_paths_are_never_treated_as_user_authored() { + // The real-world failure this guards: a path that exists only in the + // container looks user-authored until you notice a package owns it. + let from = manifest( + &[ + ('f', 10, "/usr/local/lib/libowned.so"), + ('f', 10, "/usr/local/bin/mytool"), + ], + &["/usr/local/lib/libowned.so"], + &[], + ); + let base = Manifest::default(); + assert_eq!( + compute_verbatim_paths(&from, &base, &[]), + vec!["/usr/local/bin/mytool".to_string()] + ); + } + + #[test] + fn ownership_recorded_only_in_the_base_still_filters() { + // A package that moved into the base since the snapshot was taken owns + // the path there but not in the container's older dpkg database. + let from = manifest(&[('f', 10, "/opt/tool/bin/x")], &[], &[]); + let mut base = Manifest::default(); + base.dpkg_owned.insert("/opt/tool/bin/x".to_string()); + assert!(compute_verbatim_paths(&from, &base, &[]).is_empty()); + } + + // ── Verbatim set ──────────────────────────────────────────────────────── + + #[test] + fn the_bases_own_content_is_never_copied_forward() { + // /usr/local/aws-cli and /opt/mission-control are shipped by the + // Dockerfile. Carrying the old copies over would pin the new base to + // the old base's versions — the exact thing migration fixes. + let from = manifest( + &[ + ('d', 4096, "/usr/local/aws-cli"), + ('f', 10, "/usr/local/aws-cli/v2/current/bin/aws"), + ('d', 4096, "/opt/mission-control"), + ('f', 10, "/opt/mission-control/README.md"), + ('d', 4096, "/opt/mine"), + ('f', 10, "/opt/mine/keep.txt"), + ], + &[], + &[], + ); + assert_eq!( + compute_verbatim_paths(&from, &Manifest::default(), &[]), + vec!["/opt/mine".to_string()] + ); + } + + #[test] + fn a_path_the_new_base_already_has_is_left_to_the_base() { + let from = manifest( + &[ + ('f', 10, "/usr/local/bin/triple-c-open"), + ('f', 10, "/usr/local/bin/mytool"), + ], + &[], + &[], + ); + let base = manifest(&[('f', 20, "/usr/local/bin/triple-c-open")], &[], &[]); + assert_eq!( + compute_verbatim_paths(&from, &base, &[]), + vec!["/usr/local/bin/mytool".to_string()] + ); + } + + #[test] + fn copy_roots_are_narrower_than_the_manifest_roots() { + // /usr/local/etc is walked by the manifest but is not a copy root: + // configuration is the base's to own. + let from = manifest( + &[ + ('f', 10, "/usr/local/etc/some.conf"), + ('f', 10, "/usr/local/bin/mytool"), + ], + &[], + &[], + ); + assert_eq!( + compute_verbatim_paths(&from, &Manifest::default(), &[]), + vec!["/usr/local/bin/mytool".to_string()] + ); + } + + #[test] + fn empty_directory_trees_are_not_carried_across() { + // Measured on a real project: /usr/local/share/{fonts,sgml,xml} exist + // in the snapshot, do not exist in the current base, and are owned by + // no package — a postinst made them. They carry nothing. + let from = manifest( + &[ + ('d', 4096, "/usr/local/share/fonts"), + ('d', 4096, "/usr/local/share/sgml"), + ('d', 4096, "/usr/local/share/sgml/nested"), + ('d', 4096, "/opt/real"), + ('f', 10, "/opt/real/thing"), + ], + &[], + &[], + ); + assert_eq!( + compute_verbatim_paths(&from, &Manifest::default(), &[]), + vec!["/opt/real".to_string()] + ); + } + + #[test] + fn an_empty_verbatim_set_is_the_normal_case() { + // The measured reality: essentially nothing under these roots is + // user-authored, so the copy step must be skippable. + let from = manifest(&[('d', 4096, "/usr/local/bin"), ('d', 4096, "/opt")], &[], &[]); + assert!(compute_verbatim_paths(&from, &Manifest::default(), &[]).is_empty()); + } + + #[test] + fn subtrees_are_pruned_to_their_root() { + let from = manifest( + &[ + ('d', 4096, "/opt/mytool"), + ('d', 4096, "/opt/mytool/bin"), + ('f', 100, "/opt/mytool/bin/run"), + ('f', 100, "/opt/mytool/LICENSE"), + ('f', 100, "/srv/other.txt"), + ], + &[], + &[], + ); + assert_eq!( + compute_verbatim_paths(&from, &Manifest::default(), &[]), + vec!["/opt/mytool".to_string(), "/srv/other.txt".to_string()] + ); + } + + #[test] + fn pruning_keeps_siblings_that_share_a_name_prefix() { + // "/opt/tool2" starts with "/opt/tool" as a *string* but is not under + // it as a *path*. + let set = strs(&["/opt/tool", "/opt/tool2", "/opt/tool/inner"]); + assert_eq!( + prune_to_roots(&set), + vec!["/opt/tool".to_string(), "/opt/tool2".to_string()] + ); + } + + #[test] + fn payload_size_sums_files_under_the_pruned_roots_only() { + let from = manifest( + &[ + ('d', 4096, "/opt/mytool"), + ('f', 100, "/opt/mytool/a"), + ('f', 200, "/opt/mytool/b"), + ('f', 999, "/opt/mission-control/big"), + ], + &[], + &[], + ); + let verbatim = compute_verbatim_paths(&from, &Manifest::default(), &[]); + // Directories contribute their inode size on disk, not their contents; + // counting them would double-count. Excluded subtrees contribute zero. + assert_eq!(verbatim_payload_bytes(&from, &verbatim), 300); + } + + // ── Bind-mount exclusion ──────────────────────────────────────────────── + + fn pp(mount: &str) -> ProjectPath { + ProjectPath { + host_path: format!("/host/{}", mount), + mount_name: mount.to_string(), + } + } + + #[test] + fn bind_mount_targets_are_derived_from_the_projects_own_mount_names() { + let paths = vec![pp("repo"), pp("docs"), pp("repo")]; + assert_eq!( + bind_mount_exclusions(&paths), + vec!["/workspace/docs".to_string(), "/workspace/repo".to_string()] + ); + assert!(bind_mount_exclusions(&[]).is_empty()); + } + + #[test] + fn workspace_content_on_a_bind_mount_is_excluded_but_loose_files_are_not() { + // The whole reason /workspace is a copy root: `scratch.md` at the + // workspace root is in the writable layer and is lost today. + let from = manifest( + &[ + ('d', 4096, "/workspace/repo"), + ('f', 10, "/workspace/repo/src/main.rs"), + ('f', 10, "/workspace/scratch.md"), + ('d', 4096, "/workspace/notes"), + ('f', 10, "/workspace/notes/todo.txt"), + ], + &[], + &[], + ); + let excl = bind_mount_exclusions(&[pp("repo")]); + assert_eq!( + compute_verbatim_paths(&from, &Manifest::default(), &excl), + vec![ + "/workspace/notes".to_string(), + "/workspace/scratch.md".to_string() + ] + ); + } + + #[test] + fn a_mount_name_that_prefixes_another_does_not_over_exclude() { + let from = manifest( + &[ + ('d', 4096, "/workspace/app"), + ('f', 10, "/workspace/app/x"), + ('d', 4096, "/workspace/app-notes"), + ('f', 10, "/workspace/app-notes/y"), + ], + &[], + &[], + ); + let excl = bind_mount_exclusions(&[pp("app")]); + assert_eq!( + compute_verbatim_paths(&from, &Manifest::default(), &excl), + vec!["/workspace/app-notes".to_string()] + ); + } + + #[test] + fn tar_member_names_are_relative_so_the_archive_extracts_at_root() { + assert_eq!( + tar_member_names(&["/opt/mytool".to_string(), "/srv".to_string()]), + vec!["opt/mytool".to_string(), "srv".to_string()] + ); + assert!(tar_member_names(&["/".to_string()]).is_empty()); + } + + // ── Missing features ──────────────────────────────────────────────────── + + #[test] + fn a_feature_is_missing_only_when_the_new_base_actually_has_it() { + let from = manifest(&[], &[], &["/usr/bin/jq"]); + let base = manifest(&[], &[], &["/usr/bin/jq", "/usr/bin/socat"]); + let (paths, labels) = missing_features(&from, &base); + assert_eq!(paths, vec!["/usr/bin/socat".to_string()]); + assert_eq!(labels, vec!["Auth bridge tunnel (socat)".to_string()]); + + // A capability the base dropped is never advertised as a reason to + // migrate, even though the container "differs" from the base. + let (paths, _) = missing_features(&base, &from); + assert!(paths.is_empty()); + } + + // ── /etc ──────────────────────────────────────────────────────────────── + + #[test] + fn etc_deltas_surface_the_nodesource_rename_rather_than_copying_it() { + let mut from = Manifest::default(); + from.etc_paths + .insert("/etc/apt/sources.list.d/nodesource.sources".into()); + let mut base = Manifest::default(); + base.etc_paths + .insert("/etc/apt/sources.list.d/nodesource.list".into()); + let (only_container, only_base) = etc_deltas(&from, &base); + assert_eq!( + only_container, + vec!["/etc/apt/sources.list.d/nodesource.sources".to_string()] + ); + assert_eq!( + only_base, + vec!["/etc/apt/sources.list.d/nodesource.list".to_string()] + ); + // And /etc is not a copy root, so neither can be carried across — + // having both would break every apt-get update with a duplicate source. + assert!(!COPY_ROOTS.iter().any(|r| is_under("/etc/apt", r))); + } + + // ── Crash-state machine ───────────────────────────────────────────────── + + #[test] + fn no_state_file_means_no_recovery() { + assert_eq!(decide_recovery(None, false), Recovery::None); + // A stale in-progress label with no state file is a label that rode the + // final commit into the snapshot image. It must not trigger anything. + assert_eq!(decide_recovery(None, true), Recovery::None); + } + + #[test] + fn a_crash_before_the_container_swap_self_heals() { + // `:latest` still points at the old lineage, so start_project_container + // recreates from it unaided. + assert_eq!( + decide_recovery(Some(MIGRATION_PHASE_IN_PROGRESS), false), + Recovery::SelfHeal + ); + assert_eq!( + decide_recovery(Some(MIGRATION_PHASE_INTERRUPTED), false), + Recovery::SelfHeal + ); + } + + #[test] + fn a_crash_after_the_container_swap_needs_a_decision() { + assert_eq!( + decide_recovery(Some(MIGRATION_PHASE_IN_PROGRESS), true), + Recovery::OfferResumeOrRollback + ); + assert_eq!( + decide_recovery(Some(MIGRATION_PHASE_INTERRUPTED), true), + Recovery::OfferResumeOrRollback + ); + } + + #[test] + fn a_finished_migration_waits_for_confirm_or_rollback_either_way() { + // The label is irrelevant once the final commit landed: the phase alone + // decides, because the container is the migrated one by definition. + for labelled in [true, false] { + assert_eq!( + decide_recovery(Some(MIGRATION_PHASE_AWAITING), labelled), + Recovery::OfferConfirmOrRollback + ); + } + } + + #[test] + fn an_unrecognised_phase_never_silently_discards_the_record() { + assert_eq!( + decide_recovery(Some("who-knows"), false), + Recovery::OfferConfirmOrRollback + ); + } + + // ── Misc ──────────────────────────────────────────────────────────────── + + #[test] + fn image_refs_split_on_the_tag_not_a_registry_port() { + assert_eq!( + split_image_ref("triple-c-snapshot-abc:latest"), + ("triple-c-snapshot-abc".to_string(), "latest".to_string()) + ); + assert_eq!( + split_image_ref("ghcr.io/shadowdao/triple-c-sandbox:latest"), + ( + "ghcr.io/shadowdao/triple-c-sandbox".to_string(), + "latest".to_string() + ) + ); + assert_eq!( + split_image_ref("registry:5000/img"), + ("registry:5000/img".to_string(), "latest".to_string()) + ); + } + + #[test] + fn rollback_tags_are_sortable_and_docker_legal() { + let t = rollback_tag( + &chrono::DateTime::parse_from_rfc3339("2026-08-09T17:04:05Z") + .unwrap() + .with_timezone(&chrono::Utc), + ); + assert_eq!(t, "pre-migration-20260809-170405"); + assert!(t + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')); + } + + #[test] + fn the_preflight_parser_reads_df_blocks_as_kibibytes() { + let raw = "###DF\n/dev/sdc 1055762868 12345 900000000 2% /\n###NET\nok\n###END\n"; + let p = parse_preflight(raw); + assert_eq!(p.available_bytes, 900_000_000u64 * 1024); + assert!(p.network_ok); + + let raw = "###DF\n###NET\nfailed\n###END\n"; + let p = parse_preflight(raw); + assert_eq!(p.available_bytes, 0); + assert!(!p.network_ok); + } + + #[test] + fn the_manifest_script_emits_every_section_the_parser_expects() { + let s = manifest_script(); + for section in [ + "###PATHS", "###DPKG", "###APT", "###NPM", "###FEATURES", "###ETC", "###PKGVER", + "###END", + ] { + assert!(s.contains(section), "script is missing {}", section); + } + // Every probed feature path must reach the script, or the missing- + // feature report would silently under-report. + for (path, _) in FEATURE_PROBES { + assert!(s.contains(path), "script is missing probe {}", path); + } + } + + #[test] + fn shell_quoting_survives_an_apostrophe() { + assert_eq!(shell_single_quote("/opt/a'b"), r#"'/opt/a'\''b'"#); + } + +} diff --git a/app/src-tauri/src/docker/mod.rs b/app/src-tauri/src/docker/mod.rs index 81f1205..a0e0ad7 100644 --- a/app/src-tauri/src/docker/mod.rs +++ b/app/src-tauri/src/docker/mod.rs @@ -4,6 +4,7 @@ pub mod image; pub mod exec; pub mod gateway; pub mod legacy_cleanup; +pub mod migration; pub mod stt; #[allow(unused_imports)] @@ -20,3 +21,5 @@ pub use image::*; pub use exec::*; #[allow(unused_imports)] pub use legacy_cleanup::*; +#[allow(unused_imports)] +pub use migration::*; diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 8e65728..ef204af 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -186,6 +186,12 @@ pub fn run() { commands::project_commands::stop_project_container, commands::project_commands::rebuild_project_container, commands::project_commands::reconcile_project_statuses, + // Container base-image migration + commands::migration_commands::get_container_staleness, + commands::migration_commands::migrate_project_to_base, + commands::migration_commands::confirm_migration, + commands::migration_commands::rollback_migration, + commands::migration_commands::get_migration_state, // Auth bridge commands::auth_bridge_commands::set_auth_bridge_enabled, commands::auth_bridge_commands::get_auth_bridge_status, diff --git a/app/src-tauri/src/models/migration.rs b/app/src-tauri/src/models/migration.rs new file mode 100644 index 0000000..14043cc --- /dev/null +++ b/app/src-tauri/src/models/migration.rs @@ -0,0 +1,250 @@ +//! Contract types for **container base-image migration**. +//! +//! ## Why this exists +//! +//! A project's container is created from `triple-c-snapshot-:latest` +//! whenever that image exists, and every recreation re-commits it. Nothing ever +//! moved a project back onto a *newer base image*: `container_needs_recreation` +//! compared the container's actual image against the `triple-c.image` label that +//! `create_container` wrote from the very image it created from — a tautology +//! that could never fire. So a project stayed pinned to its own snapshot +//! lineage forever and never picked up base-image fixes (a new `socat`, a new +//! `/usr/local/bin` shim, security updates). The only escape was Reset, which +//! deletes both named volumes and takes the login, the skills and every session +//! transcript with it. +//! +//! Migration is the non-destructive alternative: recreate the container from the +//! current base, then replay onto it the small set of things the base does not +//! carry, and leave the volumes strictly alone. +//! +//! ## What actually needs replaying +//! +//! `/home/claude` is the named volume `triple-c-home-`, with +//! `/home/claude/.claude` nested inside it. The image's own `/home/claude` is +//! **seed-only** — once the volume is mounted the image's copy is masked +//! permanently. So Claude Code itself (it installs to `~/.local/bin`), cargo, +//! uv, ruff, the OAuth login, `~/.claude.json`, skills, transcripts, scheduler +//! tasks and SSH keys all re-attach for free across an image swap. +//! +//! What is genuinely lost is confined to the container's writable layer: +//! root-level `apt` installs, `npm -g` packages (npm's prefix is `/usr`), +//! `/usr/local`, `/opt`, `/srv`, and anything under `/workspace` that is not on +//! a bind mount. Those four categories are exactly what +//! [`MigrationOptions`] can replay. +//! +//! ## Serde +//! +//! Plain snake_case, matching every other IPC struct in this crate +//! (`ContainerInfo`, `ClaudeSession`, …) and `app/src/lib/types.ts`. + +use serde::{Deserialize, Serialize}; + +/// How a finished migration attempt ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MigrationPhase { + /// The container now runs on the current base and everything requested was + /// replayed. + Succeeded, + /// The container now runs on the current base, but at least one package or + /// path could not be replayed. Deliberately distinct from `Failed`: one + /// missing apt package must never cost the user the whole migration. + Partial, + /// The migration could not complete. If the container had already been + /// swapped, an automatic rollback was attempted — check + /// [`MigrationReport::rollback_available`] and the message. + Failed, + /// The migration was undone; the container is back on its pre-migration + /// snapshot image. + RolledBack, +} + +/// One package that could not be replayed onto the new base. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PackageFailure { + pub name: String, + /// Trimmed tail of the package manager's own error output. + pub reason: String, +} + +/// Everything the UI needs to decide whether a project is worth migrating, and +/// to explain to the user what migrating would actually change. +/// +/// A field being empty always means "nothing found", never "not checked" — +/// [`ContainerStaleness::probe_error`] is the single place a failed inspection +/// is reported. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct ContainerStaleness { + /// The container's lineage is not the current base image. + /// Always `false` when `known` is `false` — an unknown lineage is not a + /// claim of staleness. + pub stale: bool, + /// Whether the lineage could be established at all. `false` means the + /// container (or its snapshot image) predates the `triple-c.base-image-id` + /// label, i.e. **"unknown, probe instead"** — never "stale". + pub known: bool, + /// Image ID of the base this container's lineage descends from. + pub base_image_id: Option, + /// Image ID of the base image currently configured in settings. + pub current_base_image_id: Option, + /// `Created` timestamp of the project's snapshot image, RFC 3339. + pub snapshot_created_at: Option, + /// Concrete paths the current base ships that this container does not, + /// e.g. `/usr/bin/socat`. + pub missing_paths: Vec, + /// Human labels for the same, e.g. `"Auth bridge tunnel (socat)"`. + pub missing_features: Vec, + /// `apt-mark showmanual` in the container minus the base's own set — the + /// packages a migration would replay. + pub apt_delta: Vec, + /// Globally-installed npm packages the base does not ship. + pub npm_global_delta: Vec, + /// Non-dpkg-owned paths under the verbatim-copy roots that would be carried + /// across. Empty when nothing user-authored was found. + pub verbatim_paths: Vec, + /// dpkg packages the current base carries at a different version than this + /// container does. A rough "how much security drift" number, not a promise + /// that every one of them is newer. + pub outdated_package_count: u32, + /// Set when the container/image could not be inspected. Everything else is + /// then at its default. + pub probe_error: Option, +} + +/// What a migration should replay. All three default to off so that +/// `MigrationOptions::default()` is the minimal, fastest migration. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct MigrationOptions { + /// Replay the apt and `npm -g` deltas onto the new base. + #[serde(default)] + pub replay_packages: bool, + /// Copy the verbatim payload (`/usr/local`, `/opt`, `/srv`, and the + /// non-bind-mounted parts of `/workspace`) onto the new base. + #[serde(default)] + pub copy_paths: bool, + /// Keep the `:pre-migration-` rollback tag after the migration reports + /// success. Costs the full size of the old snapshot image (snapshots share + /// almost no layers with the current base) but makes rollback instant. + #[serde(default)] + pub keep_rollback: bool, +} + +/// The outcome of one migration attempt. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct MigrationReport { + pub phase: MigrationPhase, + pub packages_requested: Vec, + pub packages_installed: Vec, + pub packages_failed: Vec, + pub paths_copied: Vec, + /// Human labels for base features the container gained, e.g. + /// `"Auth bridge tunnel (socat)"`. + pub features_restored: Vec, + /// A `:pre-migration-` image tag still exists, so + /// `rollback_migration` can put the old system layer back. + pub rollback_available: bool, + /// One paragraph fit to show the user verbatim. + pub message: String, +} + +impl MigrationReport { + /// A report for a migration that never got past pre-flight. Nothing was + /// touched, so there is nothing to roll back. + pub fn failed_preflight(message: impl Into) -> Self { + Self { + phase: MigrationPhase::Failed, + packages_requested: Vec::new(), + packages_installed: Vec::new(), + packages_failed: Vec::new(), + paths_copied: Vec::new(), + features_restored: Vec::new(), + rollback_available: false, + message: message.into(), + } + } +} + +/// What a migration decided to do, frozen at pre-flight time. +/// +/// Persisted with the state because a **resume** cannot recompute it: by the +/// time the app comes back up the container has already been replaced by one +/// created from the base, so its apt/npm sets *are* the base's and the deltas +/// would come out empty. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct MigrationPlan { + pub apt_packages: Vec, + pub npm_packages: Vec, + pub verbatim_paths: Vec, + /// Base-image paths the old container lacked, so the finished migration can + /// report which of them it actually gained. + pub missing_paths: Vec, +} + +/// Persisted, host-side migration state. Written **before** anything +/// destructive happens and removed on confirm or rollback, so a crash at any +/// point leaves a record of what was in flight. +/// +/// `phase` is a free-form string rather than [`MigrationPhase`] because it also +/// carries the *in-flight* phases, which are not outcomes: +/// +/// | `phase` | Meaning | Offered next | +/// |---|---|---| +/// | `in-progress` | A migration is running right now | — | +/// | `interrupted` | The app died after the container swap | resume, rollback | +/// | `awaiting-confirmation` | Migration finished; rollback still possible | confirm, rollback | +/// +/// See [`MIGRATION_PHASE_IN_PROGRESS`] and friends. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct MigrationState { + pub phase: String, + /// Image ID of the snapshot the project was on before the swap. + pub from_image_id: Option, + /// Image ID of the base being migrated to. + pub to_base_id: Option, + /// RFC 3339. + pub started_at: String, + /// Present once the attempt produced one. + #[serde(default)] + pub report: Option, + /// The `:pre-migration-` tag holding the old system layer, if one was + /// created. `rollback_migration` retags this back to `:latest`. + #[serde(default)] + pub rollback_image: Option, + /// Host path of the staged verbatim payload tar, if one was staged. + #[serde(default)] + pub staging_path: Option, + /// The options the attempt was started with, so a resume replays the same + /// things the user originally asked for. + #[serde(default)] + pub options: MigrationOptions, + /// The frozen pre-flight plan. See [`MigrationPlan`]. + #[serde(default)] + pub plan: Option, +} + +/// A migration is running in this process right now. +pub const MIGRATION_PHASE_IN_PROGRESS: &str = "in-progress"; +/// The app died after the container swap but before the final commit. +pub const MIGRATION_PHASE_INTERRUPTED: &str = "interrupted"; +/// The migration finished; the user has not yet confirmed or rolled back. +pub const MIGRATION_PHASE_AWAITING: &str = "awaiting-confirmation"; + +impl MigrationState { + pub fn new( + from_image_id: Option, + to_base_id: Option, + options: MigrationOptions, + ) -> Self { + Self { + phase: MIGRATION_PHASE_IN_PROGRESS.to_string(), + from_image_id, + to_base_id, + started_at: chrono::Utc::now().to_rfc3339(), + report: None, + rollback_image: None, + staging_path: None, + options, + plan: None, + } + } +} diff --git a/app/src-tauri/src/models/mod.rs b/app/src-tauri/src/models/mod.rs index 38fb820..e442ae7 100644 --- a/app/src-tauri/src/models/mod.rs +++ b/app/src-tauri/src/models/mod.rs @@ -2,10 +2,12 @@ pub mod project; pub mod container_config; pub mod app_settings; pub mod gateway_settings; +pub mod migration; pub mod update_info; pub use project::*; pub use container_config::*; pub use app_settings::*; pub use gateway_settings::*; +pub use migration::*; pub use update_info::*; diff --git a/app/src-tauri/src/storage/migration_store.rs b/app/src-tauri/src/storage/migration_store.rs new file mode 100644 index 0000000..88a4b80 --- /dev/null +++ b/app/src-tauri/src/storage/migration_store.rs @@ -0,0 +1,118 @@ +//! Host-side persistence for in-flight container base-image migrations. +//! +//! One JSON file per project under `/triple-c/migrations/`, written +//! with the same write-temp-then-rename dance as `projects.json` so a crash can +//! never leave a half-written state file. The staged verbatim payload tar lives +//! in the same directory. +//! +//! This is deliberately *not* part of `projects.json`: a migration is transient +//! and a migration record must survive independently of a project save racing +//! it. It is also the crash record — see +//! [`crate::models::MigrationState`] for the phase table. + +use std::fs; +use std::path::PathBuf; + +use crate::models::MigrationState; + +/// `/triple-c/migrations`, created on demand. +pub fn migrations_dir() -> Result { + let dir = dirs::data_dir() + .ok_or_else(|| { + "Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string() + })? + .join("triple-c") + .join("migrations"); + fs::create_dir_all(&dir) + .map_err(|e| format!("Failed to create migrations directory: {}", e))?; + Ok(dir) +} + +fn state_path(project_id: &str) -> Result { + Ok(migrations_dir()?.join(format!("{}.json", sanitize(project_id)))) +} + +/// Host path for a project's staged verbatim payload. +pub fn staging_path(project_id: &str) -> Result { + Ok(migrations_dir()?.join(format!("{}-payload.tar", sanitize(project_id)))) +} + +/// Project ids are UUIDs, but they arrive over IPC, so refuse to let one steer +/// the write anywhere but the migrations directory. +fn sanitize(project_id: &str) -> String { + project_id + .chars() + .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }) + .collect() +} + +/// Read a project's migration state. `Ok(None)` means no migration is in +/// flight; an unparseable file is treated the same way (and logged) rather than +/// blocking every future migration on a corrupt record. +pub fn load(project_id: &str) -> Result, String> { + let path = state_path(project_id)?; + if !path.exists() { + return Ok(None); + } + let data = fs::read_to_string(&path) + .map_err(|e| format!("Failed to read migration state: {}", e))?; + match serde_json::from_str::(&data) { + Ok(state) => Ok(Some(state)), + Err(e) => { + log::error!( + "Failed to parse migration state for project {}: {} — treating as absent", + project_id, + e + ); + Ok(None) + } + } +} + +/// Atomically write a project's migration state. +pub fn save(project_id: &str, state: &MigrationState) -> Result<(), String> { + let path = state_path(project_id)?; + let data = serde_json::to_string_pretty(state) + .map_err(|e| format!("Failed to serialize migration state: {}", e))?; + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, data).map_err(|e| format!("Failed to write migration state: {}", e))?; + fs::rename(&tmp, &path).map_err(|e| format!("Failed to commit migration state: {}", e))?; + Ok(()) +} + +/// Remove a project's migration state file. Missing is success. +pub fn clear(project_id: &str) -> Result<(), String> { + let path = state_path(project_id)?; + match fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("Failed to remove migration state: {}", e)), + } +} + +/// Remove a project's staged payload. Missing is success. +pub fn clear_staging(project_id: &str) -> Result<(), String> { + let path = staging_path(project_id)?; + match fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("Failed to remove staged migration payload: {}", e)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn project_ids_cannot_escape_the_migrations_directory() { + assert_eq!(sanitize("../../etc/passwd"), "______etc_passwd"); + assert_eq!(sanitize("a/b"), "a_b"); + // The real shape — a UUID — must survive untouched, or state files + // would move the first time this function changed. + assert_eq!( + sanitize("ab62cd24-51aa-4645-8f5c-17a124062050"), + "ab62cd24-51aa-4645-8f5c-17a124062050" + ); + } +} diff --git a/app/src-tauri/src/storage/mod.rs b/app/src-tauri/src/storage/mod.rs index ca3a674..1559e3e 100644 --- a/app/src-tauri/src/storage/mod.rs +++ b/app/src-tauri/src/storage/mod.rs @@ -1,3 +1,4 @@ +pub mod migration_store; pub mod projects_store; pub mod secure; pub mod settings_store; diff --git a/app/src/components/projects/MigrateContainerModal.test.tsx b/app/src/components/projects/MigrateContainerModal.test.tsx new file mode 100644 index 0000000..4da145f --- /dev/null +++ b/app/src/components/projects/MigrateContainerModal.test.tsx @@ -0,0 +1,241 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import MigrateContainerModal from "./MigrateContainerModal"; +import type { ContainerMigration } from "../../hooks/useContainerMigration"; +import type { ContainerStaleness } from "../../lib/types"; + +/** Modal focuses via rAF so the panel is laid out first; jsdom needs a flush. */ +async function flushFocus() { + await act(async () => { + vi.advanceTimersByTime(20); + }); +} + +const STALE: ContainerStaleness = { + stale: true, + known: true, + base_image_id: "sha256:aaa", + current_base_image_id: "sha256:bbb", + snapshot_created_at: "2026-03-01T09:00:00Z", + missing_paths: ["/usr/bin/socat"], + missing_features: ["Auth bridge tunnel (socat)", "Mission Control"], + apt_delta: ["socat", "bubblewrap"], + npm_global_delta: [], + verbatim_paths: [], + outdated_package_count: 61, + probe_error: null, +}; + +function migration(overrides: Partial = {}): ContainerMigration { + return { + staleness: STALE, + probing: false, + running: false, + recovered: false, + interrupted: null, + report: null, + log: [], + phaseMessage: null, + busy: false, + start: vi.fn(async () => {}), + resume: vi.fn(async () => {}), + keep: vi.fn(async () => {}), + rollback: vi.fn(async () => {}), + dismiss: vi.fn(), + refresh: vi.fn(async () => {}), + ...overrides, + }; +} + +async function renderModal( + staleness: ContainerStaleness | null = STALE, + overrides: Partial = {}, +) { + const m = migration({ staleness, ...overrides }); + const onClose = vi.fn(); + render( + , + ); + await flushFocus(); + return { m, onClose }; +} + +describe("MigrateContainerModal", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["requestAnimationFrame", "setTimeout"] }); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + describe("pre-flight", () => { + it("leads with what is kept, as a statement rather than a choice", async () => { + await renderModal(); + const kept = screen.getByText("Kept automatically"); + expect(kept).toBeInTheDocument(); + expect(screen.getByText(/no signing in again/i)).toBeInTheDocument(); + expect(screen.getByText(/every saved session transcript/i)).toBeInTheDocument(); + expect(screen.getByText(/are Docker volumes/i)).toBeInTheDocument(); + + // Reassurance comes first: it is above the replay section in the DOM. + const replay = screen.getByText(/Reinstalled from the new base's repos/); + expect(kept.compareDocumentPosition(replay)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + + // And it is a statement — there is no switch attached to it. + const keptSection = kept.closest("section"); + expect(keptSection?.querySelector('[role="switch"]')).toBeNull(); + }); + + it("hides the verbatim-copy section when nothing user-authored was found", async () => { + await renderModal({ ...STALE, verbatim_paths: [] }); + expect(screen.queryByText(/Copied across as-is/i)).not.toBeInTheDocument(); + }); + + it("shows the verbatim-copy section with its paths when there are some", async () => { + await renderModal({ + ...STALE, + verbatim_paths: ["/usr/local/bin/deploy.sh", "/etc/pki/corp.crt"], + }); + expect(screen.getByText("Copied across as-is (2)")).toBeInTheDocument(); + expect(screen.getByText("/usr/local/bin/deploy.sh")).toBeInTheDocument(); + expect(screen.getByText("/etc/pki/corp.crt")).toBeInTheDocument(); + }); + + it("counts the apt packages and states the rollback's disk cost", async () => { + await renderModal(); + expect( + screen.getByText("Reinstalled from the new base's repos (2)"), + ).toBeInTheDocument(); + expect(screen.getByText("socat")).toBeInTheDocument(); + expect(screen.getByText("bubblewrap")).toBeInTheDocument(); + expect(screen.getByText(/3.8–12.3 GB/)).toBeInTheDocument(); + expect( + screen.getByText(/Rollback restores the system layer only/i), + ).toBeInTheDocument(); + }); + + it("lists the gains as the inverse of the missing features", async () => { + await renderModal(); + expect(screen.getByText("You will gain")).toBeInTheDocument(); + expect(screen.getByText(/Auth bridge tunnel \(socat\)/)).toBeInTheDocument(); + expect(screen.getByText(/Mission Control/)).toBeInTheDocument(); + expect( + screen.getByText( + /61 packages the current base carries at a different version/i, + ), + ).toBeInTheDocument(); + }); + + it("passes the three options through when the run is started", async () => { + const { m } = await renderModal({ + ...STALE, + verbatim_paths: ["/usr/local/bin/deploy.sh"], + }); + fireEvent.click( + screen.getByRole("switch", { + name: /Keep a rollback image until I confirm/i, + }), + ); + fireEvent.click( + screen.getByRole("button", { name: "Update container base" }), + ); + expect(m.start).toHaveBeenCalledWith({ + replay_packages: true, + copy_paths: true, + keep_rollback: false, + }); + }); + + it("does not ask to copy paths when there are none to copy", async () => { + const { m } = await renderModal({ ...STALE, verbatim_paths: [] }); + fireEvent.click( + screen.getByRole("button", { name: "Update container base" }), + ); + expect(m.start).toHaveBeenCalledWith({ + replay_packages: true, + copy_paths: false, + keep_rollback: true, + }); + }); + + it("does not start anything on cancel", async () => { + const { m, onClose } = await renderModal(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onClose).toHaveBeenCalledTimes(1); + expect(m.start).not.toHaveBeenCalled(); + }); + }); + + describe("mid-run", () => { + const RUNNING: Partial = { + running: true, + log: ["Snapshotting container…", "Creating container on the new base…"], + phaseMessage: "Creating container on the new base…", + }; + + it("streams the phase message and the output", async () => { + await renderModal(STALE, RUNNING); + expect(screen.getByRole("status").textContent).toBe( + "Creating container on the new base…", + ); + const log = screen.getByTestId("migration-log"); + expect(log.textContent).toContain("Snapshotting container…"); + expect(log.textContent).toContain("Creating container on the new base…"); + }); + + it("can be dismissed without cancelling the run", async () => { + const { m, onClose } = await renderModal(STALE, RUNNING); + // A run takes minutes; blocking the app for it would be wrong, so the + // dialog closes and the work carries on. + expect( + screen.getByText(/keeps running if you close it/i), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Hide" })); + expect(onClose).toHaveBeenCalledTimes(1); + + // Nothing on the migration was touched — closing is not cancelling. + expect(m.start).not.toHaveBeenCalled(); + expect(m.rollback).not.toHaveBeenCalled(); + expect(m.dismiss).not.toHaveBeenCalled(); + }); + + it("still closes on Escape and on the header ✕ while running", async () => { + const { m, onClose } = await renderModal(STALE, RUNNING); + fireEvent.click(screen.getByRole("button", { name: "Close dialog" })); + expect(onClose).toHaveBeenCalledTimes(1); + fireEvent.keyDown(document, { key: "Escape" }); + expect(onClose).toHaveBeenCalledTimes(2); + expect(m.dismiss).not.toHaveBeenCalled(); + }); + }); + + describe("outcome", () => { + it("shows the report in place of the pre-flight once it lands", async () => { + await renderModal(STALE, { + report: { + phase: "partial", + packages_requested: ["socat", "bubblewrap"], + packages_installed: ["socat"], + packages_failed: [ + { name: "bubblewrap", reason: "held back by apt-mark" }, + ], + paths_copied: [], + features_restored: ["Auth bridge tunnel (socat)"], + rollback_available: true, + message: "", + }, + }); + expect(screen.getByText(/Updated, but not completely/i)).toBeInTheDocument(); + expect(screen.queryByText("Kept automatically")).not.toBeInTheDocument(); + expect(screen.getByText(/held back by apt-mark/)).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/components/projects/MigrateContainerModal.tsx b/app/src/components/projects/MigrateContainerModal.tsx new file mode 100644 index 0000000..807293e --- /dev/null +++ b/app/src/components/projects/MigrateContainerModal.tsx @@ -0,0 +1,310 @@ +import { useEffect, useRef, useState } from "react"; +import type { ContainerStaleness, MigrationOptions } from "../../lib/types"; +import Modal from "../ui/Modal"; +import Button from "../ui/Button"; +import Toggle from "../ui/Toggle"; +import { SwitchRow } from "../ui/Field"; +import MigrationReportCard from "./MigrationReportCard"; +import type { ContainerMigration } from "../../hooks/useContainerMigration"; +import { + KEPT_AUTOMATICALLY, + KEPT_WHY, + LOST_WITHOUT_REPLAY, + MID_RUN_SAFETY, + REPLAY_COST, + ROLLBACK_DISK_COST, + ROLLBACK_SCOPE, + formatSnapshotDate, +} from "./migrationCopy"; + +interface Props { + projectName: string; + staleness: ContainerStaleness | null; + migration: ContainerMigration; + onClose: () => void; +} + +function Section({ + title, + children, + control, +}: { + title: string; + children: React.ReactNode; + control?: React.ReactNode; +}) { + return ( +
+ {control ? ( + + ) : ( +

{title}

+ )} +
{children}
+
+ ); +} + +function BulletList({ items, mono = false }: { items: string[]; mono?: boolean }) { + return ( +
    + {items.map((item) => ( +
  • + {item} +
  • + ))} +
+ ); +} + +/** + * Pre-flight, progress and outcome for a base-image migration, in one dialog. + * + * Order matters here. The reassurance comes first — almost nothing painful is + * at risk, because the two volumes re-attach untouched — and only then the + * short list of things that genuinely have to be put back. Leading with the + * options would read as "pick which of your data to lose". + * + * Once the run starts the dialog stays **dismissible**: this takes minutes, and + * a modal that blocks the whole app for the duration is worse than no progress + * UI at all. Closing it hides a view; the work and its log live in the hook. + */ +export default function MigrateContainerModal({ + projectName, + staleness, + migration, + onClose, +}: Props) { + const [replayPackages, setReplayPackages] = useState(true); + const [copyPaths, setCopyPaths] = useState(true); + const [keepRollback, setKeepRollback] = useState(true); + const logRef = useRef(null); + + const { running, report, log, phaseMessage, busy } = migration; + const aptDelta = staleness?.apt_delta ?? []; + const npmDelta = staleness?.npm_global_delta ?? []; + const verbatim = staleness?.verbatim_paths ?? []; + const gains = staleness?.missing_features ?? []; + const snapshot = formatSnapshotDate(staleness?.snapshot_created_at ?? null); + + // Follow the tail of the apt output, the way a terminal would. + useEffect(() => { + const el = logRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [log.length]); + + const start = () => { + const options: MigrationOptions = { + replay_packages: replayPackages, + copy_paths: copyPaths && verbatim.length > 0, + keep_rollback: keepRollback, + }; + void migration.start(options); + }; + + // ---- Outcome ------------------------------------------------------------ + if (report) { + return ( + + Close + + } + > + void migration.keep().then(onClose)} + onRollback={() => void migration.rollback().then(onClose)} + onDismiss={() => { + migration.dismiss(); + onClose(); + }} + /> + + ); + } + + // ---- Progress ----------------------------------------------------------- + if (running) { + return ( + + Hide + + } + > +
+

+ {phaseMessage ?? "Starting…"} +

+
+ {log.length === 0 ? "Waiting for the first step…" : log.join("\n")} +
+

+ {MID_RUN_SAFETY} +

+
+
+ ); + } + + // ---- Pre-flight --------------------------------------------------------- + return ( + + + + + } + > +
+ {/* 1. Reassurance first. Not a choice — a statement of fact. */} +
+ +

{KEPT_WHY}

+

+ {LOST_WITHOUT_REPLAY} +

+
+ + {/* 2. The apt replay. */} +
+ } + > + {aptDelta.length === 0 ? ( +

+ No extra apt packages were found on this container. +

+ ) : ( + + )} + {npmDelta.length > 0 && ( + <> +

+ Global npm packages ({npmDelta.length}): +

+ + + )} +

{REPLAY_COST}

+
+ + {/* 3. Verbatim copies — usually nothing, so usually not shown at all. */} + {verbatim.length > 0 && ( +
+ } + > +

+ Content under /usr/local,{" "} + /opt,{" "} + /srv and non-bind-mounted{" "} + /workspace that belongs to no + package, so it cannot be reinstalled from a repository. +

+ +
+ )} + + {/* 4. The rollback image, with its real disk cost stated. */} +
+ } + > +

+ {ROLLBACK_DISK_COST} +

+

+ {ROLLBACK_SCOPE} +

+
+ + {gains.length > 0 && ( +
+

+ You will gain +

+
    + {gains.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ {/* "A different version", not "behind" — the count measures drift + from the base, not a guarantee that each one is an upgrade. */} + {(staleness?.outdated_package_count ?? 0) > 0 && ( +

+ Plus {staleness?.outdated_package_count} package + {staleness?.outdated_package_count === 1 ? "" : "s"} the current + base carries at a different version, security updates among them. +

+ )} +
+ )} +
+
+ ); +} diff --git a/app/src/components/projects/MigrationReportCard.tsx b/app/src/components/projects/MigrationReportCard.tsx new file mode 100644 index 0000000..a890c91 --- /dev/null +++ b/app/src/components/projects/MigrationReportCard.tsx @@ -0,0 +1,190 @@ +import { useState } from "react"; +import type { MigrationReport } from "../../lib/types"; +import Button from "../ui/Button"; +import StatusIndicator from "../ui/StatusIndicator"; +import { + ROLLBACK_SCOPE, + aptRetryCommand, + failureReportText, +} from "./migrationCopy"; + +interface Props { + report: MigrationReport; + /** Disables the action row while confirm/rollback is in flight. */ + busy?: boolean; + onKeep: () => void; + onRollback: () => void; + /** Only offered when there is nothing to keep or roll back. */ + onDismiss: () => void; +} + +/** + * The outcome of a migration, rendered identically in the Overview banner and + * in the modal so a user who closed the modal is not shown a different story. + * + * A **partial** is the case this component exists for. The user arrived here + * because containers degrade silently — a run that quietly dropped `socat` and + * called itself a success would be exactly the same bug in a new place. So a + * partial is painted as a warning, names every package and the reason it + * failed, and hands over the literal `apt-get` line to finish the job. + */ +export default function MigrationReportCard({ + report, + busy = false, + onKeep, + onRollback, + onDismiss, +}: Props) { + const [copied, setCopied] = useState<"command" | "detail" | null>(null); + const partial = report.phase === "partial"; + const failed = report.phase === "failed"; + const rolledBack = report.phase === "rolled_back"; + + const copy = async (what: "command" | "detail", text: string) => { + try { + await navigator.clipboard.writeText(text); + setCopied(what); + setTimeout(() => setCopied(null), 2000); + } catch { + // Clipboard can be denied; the text is selectable on screen either way. + } + }; + + // Partial and failed are painted as failures. A partial that reads as a + // success is precisely how a container ends up silently degraded. + const tone = partial || failed ? "error" : rolledBack ? "off" : "ok"; + const heading = partial + ? "Updated, but not completely" + : failed + ? "Update failed" + : rolledBack + ? "Rolled back" + : "Container base updated"; + + return ( +
+
+ +
+ + {report.phase === "succeeded" && ( +

+ {report.packages_installed.length} package + {report.packages_installed.length === 1 ? "" : "s"} reinstalled,{" "} + {report.features_restored.length} feature + {report.features_restored.length === 1 ? "" : "s"} restored. + {report.paths_copied.length > 0 + ? ` ${report.paths_copied.length} path${report.paths_copied.length === 1 ? "" : "s"} copied across.` + : ""} +

+ )} + + {failed && ( +

+ {report.message || + "Update failed. Your container has been restored to its previous state."} +

+ )} + + {rolledBack && ( +

+ {report.message || "The previous system layer has been put back."} +

+ )} + + {partial && ( +
+

+ {report.packages_installed.length} of{" "} + {report.packages_requested.length} packages went back on.{" "} + + {report.packages_failed.length} did not + + , so this container is still missing something it had before. +

+ +
+
    + {report.packages_failed.map((failure) => ( +
  • + + {failure.name} + + — {failure.reason} +
  • + ))} +
+
+ + {report.packages_failed.length > 0 && ( +
+

+ Finish by hand in a shell inside the container: +

+ + {aptRetryCommand(report.packages_failed)} + +
+ + +
+
+ )} +
+ )} + + {report.features_restored.length > 0 && !failed && ( +
+

+ Restored +

+

+ {report.features_restored.join(", ")} +

+
+ )} + + {report.message && !failed && !rolledBack && ( +

{report.message}

+ )} + + {report.rollback_available && ( +

+ {ROLLBACK_SCOPE} +

+ )} + +
+ {report.rollback_available ? ( + <> + + + + ) : ( + + )} +
+
+ ); +} diff --git a/app/src/components/projects/home/ContainerMigrationBanner.test.tsx b/app/src/components/projects/home/ContainerMigrationBanner.test.tsx new file mode 100644 index 0000000..f9f6cdf --- /dev/null +++ b/app/src/components/projects/home/ContainerMigrationBanner.test.tsx @@ -0,0 +1,306 @@ +import { describe, it, expect, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import ContainerMigrationBanner from "./ContainerMigrationBanner"; +import type { ContainerMigration } from "../../../hooks/useContainerMigration"; +import type { + ContainerStaleness, + MigrationReport, +} from "../../../lib/types"; + +const FRESH: ContainerStaleness = { + stale: false, + known: true, + base_image_id: "sha256:aaa", + current_base_image_id: "sha256:aaa", + snapshot_created_at: "2026-03-01T09:00:00Z", + missing_paths: [], + missing_features: [], + apt_delta: [], + npm_global_delta: [], + verbatim_paths: [], + outdated_package_count: 0, + probe_error: null, +}; + +const STALE: ContainerStaleness = { + ...FRESH, + stale: true, + current_base_image_id: "sha256:bbb", + missing_paths: ["/usr/bin/socat", "/usr/bin/bwrap"], + missing_features: [ + "Host-browser opening", + "Auth bridge tunnel (socat)", + "Mission Control", + ], + apt_delta: ["socat", "bubblewrap"], + outdated_package_count: 61, +}; + +function migration(overrides: Partial = {}): ContainerMigration { + return { + staleness: null, + probing: false, + running: false, + recovered: false, + interrupted: null, + report: null, + log: [], + phaseMessage: null, + busy: false, + start: vi.fn(async () => {}), + resume: vi.fn(async () => {}), + keep: vi.fn(async () => {}), + rollback: vi.fn(async () => {}), + dismiss: vi.fn(), + refresh: vi.fn(async () => {}), + ...overrides, + }; +} + +function renderBanner(m: ContainerMigration, canMigrate = true) { + const onOpen = vi.fn(); + const { container } = render( + , + ); + return { onOpen, container }; +} + +describe("ContainerMigrationBanner", () => { + it("renders nothing when the container is on the current base", () => { + const { container } = renderBanner(migration({ staleness: FRESH })); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing before the probe has returned", () => { + const { container } = renderBanner(migration({ staleness: null })); + expect(container).toBeEmptyDOMElement(); + }); + + it("leads with the missing features rather than image digests", () => { + renderBanner(migration({ staleness: STALE })); + expect(screen.getByText(/Container base is out of date/i)).toBeInTheDocument(); + expect( + screen.getByText( + /Host-browser opening, Auth bridge tunnel \(socat\) and Mission Control/, + ), + ).toBeInTheDocument(); + expect( + screen.getByText(/61 packages differ from the versions on the current base/i), + ).toBeInTheDocument(); + // Digests are evidence, not the message. + expect(screen.queryByText(/sha256/)).not.toBeInTheDocument(); + }); + + it("does not claim the packages are behind, only that they differ", () => { + renderBanner(migration({ staleness: STALE })); + // `outdated_package_count` is a drift measure; the backend explicitly does + // not promise every one of them is newer. + expect(screen.queryByText(/behind on security updates/i)).not.toBeInTheDocument(); + }); + + it("says the container was probed when there is no base-image label", () => { + // `stale` is always false when `known` is false — an unknown lineage is not + // a claim of staleness — but the probe's own findings still have to show. + renderBanner( + migration({ staleness: { ...STALE, known: false, stale: false } }), + ); + expect(screen.getByText(/probed directly/i)).toBeInTheDocument(); + expect(screen.getByText(/The probe found these missing/i)).toBeInTheDocument(); + // No version comparison happened, so none is implied. + expect(screen.queryByText(/Running on a saved image/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/out of date/i)).not.toBeInTheDocument(); + }); + + it("stays quiet for an unlabelled container the probe found nothing wrong with", () => { + const { container } = renderBanner( + migration({ + staleness: { + ...FRESH, + known: false, + stale: false, + outdated_package_count: 3, + }, + }), + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("disables the action and explains why while the container is running", () => { + renderBanner(migration({ staleness: STALE }), false); + expect( + screen.getByRole("button", { name: /Update container base/i }), + ).toBeDisabled(); + expect(screen.getByText(/Stop the container to update its base/i)).toBeInTheDocument(); + }); + + it("keeps reporting an in-flight run after the modal is closed", () => { + renderBanner( + migration({ + staleness: STALE, + running: true, + phaseMessage: "Reinstalling socat…", + }), + ); + expect(screen.getByText(/Updating container base/i)).toBeInTheDocument(); + expect(screen.getByText("Reinstalling socat…")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Show progress/i })).toBeInTheDocument(); + }); + + it("surfaces a run recovered from a crash", () => { + renderBanner(migration({ staleness: STALE, running: true, recovered: true })); + expect( + screen.getByText(/A container base update was already running/i), + ).toBeInTheDocument(); + expect( + screen.getByText(/still in progress when the app last closed/i), + ).toBeInTheDocument(); + }); + + it("does not let an interrupted migration hide behind a plain staleness notice", () => { + const m = migration({ + staleness: STALE, + interrupted: { + phase: "interrupted", + from_image_id: "sha256:aaa", + to_base_id: "sha256:bbb", + started_at: "2026-08-09T10:00:00Z", + report: null, + rollback_image: "triple-c-snapshot-p1:pre-migration-1754733600", + staging_path: null, + options: { replay_packages: true, copy_paths: false, keep_rollback: true }, + plan: null, + }, + }); + renderBanner(m); + expect( + screen.getByText(/A container base update was interrupted/i), + ).toBeInTheDocument(); + expect(screen.getByText(/part-way onto the new base/i)).toBeInTheDocument(); + // The plain "Update container base…" call to action must not be what is + // offered here — the container is mid-swap, so it is resume or roll back. + expect( + screen.queryByRole("button", { name: /Update container base/i }), + ).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Resume update" })); + expect(m.resume).toHaveBeenCalledTimes(1); + expect(screen.getByRole("button", { name: "Roll back" })).toBeInTheDocument(); + }); + + it("offers no rollback for an interrupted run that kept no rollback image", () => { + renderBanner( + migration({ + staleness: STALE, + interrupted: { + phase: "interrupted", + from_image_id: "sha256:aaa", + to_base_id: "sha256:bbb", + started_at: "2026-08-09T10:00:00Z", + report: null, + rollback_image: null, + staging_path: null, + options: { replay_packages: true, copy_paths: false, keep_rollback: false }, + plan: null, + }, + }), + ); + expect(screen.queryByRole("button", { name: "Roll back" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Resume update" })).toBeInTheDocument(); + }); + + describe("the report", () => { + const CLEAN: MigrationReport = { + phase: "succeeded", + packages_requested: ["socat", "bubblewrap"], + packages_installed: [ + "socat", + "bubblewrap", + "ca-certificates", + "openssl", + "curl", + "jq", + "ripgrep", + "unzip", + ], + packages_failed: [], + paths_copied: [], + features_restored: [ + "Host-browser opening", + "Auth bridge tunnel (socat)", + "Sandbox mode (bubblewrap)", + "Mission Control", + ], + rollback_available: true, + message: "", + }; + + const PARTIAL: MigrationReport = { + phase: "partial", + packages_requested: ["socat", "bubblewrap", "libfoo-dev"], + packages_installed: ["socat"], + packages_failed: [ + { name: "bubblewrap", reason: "held back by apt-mark" }, + { name: "libfoo-dev", reason: "no installation candidate in noble" }, + ], + paths_copied: [], + features_restored: ["Auth bridge tunnel (socat)"], + rollback_available: true, + message: "", + }; + + it("reports a clean run with counts and both choices", () => { + renderBanner(migration({ staleness: FRESH, report: CLEAN })); + expect(screen.getByText(/8 packages reinstalled/i)).toBeInTheDocument(); + expect(screen.getByText(/4 features restored/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Keep" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Roll back" })).toBeInTheDocument(); + }); + + it("names every failed package and why, and does not read as a success", () => { + renderBanner(migration({ staleness: STALE, report: PARTIAL })); + expect(screen.getByText(/Updated, but not completely/i)).toBeInTheDocument(); + expect(screen.getByText("bubblewrap")).toBeInTheDocument(); + expect(screen.getByText(/held back by apt-mark/)).toBeInTheDocument(); + expect(screen.getByText("libfoo-dev")).toBeInTheDocument(); + expect( + screen.getByText(/no installation candidate in noble/), + ).toBeInTheDocument(); + // And the exact line that finishes the job by hand. + expect( + screen.getByText("sudo apt-get install -y bubblewrap libfoo-dev"), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /Copy apt-get line/i }), + ).toBeInTheDocument(); + }); + + it("says a failed run has already been restored, and offers no rollback", () => { + renderBanner( + migration({ + staleness: STALE, + report: { + phase: "failed", + packages_requested: [], + packages_installed: [], + packages_failed: [], + paths_copied: [], + features_restored: [], + rollback_available: false, + message: + "Update failed at replay. Your container has been restored to its previous state.", + }, + }), + ); + expect(screen.getByText(/Update failed at replay/i)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Roll back" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument(); + }); + + it("does not describe rollback as a time machine", () => { + renderBanner(migration({ staleness: FRESH, report: CLEAN })); + expect( + screen.getByText(/Rollback restores the system layer only/i), + ).toBeInTheDocument(); + expect(screen.getByText(/Volumes are never touched/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/components/projects/home/ContainerMigrationBanner.tsx b/app/src/components/projects/home/ContainerMigrationBanner.tsx new file mode 100644 index 0000000..25cb3fb --- /dev/null +++ b/app/src/components/projects/home/ContainerMigrationBanner.tsx @@ -0,0 +1,232 @@ +import type { ContainerMigration } from "../../../hooks/useContainerMigration"; +import Button from "../../ui/Button"; +import StatusIndicator from "../../ui/StatusIndicator"; +import MigrationReportCard from "../MigrationReportCard"; +import { ROLLBACK_SCOPE, formatSnapshotDate, joinFeatures } from "../migrationCopy"; + +interface Props { + migration: ContainerMigration; + /** Migration mirrors Reset's gate: the container has to be stopped. */ + canMigrate: boolean; + onOpen: () => void; +} + +const SHELL = + "border rounded-[var(--radius-panel)] px-3.5 py-3 space-y-2"; + +/** + * The Overview answer to "why is this container behaving oddly?". + * + * It leads with the *features* that are missing, not image digests: a user does + * not know or care that `sha256:abc…` differs from `sha256:def…`, they care + * that host-browser opening and the auth bridge do not work. Digests are the + * evidence, not the message. + * + * It also has to survive the run: an in-flight migration, an interrupted one, + * and the report are all shown here, because the modal is dismissable and the + * outcome must not vanish with it. + */ +export default function ContainerMigrationBanner({ + migration, + canMigrate, + onOpen, +}: Props) { + const { staleness, running, recovered, interrupted, report, phaseMessage, busy } = + migration; + + // The report outranks staleness: after a run, the outcome is the news. + if (report) { + return ( +
+ void migration.keep()} + onRollback={() => void migration.rollback()} + onDismiss={migration.dismiss} + /> +
+ ); + } + + if (running) { + return ( +
+
+
+ +

+ {phaseMessage ?? "Starting…"} +

+ {recovered && ( +

+ It was still in progress when the app last closed. Picking it back up. +

+ )} +
+ +
+
+ ); + } + + // Nothing is driving this one. It outranks staleness because the container is + // sitting mid-swap, and the one thing it must never do is look like a normal + // out-of-date container that the user can take or leave. + if (interrupted) { + return ( +
+ +

+ It started{" "} + {formatSnapshotDate(interrupted.started_at) ?? "earlier"} and the app + closed before it finished, so this container is part-way onto the new + base. Resuming replays the same plan it was given. +

+

+ {ROLLBACK_SCOPE} +

+
+ + {interrupted.rollback_image && ( + + )} +
+
+ ); + } + + if (!staleness) return null; + + // `stale` is deliberately false whenever `known` is false — an unestablished + // lineage is not a claim of staleness. But a container with no base-image + // label is exactly the old container most likely to be missing things, and + // the probe says so directly. So the probe's own findings are grounds to + // speak up even though the version comparison never happened. + const probeFoundGaps = + !staleness.known && + (staleness.missing_features.length > 0 || staleness.missing_paths.length > 0); + if (!staleness.stale && !probeFoundGaps) return null; + + const snapshot = formatSnapshotDate(staleness.snapshot_created_at); + const features = joinFeatures(staleness.missing_features); + + return ( +
+
+
+ + +

+ {staleness.known + ? snapshot + ? `Running on a saved image from ${snapshot}.` + : "Running on a saved image older than the current base." + : "This container predates base-image tracking, so it was probed directly."} +

+ + {staleness.missing_features.length > 0 && ( +

+ {staleness.known ? "Missing: " : "The probe found these missing: "} + {features}. +

+ )} + + {staleness.missing_features.length === 0 && + staleness.missing_paths.length > 0 && ( +

+ {staleness.known ? "Missing: " : "The probe found these missing: "} + + {staleness.missing_paths.join(", ")} + +

+ )} + + {/* Deliberately "differ" rather than "behind": the count is a drift + measure, not a promise that every one of them is newer. */} + {staleness.outdated_package_count > 0 && ( +

+ {staleness.outdated_package_count} package + {staleness.outdated_package_count === 1 ? "" : "s"} differ from the + versions on the current base, where security updates land. +

+ )} + + {staleness.probe_error && ( +

+ Some checks did not complete: {staleness.probe_error} +

+ )} + + {!canMigrate && ( +

+ Stop the container to update its base. +

+ )} +
+ + +
+
+ ); +} diff --git a/app/src/components/projects/home/OverviewTab.tsx b/app/src/components/projects/home/OverviewTab.tsx index f80ad88..c7dfb44 100644 --- a/app/src/components/projects/home/OverviewTab.tsx +++ b/app/src/components/projects/home/OverviewTab.tsx @@ -12,6 +12,8 @@ import PermissionModeControl, { permissionModePatch, } from "../PermissionModeControl"; import CapabilityTiles from "./CapabilityTiles"; +import ContainerMigrationBanner from "./ContainerMigrationBanner"; +import type { ContainerMigration } from "../../../hooks/useContainerMigration"; import SaveIndicator from "../../ui/SaveIndicator"; import Button from "../../ui/Button"; import { formatAge } from "./format"; @@ -31,6 +33,11 @@ interface Props { saveState: SaveState; actions: ReturnType; onOpenTab: (tab: ProjectHomeTabId) => void; + /** Base-image staleness, run state and report. Owned by `ProjectHome`. */ + migration: ContainerMigration; + /** Migration mirrors Reset's gate: only offered on a stopped container. */ + canMigrate: boolean; + onOpenMigration: () => void; } export default function OverviewTab({ @@ -39,6 +46,9 @@ export default function OverviewTab({ saveState, actions, onOpenTab, + migration, + canMigrate, + onOpenMigration, }: Props) { const [sessions, setSessions] = useState([]); const [tasks, setTasks] = useState([]); @@ -120,6 +130,14 @@ export default function OverviewTab({ + {/* A container missing socat and bwrap is a capability statement, so the + out-of-date warning sits directly above the capability inventory. */} + + actions.openTerminalWithCommand(command)} diff --git a/app/src/components/projects/home/ProjectHome.tsx b/app/src/components/projects/home/ProjectHome.tsx index 22be107..c946be2 100644 --- a/app/src/components/projects/home/ProjectHome.tsx +++ b/app/src/components/projects/home/ProjectHome.tsx @@ -4,11 +4,13 @@ import { useAppState } from "../../../store/appState"; import { useProjectActions } from "../../../hooks/useProjectActions"; import { useProjects } from "../../../hooks/useProjects"; import { useProjectSave } from "../../../hooks/useSaveState"; +import { useContainerMigration } from "../../../hooks/useContainerMigration"; import { ProjectStatusIndicator } from "../../ui/StatusIndicator"; import Button from "../../ui/Button"; import OverflowMenu from "../../ui/OverflowMenu"; import ConfirmRemoveModal from "../ConfirmRemoveModal"; import ConfirmResetModal from "../ConfirmResetModal"; +import MigrateContainerModal from "../MigrateContainerModal"; import OverviewTab from "./OverviewTab"; import SessionsTab from "./SessionsTab"; import AutomationTab from "./AutomationTab"; @@ -43,6 +45,7 @@ export default function ProjectHome({ projectId, active }: Props) { const [tab, setTab] = useState("overview"); const [confirmRemove, setConfirmRemove] = useState(false); const [confirmReset, setConfirmReset] = useState(false); + const [showMigration, setShowMigration] = useState(false); const { runningSince, progress } = useAppState( useShallow((s) => ({ runningSince: s.runningSince[projectId], @@ -64,6 +67,11 @@ export default function ProjectHome({ projectId, active }: Props) { const { save, saveState } = useProjectSave( project ?? ({ id: projectId, name: "" } as never), ); + // Owned here, not in the modal: the run outlives the dialog, and the Overview + // banner has to keep showing progress and the report after it is dismissed. + const migration = useContainerMigration( + project ?? ({ id: projectId, name: "", container_id: null } as never), + ); const uptime = useMemo(() => formatUptime(runningSince), [runningSince]); @@ -81,6 +89,16 @@ export default function ProjectHome({ projectId, active }: Props) { const isTransitioning = project.status === "starting" || project.status === "stopping"; const isStopped = project.status === "stopped" || project.status === "error"; + // Rebuilding on a new base swaps the container out, so it gates exactly like + // Reset does — with the extra condition that there is a container to migrate. + // An interrupted migration is excluded too: its action is Resume, on the + // Overview banner, not a fresh pre-flight. + const canMigrate = + isStopped && + !actions.busy && + !migration.running && + !migration.interrupted && + !!project.container_id; return (
@@ -147,6 +165,11 @@ export default function ProjectHome({ projectId, active }: Props) { onSelect: actions.handleBackup, disabled: actions.backingUp || !project.container_id, }, + { + label: "Update container base…", + onSelect: () => setShowMigration(true), + disabled: !canMigrate, + }, { label: "Reset container…", onSelect: () => setConfirmReset(true), @@ -200,6 +223,9 @@ export default function ProjectHome({ projectId, active }: Props) { saveState={saveState} actions={actions} onOpenTab={setTab} + migration={migration} + canMigrate={canMigrate} + onOpenMigration={() => setShowMigration(true)} /> )} {tab === "sessions" && } @@ -213,6 +239,16 @@ export default function ProjectHome({ projectId, active }: Props) { )}
+ {showMigration && ( + setShowMigration(false)} + /> + )} {confirmReset && ( f.name).join(" ")}`; +} + +/** Plain-text form of a partial report, for the copy button. */ +export function failureReportText(failures: PackageFailure[]): string { + const lines = failures.map((f) => `${f.name}: ${f.reason}`); + return [ + "Packages that could not be reinstalled:", + ...lines, + "", + aptRetryCommand(failures), + ].join("\n"); +} diff --git a/app/src/hooks/useContainerMigration.test.tsx b/app/src/hooks/useContainerMigration.test.tsx new file mode 100644 index 0000000..7449668 --- /dev/null +++ b/app/src/hooks/useContainerMigration.test.tsx @@ -0,0 +1,262 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { useContainerMigration } from "./useContainerMigration"; +import type { + ContainerStaleness, + MigrationReport, + MigrationState, + Project, +} from "../lib/types"; + +const getContainerStaleness = vi.fn(); +const getMigrationState = vi.fn(); +const migrateProjectToBase = vi.fn(); +const confirmMigration = vi.fn(); +const rollbackMigration = vi.fn(); +const pushToast = vi.fn(); +let progress: string | undefined; + +vi.mock("../lib/tauri-commands", () => ({ + getContainerStaleness: (...a: unknown[]) => getContainerStaleness(...a), + getMigrationState: (...a: unknown[]) => getMigrationState(...a), + migrateProjectToBase: (...a: unknown[]) => migrateProjectToBase(...a), + confirmMigration: (...a: unknown[]) => confirmMigration(...a), + rollbackMigration: (...a: unknown[]) => rollbackMigration(...a), +})); + +vi.mock("../store/appState", () => ({ + useAppState: Object.assign( + (selector: (s: unknown) => unknown) => + selector({ pushToast, containerProgress: { p1: progress } }), + { + getState: () => ({ setContainerProgress: () => {} }), + }, + ), +})); + +const STALE: ContainerStaleness = { + stale: true, + known: true, + base_image_id: "sha256:aaa", + current_base_image_id: "sha256:bbb", + snapshot_created_at: "2026-03-01T09:00:00Z", + missing_paths: ["/usr/bin/socat"], + missing_features: ["Auth bridge tunnel (socat)"], + apt_delta: ["socat"], + npm_global_delta: [], + verbatim_paths: [], + outdated_package_count: 61, + probe_error: null, +}; + +const FRESH: ContainerStaleness = { + ...STALE, + stale: false, + base_image_id: "sha256:bbb", + missing_paths: [], + missing_features: [], + apt_delta: [], + outdated_package_count: 0, +}; + +const CLEAN: MigrationReport = { + phase: "succeeded", + packages_requested: ["socat"], + packages_installed: ["socat"], + packages_failed: [], + paths_copied: [], + features_restored: ["Auth bridge tunnel (socat)"], + rollback_available: true, + message: "", +}; + +const OPTIONS = { + replay_packages: true, + copy_paths: false, + keep_rollback: true, +}; + +function state(overrides: Partial = {}): MigrationState { + return { + phase: "in-progress", + from_image_id: "sha256:aaa", + to_base_id: "sha256:bbb", + started_at: "2026-08-09T10:00:00Z", + report: null, + rollback_image: "triple-c-snapshot-p1:pre-migration-1754733600", + staging_path: null, + options: OPTIONS, + plan: null, + ...overrides, + }; +} + +const project = { id: "p1", name: "api-server", container_id: "c1", status: "stopped" } as Project; + +describe("useContainerMigration", () => { + beforeEach(() => { + vi.clearAllMocks(); + progress = undefined; + getContainerStaleness.mockResolvedValue(STALE); + getMigrationState.mockResolvedValue(null); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("probes staleness for a container that exists", async () => { + const { result } = renderHook(() => useContainerMigration(project)); + await waitFor(() => expect(result.current.staleness).toEqual(STALE)); + expect(getContainerStaleness).toHaveBeenCalledWith("p1"); + }); + + it("does not probe a project whose container was never created", async () => { + renderHook(() => + useContainerMigration({ ...project, container_id: null } as Project), + ); + await waitFor(() => expect(getMigrationState).toHaveBeenCalled()); + expect(getContainerStaleness).not.toHaveBeenCalled(); + }); + + it("shows an absent banner rather than an error one when the probe fails", async () => { + getContainerStaleness.mockRejectedValue(new Error("no such container")); + const { result } = renderHook(() => useContainerMigration(project)); + await waitFor(() => expect(result.current.probing).toBe(false)); + expect(result.current.staleness).toBeNull(); + }); + + it("passes the options through and keeps the report", async () => { + migrateProjectToBase.mockResolvedValue(CLEAN); + const { result } = renderHook(() => useContainerMigration(project)); + await waitFor(() => expect(result.current.staleness).toEqual(STALE)); + + getContainerStaleness.mockResolvedValue(FRESH); + await act(async () => { + await result.current.start({ + replay_packages: true, + copy_paths: false, + keep_rollback: true, + }); + }); + + expect(migrateProjectToBase).toHaveBeenCalledWith("p1", { + replay_packages: true, + copy_paths: false, + keep_rollback: true, + }); + expect(result.current.report).toEqual(CLEAN); + expect(result.current.running).toBe(false); + }); + + it("turns a rejected migrate call into a failed report, not a silent nothing", async () => { + migrateProjectToBase.mockRejectedValue(new Error("docker daemon went away")); + const { result } = renderHook(() => useContainerMigration(project)); + await act(async () => { + await result.current.start({ + replay_packages: true, + copy_paths: false, + keep_rollback: true, + }); + }); + expect(result.current.report?.phase).toBe("failed"); + expect(result.current.report?.message).toMatch(/docker daemon went away/); + expect(result.current.report?.rollback_available).toBe(false); + }); + + it("clears the report and re-probes once the migration is kept", async () => { + migrateProjectToBase.mockResolvedValue(CLEAN); + confirmMigration.mockResolvedValue(undefined); + const { result } = renderHook(() => useContainerMigration(project)); + await act(async () => { + await result.current.start({ + replay_packages: true, + copy_paths: false, + keep_rollback: true, + }); + }); + getContainerStaleness.mockResolvedValue(FRESH); + await act(async () => { + await result.current.keep(); + }); + expect(confirmMigration).toHaveBeenCalledWith("p1"); + expect(result.current.report).toBeNull(); + await waitFor(() => expect(result.current.staleness).toEqual(FRESH)); + }); + + it("says out loud that a rollback left the volumes alone", async () => { + rollbackMigration.mockResolvedValue(undefined); + const { result } = renderHook(() => useContainerMigration(project)); + await act(async () => { + await result.current.rollback(); + }); + expect(rollbackMigration).toHaveBeenCalledWith("p1"); + expect(pushToast).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "success", + detail: expect.stringMatching(/Volumes were not touched/i), + }), + ); + }); + + describe("crash recovery", () => { + it("adopts a run that was still in progress, and polls it to a report", async () => { + getMigrationState.mockResolvedValue(state()); + + const { result } = renderHook(() => useContainerMigration(project)); + await waitFor(() => expect(result.current.running).toBe(true)); + expect(result.current.recovered).toBe(true); + + getMigrationState.mockResolvedValue( + state({ phase: "awaiting-confirmation", report: CLEAN }), + ); + await waitFor(() => expect(result.current.report).toEqual(CLEAN), { + timeout: 5000, + }); + expect(result.current.running).toBe(false); + }); + + it("surfaces a finished migration that was never acknowledged", async () => { + getMigrationState.mockResolvedValue( + state({ phase: "awaiting-confirmation", report: CLEAN }), + ); + const { result } = renderHook(() => useContainerMigration(project)); + await waitFor(() => expect(result.current.report).toEqual(CLEAN)); + expect(result.current.running).toBe(false); + }); + + it("surfaces an interrupted migration instead of leaving it invisible", async () => { + getMigrationState.mockResolvedValue(state({ phase: "interrupted" })); + const { result } = renderHook(() => useContainerMigration(project)); + await waitFor(() => expect(result.current.interrupted).not.toBeNull()); + // Nothing is driving it, so it is not "running" and has no report. + expect(result.current.running).toBe(false); + expect(result.current.report).toBeNull(); + }); + + it("resumes an interrupted migration with the options it was given", async () => { + getMigrationState.mockResolvedValue(state({ phase: "interrupted" })); + migrateProjectToBase.mockResolvedValue(CLEAN); + const { result } = renderHook(() => useContainerMigration(project)); + await waitFor(() => expect(result.current.interrupted).not.toBeNull()); + + await act(async () => { + await result.current.resume(); + }); + // The deltas cannot be recomputed after the swap, so the recorded plan's + // options are replayed verbatim rather than re-derived. + expect(migrateProjectToBase).toHaveBeenCalledWith("p1", OPTIONS); + expect(result.current.interrupted).toBeNull(); + expect(result.current.report).toEqual(CLEAN); + }); + + it("ignores an unrecognised phase from a future build rather than crashing", async () => { + getMigrationState.mockResolvedValue(state({ phase: "quantum-tunnelling" })); + const { result } = renderHook(() => useContainerMigration(project)); + await waitFor(() => expect(result.current.staleness).toEqual(STALE)); + expect(result.current.running).toBe(false); + expect(result.current.interrupted).toBeNull(); + expect(result.current.report).toBeNull(); + }); + }); +}); diff --git a/app/src/hooks/useContainerMigration.ts b/app/src/hooks/useContainerMigration.ts new file mode 100644 index 0000000..b66b97f --- /dev/null +++ b/app/src/hooks/useContainerMigration.ts @@ -0,0 +1,293 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { + ContainerStaleness, + MigrationOptions, + MigrationReport, + MigrationState, + Project, +} from "../lib/types"; +import { + MIGRATION_PHASE_AWAITING_CONFIRMATION, + MIGRATION_PHASE_IN_PROGRESS, + MIGRATION_PHASE_INTERRUPTED, +} from "../lib/types"; +import * as commands from "../lib/tauri-commands"; +import { useAppState } from "../store/appState"; + +/** + * Unsettled phases from `MigrationState.phase` (hyphenated, unlike the + * outcome phases on `MigrationReport`). Compared as strings on purpose: the + * backend types this loosely so an unrecognised value from a future build + * cannot crash the UI, and neither can it here — an unknown phase simply + * surfaces nothing rather than throwing. + */ +const IN_PROGRESS = MIGRATION_PHASE_IN_PROGRESS; +const INTERRUPTED = MIGRATION_PHASE_INTERRUPTED; +const AWAITING = MIGRATION_PHASE_AWAITING_CONFIRMATION; + +export interface ContainerMigration { + /** Null until the first probe returns, or when the container has never been created. */ + staleness: ContainerStaleness | null; + probing: boolean; + /** True while a migration is running — whether we started it or found it. */ + running: boolean; + /** True when the run in progress was recovered from disk, not started here. */ + recovered: boolean; + /** + * A migration the app died in the middle of. It is not running and it has no + * report: the container is mid-swap until someone resumes or rolls it back. + */ + interrupted: MigrationState | null; + /** Re-enter an interrupted migration. The backend continues the same run. */ + resume: () => Promise; + /** The settled report, kept until the user keeps, rolls back or dismisses it. */ + report: MigrationReport | null; + /** Progress lines from `container-progress`, oldest first. */ + log: string[]; + /** The most recent progress line, or null before the first one arrives. */ + phaseMessage: string | null; + /** True while confirm/rollback is in flight. */ + busy: boolean; + start: (options: MigrationOptions) => Promise; + keep: () => Promise; + rollback: () => Promise; + /** Clear a report we cannot act on (failed / rolled back). Local only. */ + dismiss: () => void; + refresh: () => Promise; +} + +/** + * Container base-image migration for one project. + * + * Three things have to survive a closed modal: the run itself, the progress + * log, and the report. A migration takes minutes, so the modal is a *view* onto + * this hook rather than the thing that owns the work — closing it must not + * cancel anything. The hook lives in `ProjectHome`, above both the modal and + * the Overview banner, so either surface can be showing at any point. + * + * A migration the app died in the middle of is picked up from + * `getMigrationState` on mount — as `interrupted`, which is offered for resume, + * or as `awaiting-confirmation`, whose report is put back on screen. Without + * that, a half-migrated container would look identical to a healthy one, which + * is the exact failure mode this whole feature exists to fix. + */ +export function useContainerMigration(project: Project): ContainerMigration { + const projectId = project.id; + const [staleness, setStaleness] = useState(null); + const [probing, setProbing] = useState(false); + const [running, setRunning] = useState(false); + const [recovered, setRecovered] = useState(false); + const [interrupted, setInterrupted] = useState(null); + const [report, setReport] = useState(null); + const [log, setLog] = useState([]); + const [busy, setBusy] = useState(false); + const pushToast = useAppState((s) => s.pushToast); + const progress = useAppState((s) => s.containerProgress[projectId]); + + // Guards a late response from an earlier project overwriting a newer one. + const generation = useRef(0); + + const refresh = useCallback(async () => { + const gen = ++generation.current; + if (!project.container_id) { + setStaleness(null); + return; + } + setProbing(true); + try { + const next = await commands.getContainerStaleness(projectId); + if (gen === generation.current) setStaleness(next); + } catch { + // A probe that cannot reach the container is "we do not know", which is + // an absent banner rather than an error one — the same call is retried + // whenever the container's status changes. + if (gen === generation.current) setStaleness(null); + } finally { + if (gen === generation.current) setProbing(false); + } + }, [projectId, project.container_id]); + + // Probe staleness when the container settles into a new state. The probe runs + // two filesystem walks and is explicitly not for polling, so it is skipped + // mid-transition and mid-run — a reading taken while the container is being + // swapped describes neither the old system layer nor the new one. + const settled = project.status !== "starting" && project.status !== "stopping"; + useEffect(() => { + if (running || !settled) return; + void refresh(); + }, [refresh, settled, running]); + + // Crash recovery: adopt whatever the backend still has on record. + useEffect(() => { + let cancelled = false; + commands + .getMigrationState(projectId) + .then((state) => { + if (cancelled || !state) return; + if (state.phase === IN_PROGRESS) { + // Something is still driving it; watch rather than restart. + setRunning(true); + setRecovered(true); + } else if (state.phase === INTERRUPTED) { + // Nothing is driving it. The container is mid-swap and will stay that + // way until someone resumes — so this must be visible, not silent. + setInterrupted(state); + } else if (state.phase === AWAITING && state.report) { + setReport(state.report); + } + }) + .catch(() => { + /* No recorded state is the normal case. */ + }); + return () => { + cancelled = true; + }; + }, [projectId]); + + // A recovered run has no promise to await, so poll it to completion. + useEffect(() => { + if (!running || !recovered) return; + let cancelled = false; + const timer = setInterval(() => { + commands + .getMigrationState(projectId) + .then((state: MigrationState | null) => { + if (cancelled || state?.phase === IN_PROGRESS) return; + setRunning(false); + setRecovered(false); + // A cleared record means it was confirmed or rolled back elsewhere. + if (!state) { + void refresh(); + return; + } + if (state.phase === INTERRUPTED) { + setInterrupted(state); + return; + } + if (state.report) setReport(state.report); + void refresh(); + }) + .catch(() => { + /* Keep polling; a transient IPC failure is not an outcome. */ + }); + }, 2500); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [running, recovered, projectId, refresh]); + + // Accumulate the shared progress line into a scrollback the modal can show. + // The store collapses repeats, so identical consecutive apt lines appear once. + useEffect(() => { + if (!running || !progress) return; + setLog((prev) => + prev[prev.length - 1] === progress ? prev : [...prev, progress], + ); + }, [progress, running]); + + const start = useCallback( + async (options: MigrationOptions) => { + setLog([]); + setReport(null); + setRecovered(false); + setInterrupted(null); + setRunning(true); + try { + const result = await commands.migrateProjectToBase(projectId, options); + setReport(result); + } catch (e) { + // A rejected call means the backend never produced a report. Synthesise + // the failed shape so the report surface — not a toast that scrolls + // away — is still what tells the user. + setReport({ + phase: "failed", + packages_requested: [], + packages_installed: [], + packages_failed: [], + paths_copied: [], + features_restored: [], + rollback_available: false, + message: String(e), + }); + } finally { + setRunning(false); + useAppState.getState().setContainerProgress(projectId, null); + void refresh(); + } + }, + [projectId, refresh], + ); + + /** + * Re-enter an interrupted migration. The backend continues that run rather + * than starting a new one, and the recorded options are replayed as-is — the + * deltas cannot be recomputed once the container has already been swapped. + */ + const resume = useCallback(async () => { + const pending = interrupted; + if (!pending) return; + await start(pending.options); + }, [interrupted, start]); + + const keep = useCallback(async () => { + setBusy(true); + try { + await commands.confirmMigration(projectId); + setReport(null); + await refresh(); + } catch (e) { + pushToast({ + kind: "error", + message: `Could not discard the rollback image for “${project.name}”`, + detail: String(e), + }); + } finally { + setBusy(false); + } + }, [projectId, project.name, refresh, pushToast]); + + const rollback = useCallback(async () => { + setBusy(true); + try { + await commands.rollbackMigration(projectId); + setReport(null); + setInterrupted(null); + pushToast({ + kind: "success", + message: `“${project.name}” is back on its previous system layer.`, + detail: + "Volumes were not touched, so anything written to your home directory or workspace during the update is still there.", + }); + await refresh(); + } catch (e) { + pushToast({ + kind: "error", + message: `Rollback failed for “${project.name}”`, + detail: String(e), + }); + } finally { + setBusy(false); + } + }, [projectId, project.name, refresh, pushToast]); + + const dismiss = useCallback(() => setReport(null), []); + + return { + staleness, + probing, + running, + recovered, + interrupted, + report, + log, + phaseMessage: log.length > 0 ? log[log.length - 1] : null, + busy, + start, + resume, + keep, + rollback, + dismiss, + refresh, + }; +} diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index b6ca1ef..6766e62 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, PlaywrightDetection } from "./types"; +import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, PlaywrightDetection, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState } from "./types"; // Docker export const checkDocker = () => invoke("check_docker"); @@ -200,3 +200,44 @@ export const submitClaudeTokenCode = (code: string) => export const cancelClaudeToken = () => invoke("cancel_claude_token"); export const hasClaudeToken = () => invoke("has_claude_token"); export const clearClaudeToken = () => invoke("clear_claude_token"); + +// Container base-image migration — move a project onto the current base image +// without deleting its volumes. Reset is the destructive alternative: it wipes +// ~/.claude, the OAuth credential, installed skills and every transcript. +// +// Flow: getContainerStaleness (read-only, ~6s — two filesystem probes, so call +// it on demand rather than polling) → migrateProjectToBase → the project sits +// in "awaiting-confirmation" while the user tries it → confirmMigration or +// rollbackMigration. +// +// Rollback restores the **system layer only**. Both named volumes are untouched +// throughout, so anything written to $HOME during the migrated session — a new +// login, new skills, new transcripts — survives a rollback. +// +// Progress arrives on the existing `container-progress` event. + +/** Read-only. Runs two container/image filesystem probes; not for polling. */ +export const getContainerStaleness = (projectId: string) => + invoke("get_container_staleness", { projectId }); + +/** Runs the whole migration and resolves with its report. Long-running — the + * apt replay alone was measured at ~70s for 8 packages. Calling it again while + * a migration is `interrupted` resumes that one instead of starting a new one. */ +export const migrateProjectToBase = (projectId: string, options: MigrationOptions) => + invoke("migrate_project_to_base", { projectId, options }); + +/** Accept the migration: drops the rollback tag and the staged payload, and + * clears the record. Idempotent. */ +export const confirmMigration = (projectId: string) => + invoke("confirm_migration", { projectId }); + +/** Undo the migration: recreates the container from its pre-migration image. + * Fails if the migration kept no rollback image (`keep_rollback: false`). */ +export const rollbackMigration = (projectId: string) => + invoke("rollback_migration", { projectId }); + +/** The persisted record, or null when no migration is in flight. Worth calling + * after `reconcileProjectStatuses` at startup: a migration interrupted by an + * app crash shows up here as phase "interrupted". */ +export const getMigrationState = (projectId: string) => + invoke("get_migration_state", { projectId }); diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index efc3803..5ed8040 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -478,3 +478,144 @@ export interface ClaudeTokenOutputEvent { project_id: string; chunk: string; } + +// ── Container base-image migration ─────────────────────────────────────────── +// +// A project's container is created from its own `triple-c-snapshot-:latest` +// image and re-committed on every recreation, so it stays on the base image it +// was first built from forever. Migration moves it onto the *current* base +// **without touching either named volume** — unlike Reset, which deletes them +// and takes the login, skills and transcripts with it. +// +// Because `/home/claude` is a volume and the image's copy of it is masked after +// the first mount, almost nothing needs replaying: Claude Code itself, cargo, +// uv, ruff, `~/.claude.json`, the OAuth credential, skills, transcripts, +// scheduled tasks and SSH keys all re-attach for free. What is genuinely lost +// on an image swap is confined to the writable layer: root-level apt installs, +// `npm -g` packages, `/usr/local`, `/opt`, `/srv`, and non-bind-mounted +// `/workspace` content. Those are exactly what `MigrationOptions` replays. +// +// Mirrors Rust `models/migration.rs` (serde snake_case). + +/** How a finished migration attempt ended. Mirrors Rust `MigrationPhase`. */ +export type MigrationPhase = "succeeded" | "partial" | "failed" | "rolled_back"; + +/** One package that could not be replayed onto the new base. */ +export interface PackageFailure { + name: string; + /** Tail of the package manager's own error output. */ + reason: string; +} + +/** Why a project is worth migrating, and what migrating would carry across. + * + * An empty array always means "nothing found", never "not checked" — + * `probe_error` is the single place a failed inspection is reported. */ +export interface ContainerStaleness { + /** The container's lineage is not the current base. Always false when + * `known` is false: an unknown lineage is not a claim of staleness. */ + stale: boolean; + /** Whether the lineage could be established at all. False means the + * container predates the `triple-c.base-image-id` label — "unknown, probe + * instead", not "stale". */ + known: boolean; + base_image_id: string | null; + current_base_image_id: string | null; + /** `Created` of the project's snapshot image, RFC 3339. */ + snapshot_created_at: string | null; + /** Concrete paths the base ships and this container lacks, e.g. `/usr/bin/socat`. */ + missing_paths: string[]; + /** Human labels for the same, e.g. "Auth bridge tunnel (socat)". */ + missing_features: string[]; + /** apt packages the project added on top of the base; migration replays these. */ + apt_delta: string[]; + /** Global npm packages the base does not ship. */ + npm_global_delta: string[]; + /** Non-package paths under /usr/local, /opt, /srv and /workspace that would + * be carried across. Empty when nothing user-authored was found — which is + * the common case. */ + verbatim_paths: string[]; + /** dpkg packages the base carries at a different version. A drift measure, + * not a promise that every one is newer. */ + outdated_package_count: number; + /** Set when the container/image could not be inspected; everything else is + * then at its default. */ + probe_error: string | null; +} + +/** What a migration should replay. All default to false. */ +export interface MigrationOptions { + /** Replay the apt and `npm -g` deltas onto the new base. */ + replay_packages: boolean; + /** Copy the verbatim payload (/usr/local, /opt, /srv, non-bind-mounted /workspace). */ + copy_paths: boolean; + /** Keep the `:pre-migration-` rollback tag after the migration reports + * success, so it can still be undone. Costs roughly a whole snapshot on disk + * (3.8–12.3 GB on real projects) because snapshots share almost no layers + * with the current base. When false the tag is dropped as soon as the + * migration is known to have worked, and `rollback_available` is false. */ + keep_rollback: boolean; +} + +/** The outcome of one migration attempt. */ +export interface MigrationReport { + phase: MigrationPhase; + packages_requested: string[]; + packages_installed: string[]; + packages_failed: PackageFailure[]; + paths_copied: string[]; + /** Human labels for base features the container gained. */ + features_restored: string[]; + /** A `:pre-migration-` image still exists, so `rollbackMigration` works. */ + rollback_available: boolean; + /** One paragraph fit to show the user verbatim. */ + message: string; +} + +/** In-flight phases of `MigrationState.phase`. Distinct from `MigrationPhase`, + * which describes *outcomes*. + * + * These are **hyphenated**, matching the `triple-c.migration-state=in-progress` + * container label so there is exactly one spelling in the system. Compare + * against the constants below rather than writing the literals — that is what + * they are for. */ +export type MigrationStatePhase = + | "in-progress" + | "interrupted" + | "awaiting-confirmation"; + +/** A migration is running right now. Poll `getMigrationState` until it changes. */ +export const MIGRATION_PHASE_IN_PROGRESS = "in-progress"; +/** The app died after the container was swapped. Offer resume (call + * `migrateProjectToBase` again — it picks the interrupted run up) or rollback. */ +export const MIGRATION_PHASE_INTERRUPTED = "interrupted"; +/** Finished; `report` is populated. Offer confirm or rollback. */ +export const MIGRATION_PHASE_AWAITING_CONFIRMATION = "awaiting-confirmation"; + +/** What a migration decided to do, frozen at pre-flight time so a resume + * replays the same thing (the deltas cannot be recomputed after the swap). */ +export interface MigrationPlan { + apt_packages: string[]; + npm_packages: string[]; + verbatim_paths: string[]; + missing_paths: string[]; +} + +/** Persisted host-side migration record. Present only while a migration is in + * flight or waiting for a decision; `confirmMigration` and `rollbackMigration` + * both clear it. */ +export interface MigrationState { + /** One of `MigrationStatePhase`; typed loosely because an unrecognised value + * from a future build must not crash the UI. */ + phase: string; + from_image_id: string | null; + to_base_id: string | null; + started_at: string; + report: MigrationReport | null; + /** The `:pre-migration-` tag holding the old system layer, if kept. */ + rollback_image: string | null; + /** Host path of the staged payload tar, while one exists. */ + staging_path: string | null; + options: MigrationOptions; + plan: MigrationPlan | null; +} diff --git a/container/entrypoint.sh b/container/entrypoint.sh index 579e97f..e7ce594 100644 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -2,6 +2,16 @@ # NOTE: set -e is intentionally omitted. A failing usermod/groupmod must not # kill the entire entrypoint — SSH setup, git config, and the final exec # must still run so the container is usable even if remapping fails. +# +# NOTE: /home/claude is the mount point of the named volume +# triple-c-home-{projectId}, so the *image's* copy of that directory is +# seed-only: after a project's first start it is masked permanently. Anything +# this script writes under /home/claude on **every** start does reach existing +# projects (that is why the CLAUDE.md, git config and Mission Control skill +# copies are written here rather than baked into the image). Anything added to +# /home/claude in the Dockerfile reaches new projects only, forever. Put +# upgradable content in /usr/local/bin or /opt, or seed it from here. +# See "Container Lifecycle" in the repo's CLAUDE.md. # ── UID/GID remapping ────────────────────────────────────────────────────── # Match the container's claude user to the host user's UID/GID so that