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:
@@ -4,11 +4,13 @@ import { useAppState } from "../../../store/appState";
|
||||
import { useProjectActions } from "../../../hooks/useProjectActions";
|
||||
import { useProjects } from "../../../hooks/useProjects";
|
||||
import { useProjectSave } from "../../../hooks/useSaveState";
|
||||
import { useContainerMigration } from "../../../hooks/useContainerMigration";
|
||||
import { ProjectStatusIndicator } from "../../ui/StatusIndicator";
|
||||
import Button from "../../ui/Button";
|
||||
import OverflowMenu from "../../ui/OverflowMenu";
|
||||
import ConfirmRemoveModal from "../ConfirmRemoveModal";
|
||||
import ConfirmResetModal from "../ConfirmResetModal";
|
||||
import MigrateContainerModal from "../MigrateContainerModal";
|
||||
import OverviewTab from "./OverviewTab";
|
||||
import SessionsTab from "./SessionsTab";
|
||||
import AutomationTab from "./AutomationTab";
|
||||
@@ -43,6 +45,7 @@ export default function ProjectHome({ projectId, active }: Props) {
|
||||
const [tab, setTab] = useState<ProjectHomeTabId>("overview");
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
const [showMigration, setShowMigration] = useState(false);
|
||||
const { runningSince, progress } = useAppState(
|
||||
useShallow((s) => ({
|
||||
runningSince: s.runningSince[projectId],
|
||||
@@ -64,6 +67,11 @@ export default function ProjectHome({ projectId, active }: Props) {
|
||||
const { save, saveState } = useProjectSave(
|
||||
project ?? ({ id: projectId, name: "" } as never),
|
||||
);
|
||||
// Owned here, not in the modal: the run outlives the dialog, and the Overview
|
||||
// banner has to keep showing progress and the report after it is dismissed.
|
||||
const migration = useContainerMigration(
|
||||
project ?? ({ id: projectId, name: "", container_id: null } as never),
|
||||
);
|
||||
|
||||
const uptime = useMemo(() => formatUptime(runningSince), [runningSince]);
|
||||
|
||||
@@ -81,6 +89,16 @@ export default function ProjectHome({ projectId, active }: Props) {
|
||||
const isTransitioning =
|
||||
project.status === "starting" || project.status === "stopping";
|
||||
const isStopped = project.status === "stopped" || project.status === "error";
|
||||
// Rebuilding on a new base swaps the container out, so it gates exactly like
|
||||
// Reset does — with the extra condition that there is a container to migrate.
|
||||
// An interrupted migration is excluded too: its action is Resume, on the
|
||||
// Overview banner, not a fresh pre-flight.
|
||||
const canMigrate =
|
||||
isStopped &&
|
||||
!actions.busy &&
|
||||
!migration.running &&
|
||||
!migration.interrupted &&
|
||||
!!project.container_id;
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col h-full min-h-0 ${active ? "" : "hidden"}`}>
|
||||
@@ -147,6 +165,11 @@ export default function ProjectHome({ projectId, active }: Props) {
|
||||
onSelect: actions.handleBackup,
|
||||
disabled: actions.backingUp || !project.container_id,
|
||||
},
|
||||
{
|
||||
label: "Update container base…",
|
||||
onSelect: () => setShowMigration(true),
|
||||
disabled: !canMigrate,
|
||||
},
|
||||
{
|
||||
label: "Reset container…",
|
||||
onSelect: () => setConfirmReset(true),
|
||||
@@ -200,6 +223,9 @@ export default function ProjectHome({ projectId, active }: Props) {
|
||||
saveState={saveState}
|
||||
actions={actions}
|
||||
onOpenTab={setTab}
|
||||
migration={migration}
|
||||
canMigrate={canMigrate}
|
||||
onOpenMigration={() => setShowMigration(true)}
|
||||
/>
|
||||
)}
|
||||
{tab === "sessions" && <SessionsTab project={project} actions={actions} />}
|
||||
@@ -213,6 +239,16 @@ export default function ProjectHome({ projectId, active }: Props) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showMigration && (
|
||||
<MigrateContainerModal
|
||||
projectName={project.name}
|
||||
staleness={migration.staleness}
|
||||
migration={migration}
|
||||
// Closing is not cancelling — the run keeps going and the Overview
|
||||
// banner keeps reporting it.
|
||||
onClose={() => setShowMigration(false)}
|
||||
/>
|
||||
)}
|
||||
{confirmReset && (
|
||||
<ConfirmResetModal
|
||||
projectName={project.name}
|
||||
|
||||
Reference in New Issue
Block a user