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>
This commit is contained in:
2026-08-09 18:19:12 -07:00
co-authored by Claude Opus 5
parent cc5f691677
commit d42b741337
26 changed files with 5704 additions and 58 deletions
+131 -16
View File
@@ -700,10 +700,55 @@ pub async fn find_existing_container(project: &Project) -> Result<Option<String>
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();