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:
@@ -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();
|
||||
|
||||
@@ -37,6 +37,22 @@ pub async fn create_attached_exec(
|
||||
container_id: &str,
|
||||
cmd: Vec<String>,
|
||||
tty: bool,
|
||||
) -> Result<AttachedExec, String> {
|
||||
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<String>,
|
||||
tty: bool,
|
||||
user: &str,
|
||||
working_dir: &str,
|
||||
) -> Result<AttachedExec, String> {
|
||||
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 `<dest_dir>/<file_name>` 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<String, String> {
|
||||
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<String>) -> Result<String, String> {
|
||||
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<String>,
|
||||
env: Vec<String>,
|
||||
) -> 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<String>,
|
||||
env: Vec<String>,
|
||||
) -> 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()
|
||||
},
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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::*;
|
||||
|
||||
Reference in New Issue
Block a user