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:
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, PlaywrightDetection } from "./types";
|
||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, PlaywrightDetection, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState } from "./types";
|
||||
|
||||
// Docker
|
||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||
@@ -200,3 +200,44 @@ export const submitClaudeTokenCode = (code: string) =>
|
||||
export const cancelClaudeToken = () => invoke<void>("cancel_claude_token");
|
||||
export const hasClaudeToken = () => invoke<boolean>("has_claude_token");
|
||||
export const clearClaudeToken = () => invoke<void>("clear_claude_token");
|
||||
|
||||
// Container base-image migration — move a project onto the current base image
|
||||
// without deleting its volumes. Reset is the destructive alternative: it wipes
|
||||
// ~/.claude, the OAuth credential, installed skills and every transcript.
|
||||
//
|
||||
// Flow: getContainerStaleness (read-only, ~6s — two filesystem probes, so call
|
||||
// it on demand rather than polling) → migrateProjectToBase → the project sits
|
||||
// in "awaiting-confirmation" while the user tries it → confirmMigration or
|
||||
// rollbackMigration.
|
||||
//
|
||||
// Rollback restores the **system layer only**. Both named volumes are untouched
|
||||
// throughout, so anything written to $HOME during the migrated session — a new
|
||||
// login, new skills, new transcripts — survives a rollback.
|
||||
//
|
||||
// Progress arrives on the existing `container-progress` event.
|
||||
|
||||
/** Read-only. Runs two container/image filesystem probes; not for polling. */
|
||||
export const getContainerStaleness = (projectId: string) =>
|
||||
invoke<ContainerStaleness>("get_container_staleness", { projectId });
|
||||
|
||||
/** Runs the whole migration and resolves with its report. Long-running — the
|
||||
* apt replay alone was measured at ~70s for 8 packages. Calling it again while
|
||||
* a migration is `interrupted` resumes that one instead of starting a new one. */
|
||||
export const migrateProjectToBase = (projectId: string, options: MigrationOptions) =>
|
||||
invoke<MigrationReport>("migrate_project_to_base", { projectId, options });
|
||||
|
||||
/** Accept the migration: drops the rollback tag and the staged payload, and
|
||||
* clears the record. Idempotent. */
|
||||
export const confirmMigration = (projectId: string) =>
|
||||
invoke<void>("confirm_migration", { projectId });
|
||||
|
||||
/** Undo the migration: recreates the container from its pre-migration image.
|
||||
* Fails if the migration kept no rollback image (`keep_rollback: false`). */
|
||||
export const rollbackMigration = (projectId: string) =>
|
||||
invoke<void>("rollback_migration", { projectId });
|
||||
|
||||
/** The persisted record, or null when no migration is in flight. Worth calling
|
||||
* after `reconcileProjectStatuses` at startup: a migration interrupted by an
|
||||
* app crash shows up here as phase "interrupted". */
|
||||
export const getMigrationState = (projectId: string) =>
|
||||
invoke<MigrationState | null>("get_migration_state", { projectId });
|
||||
|
||||
Reference in New Issue
Block a user