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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user