Files
Triple-C/app/src-tauri/src/commands/migration_commands.rs
T
shadow-testandClaude Opus 5 d42b741337 Migrate a project onto a new base image without losing its volumes
Projects were pinned to the image they were first created from. Both
create paths preferred triple-c-snapshot-<id>: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) <noreply@anthropic.com>
2026-08-09 18:19:12 -07:00

1388 lines
54 KiB
Rust

//! 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-<ts> ← 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-<id>: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-<ts>` 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<ContainerStaleness, String> {
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<String> {
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::collections::HashSet<String>>,
> = std::sync::OnceLock::new();
fn active_migrations() -> &'static std::sync::Mutex<std::collections::HashSet<String>> {
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<Self> {
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<MigrationReport, String> {
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<MigrationReport, 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 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<MigrationReport, 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 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<MigrationReport, String> {
let project_id = project.id.clone();
let plan = mstate.plan.clone().unwrap_or_default();
let options = mstate.options;
let mut packages_requested: Vec<String> = Vec::new();
let mut packages_installed: Vec<String> = Vec::new();
let mut packages_failed: Vec<PackageFailure> = Vec::new();
let mut paths_copied: Vec<String> = Vec::new();
let mut notes: Vec<String> = 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<String> = 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,
&notes,
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<Option<MigrationState>, 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<String, String> {
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<String> = 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<String>, Vec<PackageFailure>) {
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::<Vec<_>>()
.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<String>, Vec<PackageFailure>) {
let bulk = format!(
"npm install -g --no-fund --no-audit {}",
packages
.iter()
.map(|p| shell_quote(p))
.collect::<Vec<_>>()
.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<String> = 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());
}
}