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:
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Shared wording for container base-image migration.
|
||||
*
|
||||
* The banner, the pre-flight modal and the report all have to make the same
|
||||
* promise about what survives, or the feature reads as another Reset. It is
|
||||
* written once here so the three surfaces cannot drift apart.
|
||||
*/
|
||||
|
||||
import type { PackageFailure } from "../../lib/types";
|
||||
|
||||
/**
|
||||
* What re-attaches untouched. These are not copied, rebuilt or re-authenticated
|
||||
* — they live on the two Docker volumes, which the new container mounts as-is.
|
||||
*/
|
||||
export const KEPT_AUTOMATICALLY = [
|
||||
"Your claude login and ~/.claude.json — no signing in again",
|
||||
"Skills, agents, commands, hooks, plugins and MCP config",
|
||||
"Every saved session transcript, so past sessions still resume",
|
||||
"Scheduler tasks and their logs",
|
||||
"SSH keys, git config and shell history",
|
||||
"Claude Code itself, plus Rust/cargo, uv and ruff in your home directory",
|
||||
];
|
||||
|
||||
export const KEPT_WHY =
|
||||
"/home/claude and ~/.claude are Docker volumes. They detach from the old container and re-attach to the new one unchanged.";
|
||||
|
||||
export const LOST_WITHOUT_REPLAY =
|
||||
"What a new base does not carry over is the root-level system packages you installed with apt. Those are the only thing this update has to put back.";
|
||||
|
||||
/**
|
||||
* Said plainly everywhere rollback is offered. Rollback is not a time machine:
|
||||
* it swaps the system layer back and leaves both volumes exactly where the
|
||||
* migrated session left them.
|
||||
*/
|
||||
export const ROLLBACK_SCOPE =
|
||||
"Rollback restores the system layer only. Your volumes are never touched, so anything Claude wrote to your home directory or a mounted workspace during the migrated session stays as it is.";
|
||||
|
||||
export const ROLLBACK_DISK_COST =
|
||||
"A rollback image is close to a full second copy of the container — snapshots here run 3.8–12.3 GB and share almost nothing with the new base, so it costs nearly its full size on disk. It is deleted the moment you press Keep.";
|
||||
|
||||
/** Shown mid-run, where rollback is not a button but is still the safety net. */
|
||||
export const MID_RUN_SAFETY =
|
||||
"If this fails, the container is put back on its previous system layer automatically. Your volumes are not touched at any point.";
|
||||
|
||||
export const REPLAY_COST =
|
||||
"Needs network access and usually takes 1–2 minutes.";
|
||||
|
||||
/** `1 Mar` — short enough to sit inline in the banner sentence. */
|
||||
export function formatSnapshotDate(iso: string | null): string | null {
|
||||
if (!iso) return null;
|
||||
const ms = Date.parse(iso);
|
||||
if (Number.isNaN(ms)) return null;
|
||||
return new Date(ms).toLocaleDateString(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
}
|
||||
|
||||
/** Join a list into prose: "a, b and c". Used for the missing-features line. */
|
||||
export function joinFeatures(features: string[]): string {
|
||||
if (features.length === 0) return "";
|
||||
if (features.length === 1) return features[0];
|
||||
return `${features.slice(0, -1).join(", ")} and ${features[features.length - 1]}`;
|
||||
}
|
||||
|
||||
/** The exact line to paste into a shell to finish a partial migration by hand. */
|
||||
export function aptRetryCommand(failures: PackageFailure[]): string {
|
||||
return `sudo apt-get install -y ${failures.map((f) => 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");
|
||||
}
|
||||
Reference in New Issue
Block a user