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:
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ pub mod gateway_commands;
|
||||
pub mod help_commands;
|
||||
pub mod inspect_commands;
|
||||
pub mod install_helper_commands;
|
||||
pub mod migration_commands;
|
||||
pub mod project_commands;
|
||||
pub mod settings_commands;
|
||||
pub mod stt_commands;
|
||||
|
||||
@@ -2,11 +2,11 @@ use tauri::{Emitter, State};
|
||||
|
||||
use crate::commands::aws_commands;
|
||||
use crate::docker;
|
||||
use crate::models::{container_config, Backend, BedrockAuthMethod, Project, ProjectPath, ProjectStatus};
|
||||
use crate::models::{container_config, AppSettings, Backend, BedrockAuthMethod, Project, ProjectPath, ProjectStatus};
|
||||
use crate::storage::secure;
|
||||
use crate::AppState;
|
||||
|
||||
fn emit_progress(app_handle: &tauri::AppHandle, project_id: &str, message: &str) {
|
||||
pub(crate) fn emit_progress(app_handle: &tauri::AppHandle, project_id: &str, message: &str) {
|
||||
let _ = app_handle.emit(
|
||||
"container-progress",
|
||||
serde_json::json!({
|
||||
@@ -43,8 +43,49 @@ fn store_secrets_for_project(project: &Project) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create the project's container, threading every global setting through.
|
||||
///
|
||||
/// Exists so that the two ordinary create paths below and base-image migration
|
||||
/// cannot drift apart — a container created by a migration must be
|
||||
/// indistinguishable from one created by a normal start, or the next
|
||||
/// `container_needs_recreation` would immediately throw it away.
|
||||
///
|
||||
/// `create_image` is what to create *from* (the snapshot or the base);
|
||||
/// `base_image_name` is the configured base, which `create_container` needs in
|
||||
/// order to tell those two apart when it stamps the lineage labels.
|
||||
pub(crate) async fn create_container_for_project(
|
||||
project: &Project,
|
||||
settings: &AppSettings,
|
||||
docker_socket: &str,
|
||||
aws_config_path: Option<&str>,
|
||||
create_image: &str,
|
||||
base_image_name: &str,
|
||||
extras: docker::CreateExtras<'_>,
|
||||
) -> Result<String, String> {
|
||||
docker::create_container(
|
||||
project,
|
||||
docker_socket,
|
||||
create_image,
|
||||
base_image_name,
|
||||
extras,
|
||||
aws_config_path,
|
||||
&settings.global_aws,
|
||||
&settings.global_ollama,
|
||||
&settings.global_llamacpp,
|
||||
&settings.global_openai_compatible,
|
||||
settings.global_claude_instructions.as_deref(),
|
||||
&settings.global_custom_env_vars,
|
||||
settings.timezone.as_deref(),
|
||||
settings.global_claude_code_settings.as_ref(),
|
||||
settings.default_ssh_key_path.as_deref(),
|
||||
settings.default_git_user_name.as_deref(),
|
||||
settings.default_git_user_email.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Populate secret fields on a project struct from the OS keychain.
|
||||
fn load_secrets_for_project(project: &mut Project) {
|
||||
pub(crate) fn load_secrets_for_project(project: &mut Project) {
|
||||
project.git_token = secure::get_project_secret(&project.id, "git-token")
|
||||
.unwrap_or(None);
|
||||
if let Some(ref mut bedrock) = project.bedrock_config {
|
||||
@@ -317,11 +358,26 @@ pub async fn start_project_container(
|
||||
// AWS config path from global settings
|
||||
let aws_config_path = settings.global_aws.aws_config_path.clone();
|
||||
|
||||
// What we would create this container from *right now*: the project's
|
||||
// snapshot when one exists, else the configured base. This is the value
|
||||
// `container_needs_recreation` compares against the container's
|
||||
// `triple-c.create-image` label — the check that replaced the old
|
||||
// tautological one. It is resolved *before* the commit below, so it
|
||||
// describes the pre-commit world the existing container was born into.
|
||||
let snapshot_image = docker::get_snapshot_image_name(&project);
|
||||
let expected_create_image =
|
||||
if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||
snapshot_image.clone()
|
||||
} else {
|
||||
image_name.clone()
|
||||
};
|
||||
|
||||
let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? {
|
||||
// Check if config changed — if so, snapshot + recreate
|
||||
let needs_recreate = docker::container_needs_recreation(
|
||||
&existing_id,
|
||||
&project,
|
||||
&expected_create_image,
|
||||
&settings.global_aws,
|
||||
&settings.global_ollama,
|
||||
&settings.global_llamacpp,
|
||||
@@ -352,30 +408,24 @@ pub async fn start_project_container(
|
||||
docker::remove_legacy_mcp_containers(&project.id).await;
|
||||
docker::remove_legacy_project_network(&project.id).await;
|
||||
|
||||
// Create from snapshot image (preserves system-level changes)
|
||||
let snapshot_image = docker::get_snapshot_image_name(&project);
|
||||
// Create from snapshot image (preserves system-level changes).
|
||||
// Re-resolved after the commit above: when no snapshot existed
|
||||
// before, one does now, and creating from the base instead
|
||||
// would throw away the state that was just saved.
|
||||
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||
snapshot_image
|
||||
snapshot_image.clone()
|
||||
} else {
|
||||
image_name.clone()
|
||||
};
|
||||
|
||||
let new_id = docker::create_container(
|
||||
let new_id = create_container_for_project(
|
||||
&project,
|
||||
&settings,
|
||||
&docker_socket,
|
||||
&create_image,
|
||||
aws_config_path.as_deref(),
|
||||
&settings.global_aws,
|
||||
&settings.global_ollama,
|
||||
&settings.global_llamacpp,
|
||||
&settings.global_openai_compatible,
|
||||
settings.global_claude_instructions.as_deref(),
|
||||
&settings.global_custom_env_vars,
|
||||
settings.timezone.as_deref(),
|
||||
settings.global_claude_code_settings.as_ref(),
|
||||
settings.default_ssh_key_path.as_deref(),
|
||||
settings.default_git_user_name.as_deref(),
|
||||
settings.default_git_user_email.as_deref(),
|
||||
&create_image,
|
||||
&image_name,
|
||||
docker::CreateExtras::default(),
|
||||
).await?;
|
||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||
docker::start_container(&new_id).await?;
|
||||
@@ -389,31 +439,20 @@ pub async fn start_project_container(
|
||||
// Container doesn't exist (first start, or Docker pruned it).
|
||||
// Check for a snapshot image first — it preserves system-level
|
||||
// changes (apt/pip/npm installs) from the previous session.
|
||||
let snapshot_image = docker::get_snapshot_image_name(&project);
|
||||
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||
if expected_create_image == snapshot_image {
|
||||
log::info!("Creating container from snapshot image for project {}", project.id);
|
||||
snapshot_image
|
||||
} else {
|
||||
image_name.clone()
|
||||
};
|
||||
}
|
||||
let create_image = expected_create_image.clone();
|
||||
|
||||
emit_progress(&app_handle, &project_id, "Creating container...");
|
||||
let new_id = docker::create_container(
|
||||
let new_id = create_container_for_project(
|
||||
&project,
|
||||
&settings,
|
||||
&docker_socket,
|
||||
&create_image,
|
||||
aws_config_path.as_deref(),
|
||||
&settings.global_aws,
|
||||
&settings.global_ollama,
|
||||
&settings.global_llamacpp,
|
||||
&settings.global_openai_compatible,
|
||||
settings.global_claude_instructions.as_deref(),
|
||||
&settings.global_custom_env_vars,
|
||||
settings.timezone.as_deref(),
|
||||
settings.global_claude_code_settings.as_ref(),
|
||||
settings.default_ssh_key_path.as_deref(),
|
||||
settings.default_git_user_name.as_deref(),
|
||||
settings.default_git_user_email.as_deref(),
|
||||
&create_image,
|
||||
&image_name,
|
||||
docker::CreateExtras::default(),
|
||||
).await?;
|
||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||
docker::start_container(&new_id).await?;
|
||||
@@ -530,6 +569,13 @@ pub async fn rebuild_project_container(
|
||||
/// Called by the frontend after Docker is confirmed available. Projects
|
||||
/// marked as Running whose containers are no longer running get reset
|
||||
/// to Stopped.
|
||||
///
|
||||
/// This is also where an interrupted **base-image migration** is picked up.
|
||||
/// It runs at startup, which is exactly when a migration that died with the app
|
||||
/// needs to be noticed — see
|
||||
/// [`crate::commands::migration_commands::reconcile_migration`]. The migration
|
||||
/// pass runs over *every* project, not just the Running ones, because a project
|
||||
/// whose container was removed mid-migration reports Stopped.
|
||||
#[tauri::command]
|
||||
pub async fn reconcile_project_statuses(
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -537,6 +583,10 @@ pub async fn reconcile_project_statuses(
|
||||
) -> Result<Vec<Project>, String> {
|
||||
let projects = state.projects_store.list();
|
||||
|
||||
for project in &projects {
|
||||
crate::commands::migration_commands::reconcile_migration(project, &app_handle).await;
|
||||
}
|
||||
|
||||
for project in &projects {
|
||||
if project.status != ProjectStatus::Running && project.status != ProjectStatus::Error {
|
||||
continue;
|
||||
|
||||
@@ -700,10 +700,55 @@ pub async fn find_existing_container(project: &Project) -> Result<Option<String>
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Extra creation inputs that only base-image migration cares about, kept in
|
||||
/// one struct so `create_container`'s already-long parameter list does not grow
|
||||
/// two more positional arguments that every ordinary call site would have to
|
||||
/// pass as `None`-ish placeholders.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct CreateExtras<'a> {
|
||||
/// Extra labels merged in last, overriding anything computed here.
|
||||
/// Migration uses this to stamp `triple-c.migration-state=in-progress`.
|
||||
pub extra_labels: &'a [(&'a str, &'a str)],
|
||||
}
|
||||
|
||||
/// Resolve the value for the `triple-c.base-image-id` label.
|
||||
///
|
||||
/// This is the **image ID**, not a `RepoDigests` entry: a locally built image
|
||||
/// (`triple-c:latest`) and any custom image have no repo digest at all, so a
|
||||
/// digest-based lineage would be blank for exactly the users most likely to
|
||||
/// change their base.
|
||||
///
|
||||
/// Two cases:
|
||||
/// * creating **from the base** — the base's own current `.Id`;
|
||||
/// * creating **from the project's snapshot** — carry forward whatever lineage
|
||||
/// the snapshot image already records, because a snapshot is a commit of a
|
||||
/// container that itself descended from some base. Committing propagates
|
||||
/// container labels onto the image (verified), which is what makes the
|
||||
/// carry-forward chain hold across every recreation.
|
||||
///
|
||||
/// An empty string means "unknown" — a snapshot that predates this label. It is
|
||||
/// deliberately *not* the same as "stale"; see [`crate::models::ContainerStaleness::known`].
|
||||
async fn resolve_base_image_id(image_name: &str, base_image_name: &str) -> String {
|
||||
if image_name == base_image_name {
|
||||
return super::migration::image_id(base_image_name)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
}
|
||||
super::migration::image_labels(image_name)
|
||||
.await
|
||||
.get(super::migration::LABEL_BASE_IMAGE_ID)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub async fn create_container(
|
||||
project: &Project,
|
||||
docker_socket_path: &str,
|
||||
image_name: &str,
|
||||
base_image_name: &str,
|
||||
extras: CreateExtras<'_>,
|
||||
aws_config_path: Option<&str>,
|
||||
global_aws: &GlobalAwsSettings,
|
||||
global_ollama: &GlobalOllamaSettings,
|
||||
@@ -1232,6 +1277,51 @@ pub async fn create_container(
|
||||
labels.insert("triple-c.claude-token-version".to_string(),
|
||||
shared_claude.as_ref().map(|(_, v)| v.clone()).unwrap_or_default());
|
||||
|
||||
// ── Base-image lineage ───────────────────────────────────────────────────
|
||||
// `triple-c.create-image` is what this container was actually created
|
||||
// from — the snapshot when one exists, otherwise the configured base. It is
|
||||
// what `container_needs_recreation` compares against; the older
|
||||
// `triple-c.image` label recorded the same thing but was compared against
|
||||
// the container's *own* image, which is where it came from, so that check
|
||||
// was a tautology and never fired. `triple-c.image` is still written for
|
||||
// continuity with existing containers but is no longer compared.
|
||||
//
|
||||
// `triple-c.base-image-id` records the lineage — see `resolve_base_image_id`.
|
||||
//
|
||||
// All three (plus the migration marker) are written **unconditionally**,
|
||||
// even when empty. Docker merges an image's labels into a container's at
|
||||
// creation, and `docker commit` copies container labels onto the snapshot
|
||||
// image, so a value stamped once would otherwise ride the snapshot into
|
||||
// every future container forever. Writing the key explicitly overrides the
|
||||
// inherited one — the same defence MANAGED_AUTH_KEYS applies to env.
|
||||
labels.insert(
|
||||
super::migration::LABEL_CREATE_IMAGE.to_string(),
|
||||
image_name.to_string(),
|
||||
);
|
||||
labels.insert(
|
||||
super::migration::LABEL_BASE_IMAGE_ID.to_string(),
|
||||
resolve_base_image_id(image_name, base_image_name).await,
|
||||
);
|
||||
labels.insert(
|
||||
super::migration::LABEL_MIGRATION_STATE.to_string(),
|
||||
String::new(),
|
||||
);
|
||||
// Same defence, applied to the legacy MCP shim — and here it fixes a real,
|
||||
// observed bug rather than pre-empting one. `container_needs_recreation`
|
||||
// recreates any container carrying a non-empty `triple-c.mcp-fingerprint`,
|
||||
// but nothing has written that label since the MCP feature was removed. It
|
||||
// survives only by *inheritance* from a snapshot image committed by an
|
||||
// older build (one such image was found on this host with a non-empty
|
||||
// value), and every recreation re-commits it — so the shim can never
|
||||
// terminate and the project is recreated on every single start. Writing it
|
||||
// explicitly empty makes the shim fire exactly once, which is what it was
|
||||
// always meant to do.
|
||||
labels.insert("triple-c.mcp-fingerprint".to_string(), String::new());
|
||||
|
||||
for (key, value) in extras.extra_labels {
|
||||
labels.insert((*key).to_string(), (*value).to_string());
|
||||
}
|
||||
|
||||
let host_config = HostConfig {
|
||||
mounts: Some(mounts),
|
||||
port_bindings: if port_bindings.is_empty() { None } else { Some(port_bindings) },
|
||||
@@ -1506,6 +1596,7 @@ pub async fn remove_project_volumes(project: &Project) -> Result<(), String> {
|
||||
pub async fn container_needs_recreation(
|
||||
container_id: &str,
|
||||
project: &Project,
|
||||
expected_create_image: &str,
|
||||
global_aws: &GlobalAwsSettings,
|
||||
global_ollama: &GlobalOllamaSettings,
|
||||
global_llamacpp: &GlobalLlamaCppSettings,
|
||||
@@ -1614,25 +1705,49 @@ pub async fn container_needs_recreation(
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// ── Image ────────────────────────────────────────────────────────────
|
||||
// The image label is set at creation time; if the user changed the
|
||||
// configured image we need to recreate. We only compare when the
|
||||
// label exists (containers created before this change won't have it).
|
||||
if let Some(container_image) = get_label("triple-c.image") {
|
||||
// The caller doesn't pass the image name, but we can read the
|
||||
// container's actual image from Docker inspect.
|
||||
let actual_image = info
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|c| c.image.as_ref());
|
||||
if let Some(actual) = actual_image {
|
||||
if *actual != container_image {
|
||||
log::info!("Image mismatch (actual={:?}, label={:?})", actual, container_image);
|
||||
return Ok(true);
|
||||
}
|
||||
// ── Create image ─────────────────────────────────────────────────────
|
||||
// What this container was created from, against what we would create it
|
||||
// from *now* — the caller resolves that (snapshot-if-it-exists, else the
|
||||
// configured base) and passes it in as `expected_create_image`, preserving
|
||||
// exactly today's semantics.
|
||||
//
|
||||
// This replaces a check that compared the container's actual image against
|
||||
// the `triple-c.image` label. `create_container` wrote that label from the
|
||||
// very image it created from, so the two could never differ: it was a
|
||||
// tautology that never once fired, and it is the reason a project stayed
|
||||
// pinned to its own snapshot lineage forever.
|
||||
//
|
||||
// A missing `triple-c.create-image` label means the container predates this
|
||||
// fix — unknown, so leave it alone rather than churn every existing
|
||||
// container on first launch after an update.
|
||||
if let Some(container_create_image) = get_label(crate::docker::migration::LABEL_CREATE_IMAGE) {
|
||||
if container_create_image != expected_create_image {
|
||||
log::info!(
|
||||
"Create-image mismatch (container={:?}, expected={:?})",
|
||||
container_create_image,
|
||||
expected_create_image
|
||||
);
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Base image id: deliberately NOT compared here ────────────────────
|
||||
// This departs from the CLAUDE.md rule that new container state gets a
|
||||
// label and a comparison, and the departure is the point.
|
||||
//
|
||||
// `triple-c.base-image-id` records which base a container's lineage
|
||||
// descends from. Comparing it here would mean that publishing a new base
|
||||
// image silently recreates every project on next start — and, because
|
||||
// `expected_create_image` is the snapshot whenever one exists, it would
|
||||
// recreate them *from their own snapshot*: pure churn, on the old base,
|
||||
// with no benefit. Worse, it would consume the very signal ("this project
|
||||
// is behind the base") that is supposed to prompt the user, without
|
||||
// actually migrating anything.
|
||||
//
|
||||
// Staleness is therefore a *surfaced* signal gating an explicit user
|
||||
// action — `get_container_staleness` / `migrate_project_to_base` — not an
|
||||
// automatic recreation trigger.
|
||||
|
||||
// ── Timezone ─────────────────────────────────────────────────────────
|
||||
let expected_tz = timezone.unwrap_or("");
|
||||
let container_tz = get_label("triple-c.timezone").unwrap_or_default();
|
||||
|
||||
@@ -37,6 +37,22 @@ pub async fn create_attached_exec(
|
||||
container_id: &str,
|
||||
cmd: Vec<String>,
|
||||
tty: bool,
|
||||
) -> Result<AttachedExec, String> {
|
||||
create_attached_exec_as(container_id, cmd, tty, "claude", "/workspace").await
|
||||
}
|
||||
|
||||
/// [`create_attached_exec`] with the user and working directory spelled out.
|
||||
///
|
||||
/// Only base-image migration needs this: replaying `apt` and unpacking a
|
||||
/// payload tar at `/` have to run as **root**, and every other caller wants the
|
||||
/// `claude` / `/workspace` defaults that [`create_attached_exec`] supplies. It
|
||||
/// stays the single place an attached exec is opened.
|
||||
pub async fn create_attached_exec_as(
|
||||
container_id: &str,
|
||||
cmd: Vec<String>,
|
||||
tty: bool,
|
||||
user: &str,
|
||||
working_dir: &str,
|
||||
) -> Result<AttachedExec, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
@@ -49,8 +65,8 @@ pub async fn create_attached_exec(
|
||||
attach_stderr: Some(true),
|
||||
tty: Some(tty),
|
||||
cmd: Some(cmd),
|
||||
user: Some("claude".to_string()),
|
||||
working_dir: Some("/workspace".to_string()),
|
||||
user: Some(user.to_string()),
|
||||
working_dir: Some(working_dir.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
@@ -371,6 +387,51 @@ pub async fn upload_host_file_to_container(
|
||||
Ok(format!("/tmp/{}", dest_name))
|
||||
}
|
||||
|
||||
/// Write `data` into the container at `<dest_dir>/<file_name>` with `mode`.
|
||||
///
|
||||
/// For small, generated files — migration uses it for the `tar -T` include
|
||||
/// list, which can be too long to pass as argv. Anything large should be
|
||||
/// streamed through an attached exec's stdin instead, since this buffers the
|
||||
/// whole payload in memory twice (once raw, once tarred).
|
||||
pub async fn upload_bytes_to_container(
|
||||
container_id: &str,
|
||||
dest_dir: &str,
|
||||
file_name: &str,
|
||||
data: &[u8],
|
||||
mode: u32,
|
||||
) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
let mut tar_buf = Vec::with_capacity(data.len() + 1024);
|
||||
{
|
||||
let mut builder = tar::Builder::new(&mut tar_buf);
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(data.len() as u64);
|
||||
header.set_mode(mode);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, file_name, data)
|
||||
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
||||
builder
|
||||
.finish()
|
||||
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
||||
}
|
||||
|
||||
docker
|
||||
.upload_to_container(
|
||||
container_id,
|
||||
Some(UploadToContainerOptions {
|
||||
path: dest_dir.to_string(),
|
||||
..Default::default()
|
||||
}),
|
||||
tar_buf.into(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
|
||||
|
||||
Ok(format!("{}/{}", dest_dir.trim_end_matches('/'), file_name))
|
||||
}
|
||||
|
||||
/// Run a one-shot (non-interactive) exec command in a container and collect stdout.
|
||||
pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String, String> {
|
||||
exec_oneshot_env(container_id, cmd, Vec::new()).await
|
||||
@@ -400,6 +461,22 @@ pub async fn exec_oneshot_env_status(
|
||||
container_id: &str,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
) -> Result<(String, i64), String> {
|
||||
exec_oneshot_as(container_id, "claude", cmd, env).await
|
||||
}
|
||||
|
||||
/// [`exec_oneshot_env_status`] with the user spelled out.
|
||||
///
|
||||
/// Base-image migration is the only caller that needs anything but `claude`:
|
||||
/// `apt-get`, `npm -g` and the payload unpack all run as **root**. Note that
|
||||
/// the container does grant `claude` passwordless sudo, but going through
|
||||
/// `sudo` would put the whole command in `ps` output and add a second failure
|
||||
/// mode to interpret, so the exec is simply created as root.
|
||||
pub async fn exec_oneshot_as(
|
||||
container_id: &str,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
) -> Result<(String, i64), String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
@@ -411,7 +488,7 @@ pub async fn exec_oneshot_env_status(
|
||||
attach_stderr: Some(true),
|
||||
cmd: Some(cmd),
|
||||
env: if env.is_empty() { None } else { Some(env) },
|
||||
user: Some("claude".to_string()),
|
||||
user: Some(user.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ pub mod image;
|
||||
pub mod exec;
|
||||
pub mod gateway;
|
||||
pub mod legacy_cleanup;
|
||||
pub mod migration;
|
||||
pub mod stt;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
@@ -20,3 +21,5 @@ pub use image::*;
|
||||
pub use exec::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use legacy_cleanup::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use migration::*;
|
||||
|
||||
@@ -186,6 +186,12 @@ pub fn run() {
|
||||
commands::project_commands::stop_project_container,
|
||||
commands::project_commands::rebuild_project_container,
|
||||
commands::project_commands::reconcile_project_statuses,
|
||||
// Container base-image migration
|
||||
commands::migration_commands::get_container_staleness,
|
||||
commands::migration_commands::migrate_project_to_base,
|
||||
commands::migration_commands::confirm_migration,
|
||||
commands::migration_commands::rollback_migration,
|
||||
commands::migration_commands::get_migration_state,
|
||||
// Auth bridge
|
||||
commands::auth_bridge_commands::set_auth_bridge_enabled,
|
||||
commands::auth_bridge_commands::get_auth_bridge_status,
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
//! Contract types for **container base-image migration**.
|
||||
//!
|
||||
//! ## Why this exists
|
||||
//!
|
||||
//! A project's container is created from `triple-c-snapshot-<id>:latest`
|
||||
//! whenever that image exists, and every recreation re-commits it. Nothing ever
|
||||
//! moved a project back onto a *newer base image*: `container_needs_recreation`
|
||||
//! compared the container's actual image against the `triple-c.image` label that
|
||||
//! `create_container` wrote from the very image it created from — a tautology
|
||||
//! that could never fire. So a project stayed pinned to its own snapshot
|
||||
//! lineage forever and never picked up base-image fixes (a new `socat`, a new
|
||||
//! `/usr/local/bin` shim, security updates). The only escape was Reset, which
|
||||
//! deletes both named volumes and takes the login, the skills and every session
|
||||
//! transcript with it.
|
||||
//!
|
||||
//! Migration is the non-destructive alternative: recreate the container from the
|
||||
//! current base, then replay onto it the small set of things the base does not
|
||||
//! carry, and leave the volumes strictly alone.
|
||||
//!
|
||||
//! ## What actually needs replaying
|
||||
//!
|
||||
//! `/home/claude` is the named volume `triple-c-home-<id>`, with
|
||||
//! `/home/claude/.claude` nested inside it. The image's own `/home/claude` is
|
||||
//! **seed-only** — once the volume is mounted the image's copy is masked
|
||||
//! permanently. So Claude Code itself (it installs to `~/.local/bin`), cargo,
|
||||
//! uv, ruff, the OAuth login, `~/.claude.json`, skills, transcripts, scheduler
|
||||
//! tasks and SSH keys all re-attach for free across an image swap.
|
||||
//!
|
||||
//! What is genuinely lost is confined to the container's writable layer:
|
||||
//! root-level `apt` installs, `npm -g` packages (npm's prefix is `/usr`),
|
||||
//! `/usr/local`, `/opt`, `/srv`, and anything under `/workspace` that is not on
|
||||
//! a bind mount. Those four categories are exactly what
|
||||
//! [`MigrationOptions`] can replay.
|
||||
//!
|
||||
//! ## Serde
|
||||
//!
|
||||
//! Plain snake_case, matching every other IPC struct in this crate
|
||||
//! (`ContainerInfo`, `ClaudeSession`, …) and `app/src/lib/types.ts`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// How a finished migration attempt ended.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MigrationPhase {
|
||||
/// The container now runs on the current base and everything requested was
|
||||
/// replayed.
|
||||
Succeeded,
|
||||
/// The container now runs on the current base, but at least one package or
|
||||
/// path could not be replayed. Deliberately distinct from `Failed`: one
|
||||
/// missing apt package must never cost the user the whole migration.
|
||||
Partial,
|
||||
/// The migration could not complete. If the container had already been
|
||||
/// swapped, an automatic rollback was attempted — check
|
||||
/// [`MigrationReport::rollback_available`] and the message.
|
||||
Failed,
|
||||
/// The migration was undone; the container is back on its pre-migration
|
||||
/// snapshot image.
|
||||
RolledBack,
|
||||
}
|
||||
|
||||
/// One package that could not be replayed onto the new base.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PackageFailure {
|
||||
pub name: String,
|
||||
/// Trimmed tail of the package manager's own error output.
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// Everything the UI needs to decide whether a project is worth migrating, and
|
||||
/// to explain to the user what migrating would actually change.
|
||||
///
|
||||
/// A field being empty always means "nothing found", never "not checked" —
|
||||
/// [`ContainerStaleness::probe_error`] is the single place a failed inspection
|
||||
/// is reported.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ContainerStaleness {
|
||||
/// The container's lineage is not the current base image.
|
||||
/// Always `false` when `known` is `false` — an unknown lineage is not a
|
||||
/// claim of staleness.
|
||||
pub stale: bool,
|
||||
/// Whether the lineage could be established at all. `false` means the
|
||||
/// container (or its snapshot image) predates the `triple-c.base-image-id`
|
||||
/// label, i.e. **"unknown, probe instead"** — never "stale".
|
||||
pub known: bool,
|
||||
/// Image ID of the base this container's lineage descends from.
|
||||
pub base_image_id: Option<String>,
|
||||
/// Image ID of the base image currently configured in settings.
|
||||
pub current_base_image_id: Option<String>,
|
||||
/// `Created` timestamp of the project's snapshot image, RFC 3339.
|
||||
pub snapshot_created_at: Option<String>,
|
||||
/// Concrete paths the current base ships that this container does not,
|
||||
/// e.g. `/usr/bin/socat`.
|
||||
pub missing_paths: Vec<String>,
|
||||
/// Human labels for the same, e.g. `"Auth bridge tunnel (socat)"`.
|
||||
pub missing_features: Vec<String>,
|
||||
/// `apt-mark showmanual` in the container minus the base's own set — the
|
||||
/// packages a migration would replay.
|
||||
pub apt_delta: Vec<String>,
|
||||
/// Globally-installed npm packages the base does not ship.
|
||||
pub npm_global_delta: Vec<String>,
|
||||
/// Non-dpkg-owned paths under the verbatim-copy roots that would be carried
|
||||
/// across. Empty when nothing user-authored was found.
|
||||
pub verbatim_paths: Vec<String>,
|
||||
/// dpkg packages the current base carries at a different version than this
|
||||
/// container does. A rough "how much security drift" number, not a promise
|
||||
/// that every one of them is newer.
|
||||
pub outdated_package_count: u32,
|
||||
/// Set when the container/image could not be inspected. Everything else is
|
||||
/// then at its default.
|
||||
pub probe_error: Option<String>,
|
||||
}
|
||||
|
||||
/// What a migration should replay. All three default to off so that
|
||||
/// `MigrationOptions::default()` is the minimal, fastest migration.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MigrationOptions {
|
||||
/// Replay the apt and `npm -g` deltas onto the new base.
|
||||
#[serde(default)]
|
||||
pub replay_packages: bool,
|
||||
/// Copy the verbatim payload (`/usr/local`, `/opt`, `/srv`, and the
|
||||
/// non-bind-mounted parts of `/workspace`) onto the new base.
|
||||
#[serde(default)]
|
||||
pub copy_paths: bool,
|
||||
/// Keep the `:pre-migration-<ts>` rollback tag after the migration reports
|
||||
/// success. Costs the full size of the old snapshot image (snapshots share
|
||||
/// almost no layers with the current base) but makes rollback instant.
|
||||
#[serde(default)]
|
||||
pub keep_rollback: bool,
|
||||
}
|
||||
|
||||
/// The outcome of one migration attempt.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MigrationReport {
|
||||
pub phase: MigrationPhase,
|
||||
pub packages_requested: Vec<String>,
|
||||
pub packages_installed: Vec<String>,
|
||||
pub packages_failed: Vec<PackageFailure>,
|
||||
pub paths_copied: Vec<String>,
|
||||
/// Human labels for base features the container gained, e.g.
|
||||
/// `"Auth bridge tunnel (socat)"`.
|
||||
pub features_restored: Vec<String>,
|
||||
/// A `:pre-migration-<ts>` image tag still exists, so
|
||||
/// `rollback_migration` can put the old system layer back.
|
||||
pub rollback_available: bool,
|
||||
/// One paragraph fit to show the user verbatim.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl MigrationReport {
|
||||
/// A report for a migration that never got past pre-flight. Nothing was
|
||||
/// touched, so there is nothing to roll back.
|
||||
pub fn failed_preflight(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
phase: MigrationPhase::Failed,
|
||||
packages_requested: Vec::new(),
|
||||
packages_installed: Vec::new(),
|
||||
packages_failed: Vec::new(),
|
||||
paths_copied: Vec::new(),
|
||||
features_restored: Vec::new(),
|
||||
rollback_available: false,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a migration decided to do, frozen at pre-flight time.
|
||||
///
|
||||
/// Persisted with the state because a **resume** cannot recompute it: by the
|
||||
/// time the app comes back up the container has already been replaced by one
|
||||
/// created from the base, so its apt/npm sets *are* the base's and the deltas
|
||||
/// would come out empty.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MigrationPlan {
|
||||
pub apt_packages: Vec<String>,
|
||||
pub npm_packages: Vec<String>,
|
||||
pub verbatim_paths: Vec<String>,
|
||||
/// Base-image paths the old container lacked, so the finished migration can
|
||||
/// report which of them it actually gained.
|
||||
pub missing_paths: Vec<String>,
|
||||
}
|
||||
|
||||
/// Persisted, host-side migration state. Written **before** anything
|
||||
/// destructive happens and removed on confirm or rollback, so a crash at any
|
||||
/// point leaves a record of what was in flight.
|
||||
///
|
||||
/// `phase` is a free-form string rather than [`MigrationPhase`] because it also
|
||||
/// carries the *in-flight* phases, which are not outcomes:
|
||||
///
|
||||
/// | `phase` | Meaning | Offered next |
|
||||
/// |---|---|---|
|
||||
/// | `in-progress` | A migration is running right now | — |
|
||||
/// | `interrupted` | The app died after the container swap | resume, rollback |
|
||||
/// | `awaiting-confirmation` | Migration finished; rollback still possible | confirm, rollback |
|
||||
///
|
||||
/// See [`MIGRATION_PHASE_IN_PROGRESS`] and friends.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MigrationState {
|
||||
pub phase: String,
|
||||
/// Image ID of the snapshot the project was on before the swap.
|
||||
pub from_image_id: Option<String>,
|
||||
/// Image ID of the base being migrated to.
|
||||
pub to_base_id: Option<String>,
|
||||
/// RFC 3339.
|
||||
pub started_at: String,
|
||||
/// Present once the attempt produced one.
|
||||
#[serde(default)]
|
||||
pub report: Option<MigrationReport>,
|
||||
/// The `:pre-migration-<ts>` tag holding the old system layer, if one was
|
||||
/// created. `rollback_migration` retags this back to `:latest`.
|
||||
#[serde(default)]
|
||||
pub rollback_image: Option<String>,
|
||||
/// Host path of the staged verbatim payload tar, if one was staged.
|
||||
#[serde(default)]
|
||||
pub staging_path: Option<String>,
|
||||
/// The options the attempt was started with, so a resume replays the same
|
||||
/// things the user originally asked for.
|
||||
#[serde(default)]
|
||||
pub options: MigrationOptions,
|
||||
/// The frozen pre-flight plan. See [`MigrationPlan`].
|
||||
#[serde(default)]
|
||||
pub plan: Option<MigrationPlan>,
|
||||
}
|
||||
|
||||
/// A migration is running in this process right now.
|
||||
pub const MIGRATION_PHASE_IN_PROGRESS: &str = "in-progress";
|
||||
/// The app died after the container swap but before the final commit.
|
||||
pub const MIGRATION_PHASE_INTERRUPTED: &str = "interrupted";
|
||||
/// The migration finished; the user has not yet confirmed or rolled back.
|
||||
pub const MIGRATION_PHASE_AWAITING: &str = "awaiting-confirmation";
|
||||
|
||||
impl MigrationState {
|
||||
pub fn new(
|
||||
from_image_id: Option<String>,
|
||||
to_base_id: Option<String>,
|
||||
options: MigrationOptions,
|
||||
) -> Self {
|
||||
Self {
|
||||
phase: MIGRATION_PHASE_IN_PROGRESS.to_string(),
|
||||
from_image_id,
|
||||
to_base_id,
|
||||
started_at: chrono::Utc::now().to_rfc3339(),
|
||||
report: None,
|
||||
rollback_image: None,
|
||||
staging_path: None,
|
||||
options,
|
||||
plan: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,12 @@ pub mod project;
|
||||
pub mod container_config;
|
||||
pub mod app_settings;
|
||||
pub mod gateway_settings;
|
||||
pub mod migration;
|
||||
pub mod update_info;
|
||||
|
||||
pub use project::*;
|
||||
pub use container_config::*;
|
||||
pub use app_settings::*;
|
||||
pub use gateway_settings::*;
|
||||
pub use migration::*;
|
||||
pub use update_info::*;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Host-side persistence for in-flight container base-image migrations.
|
||||
//!
|
||||
//! One JSON file per project under `<data_dir>/triple-c/migrations/`, written
|
||||
//! with the same write-temp-then-rename dance as `projects.json` so a crash can
|
||||
//! never leave a half-written state file. The staged verbatim payload tar lives
|
||||
//! in the same directory.
|
||||
//!
|
||||
//! This is deliberately *not* part of `projects.json`: a migration is transient
|
||||
//! and a migration record must survive independently of a project save racing
|
||||
//! it. It is also the crash record — see
|
||||
//! [`crate::models::MigrationState`] for the phase table.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::models::MigrationState;
|
||||
|
||||
/// `<data_dir>/triple-c/migrations`, created on demand.
|
||||
pub fn migrations_dir() -> Result<PathBuf, String> {
|
||||
let dir = dirs::data_dir()
|
||||
.ok_or_else(|| {
|
||||
"Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string()
|
||||
})?
|
||||
.join("triple-c")
|
||||
.join("migrations");
|
||||
fs::create_dir_all(&dir)
|
||||
.map_err(|e| format!("Failed to create migrations directory: {}", e))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
fn state_path(project_id: &str) -> Result<PathBuf, String> {
|
||||
Ok(migrations_dir()?.join(format!("{}.json", sanitize(project_id))))
|
||||
}
|
||||
|
||||
/// Host path for a project's staged verbatim payload.
|
||||
pub fn staging_path(project_id: &str) -> Result<PathBuf, String> {
|
||||
Ok(migrations_dir()?.join(format!("{}-payload.tar", sanitize(project_id))))
|
||||
}
|
||||
|
||||
/// Project ids are UUIDs, but they arrive over IPC, so refuse to let one steer
|
||||
/// the write anywhere but the migrations directory.
|
||||
fn sanitize(project_id: &str) -> String {
|
||||
project_id
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read a project's migration state. `Ok(None)` means no migration is in
|
||||
/// flight; an unparseable file is treated the same way (and logged) rather than
|
||||
/// blocking every future migration on a corrupt record.
|
||||
pub fn load(project_id: &str) -> Result<Option<MigrationState>, String> {
|
||||
let path = state_path(project_id)?;
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let data = fs::read_to_string(&path)
|
||||
.map_err(|e| format!("Failed to read migration state: {}", e))?;
|
||||
match serde_json::from_str::<MigrationState>(&data) {
|
||||
Ok(state) => Ok(Some(state)),
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"Failed to parse migration state for project {}: {} — treating as absent",
|
||||
project_id,
|
||||
e
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically write a project's migration state.
|
||||
pub fn save(project_id: &str, state: &MigrationState) -> Result<(), String> {
|
||||
let path = state_path(project_id)?;
|
||||
let data = serde_json::to_string_pretty(state)
|
||||
.map_err(|e| format!("Failed to serialize migration state: {}", e))?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
fs::write(&tmp, data).map_err(|e| format!("Failed to write migration state: {}", e))?;
|
||||
fs::rename(&tmp, &path).map_err(|e| format!("Failed to commit migration state: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a project's migration state file. Missing is success.
|
||||
pub fn clear(project_id: &str) -> Result<(), String> {
|
||||
let path = state_path(project_id)?;
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(format!("Failed to remove migration state: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a project's staged payload. Missing is success.
|
||||
pub fn clear_staging(project_id: &str) -> Result<(), String> {
|
||||
let path = staging_path(project_id)?;
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(format!("Failed to remove staged migration payload: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn project_ids_cannot_escape_the_migrations_directory() {
|
||||
assert_eq!(sanitize("../../etc/passwd"), "______etc_passwd");
|
||||
assert_eq!(sanitize("a/b"), "a_b");
|
||||
// The real shape — a UUID — must survive untouched, or state files
|
||||
// would move the first time this function changed.
|
||||
assert_eq!(
|
||||
sanitize("ab62cd24-51aa-4645-8f5c-17a124062050"),
|
||||
"ab62cd24-51aa-4645-8f5c-17a124062050"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod migration_store;
|
||||
pub mod projects_store;
|
||||
pub mod secure;
|
||||
pub mod settings_store;
|
||||
|
||||
Reference in New Issue
Block a user