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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user