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
@@ -0,0 +1,310 @@
import { useEffect, useRef, useState } from "react";
import type { ContainerStaleness, MigrationOptions } from "../../lib/types";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import Toggle from "../ui/Toggle";
import { SwitchRow } from "../ui/Field";
import MigrationReportCard from "./MigrationReportCard";
import type { ContainerMigration } from "../../hooks/useContainerMigration";
import {
KEPT_AUTOMATICALLY,
KEPT_WHY,
LOST_WITHOUT_REPLAY,
MID_RUN_SAFETY,
REPLAY_COST,
ROLLBACK_DISK_COST,
ROLLBACK_SCOPE,
formatSnapshotDate,
} from "./migrationCopy";
interface Props {
projectName: string;
staleness: ContainerStaleness | null;
migration: ContainerMigration;
onClose: () => void;
}
function Section({
title,
children,
control,
}: {
title: string;
children: React.ReactNode;
control?: React.ReactNode;
}) {
return (
<section className="border border-[var(--border-color)] rounded-[var(--radius-panel)] bg-[var(--bg-secondary)] px-3.5 py-3">
{control ? (
<SwitchRow label={title} control={control} />
) : (
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">{title}</h3>
)}
<div className="mt-2 space-y-1.5">{children}</div>
</section>
);
}
function BulletList({ items, mono = false }: { items: string[]; mono?: boolean }) {
return (
<ul className="space-y-1 pl-4 list-disc marker:text-[var(--text-disabled)]">
{items.map((item) => (
<li
key={item}
className={`text-xs leading-snug text-[var(--text-secondary)] ${
mono ? "font-mono break-all" : ""
}`}
>
{item}
</li>
))}
</ul>
);
}
/**
* Pre-flight, progress and outcome for a base-image migration, in one dialog.
*
* Order matters here. The reassurance comes first — almost nothing painful is
* at risk, because the two volumes re-attach untouched — and only then the
* short list of things that genuinely have to be put back. Leading with the
* options would read as "pick which of your data to lose".
*
* Once the run starts the dialog stays **dismissible**: this takes minutes, and
* a modal that blocks the whole app for the duration is worse than no progress
* UI at all. Closing it hides a view; the work and its log live in the hook.
*/
export default function MigrateContainerModal({
projectName,
staleness,
migration,
onClose,
}: Props) {
const [replayPackages, setReplayPackages] = useState(true);
const [copyPaths, setCopyPaths] = useState(true);
const [keepRollback, setKeepRollback] = useState(true);
const logRef = useRef<HTMLDivElement>(null);
const { running, report, log, phaseMessage, busy } = migration;
const aptDelta = staleness?.apt_delta ?? [];
const npmDelta = staleness?.npm_global_delta ?? [];
const verbatim = staleness?.verbatim_paths ?? [];
const gains = staleness?.missing_features ?? [];
const snapshot = formatSnapshotDate(staleness?.snapshot_created_at ?? null);
// Follow the tail of the apt output, the way a terminal would.
useEffect(() => {
const el = logRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [log.length]);
const start = () => {
const options: MigrationOptions = {
replay_packages: replayPackages,
copy_paths: copyPaths && verbatim.length > 0,
keep_rollback: keepRollback,
};
void migration.start(options);
};
// ---- Outcome ------------------------------------------------------------
if (report) {
return (
<Modal
title={`Update container base — ${projectName}`}
onClose={onClose}
widthClassName="w-[34rem]"
footer={
<Button size="md" variant="ghost" onClick={onClose}>
Close
</Button>
}
>
<MigrationReportCard
report={report}
busy={busy}
onKeep={() => void migration.keep().then(onClose)}
onRollback={() => void migration.rollback().then(onClose)}
onDismiss={() => {
migration.dismiss();
onClose();
}}
/>
</Modal>
);
}
// ---- Progress -----------------------------------------------------------
if (running) {
return (
<Modal
title={`Updating container base — ${projectName}`}
description="This keeps running if you close it. You can carry on using the app."
onClose={onClose}
widthClassName="w-[34rem]"
footer={
<Button size="md" variant="ghost" onClick={onClose}>
Hide
</Button>
}
>
<div className="space-y-3">
<p
role="status"
aria-live="polite"
className="text-[13px] text-[var(--text-primary)]"
>
{phaseMessage ?? "Starting…"}
</p>
<div
ref={logRef}
data-testid="migration-log"
className="h-56 overflow-y-auto px-2.5 py-2 font-mono text-[11px] leading-relaxed text-[var(--text-secondary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] whitespace-pre-wrap break-all select-text"
>
{log.length === 0 ? "Waiting for the first step…" : log.join("\n")}
</div>
<p className="text-xs text-[var(--text-secondary)] leading-snug">
{MID_RUN_SAFETY}
</p>
</div>
</Modal>
);
}
// ---- Pre-flight ---------------------------------------------------------
return (
<Modal
title={`Update container base — ${projectName}`}
description={
snapshot
? `Rebuilds this container on the current base image. It is running on a saved image from ${snapshot}.`
: "Rebuilds this container on the current base image."
}
onClose={onClose}
widthClassName="w-[36rem]"
footer={
<>
<Button size="md" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button size="md" variant="primary" onClick={start}>
Update container base
</Button>
</>
}
>
<div className="space-y-3">
{/* 1. Reassurance first. Not a choice — a statement of fact. */}
<Section title="Kept automatically">
<BulletList items={KEPT_AUTOMATICALLY} />
<p className="text-xs text-[var(--text-secondary)] leading-snug">{KEPT_WHY}</p>
<p className="text-xs text-[var(--text-secondary)] leading-snug">
{LOST_WITHOUT_REPLAY}
</p>
</Section>
{/* 2. The apt replay. */}
<Section
title={`Reinstalled from the new base's repos (${aptDelta.length})`}
control={
<Toggle
label="Reinstall system packages from the new base's repositories"
checked={replayPackages}
onChange={setReplayPackages}
/>
}
>
{aptDelta.length === 0 ? (
<p className="text-xs text-[var(--text-secondary)]">
No extra apt packages were found on this container.
</p>
) : (
<BulletList items={aptDelta} mono />
)}
{npmDelta.length > 0 && (
<>
<p className="text-xs text-[var(--text-secondary)] pt-1">
Global npm packages ({npmDelta.length}):
</p>
<BulletList items={npmDelta} mono />
</>
)}
<p className="text-xs text-[var(--text-secondary)]">{REPLAY_COST}</p>
</Section>
{/* 3. Verbatim copies — usually nothing, so usually not shown at all. */}
{verbatim.length > 0 && (
<Section
title={`Copied across as-is (${verbatim.length})`}
control={
<Toggle
label="Copy user-authored files across as-is"
checked={copyPaths}
onChange={setCopyPaths}
/>
}
>
<p className="text-xs text-[var(--text-secondary)]">
Content under <code className="font-mono">/usr/local</code>,{" "}
<code className="font-mono">/opt</code>,{" "}
<code className="font-mono">/srv</code> and non-bind-mounted{" "}
<code className="font-mono">/workspace</code> that belongs to no
package, so it cannot be reinstalled from a repository.
</p>
<BulletList items={verbatim} mono />
</Section>
)}
{/* 4. The rollback image, with its real disk cost stated. */}
<Section
title="Keep a rollback image until I confirm"
control={
<Toggle
label="Keep a rollback image until I confirm"
checked={keepRollback}
onChange={setKeepRollback}
tone="caution"
/>
}
>
<p className="text-xs text-[var(--text-secondary)] leading-snug">
{ROLLBACK_DISK_COST}
</p>
<p className="text-xs text-[var(--text-secondary)] leading-snug">
{ROLLBACK_SCOPE}
</p>
</Section>
{gains.length > 0 && (
<section className="border border-[var(--success)]/40 bg-[var(--success-muted)] rounded-[var(--radius-panel)] px-3.5 py-3">
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
You will gain
</h3>
<ul className="mt-1.5 space-y-1">
{gains.map((feature) => (
<li
key={feature}
className="text-xs leading-snug text-[var(--text-secondary)]"
>
<span aria-hidden="true" className="text-[var(--success)]">
+{" "}
</span>
{feature}
</li>
))}
</ul>
{/* "A different version", not "behind" — the count measures drift
from the base, not a guarantee that each one is an upgrade. */}
{(staleness?.outdated_package_count ?? 0) > 0 && (
<p className="mt-1.5 text-xs text-[var(--text-secondary)]">
Plus {staleness?.outdated_package_count} package
{staleness?.outdated_package_count === 1 ? "" : "s"} the current
base carries at a different version, security updates among them.
</p>
)}
</section>
)}
</div>
</Modal>
);
}