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 });
|
||||
|
||||
@@ -478,3 +478,144 @@ export interface ClaudeTokenOutputEvent {
|
||||
project_id: string;
|
||||
chunk: string;
|
||||
}
|
||||
|
||||
// ── Container base-image migration ───────────────────────────────────────────
|
||||
//
|
||||
// A project's container is created from its own `triple-c-snapshot-<id>:latest`
|
||||
// image and re-committed on every recreation, so it stays on the base image it
|
||||
// was first built from forever. Migration moves it onto the *current* base
|
||||
// **without touching either named volume** — unlike Reset, which deletes them
|
||||
// and takes the login, skills and transcripts with it.
|
||||
//
|
||||
// Because `/home/claude` is a volume and the image's copy of it is masked after
|
||||
// the first mount, almost nothing needs replaying: Claude Code itself, cargo,
|
||||
// uv, ruff, `~/.claude.json`, the OAuth credential, skills, transcripts,
|
||||
// scheduled tasks and SSH keys all re-attach for free. What is genuinely lost
|
||||
// on an image swap is confined to the writable layer: root-level apt installs,
|
||||
// `npm -g` packages, `/usr/local`, `/opt`, `/srv`, and non-bind-mounted
|
||||
// `/workspace` content. Those are exactly what `MigrationOptions` replays.
|
||||
//
|
||||
// Mirrors Rust `models/migration.rs` (serde snake_case).
|
||||
|
||||
/** How a finished migration attempt ended. Mirrors Rust `MigrationPhase`. */
|
||||
export type MigrationPhase = "succeeded" | "partial" | "failed" | "rolled_back";
|
||||
|
||||
/** One package that could not be replayed onto the new base. */
|
||||
export interface PackageFailure {
|
||||
name: string;
|
||||
/** Tail of the package manager's own error output. */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** Why a project is worth migrating, and what migrating would carry across.
|
||||
*
|
||||
* An empty array always means "nothing found", never "not checked" —
|
||||
* `probe_error` is the single place a failed inspection is reported. */
|
||||
export interface ContainerStaleness {
|
||||
/** The container's lineage is not the current base. Always false when
|
||||
* `known` is false: an unknown lineage is not a claim of staleness. */
|
||||
stale: boolean;
|
||||
/** Whether the lineage could be established at all. False means the
|
||||
* container predates the `triple-c.base-image-id` label — "unknown, probe
|
||||
* instead", not "stale". */
|
||||
known: boolean;
|
||||
base_image_id: string | null;
|
||||
current_base_image_id: string | null;
|
||||
/** `Created` of the project's snapshot image, RFC 3339. */
|
||||
snapshot_created_at: string | null;
|
||||
/** Concrete paths the base ships and this container lacks, e.g. `/usr/bin/socat`. */
|
||||
missing_paths: string[];
|
||||
/** Human labels for the same, e.g. "Auth bridge tunnel (socat)". */
|
||||
missing_features: string[];
|
||||
/** apt packages the project added on top of the base; migration replays these. */
|
||||
apt_delta: string[];
|
||||
/** Global npm packages the base does not ship. */
|
||||
npm_global_delta: string[];
|
||||
/** Non-package paths under /usr/local, /opt, /srv and /workspace that would
|
||||
* be carried across. Empty when nothing user-authored was found — which is
|
||||
* the common case. */
|
||||
verbatim_paths: string[];
|
||||
/** dpkg packages the base carries at a different version. A drift measure,
|
||||
* not a promise that every one is newer. */
|
||||
outdated_package_count: number;
|
||||
/** Set when the container/image could not be inspected; everything else is
|
||||
* then at its default. */
|
||||
probe_error: string | null;
|
||||
}
|
||||
|
||||
/** What a migration should replay. All default to false. */
|
||||
export interface MigrationOptions {
|
||||
/** Replay the apt and `npm -g` deltas onto the new base. */
|
||||
replay_packages: boolean;
|
||||
/** Copy the verbatim payload (/usr/local, /opt, /srv, non-bind-mounted /workspace). */
|
||||
copy_paths: boolean;
|
||||
/** Keep the `:pre-migration-<ts>` rollback tag after the migration reports
|
||||
* success, so it can still be undone. Costs roughly a whole snapshot on disk
|
||||
* (3.8–12.3 GB on real projects) because snapshots share almost no layers
|
||||
* with the current base. When false the tag is dropped as soon as the
|
||||
* migration is known to have worked, and `rollback_available` is false. */
|
||||
keep_rollback: boolean;
|
||||
}
|
||||
|
||||
/** The outcome of one migration attempt. */
|
||||
export interface MigrationReport {
|
||||
phase: MigrationPhase;
|
||||
packages_requested: string[];
|
||||
packages_installed: string[];
|
||||
packages_failed: PackageFailure[];
|
||||
paths_copied: string[];
|
||||
/** Human labels for base features the container gained. */
|
||||
features_restored: string[];
|
||||
/** A `:pre-migration-<ts>` image still exists, so `rollbackMigration` works. */
|
||||
rollback_available: boolean;
|
||||
/** One paragraph fit to show the user verbatim. */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** In-flight phases of `MigrationState.phase`. Distinct from `MigrationPhase`,
|
||||
* which describes *outcomes*.
|
||||
*
|
||||
* These are **hyphenated**, matching the `triple-c.migration-state=in-progress`
|
||||
* container label so there is exactly one spelling in the system. Compare
|
||||
* against the constants below rather than writing the literals — that is what
|
||||
* they are for. */
|
||||
export type MigrationStatePhase =
|
||||
| "in-progress"
|
||||
| "interrupted"
|
||||
| "awaiting-confirmation";
|
||||
|
||||
/** A migration is running right now. Poll `getMigrationState` until it changes. */
|
||||
export const MIGRATION_PHASE_IN_PROGRESS = "in-progress";
|
||||
/** The app died after the container was swapped. Offer resume (call
|
||||
* `migrateProjectToBase` again — it picks the interrupted run up) or rollback. */
|
||||
export const MIGRATION_PHASE_INTERRUPTED = "interrupted";
|
||||
/** Finished; `report` is populated. Offer confirm or rollback. */
|
||||
export const MIGRATION_PHASE_AWAITING_CONFIRMATION = "awaiting-confirmation";
|
||||
|
||||
/** What a migration decided to do, frozen at pre-flight time so a resume
|
||||
* replays the same thing (the deltas cannot be recomputed after the swap). */
|
||||
export interface MigrationPlan {
|
||||
apt_packages: string[];
|
||||
npm_packages: string[];
|
||||
verbatim_paths: string[];
|
||||
missing_paths: string[];
|
||||
}
|
||||
|
||||
/** Persisted host-side migration record. Present only while a migration is in
|
||||
* flight or waiting for a decision; `confirmMigration` and `rollbackMigration`
|
||||
* both clear it. */
|
||||
export interface MigrationState {
|
||||
/** One of `MigrationStatePhase`; typed loosely because an unrecognised value
|
||||
* from a future build must not crash the UI. */
|
||||
phase: string;
|
||||
from_image_id: string | null;
|
||||
to_base_id: string | null;
|
||||
started_at: string;
|
||||
report: MigrationReport | null;
|
||||
/** The `:pre-migration-<ts>` tag holding the old system layer, if kept. */
|
||||
rollback_image: string | null;
|
||||
/** Host path of the staged payload tar, while one exists. */
|
||||
staging_path: string | null;
|
||||
options: MigrationOptions;
|
||||
plan: MigrationPlan | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user