Add llama.cpp + OpenAI backends, URL relay, browser view, and base-image migration #14
@@ -115,6 +115,9 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
|||||||
Binds `0.0.0.0` — unlike STT — because *project containers*, not the host process, consume
|
Binds `0.0.0.0` — unlike STT — because *project containers*, not the host process, consume
|
||||||
it; it therefore **always** sets a LiteLLM `master_key`, since LiteLLM without one accepts
|
it; it therefore **always** sets a LiteLLM `master_key`, since LiteLLM without one accepts
|
||||||
any key.
|
any key.
|
||||||
|
- `migration.rs` — Base-image migration: manifest capture via throwaway containers, the pure
|
||||||
|
delta computation (dpkg-ownership filter, bind-mount exclusion, verbatim-copy set), and the
|
||||||
|
crash-recovery state machine. See "Base-image migration" below.
|
||||||
- `legacy_cleanup.rs` — One-release migration shim removing leftovers from the deleted MCP
|
- `legacy_cleanup.rs` — One-release migration shim removing leftovers from the deleted MCP
|
||||||
feature (containers labelled `triple-c.mcp-server`, `triple-c-net-*` networks). Deletable once
|
feature (containers labelled `triple-c.mcp-server`, `triple-c-net-*` networks). Deletable once
|
||||||
users have migrated.
|
users have migrated.
|
||||||
@@ -131,6 +134,23 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
|||||||
- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, Claude Code settings.json injection, then `sleep infinity`
|
- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, Claude Code settings.json injection, then `sleep infinity`
|
||||||
- **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations
|
- **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations
|
||||||
|
|
||||||
|
**`/home/claude` in the image is seed-only.** It is the mount point of the named volume
|
||||||
|
`triple-c-home-{projectId}`, so after a project's *first* start the image's copy of that directory
|
||||||
|
is masked permanently and can never be updated again. A change you make under `/home/claude` in
|
||||||
|
the `Dockerfile` or in `entrypoint.sh`'s "copy this into the home dir" style reaches **new
|
||||||
|
projects only** — existing ones will never see it, with or without a base-image migration.
|
||||||
|
|
||||||
|
So: **anything that must stay upgradable belongs in `/usr/local/bin` or `/opt`, or must be seeded
|
||||||
|
by `entrypoint.sh` at runtime** (i.e. written on every start, from a source outside the home
|
||||||
|
volume, the way `CLAUDE_INSTRUCTIONS` → `~/.claude/CLAUDE.md` and the Mission Control skill copy
|
||||||
|
already are). Putting it in the image's `/home/claude` and expecting an image update to deliver it
|
||||||
|
is the mistake.
|
||||||
|
|
||||||
|
The flip side is the useful half of the same fact: Claude Code itself (`~/.local/bin`), cargo, uv,
|
||||||
|
ruff, the OAuth login, `~/.claude.json`, skills, transcripts, scheduler tasks and SSH keys all
|
||||||
|
re-attach for free when a container is recreated from a *different* image — which is what makes
|
||||||
|
base-image migration cheap.
|
||||||
|
|
||||||
### Container Lifecycle
|
### Container Lifecycle
|
||||||
|
|
||||||
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation.
|
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation.
|
||||||
@@ -141,6 +161,33 @@ Containers use a **stop/start** model (not create/destroy). Installed packages p
|
|||||||
intentional (Reset exists to get back to a clean base image), but do not describe Reset as
|
intentional (Reset exists to get back to a clean base image), but do not describe Reset as
|
||||||
preserving credentials.
|
preserving credentials.
|
||||||
|
|
||||||
|
### Base-image migration (`docker/migration.rs`, `commands/migration_commands.rs`)
|
||||||
|
|
||||||
|
A container is created from `triple-c-snapshot-{projectId}:latest` whenever that image exists, and
|
||||||
|
every recreation re-commits it — so without an explicit act, a project stays on the base image it
|
||||||
|
was first built from **forever** and never picks up a new `socat`, a new `/usr/local/bin` shim or a
|
||||||
|
security update. Migration is the non-destructive way out; Reset is the destructive one.
|
||||||
|
|
||||||
|
- **Staleness is a surfaced signal, not an automatic trigger.** `triple-c.base-image-id` records
|
||||||
|
the lineage but is deliberately **not** compared in `container_needs_recreation` — see the long
|
||||||
|
comment there. Comparing it would recreate every project *from its own snapshot* on the next base
|
||||||
|
bump: churn on the old base, and it would consume the "you should migrate" signal without
|
||||||
|
migrating. `get_container_staleness` surfaces it; `migrate_project_to_base` acts on it.
|
||||||
|
- **A missing lineage label means "unknown, probe instead", never "stale".**
|
||||||
|
- **`:latest` keeps pointing at the old lineage until the final commit.** That is what makes every
|
||||||
|
crash before that point self-heal — `start_project_container` just recreates from the old
|
||||||
|
snapshot. After the container swap, the new container's `triple-c.migration-state=in-progress`
|
||||||
|
label plus the persisted state file let `reconcile_project_statuses` offer resume or rollback.
|
||||||
|
- **Rollback restores the system layer only.** The volumes are never touched at any point, so work
|
||||||
|
done in `$HOME` during a migrated session survives a rollback. Say so in any UI copy.
|
||||||
|
- **`/etc` is never copied**, only reported: the snapshot lineage has
|
||||||
|
`/etc/apt/sources.list.d/nodesource.sources` where the current base has `nodesource.list`, and
|
||||||
|
having both breaks every `apt-get update` on a duplicate source. Verified, not theoretical.
|
||||||
|
- **`docker diff` is useless here** — on a snapshot-derived container it reports only changes since
|
||||||
|
the last commit. Migration diffs two filesystem manifests instead, filtered through dpkg
|
||||||
|
ownership and presence-in-the-new-base. Measured on a real project, that turns 8,677 raw path
|
||||||
|
differences into 2 genuinely user-authored ones.
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
Per-project, independently configured:
|
Per-project, independently configured:
|
||||||
@@ -182,6 +229,16 @@ Anthropic and Bedrock deliberately keep Claude Code's own defaults.
|
|||||||
environment or configuration, you must also write a corresponding `triple-c.*` label at creation
|
environment or configuration, you must also write a corresponding `triple-c.*` label at creation
|
||||||
and compare it there, or the change will silently not take effect until some unrelated setting
|
and compare it there, or the change will silently not take effect until some unrelated setting
|
||||||
forces a rebuild. Never put a secret in a label; labels are readable via `docker inspect`.
|
forces a rebuild. Never put a secret in a label; labels are readable via `docker inspect`.
|
||||||
|
(`triple-c.base-image-id` is the one deliberate exception — it is written but not compared; the
|
||||||
|
reasoning is in the comment beside the check.)
|
||||||
|
- **Always write a `triple-c.*` label explicitly, even when the value is 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 label stamped once rides that snapshot into *every* future container
|
||||||
|
forever. Verified on this host, and it is not hypothetical: `triple-c.mcp-fingerprint` has not
|
||||||
|
been written by any code since the MCP feature was removed, yet a snapshot image was found still
|
||||||
|
carrying a non-empty one, which made its one-shot recreation shim recreate that project on every
|
||||||
|
single start. Writing the key explicitly overrides the inherited value — the same defence
|
||||||
|
`MANAGED_AUTH_KEYS` applies to env vars.
|
||||||
- **New model fields need an explicit serde default when the correct default isn't the zero value.**
|
- **New model fields need an explicit serde default when the correct default isn't the zero value.**
|
||||||
`#[serde(default)]` on a `bool` yields `false`; follow the `default_full_permissions` pattern in
|
`#[serde(default)]` on a `bool` yields `false`; follow the `default_full_permissions` pattern in
|
||||||
`models/project.rs` for anything that should default to true.
|
`models/project.rs` for anything that should default to true.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ pub mod gateway_commands;
|
|||||||
pub mod help_commands;
|
pub mod help_commands;
|
||||||
pub mod inspect_commands;
|
pub mod inspect_commands;
|
||||||
pub mod install_helper_commands;
|
pub mod install_helper_commands;
|
||||||
|
pub mod migration_commands;
|
||||||
pub mod project_commands;
|
pub mod project_commands;
|
||||||
pub mod settings_commands;
|
pub mod settings_commands;
|
||||||
pub mod stt_commands;
|
pub mod stt_commands;
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ use tauri::{Emitter, State};
|
|||||||
|
|
||||||
use crate::commands::aws_commands;
|
use crate::commands::aws_commands;
|
||||||
use crate::docker;
|
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::storage::secure;
|
||||||
use crate::AppState;
|
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(
|
let _ = app_handle.emit(
|
||||||
"container-progress",
|
"container-progress",
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
@@ -43,8 +43,49 @@ fn store_secrets_for_project(project: &Project) -> Result<(), String> {
|
|||||||
Ok(())
|
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.
|
/// 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")
|
project.git_token = secure::get_project_secret(&project.id, "git-token")
|
||||||
.unwrap_or(None);
|
.unwrap_or(None);
|
||||||
if let Some(ref mut bedrock) = project.bedrock_config {
|
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
|
// AWS config path from global settings
|
||||||
let aws_config_path = settings.global_aws.aws_config_path.clone();
|
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? {
|
let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? {
|
||||||
// Check if config changed — if so, snapshot + recreate
|
// Check if config changed — if so, snapshot + recreate
|
||||||
let needs_recreate = docker::container_needs_recreation(
|
let needs_recreate = docker::container_needs_recreation(
|
||||||
&existing_id,
|
&existing_id,
|
||||||
&project,
|
&project,
|
||||||
|
&expected_create_image,
|
||||||
&settings.global_aws,
|
&settings.global_aws,
|
||||||
&settings.global_ollama,
|
&settings.global_ollama,
|
||||||
&settings.global_llamacpp,
|
&settings.global_llamacpp,
|
||||||
@@ -352,30 +408,24 @@ pub async fn start_project_container(
|
|||||||
docker::remove_legacy_mcp_containers(&project.id).await;
|
docker::remove_legacy_mcp_containers(&project.id).await;
|
||||||
docker::remove_legacy_project_network(&project.id).await;
|
docker::remove_legacy_project_network(&project.id).await;
|
||||||
|
|
||||||
// Create from snapshot image (preserves system-level changes)
|
// Create from snapshot image (preserves system-level changes).
|
||||||
let snapshot_image = docker::get_snapshot_image_name(&project);
|
// 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) {
|
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||||
snapshot_image
|
snapshot_image.clone()
|
||||||
} else {
|
} else {
|
||||||
image_name.clone()
|
image_name.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
let new_id = docker::create_container(
|
let new_id = create_container_for_project(
|
||||||
&project,
|
&project,
|
||||||
|
&settings,
|
||||||
&docker_socket,
|
&docker_socket,
|
||||||
&create_image,
|
|
||||||
aws_config_path.as_deref(),
|
aws_config_path.as_deref(),
|
||||||
&settings.global_aws,
|
&create_image,
|
||||||
&settings.global_ollama,
|
&image_name,
|
||||||
&settings.global_llamacpp,
|
docker::CreateExtras::default(),
|
||||||
&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?;
|
).await?;
|
||||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||||
docker::start_container(&new_id).await?;
|
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).
|
// Container doesn't exist (first start, or Docker pruned it).
|
||||||
// Check for a snapshot image first — it preserves system-level
|
// Check for a snapshot image first — it preserves system-level
|
||||||
// changes (apt/pip/npm installs) from the previous session.
|
// changes (apt/pip/npm installs) from the previous session.
|
||||||
let snapshot_image = docker::get_snapshot_image_name(&project);
|
if expected_create_image == snapshot_image {
|
||||||
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
|
||||||
log::info!("Creating container from snapshot image for project {}", project.id);
|
log::info!("Creating container from snapshot image for project {}", project.id);
|
||||||
snapshot_image
|
}
|
||||||
} else {
|
let create_image = expected_create_image.clone();
|
||||||
image_name.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
emit_progress(&app_handle, &project_id, "Creating container...");
|
emit_progress(&app_handle, &project_id, "Creating container...");
|
||||||
let new_id = docker::create_container(
|
let new_id = create_container_for_project(
|
||||||
&project,
|
&project,
|
||||||
|
&settings,
|
||||||
&docker_socket,
|
&docker_socket,
|
||||||
&create_image,
|
|
||||||
aws_config_path.as_deref(),
|
aws_config_path.as_deref(),
|
||||||
&settings.global_aws,
|
&create_image,
|
||||||
&settings.global_ollama,
|
&image_name,
|
||||||
&settings.global_llamacpp,
|
docker::CreateExtras::default(),
|
||||||
&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?;
|
).await?;
|
||||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||||
docker::start_container(&new_id).await?;
|
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
|
/// Called by the frontend after Docker is confirmed available. Projects
|
||||||
/// marked as Running whose containers are no longer running get reset
|
/// marked as Running whose containers are no longer running get reset
|
||||||
/// to Stopped.
|
/// 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]
|
#[tauri::command]
|
||||||
pub async fn reconcile_project_statuses(
|
pub async fn reconcile_project_statuses(
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
@@ -537,6 +583,10 @@ pub async fn reconcile_project_statuses(
|
|||||||
) -> Result<Vec<Project>, String> {
|
) -> Result<Vec<Project>, String> {
|
||||||
let projects = state.projects_store.list();
|
let projects = state.projects_store.list();
|
||||||
|
|
||||||
|
for project in &projects {
|
||||||
|
crate::commands::migration_commands::reconcile_migration(project, &app_handle).await;
|
||||||
|
}
|
||||||
|
|
||||||
for project in &projects {
|
for project in &projects {
|
||||||
if project.status != ProjectStatus::Running && project.status != ProjectStatus::Error {
|
if project.status != ProjectStatus::Running && project.status != ProjectStatus::Error {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -700,10 +700,55 @@ pub async fn find_existing_container(project: &Project) -> Result<Option<String>
|
|||||||
Ok(None)
|
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(
|
pub async fn create_container(
|
||||||
project: &Project,
|
project: &Project,
|
||||||
docker_socket_path: &str,
|
docker_socket_path: &str,
|
||||||
image_name: &str,
|
image_name: &str,
|
||||||
|
base_image_name: &str,
|
||||||
|
extras: CreateExtras<'_>,
|
||||||
aws_config_path: Option<&str>,
|
aws_config_path: Option<&str>,
|
||||||
global_aws: &GlobalAwsSettings,
|
global_aws: &GlobalAwsSettings,
|
||||||
global_ollama: &GlobalOllamaSettings,
|
global_ollama: &GlobalOllamaSettings,
|
||||||
@@ -1232,6 +1277,51 @@ pub async fn create_container(
|
|||||||
labels.insert("triple-c.claude-token-version".to_string(),
|
labels.insert("triple-c.claude-token-version".to_string(),
|
||||||
shared_claude.as_ref().map(|(_, v)| v.clone()).unwrap_or_default());
|
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 {
|
let host_config = HostConfig {
|
||||||
mounts: Some(mounts),
|
mounts: Some(mounts),
|
||||||
port_bindings: if port_bindings.is_empty() { None } else { Some(port_bindings) },
|
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(
|
pub async fn container_needs_recreation(
|
||||||
container_id: &str,
|
container_id: &str,
|
||||||
project: &Project,
|
project: &Project,
|
||||||
|
expected_create_image: &str,
|
||||||
global_aws: &GlobalAwsSettings,
|
global_aws: &GlobalAwsSettings,
|
||||||
global_ollama: &GlobalOllamaSettings,
|
global_ollama: &GlobalOllamaSettings,
|
||||||
global_llamacpp: &GlobalLlamaCppSettings,
|
global_llamacpp: &GlobalLlamaCppSettings,
|
||||||
@@ -1614,24 +1705,48 @@ pub async fn container_needs_recreation(
|
|||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Image ────────────────────────────────────────────────────────────
|
// ── Create image ─────────────────────────────────────────────────────
|
||||||
// The image label is set at creation time; if the user changed the
|
// What this container was created from, against what we would create it
|
||||||
// configured image we need to recreate. We only compare when the
|
// from *now* — the caller resolves that (snapshot-if-it-exists, else the
|
||||||
// label exists (containers created before this change won't have it).
|
// configured base) and passes it in as `expected_create_image`, preserving
|
||||||
if let Some(container_image) = get_label("triple-c.image") {
|
// exactly today's semantics.
|
||||||
// The caller doesn't pass the image name, but we can read the
|
//
|
||||||
// container's actual image from Docker inspect.
|
// This replaces a check that compared the container's actual image against
|
||||||
let actual_image = info
|
// the `triple-c.image` label. `create_container` wrote that label from the
|
||||||
.config
|
// very image it created from, so the two could never differ: it was a
|
||||||
.as_ref()
|
// tautology that never once fired, and it is the reason a project stayed
|
||||||
.and_then(|c| c.image.as_ref());
|
// pinned to its own snapshot lineage forever.
|
||||||
if let Some(actual) = actual_image {
|
//
|
||||||
if *actual != container_image {
|
// A missing `triple-c.create-image` label means the container predates this
|
||||||
log::info!("Image mismatch (actual={:?}, label={:?})", actual, container_image);
|
// 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);
|
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 ─────────────────────────────────────────────────────────
|
// ── Timezone ─────────────────────────────────────────────────────────
|
||||||
let expected_tz = timezone.unwrap_or("");
|
let expected_tz = timezone.unwrap_or("");
|
||||||
|
|||||||
@@ -37,6 +37,22 @@ pub async fn create_attached_exec(
|
|||||||
container_id: &str,
|
container_id: &str,
|
||||||
cmd: Vec<String>,
|
cmd: Vec<String>,
|
||||||
tty: bool,
|
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> {
|
) -> Result<AttachedExec, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
|
|
||||||
@@ -49,8 +65,8 @@ pub async fn create_attached_exec(
|
|||||||
attach_stderr: Some(true),
|
attach_stderr: Some(true),
|
||||||
tty: Some(tty),
|
tty: Some(tty),
|
||||||
cmd: Some(cmd),
|
cmd: Some(cmd),
|
||||||
user: Some("claude".to_string()),
|
user: Some(user.to_string()),
|
||||||
working_dir: Some("/workspace".to_string()),
|
working_dir: Some(working_dir.to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -371,6 +387,51 @@ pub async fn upload_host_file_to_container(
|
|||||||
Ok(format!("/tmp/{}", dest_name))
|
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.
|
/// 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> {
|
pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String, String> {
|
||||||
exec_oneshot_env(container_id, cmd, Vec::new()).await
|
exec_oneshot_env(container_id, cmd, Vec::new()).await
|
||||||
@@ -400,6 +461,22 @@ pub async fn exec_oneshot_env_status(
|
|||||||
container_id: &str,
|
container_id: &str,
|
||||||
cmd: Vec<String>,
|
cmd: Vec<String>,
|
||||||
env: 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> {
|
) -> Result<(String, i64), String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
|
|
||||||
@@ -411,7 +488,7 @@ pub async fn exec_oneshot_env_status(
|
|||||||
attach_stderr: Some(true),
|
attach_stderr: Some(true),
|
||||||
cmd: Some(cmd),
|
cmd: Some(cmd),
|
||||||
env: if env.is_empty() { None } else { Some(env) },
|
env: if env.is_empty() { None } else { Some(env) },
|
||||||
user: Some("claude".to_string()),
|
user: Some(user.to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ pub mod image;
|
|||||||
pub mod exec;
|
pub mod exec;
|
||||||
pub mod gateway;
|
pub mod gateway;
|
||||||
pub mod legacy_cleanup;
|
pub mod legacy_cleanup;
|
||||||
|
pub mod migration;
|
||||||
pub mod stt;
|
pub mod stt;
|
||||||
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
@@ -20,3 +21,5 @@ pub use image::*;
|
|||||||
pub use exec::*;
|
pub use exec::*;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use legacy_cleanup::*;
|
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::stop_project_container,
|
||||||
commands::project_commands::rebuild_project_container,
|
commands::project_commands::rebuild_project_container,
|
||||||
commands::project_commands::reconcile_project_statuses,
|
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
|
// Auth bridge
|
||||||
commands::auth_bridge_commands::set_auth_bridge_enabled,
|
commands::auth_bridge_commands::set_auth_bridge_enabled,
|
||||||
commands::auth_bridge_commands::get_auth_bridge_status,
|
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 container_config;
|
||||||
pub mod app_settings;
|
pub mod app_settings;
|
||||||
pub mod gateway_settings;
|
pub mod gateway_settings;
|
||||||
|
pub mod migration;
|
||||||
pub mod update_info;
|
pub mod update_info;
|
||||||
|
|
||||||
pub use project::*;
|
pub use project::*;
|
||||||
pub use container_config::*;
|
pub use container_config::*;
|
||||||
pub use app_settings::*;
|
pub use app_settings::*;
|
||||||
pub use gateway_settings::*;
|
pub use gateway_settings::*;
|
||||||
|
pub use migration::*;
|
||||||
pub use update_info::*;
|
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 projects_store;
|
||||||
pub mod secure;
|
pub mod secure;
|
||||||
pub mod settings_store;
|
pub mod settings_store;
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||||
|
import MigrateContainerModal from "./MigrateContainerModal";
|
||||||
|
import type { ContainerMigration } from "../../hooks/useContainerMigration";
|
||||||
|
import type { ContainerStaleness } from "../../lib/types";
|
||||||
|
|
||||||
|
/** Modal focuses via rAF so the panel is laid out first; jsdom needs a flush. */
|
||||||
|
async function flushFocus() {
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(20);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const STALE: ContainerStaleness = {
|
||||||
|
stale: true,
|
||||||
|
known: true,
|
||||||
|
base_image_id: "sha256:aaa",
|
||||||
|
current_base_image_id: "sha256:bbb",
|
||||||
|
snapshot_created_at: "2026-03-01T09:00:00Z",
|
||||||
|
missing_paths: ["/usr/bin/socat"],
|
||||||
|
missing_features: ["Auth bridge tunnel (socat)", "Mission Control"],
|
||||||
|
apt_delta: ["socat", "bubblewrap"],
|
||||||
|
npm_global_delta: [],
|
||||||
|
verbatim_paths: [],
|
||||||
|
outdated_package_count: 61,
|
||||||
|
probe_error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function migration(overrides: Partial<ContainerMigration> = {}): ContainerMigration {
|
||||||
|
return {
|
||||||
|
staleness: STALE,
|
||||||
|
probing: false,
|
||||||
|
running: false,
|
||||||
|
recovered: false,
|
||||||
|
interrupted: null,
|
||||||
|
report: null,
|
||||||
|
log: [],
|
||||||
|
phaseMessage: null,
|
||||||
|
busy: false,
|
||||||
|
start: vi.fn(async () => {}),
|
||||||
|
resume: vi.fn(async () => {}),
|
||||||
|
keep: vi.fn(async () => {}),
|
||||||
|
rollback: vi.fn(async () => {}),
|
||||||
|
dismiss: vi.fn(),
|
||||||
|
refresh: vi.fn(async () => {}),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderModal(
|
||||||
|
staleness: ContainerStaleness | null = STALE,
|
||||||
|
overrides: Partial<ContainerMigration> = {},
|
||||||
|
) {
|
||||||
|
const m = migration({ staleness, ...overrides });
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<MigrateContainerModal
|
||||||
|
projectName="api-server"
|
||||||
|
staleness={staleness}
|
||||||
|
migration={m}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await flushFocus();
|
||||||
|
return { m, onClose };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("MigrateContainerModal", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ toFake: ["requestAnimationFrame", "setTimeout"] });
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pre-flight", () => {
|
||||||
|
it("leads with what is kept, as a statement rather than a choice", async () => {
|
||||||
|
await renderModal();
|
||||||
|
const kept = screen.getByText("Kept automatically");
|
||||||
|
expect(kept).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/no signing in again/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/every saved session transcript/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/are Docker volumes/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Reassurance comes first: it is above the replay section in the DOM.
|
||||||
|
const replay = screen.getByText(/Reinstalled from the new base's repos/);
|
||||||
|
expect(kept.compareDocumentPosition(replay)).toBe(
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
|
);
|
||||||
|
|
||||||
|
// And it is a statement — there is no switch attached to it.
|
||||||
|
const keptSection = kept.closest("section");
|
||||||
|
expect(keptSection?.querySelector('[role="switch"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the verbatim-copy section when nothing user-authored was found", async () => {
|
||||||
|
await renderModal({ ...STALE, verbatim_paths: [] });
|
||||||
|
expect(screen.queryByText(/Copied across as-is/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the verbatim-copy section with its paths when there are some", async () => {
|
||||||
|
await renderModal({
|
||||||
|
...STALE,
|
||||||
|
verbatim_paths: ["/usr/local/bin/deploy.sh", "/etc/pki/corp.crt"],
|
||||||
|
});
|
||||||
|
expect(screen.getByText("Copied across as-is (2)")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("/usr/local/bin/deploy.sh")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("/etc/pki/corp.crt")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts the apt packages and states the rollback's disk cost", async () => {
|
||||||
|
await renderModal();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Reinstalled from the new base's repos (2)"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("socat")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("bubblewrap")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/3.8–12.3 GB/)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Rollback restores the system layer only/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists the gains as the inverse of the missing features", async () => {
|
||||||
|
await renderModal();
|
||||||
|
expect(screen.getByText("You will gain")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Auth bridge tunnel \(socat\)/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Mission Control/)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
/61 packages the current base carries at a different version/i,
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the three options through when the run is started", async () => {
|
||||||
|
const { m } = await renderModal({
|
||||||
|
...STALE,
|
||||||
|
verbatim_paths: ["/usr/local/bin/deploy.sh"],
|
||||||
|
});
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("switch", {
|
||||||
|
name: /Keep a rollback image until I confirm/i,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Update container base" }),
|
||||||
|
);
|
||||||
|
expect(m.start).toHaveBeenCalledWith({
|
||||||
|
replay_packages: true,
|
||||||
|
copy_paths: true,
|
||||||
|
keep_rollback: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not ask to copy paths when there are none to copy", async () => {
|
||||||
|
const { m } = await renderModal({ ...STALE, verbatim_paths: [] });
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Update container base" }),
|
||||||
|
);
|
||||||
|
expect(m.start).toHaveBeenCalledWith({
|
||||||
|
replay_packages: true,
|
||||||
|
copy_paths: false,
|
||||||
|
keep_rollback: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not start anything on cancel", async () => {
|
||||||
|
const { m, onClose } = await renderModal();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(m.start).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mid-run", () => {
|
||||||
|
const RUNNING: Partial<ContainerMigration> = {
|
||||||
|
running: true,
|
||||||
|
log: ["Snapshotting container…", "Creating container on the new base…"],
|
||||||
|
phaseMessage: "Creating container on the new base…",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("streams the phase message and the output", async () => {
|
||||||
|
await renderModal(STALE, RUNNING);
|
||||||
|
expect(screen.getByRole("status").textContent).toBe(
|
||||||
|
"Creating container on the new base…",
|
||||||
|
);
|
||||||
|
const log = screen.getByTestId("migration-log");
|
||||||
|
expect(log.textContent).toContain("Snapshotting container…");
|
||||||
|
expect(log.textContent).toContain("Creating container on the new base…");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can be dismissed without cancelling the run", async () => {
|
||||||
|
const { m, onClose } = await renderModal(STALE, RUNNING);
|
||||||
|
// A run takes minutes; blocking the app for it would be wrong, so the
|
||||||
|
// dialog closes and the work carries on.
|
||||||
|
expect(
|
||||||
|
screen.getByText(/keeps running if you close it/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Hide" }));
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Nothing on the migration was touched — closing is not cancelling.
|
||||||
|
expect(m.start).not.toHaveBeenCalled();
|
||||||
|
expect(m.rollback).not.toHaveBeenCalled();
|
||||||
|
expect(m.dismiss).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still closes on Escape and on the header ✕ while running", async () => {
|
||||||
|
const { m, onClose } = await renderModal(STALE, RUNNING);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Close dialog" }));
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(2);
|
||||||
|
expect(m.dismiss).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("outcome", () => {
|
||||||
|
it("shows the report in place of the pre-flight once it lands", async () => {
|
||||||
|
await renderModal(STALE, {
|
||||||
|
report: {
|
||||||
|
phase: "partial",
|
||||||
|
packages_requested: ["socat", "bubblewrap"],
|
||||||
|
packages_installed: ["socat"],
|
||||||
|
packages_failed: [
|
||||||
|
{ name: "bubblewrap", reason: "held back by apt-mark" },
|
||||||
|
],
|
||||||
|
paths_copied: [],
|
||||||
|
features_restored: ["Auth bridge tunnel (socat)"],
|
||||||
|
rollback_available: true,
|
||||||
|
message: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/Updated, but not completely/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Kept automatically")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/held back by apt-mark/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { MigrationReport } from "../../lib/types";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import StatusIndicator from "../ui/StatusIndicator";
|
||||||
|
import {
|
||||||
|
ROLLBACK_SCOPE,
|
||||||
|
aptRetryCommand,
|
||||||
|
failureReportText,
|
||||||
|
} from "./migrationCopy";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
report: MigrationReport;
|
||||||
|
/** Disables the action row while confirm/rollback is in flight. */
|
||||||
|
busy?: boolean;
|
||||||
|
onKeep: () => void;
|
||||||
|
onRollback: () => void;
|
||||||
|
/** Only offered when there is nothing to keep or roll back. */
|
||||||
|
onDismiss: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The outcome of a migration, rendered identically in the Overview banner and
|
||||||
|
* in the modal so a user who closed the modal is not shown a different story.
|
||||||
|
*
|
||||||
|
* A **partial** is the case this component exists for. The user arrived here
|
||||||
|
* because containers degrade silently — a run that quietly dropped `socat` and
|
||||||
|
* called itself a success would be exactly the same bug in a new place. So a
|
||||||
|
* partial is painted as a warning, names every package and the reason it
|
||||||
|
* failed, and hands over the literal `apt-get` line to finish the job.
|
||||||
|
*/
|
||||||
|
export default function MigrationReportCard({
|
||||||
|
report,
|
||||||
|
busy = false,
|
||||||
|
onKeep,
|
||||||
|
onRollback,
|
||||||
|
onDismiss,
|
||||||
|
}: Props) {
|
||||||
|
const [copied, setCopied] = useState<"command" | "detail" | null>(null);
|
||||||
|
const partial = report.phase === "partial";
|
||||||
|
const failed = report.phase === "failed";
|
||||||
|
const rolledBack = report.phase === "rolled_back";
|
||||||
|
|
||||||
|
const copy = async (what: "command" | "detail", text: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(what);
|
||||||
|
setTimeout(() => setCopied(null), 2000);
|
||||||
|
} catch {
|
||||||
|
// Clipboard can be denied; the text is selectable on screen either way.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Partial and failed are painted as failures. A partial that reads as a
|
||||||
|
// success is precisely how a container ends up silently degraded.
|
||||||
|
const tone = partial || failed ? "error" : rolledBack ? "off" : "ok";
|
||||||
|
const heading = partial
|
||||||
|
? "Updated, but not completely"
|
||||||
|
: failed
|
||||||
|
? "Update failed"
|
||||||
|
: rolledBack
|
||||||
|
? "Rolled back"
|
||||||
|
: "Container base updated";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<StatusIndicator tone={tone} label={heading} className="text-[13px] font-semibold" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{report.phase === "succeeded" && (
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
{report.packages_installed.length} package
|
||||||
|
{report.packages_installed.length === 1 ? "" : "s"} reinstalled,{" "}
|
||||||
|
{report.features_restored.length} feature
|
||||||
|
{report.features_restored.length === 1 ? "" : "s"} restored.
|
||||||
|
{report.paths_copied.length > 0
|
||||||
|
? ` ${report.paths_copied.length} path${report.paths_copied.length === 1 ? "" : "s"} copied across.`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{failed && (
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
{report.message ||
|
||||||
|
"Update failed. Your container has been restored to its previous state."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rolledBack && (
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
{report.message || "The previous system layer has been put back."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{partial && (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<p className="text-[13px] text-[var(--text-primary)]">
|
||||||
|
{report.packages_installed.length} of{" "}
|
||||||
|
{report.packages_requested.length} packages went back on.{" "}
|
||||||
|
<strong>
|
||||||
|
{report.packages_failed.length} did not
|
||||||
|
</strong>
|
||||||
|
, so this container is still missing something it had before.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="rounded-[var(--radius-control)] border border-[var(--error)]/40 bg-[var(--error-muted)] px-3 py-2 select-text"
|
||||||
|
data-testid="migration-failures"
|
||||||
|
>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{report.packages_failed.map((failure) => (
|
||||||
|
<li key={failure.name} className="text-xs leading-snug">
|
||||||
|
<span className="font-mono font-semibold text-[var(--text-primary)]">
|
||||||
|
{failure.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--text-secondary)]"> — {failure.reason}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{report.packages_failed.length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Finish by hand in a shell inside the container:
|
||||||
|
</p>
|
||||||
|
<code className="block px-2.5 py-1.5 font-mono text-xs text-[var(--text-primary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] overflow-x-auto whitespace-pre select-text">
|
||||||
|
{aptRetryCommand(report.packages_failed)}
|
||||||
|
</code>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
copy("command", aptRetryCommand(report.packages_failed))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{copied === "command" ? "Copied ✓" : "Copy apt-get line"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
copy("detail", failureReportText(report.packages_failed))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{copied === "detail" ? "Copied ✓" : "Copy failure details"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{report.features_restored.length > 0 && !failed && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)]">
|
||||||
|
Restored
|
||||||
|
</h4>
|
||||||
|
<p className="mt-0.5 text-xs text-[var(--text-secondary)]">
|
||||||
|
{report.features_restored.join(", ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{report.message && !failed && !rolledBack && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] select-text">{report.message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{report.rollback_available && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
{ROLLBACK_SCOPE}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||||
|
{report.rollback_available ? (
|
||||||
|
<>
|
||||||
|
<Button size="md" variant="primary" disabled={busy} onClick={onKeep}>
|
||||||
|
Keep
|
||||||
|
</Button>
|
||||||
|
<Button size="md" variant="danger" disabled={busy} onClick={onRollback}>
|
||||||
|
Roll back
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button size="md" disabled={busy} onClick={onDismiss}>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import ContainerMigrationBanner from "./ContainerMigrationBanner";
|
||||||
|
import type { ContainerMigration } from "../../../hooks/useContainerMigration";
|
||||||
|
import type {
|
||||||
|
ContainerStaleness,
|
||||||
|
MigrationReport,
|
||||||
|
} from "../../../lib/types";
|
||||||
|
|
||||||
|
const FRESH: ContainerStaleness = {
|
||||||
|
stale: false,
|
||||||
|
known: true,
|
||||||
|
base_image_id: "sha256:aaa",
|
||||||
|
current_base_image_id: "sha256:aaa",
|
||||||
|
snapshot_created_at: "2026-03-01T09:00:00Z",
|
||||||
|
missing_paths: [],
|
||||||
|
missing_features: [],
|
||||||
|
apt_delta: [],
|
||||||
|
npm_global_delta: [],
|
||||||
|
verbatim_paths: [],
|
||||||
|
outdated_package_count: 0,
|
||||||
|
probe_error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const STALE: ContainerStaleness = {
|
||||||
|
...FRESH,
|
||||||
|
stale: true,
|
||||||
|
current_base_image_id: "sha256:bbb",
|
||||||
|
missing_paths: ["/usr/bin/socat", "/usr/bin/bwrap"],
|
||||||
|
missing_features: [
|
||||||
|
"Host-browser opening",
|
||||||
|
"Auth bridge tunnel (socat)",
|
||||||
|
"Mission Control",
|
||||||
|
],
|
||||||
|
apt_delta: ["socat", "bubblewrap"],
|
||||||
|
outdated_package_count: 61,
|
||||||
|
};
|
||||||
|
|
||||||
|
function migration(overrides: Partial<ContainerMigration> = {}): ContainerMigration {
|
||||||
|
return {
|
||||||
|
staleness: null,
|
||||||
|
probing: false,
|
||||||
|
running: false,
|
||||||
|
recovered: false,
|
||||||
|
interrupted: null,
|
||||||
|
report: null,
|
||||||
|
log: [],
|
||||||
|
phaseMessage: null,
|
||||||
|
busy: false,
|
||||||
|
start: vi.fn(async () => {}),
|
||||||
|
resume: vi.fn(async () => {}),
|
||||||
|
keep: vi.fn(async () => {}),
|
||||||
|
rollback: vi.fn(async () => {}),
|
||||||
|
dismiss: vi.fn(),
|
||||||
|
refresh: vi.fn(async () => {}),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBanner(m: ContainerMigration, canMigrate = true) {
|
||||||
|
const onOpen = vi.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<ContainerMigrationBanner migration={m} canMigrate={canMigrate} onOpen={onOpen} />,
|
||||||
|
);
|
||||||
|
return { onOpen, container };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ContainerMigrationBanner", () => {
|
||||||
|
it("renders nothing when the container is on the current base", () => {
|
||||||
|
const { container } = renderBanner(migration({ staleness: FRESH }));
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing before the probe has returned", () => {
|
||||||
|
const { container } = renderBanner(migration({ staleness: null }));
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leads with the missing features rather than image digests", () => {
|
||||||
|
renderBanner(migration({ staleness: STALE }));
|
||||||
|
expect(screen.getByText(/Container base is out of date/i)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
/Host-browser opening, Auth bridge tunnel \(socat\) and Mission Control/,
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/61 packages differ from the versions on the current base/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// Digests are evidence, not the message.
|
||||||
|
expect(screen.queryByText(/sha256/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not claim the packages are behind, only that they differ", () => {
|
||||||
|
renderBanner(migration({ staleness: STALE }));
|
||||||
|
// `outdated_package_count` is a drift measure; the backend explicitly does
|
||||||
|
// not promise every one of them is newer.
|
||||||
|
expect(screen.queryByText(/behind on security updates/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says the container was probed when there is no base-image label", () => {
|
||||||
|
// `stale` is always false when `known` is false — an unknown lineage is not
|
||||||
|
// a claim of staleness — but the probe's own findings still have to show.
|
||||||
|
renderBanner(
|
||||||
|
migration({ staleness: { ...STALE, known: false, stale: false } }),
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/probed directly/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/The probe found these missing/i)).toBeInTheDocument();
|
||||||
|
// No version comparison happened, so none is implied.
|
||||||
|
expect(screen.queryByText(/Running on a saved image/i)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/out of date/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays quiet for an unlabelled container the probe found nothing wrong with", () => {
|
||||||
|
const { container } = renderBanner(
|
||||||
|
migration({
|
||||||
|
staleness: {
|
||||||
|
...FRESH,
|
||||||
|
known: false,
|
||||||
|
stale: false,
|
||||||
|
outdated_package_count: 3,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables the action and explains why while the container is running", () => {
|
||||||
|
renderBanner(migration({ staleness: STALE }), false);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /Update container base/i }),
|
||||||
|
).toBeDisabled();
|
||||||
|
expect(screen.getByText(/Stop the container to update its base/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps reporting an in-flight run after the modal is closed", () => {
|
||||||
|
renderBanner(
|
||||||
|
migration({
|
||||||
|
staleness: STALE,
|
||||||
|
running: true,
|
||||||
|
phaseMessage: "Reinstalling socat…",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/Updating container base/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Reinstalling socat…")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /Show progress/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces a run recovered from a crash", () => {
|
||||||
|
renderBanner(migration({ staleness: STALE, running: true, recovered: true }));
|
||||||
|
expect(
|
||||||
|
screen.getByText(/A container base update was already running/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/still in progress when the app last closed/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let an interrupted migration hide behind a plain staleness notice", () => {
|
||||||
|
const m = migration({
|
||||||
|
staleness: STALE,
|
||||||
|
interrupted: {
|
||||||
|
phase: "interrupted",
|
||||||
|
from_image_id: "sha256:aaa",
|
||||||
|
to_base_id: "sha256:bbb",
|
||||||
|
started_at: "2026-08-09T10:00:00Z",
|
||||||
|
report: null,
|
||||||
|
rollback_image: "triple-c-snapshot-p1:pre-migration-1754733600",
|
||||||
|
staging_path: null,
|
||||||
|
options: { replay_packages: true, copy_paths: false, keep_rollback: true },
|
||||||
|
plan: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
renderBanner(m);
|
||||||
|
expect(
|
||||||
|
screen.getByText(/A container base update was interrupted/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/part-way onto the new base/i)).toBeInTheDocument();
|
||||||
|
// The plain "Update container base…" call to action must not be what is
|
||||||
|
// offered here — the container is mid-swap, so it is resume or roll back.
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: /Update container base/i }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Resume update" }));
|
||||||
|
expect(m.resume).toHaveBeenCalledTimes(1);
|
||||||
|
expect(screen.getByRole("button", { name: "Roll back" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers no rollback for an interrupted run that kept no rollback image", () => {
|
||||||
|
renderBanner(
|
||||||
|
migration({
|
||||||
|
staleness: STALE,
|
||||||
|
interrupted: {
|
||||||
|
phase: "interrupted",
|
||||||
|
from_image_id: "sha256:aaa",
|
||||||
|
to_base_id: "sha256:bbb",
|
||||||
|
started_at: "2026-08-09T10:00:00Z",
|
||||||
|
report: null,
|
||||||
|
rollback_image: null,
|
||||||
|
staging_path: null,
|
||||||
|
options: { replay_packages: true, copy_paths: false, keep_rollback: false },
|
||||||
|
plan: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("button", { name: "Roll back" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Resume update" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the report", () => {
|
||||||
|
const CLEAN: MigrationReport = {
|
||||||
|
phase: "succeeded",
|
||||||
|
packages_requested: ["socat", "bubblewrap"],
|
||||||
|
packages_installed: [
|
||||||
|
"socat",
|
||||||
|
"bubblewrap",
|
||||||
|
"ca-certificates",
|
||||||
|
"openssl",
|
||||||
|
"curl",
|
||||||
|
"jq",
|
||||||
|
"ripgrep",
|
||||||
|
"unzip",
|
||||||
|
],
|
||||||
|
packages_failed: [],
|
||||||
|
paths_copied: [],
|
||||||
|
features_restored: [
|
||||||
|
"Host-browser opening",
|
||||||
|
"Auth bridge tunnel (socat)",
|
||||||
|
"Sandbox mode (bubblewrap)",
|
||||||
|
"Mission Control",
|
||||||
|
],
|
||||||
|
rollback_available: true,
|
||||||
|
message: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PARTIAL: MigrationReport = {
|
||||||
|
phase: "partial",
|
||||||
|
packages_requested: ["socat", "bubblewrap", "libfoo-dev"],
|
||||||
|
packages_installed: ["socat"],
|
||||||
|
packages_failed: [
|
||||||
|
{ name: "bubblewrap", reason: "held back by apt-mark" },
|
||||||
|
{ name: "libfoo-dev", reason: "no installation candidate in noble" },
|
||||||
|
],
|
||||||
|
paths_copied: [],
|
||||||
|
features_restored: ["Auth bridge tunnel (socat)"],
|
||||||
|
rollback_available: true,
|
||||||
|
message: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("reports a clean run with counts and both choices", () => {
|
||||||
|
renderBanner(migration({ staleness: FRESH, report: CLEAN }));
|
||||||
|
expect(screen.getByText(/8 packages reinstalled/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/4 features restored/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Keep" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Roll back" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names every failed package and why, and does not read as a success", () => {
|
||||||
|
renderBanner(migration({ staleness: STALE, report: PARTIAL }));
|
||||||
|
expect(screen.getByText(/Updated, but not completely/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("bubblewrap")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/held back by apt-mark/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("libfoo-dev")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/no installation candidate in noble/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// And the exact line that finishes the job by hand.
|
||||||
|
expect(
|
||||||
|
screen.getByText("sudo apt-get install -y bubblewrap libfoo-dev"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /Copy apt-get line/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says a failed run has already been restored, and offers no rollback", () => {
|
||||||
|
renderBanner(
|
||||||
|
migration({
|
||||||
|
staleness: STALE,
|
||||||
|
report: {
|
||||||
|
phase: "failed",
|
||||||
|
packages_requested: [],
|
||||||
|
packages_installed: [],
|
||||||
|
packages_failed: [],
|
||||||
|
paths_copied: [],
|
||||||
|
features_restored: [],
|
||||||
|
rollback_available: false,
|
||||||
|
message:
|
||||||
|
"Update failed at replay. Your container has been restored to its previous state.",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/Update failed at replay/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Roll back" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not describe rollback as a time machine", () => {
|
||||||
|
renderBanner(migration({ staleness: FRESH, report: CLEAN }));
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Rollback restores the system layer only/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Volumes are never touched/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import type { ContainerMigration } from "../../../hooks/useContainerMigration";
|
||||||
|
import Button from "../../ui/Button";
|
||||||
|
import StatusIndicator from "../../ui/StatusIndicator";
|
||||||
|
import MigrationReportCard from "../MigrationReportCard";
|
||||||
|
import { ROLLBACK_SCOPE, formatSnapshotDate, joinFeatures } from "../migrationCopy";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
migration: ContainerMigration;
|
||||||
|
/** Migration mirrors Reset's gate: the container has to be stopped. */
|
||||||
|
canMigrate: boolean;
|
||||||
|
onOpen: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHELL =
|
||||||
|
"border rounded-[var(--radius-panel)] px-3.5 py-3 space-y-2";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Overview answer to "why is this container behaving oddly?".
|
||||||
|
*
|
||||||
|
* It leads with the *features* that are missing, not image digests: a user does
|
||||||
|
* not know or care that `sha256:abc…` differs from `sha256:def…`, they care
|
||||||
|
* that host-browser opening and the auth bridge do not work. Digests are the
|
||||||
|
* evidence, not the message.
|
||||||
|
*
|
||||||
|
* It also has to survive the run: an in-flight migration, an interrupted one,
|
||||||
|
* and the report are all shown here, because the modal is dismissable and the
|
||||||
|
* outcome must not vanish with it.
|
||||||
|
*/
|
||||||
|
export default function ContainerMigrationBanner({
|
||||||
|
migration,
|
||||||
|
canMigrate,
|
||||||
|
onOpen,
|
||||||
|
}: Props) {
|
||||||
|
const { staleness, running, recovered, interrupted, report, phaseMessage, busy } =
|
||||||
|
migration;
|
||||||
|
|
||||||
|
// The report outranks staleness: after a run, the outcome is the news.
|
||||||
|
if (report) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`${SHELL} ${
|
||||||
|
report.phase === "partial" || report.phase === "failed"
|
||||||
|
? "border-[var(--error)]/40 bg-[var(--error-muted)]"
|
||||||
|
: "border-[var(--border-color)] bg-[var(--bg-secondary)]"
|
||||||
|
}`}
|
||||||
|
aria-label="Container base update result"
|
||||||
|
>
|
||||||
|
<MigrationReportCard
|
||||||
|
report={report}
|
||||||
|
busy={busy}
|
||||||
|
onKeep={() => void migration.keep()}
|
||||||
|
onRollback={() => void migration.rollback()}
|
||||||
|
onDismiss={migration.dismiss}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (running) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`${SHELL} border-[var(--warning)]/40 bg-[var(--warning-muted)]`}
|
||||||
|
aria-label="Container base update in progress"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<StatusIndicator
|
||||||
|
tone="busy"
|
||||||
|
label={
|
||||||
|
recovered
|
||||||
|
? "A container base update was already running"
|
||||||
|
: "Updating container base"
|
||||||
|
}
|
||||||
|
className="text-[13px] font-semibold"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-[var(--text-secondary)] truncate">
|
||||||
|
{phaseMessage ?? "Starting…"}
|
||||||
|
</p>
|
||||||
|
{recovered && (
|
||||||
|
<p className="mt-1 text-xs text-[var(--text-secondary)]">
|
||||||
|
It was still in progress when the app last closed. Picking it back up.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button size="md" onClick={onOpen}>
|
||||||
|
Show progress
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing is driving this one. It outranks staleness because the container is
|
||||||
|
// sitting mid-swap, and the one thing it must never do is look like a normal
|
||||||
|
// out-of-date container that the user can take or leave.
|
||||||
|
if (interrupted) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`${SHELL} border-[var(--error)]/40 bg-[var(--error-muted)]`}
|
||||||
|
aria-label="Container base update was interrupted"
|
||||||
|
>
|
||||||
|
<StatusIndicator
|
||||||
|
tone="error"
|
||||||
|
label="A container base update was interrupted"
|
||||||
|
className="text-[13px] font-semibold"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
It started{" "}
|
||||||
|
{formatSnapshotDate(interrupted.started_at) ?? "earlier"} and the app
|
||||||
|
closed before it finished, so this container is part-way onto the new
|
||||||
|
base. Resuming replays the same plan it was given.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
{ROLLBACK_SCOPE}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant="primary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void migration.resume()}
|
||||||
|
>
|
||||||
|
Resume update
|
||||||
|
</Button>
|
||||||
|
{interrupted.rollback_image && (
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant="danger"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void migration.rollback()}
|
||||||
|
>
|
||||||
|
Roll back
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!staleness) return null;
|
||||||
|
|
||||||
|
// `stale` is deliberately false whenever `known` is false — an unestablished
|
||||||
|
// lineage is not a claim of staleness. But a container with no base-image
|
||||||
|
// label is exactly the old container most likely to be missing things, and
|
||||||
|
// the probe says so directly. So the probe's own findings are grounds to
|
||||||
|
// speak up even though the version comparison never happened.
|
||||||
|
const probeFoundGaps =
|
||||||
|
!staleness.known &&
|
||||||
|
(staleness.missing_features.length > 0 || staleness.missing_paths.length > 0);
|
||||||
|
if (!staleness.stale && !probeFoundGaps) return null;
|
||||||
|
|
||||||
|
const snapshot = formatSnapshotDate(staleness.snapshot_created_at);
|
||||||
|
const features = joinFeatures(staleness.missing_features);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`${SHELL} border-[var(--warning)]/40 bg-[var(--warning-muted)]`}
|
||||||
|
aria-label="Container base is out of date"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<StatusIndicator
|
||||||
|
tone="error"
|
||||||
|
label={
|
||||||
|
staleness.known
|
||||||
|
? "Container base is out of date"
|
||||||
|
: "Container is missing things the current base ships"
|
||||||
|
}
|
||||||
|
className="text-[13px] font-semibold"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
{staleness.known
|
||||||
|
? snapshot
|
||||||
|
? `Running on a saved image from ${snapshot}.`
|
||||||
|
: "Running on a saved image older than the current base."
|
||||||
|
: "This container predates base-image tracking, so it was probed directly."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{staleness.missing_features.length > 0 && (
|
||||||
|
<p className="text-xs leading-snug text-[var(--text-primary)]">
|
||||||
|
{staleness.known ? "Missing: " : "The probe found these missing: "}
|
||||||
|
<span className="text-[var(--text-secondary)]">{features}.</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{staleness.missing_features.length === 0 &&
|
||||||
|
staleness.missing_paths.length > 0 && (
|
||||||
|
<p className="text-xs leading-snug text-[var(--text-primary)]">
|
||||||
|
{staleness.known ? "Missing: " : "The probe found these missing: "}
|
||||||
|
<span className="font-mono text-[var(--text-secondary)]">
|
||||||
|
{staleness.missing_paths.join(", ")}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Deliberately "differ" rather than "behind": the count is a drift
|
||||||
|
measure, not a promise that every one of them is newer. */}
|
||||||
|
{staleness.outdated_package_count > 0 && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
{staleness.outdated_package_count} package
|
||||||
|
{staleness.outdated_package_count === 1 ? "" : "s"} differ from the
|
||||||
|
versions on the current base, where security updates land.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{staleness.probe_error && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
Some checks did not complete: {staleness.probe_error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!canMigrate && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
Stop the container to update its base.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant="primary"
|
||||||
|
disabled={!canMigrate}
|
||||||
|
onClick={onOpen}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
|
Update container base…
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import PermissionModeControl, {
|
|||||||
permissionModePatch,
|
permissionModePatch,
|
||||||
} from "../PermissionModeControl";
|
} from "../PermissionModeControl";
|
||||||
import CapabilityTiles from "./CapabilityTiles";
|
import CapabilityTiles from "./CapabilityTiles";
|
||||||
|
import ContainerMigrationBanner from "./ContainerMigrationBanner";
|
||||||
|
import type { ContainerMigration } from "../../../hooks/useContainerMigration";
|
||||||
import SaveIndicator from "../../ui/SaveIndicator";
|
import SaveIndicator from "../../ui/SaveIndicator";
|
||||||
import Button from "../../ui/Button";
|
import Button from "../../ui/Button";
|
||||||
import { formatAge } from "./format";
|
import { formatAge } from "./format";
|
||||||
@@ -31,6 +33,11 @@ interface Props {
|
|||||||
saveState: SaveState;
|
saveState: SaveState;
|
||||||
actions: ReturnType<typeof useProjectActions>;
|
actions: ReturnType<typeof useProjectActions>;
|
||||||
onOpenTab: (tab: ProjectHomeTabId) => void;
|
onOpenTab: (tab: ProjectHomeTabId) => void;
|
||||||
|
/** Base-image staleness, run state and report. Owned by `ProjectHome`. */
|
||||||
|
migration: ContainerMigration;
|
||||||
|
/** Migration mirrors Reset's gate: only offered on a stopped container. */
|
||||||
|
canMigrate: boolean;
|
||||||
|
onOpenMigration: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OverviewTab({
|
export default function OverviewTab({
|
||||||
@@ -39,6 +46,9 @@ export default function OverviewTab({
|
|||||||
saveState,
|
saveState,
|
||||||
actions,
|
actions,
|
||||||
onOpenTab,
|
onOpenTab,
|
||||||
|
migration,
|
||||||
|
canMigrate,
|
||||||
|
onOpenMigration,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [sessions, setSessions] = useState<ClaudeSession[]>([]);
|
const [sessions, setSessions] = useState<ClaudeSession[]>([]);
|
||||||
const [tasks, setTasks] = useState<ScheduledTask[]>([]);
|
const [tasks, setTasks] = useState<ScheduledTask[]>([]);
|
||||||
@@ -120,6 +130,14 @@ export default function OverviewTab({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* A container missing socat and bwrap is a capability statement, so the
|
||||||
|
out-of-date warning sits directly above the capability inventory. */}
|
||||||
|
<ContainerMigrationBanner
|
||||||
|
migration={migration}
|
||||||
|
canMigrate={canMigrate}
|
||||||
|
onOpen={onOpenMigration}
|
||||||
|
/>
|
||||||
|
|
||||||
<CapabilityTiles
|
<CapabilityTiles
|
||||||
project={project}
|
project={project}
|
||||||
onManageInTerminal={(command) => actions.openTerminalWithCommand(command)}
|
onManageInTerminal={(command) => actions.openTerminalWithCommand(command)}
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import { useAppState } from "../../../store/appState";
|
|||||||
import { useProjectActions } from "../../../hooks/useProjectActions";
|
import { useProjectActions } from "../../../hooks/useProjectActions";
|
||||||
import { useProjects } from "../../../hooks/useProjects";
|
import { useProjects } from "../../../hooks/useProjects";
|
||||||
import { useProjectSave } from "../../../hooks/useSaveState";
|
import { useProjectSave } from "../../../hooks/useSaveState";
|
||||||
|
import { useContainerMigration } from "../../../hooks/useContainerMigration";
|
||||||
import { ProjectStatusIndicator } from "../../ui/StatusIndicator";
|
import { ProjectStatusIndicator } from "../../ui/StatusIndicator";
|
||||||
import Button from "../../ui/Button";
|
import Button from "../../ui/Button";
|
||||||
import OverflowMenu from "../../ui/OverflowMenu";
|
import OverflowMenu from "../../ui/OverflowMenu";
|
||||||
import ConfirmRemoveModal from "../ConfirmRemoveModal";
|
import ConfirmRemoveModal from "../ConfirmRemoveModal";
|
||||||
import ConfirmResetModal from "../ConfirmResetModal";
|
import ConfirmResetModal from "../ConfirmResetModal";
|
||||||
|
import MigrateContainerModal from "../MigrateContainerModal";
|
||||||
import OverviewTab from "./OverviewTab";
|
import OverviewTab from "./OverviewTab";
|
||||||
import SessionsTab from "./SessionsTab";
|
import SessionsTab from "./SessionsTab";
|
||||||
import AutomationTab from "./AutomationTab";
|
import AutomationTab from "./AutomationTab";
|
||||||
@@ -43,6 +45,7 @@ export default function ProjectHome({ projectId, active }: Props) {
|
|||||||
const [tab, setTab] = useState<ProjectHomeTabId>("overview");
|
const [tab, setTab] = useState<ProjectHomeTabId>("overview");
|
||||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||||
const [confirmReset, setConfirmReset] = useState(false);
|
const [confirmReset, setConfirmReset] = useState(false);
|
||||||
|
const [showMigration, setShowMigration] = useState(false);
|
||||||
const { runningSince, progress } = useAppState(
|
const { runningSince, progress } = useAppState(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
runningSince: s.runningSince[projectId],
|
runningSince: s.runningSince[projectId],
|
||||||
@@ -64,6 +67,11 @@ export default function ProjectHome({ projectId, active }: Props) {
|
|||||||
const { save, saveState } = useProjectSave(
|
const { save, saveState } = useProjectSave(
|
||||||
project ?? ({ id: projectId, name: "" } as never),
|
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]);
|
const uptime = useMemo(() => formatUptime(runningSince), [runningSince]);
|
||||||
|
|
||||||
@@ -81,6 +89,16 @@ export default function ProjectHome({ projectId, active }: Props) {
|
|||||||
const isTransitioning =
|
const isTransitioning =
|
||||||
project.status === "starting" || project.status === "stopping";
|
project.status === "starting" || project.status === "stopping";
|
||||||
const isStopped = project.status === "stopped" || project.status === "error";
|
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 (
|
return (
|
||||||
<div className={`flex flex-col h-full min-h-0 ${active ? "" : "hidden"}`}>
|
<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,
|
onSelect: actions.handleBackup,
|
||||||
disabled: actions.backingUp || !project.container_id,
|
disabled: actions.backingUp || !project.container_id,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Update container base…",
|
||||||
|
onSelect: () => setShowMigration(true),
|
||||||
|
disabled: !canMigrate,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Reset container…",
|
label: "Reset container…",
|
||||||
onSelect: () => setConfirmReset(true),
|
onSelect: () => setConfirmReset(true),
|
||||||
@@ -200,6 +223,9 @@ export default function ProjectHome({ projectId, active }: Props) {
|
|||||||
saveState={saveState}
|
saveState={saveState}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
onOpenTab={setTab}
|
onOpenTab={setTab}
|
||||||
|
migration={migration}
|
||||||
|
canMigrate={canMigrate}
|
||||||
|
onOpenMigration={() => setShowMigration(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tab === "sessions" && <SessionsTab project={project} actions={actions} />}
|
{tab === "sessions" && <SessionsTab project={project} actions={actions} />}
|
||||||
@@ -213,6 +239,16 @@ export default function ProjectHome({ projectId, active }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{confirmReset && (
|
||||||
<ConfirmResetModal
|
<ConfirmResetModal
|
||||||
projectName={project.name}
|
projectName={project.name}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Shared wording for container base-image migration.
|
||||||
|
*
|
||||||
|
* The banner, the pre-flight modal and the report all have to make the same
|
||||||
|
* promise about what survives, or the feature reads as another Reset. It is
|
||||||
|
* written once here so the three surfaces cannot drift apart.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PackageFailure } from "../../lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What re-attaches untouched. These are not copied, rebuilt or re-authenticated
|
||||||
|
* — they live on the two Docker volumes, which the new container mounts as-is.
|
||||||
|
*/
|
||||||
|
export const KEPT_AUTOMATICALLY = [
|
||||||
|
"Your claude login and ~/.claude.json — no signing in again",
|
||||||
|
"Skills, agents, commands, hooks, plugins and MCP config",
|
||||||
|
"Every saved session transcript, so past sessions still resume",
|
||||||
|
"Scheduler tasks and their logs",
|
||||||
|
"SSH keys, git config and shell history",
|
||||||
|
"Claude Code itself, plus Rust/cargo, uv and ruff in your home directory",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const KEPT_WHY =
|
||||||
|
"/home/claude and ~/.claude are Docker volumes. They detach from the old container and re-attach to the new one unchanged.";
|
||||||
|
|
||||||
|
export const LOST_WITHOUT_REPLAY =
|
||||||
|
"What a new base does not carry over is the root-level system packages you installed with apt. Those are the only thing this update has to put back.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Said plainly everywhere rollback is offered. Rollback is not a time machine:
|
||||||
|
* it swaps the system layer back and leaves both volumes exactly where the
|
||||||
|
* migrated session left them.
|
||||||
|
*/
|
||||||
|
export const ROLLBACK_SCOPE =
|
||||||
|
"Rollback restores the system layer only. Your volumes are never touched, so anything Claude wrote to your home directory or a mounted workspace during the migrated session stays as it is.";
|
||||||
|
|
||||||
|
export const ROLLBACK_DISK_COST =
|
||||||
|
"A rollback image is close to a full second copy of the container — snapshots here run 3.8–12.3 GB and share almost nothing with the new base, so it costs nearly its full size on disk. It is deleted the moment you press Keep.";
|
||||||
|
|
||||||
|
/** Shown mid-run, where rollback is not a button but is still the safety net. */
|
||||||
|
export const MID_RUN_SAFETY =
|
||||||
|
"If this fails, the container is put back on its previous system layer automatically. Your volumes are not touched at any point.";
|
||||||
|
|
||||||
|
export const REPLAY_COST =
|
||||||
|
"Needs network access and usually takes 1–2 minutes.";
|
||||||
|
|
||||||
|
/** `1 Mar` — short enough to sit inline in the banner sentence. */
|
||||||
|
export function formatSnapshotDate(iso: string | null): string | null {
|
||||||
|
if (!iso) return null;
|
||||||
|
const ms = Date.parse(iso);
|
||||||
|
if (Number.isNaN(ms)) return null;
|
||||||
|
return new Date(ms).toLocaleDateString(undefined, {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Join a list into prose: "a, b and c". Used for the missing-features line. */
|
||||||
|
export function joinFeatures(features: string[]): string {
|
||||||
|
if (features.length === 0) return "";
|
||||||
|
if (features.length === 1) return features[0];
|
||||||
|
return `${features.slice(0, -1).join(", ")} and ${features[features.length - 1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The exact line to paste into a shell to finish a partial migration by hand. */
|
||||||
|
export function aptRetryCommand(failures: PackageFailure[]): string {
|
||||||
|
return `sudo apt-get install -y ${failures.map((f) => f.name).join(" ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain-text form of a partial report, for the copy button. */
|
||||||
|
export function failureReportText(failures: PackageFailure[]): string {
|
||||||
|
const lines = failures.map((f) => `${f.name}: ${f.reason}`);
|
||||||
|
return [
|
||||||
|
"Packages that could not be reinstalled:",
|
||||||
|
...lines,
|
||||||
|
"",
|
||||||
|
aptRetryCommand(failures),
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { useContainerMigration } from "./useContainerMigration";
|
||||||
|
import type {
|
||||||
|
ContainerStaleness,
|
||||||
|
MigrationReport,
|
||||||
|
MigrationState,
|
||||||
|
Project,
|
||||||
|
} from "../lib/types";
|
||||||
|
|
||||||
|
const getContainerStaleness = vi.fn();
|
||||||
|
const getMigrationState = vi.fn();
|
||||||
|
const migrateProjectToBase = vi.fn();
|
||||||
|
const confirmMigration = vi.fn();
|
||||||
|
const rollbackMigration = vi.fn();
|
||||||
|
const pushToast = vi.fn();
|
||||||
|
let progress: string | undefined;
|
||||||
|
|
||||||
|
vi.mock("../lib/tauri-commands", () => ({
|
||||||
|
getContainerStaleness: (...a: unknown[]) => getContainerStaleness(...a),
|
||||||
|
getMigrationState: (...a: unknown[]) => getMigrationState(...a),
|
||||||
|
migrateProjectToBase: (...a: unknown[]) => migrateProjectToBase(...a),
|
||||||
|
confirmMigration: (...a: unknown[]) => confirmMigration(...a),
|
||||||
|
rollbackMigration: (...a: unknown[]) => rollbackMigration(...a),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../store/appState", () => ({
|
||||||
|
useAppState: Object.assign(
|
||||||
|
(selector: (s: unknown) => unknown) =>
|
||||||
|
selector({ pushToast, containerProgress: { p1: progress } }),
|
||||||
|
{
|
||||||
|
getState: () => ({ setContainerProgress: () => {} }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const STALE: ContainerStaleness = {
|
||||||
|
stale: true,
|
||||||
|
known: true,
|
||||||
|
base_image_id: "sha256:aaa",
|
||||||
|
current_base_image_id: "sha256:bbb",
|
||||||
|
snapshot_created_at: "2026-03-01T09:00:00Z",
|
||||||
|
missing_paths: ["/usr/bin/socat"],
|
||||||
|
missing_features: ["Auth bridge tunnel (socat)"],
|
||||||
|
apt_delta: ["socat"],
|
||||||
|
npm_global_delta: [],
|
||||||
|
verbatim_paths: [],
|
||||||
|
outdated_package_count: 61,
|
||||||
|
probe_error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const FRESH: ContainerStaleness = {
|
||||||
|
...STALE,
|
||||||
|
stale: false,
|
||||||
|
base_image_id: "sha256:bbb",
|
||||||
|
missing_paths: [],
|
||||||
|
missing_features: [],
|
||||||
|
apt_delta: [],
|
||||||
|
outdated_package_count: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CLEAN: MigrationReport = {
|
||||||
|
phase: "succeeded",
|
||||||
|
packages_requested: ["socat"],
|
||||||
|
packages_installed: ["socat"],
|
||||||
|
packages_failed: [],
|
||||||
|
paths_copied: [],
|
||||||
|
features_restored: ["Auth bridge tunnel (socat)"],
|
||||||
|
rollback_available: true,
|
||||||
|
message: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const OPTIONS = {
|
||||||
|
replay_packages: true,
|
||||||
|
copy_paths: false,
|
||||||
|
keep_rollback: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function state(overrides: Partial<MigrationState> = {}): MigrationState {
|
||||||
|
return {
|
||||||
|
phase: "in-progress",
|
||||||
|
from_image_id: "sha256:aaa",
|
||||||
|
to_base_id: "sha256:bbb",
|
||||||
|
started_at: "2026-08-09T10:00:00Z",
|
||||||
|
report: null,
|
||||||
|
rollback_image: "triple-c-snapshot-p1:pre-migration-1754733600",
|
||||||
|
staging_path: null,
|
||||||
|
options: OPTIONS,
|
||||||
|
plan: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = { id: "p1", name: "api-server", container_id: "c1", status: "stopped" } as Project;
|
||||||
|
|
||||||
|
describe("useContainerMigration", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
progress = undefined;
|
||||||
|
getContainerStaleness.mockResolvedValue(STALE);
|
||||||
|
getMigrationState.mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("probes staleness for a container that exists", async () => {
|
||||||
|
const { result } = renderHook(() => useContainerMigration(project));
|
||||||
|
await waitFor(() => expect(result.current.staleness).toEqual(STALE));
|
||||||
|
expect(getContainerStaleness).toHaveBeenCalledWith("p1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not probe a project whose container was never created", async () => {
|
||||||
|
renderHook(() =>
|
||||||
|
useContainerMigration({ ...project, container_id: null } as Project),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(getMigrationState).toHaveBeenCalled());
|
||||||
|
expect(getContainerStaleness).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an absent banner rather than an error one when the probe fails", async () => {
|
||||||
|
getContainerStaleness.mockRejectedValue(new Error("no such container"));
|
||||||
|
const { result } = renderHook(() => useContainerMigration(project));
|
||||||
|
await waitFor(() => expect(result.current.probing).toBe(false));
|
||||||
|
expect(result.current.staleness).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the options through and keeps the report", async () => {
|
||||||
|
migrateProjectToBase.mockResolvedValue(CLEAN);
|
||||||
|
const { result } = renderHook(() => useContainerMigration(project));
|
||||||
|
await waitFor(() => expect(result.current.staleness).toEqual(STALE));
|
||||||
|
|
||||||
|
getContainerStaleness.mockResolvedValue(FRESH);
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.start({
|
||||||
|
replay_packages: true,
|
||||||
|
copy_paths: false,
|
||||||
|
keep_rollback: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(migrateProjectToBase).toHaveBeenCalledWith("p1", {
|
||||||
|
replay_packages: true,
|
||||||
|
copy_paths: false,
|
||||||
|
keep_rollback: true,
|
||||||
|
});
|
||||||
|
expect(result.current.report).toEqual(CLEAN);
|
||||||
|
expect(result.current.running).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns a rejected migrate call into a failed report, not a silent nothing", async () => {
|
||||||
|
migrateProjectToBase.mockRejectedValue(new Error("docker daemon went away"));
|
||||||
|
const { result } = renderHook(() => useContainerMigration(project));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.start({
|
||||||
|
replay_packages: true,
|
||||||
|
copy_paths: false,
|
||||||
|
keep_rollback: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(result.current.report?.phase).toBe("failed");
|
||||||
|
expect(result.current.report?.message).toMatch(/docker daemon went away/);
|
||||||
|
expect(result.current.report?.rollback_available).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the report and re-probes once the migration is kept", async () => {
|
||||||
|
migrateProjectToBase.mockResolvedValue(CLEAN);
|
||||||
|
confirmMigration.mockResolvedValue(undefined);
|
||||||
|
const { result } = renderHook(() => useContainerMigration(project));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.start({
|
||||||
|
replay_packages: true,
|
||||||
|
copy_paths: false,
|
||||||
|
keep_rollback: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
getContainerStaleness.mockResolvedValue(FRESH);
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.keep();
|
||||||
|
});
|
||||||
|
expect(confirmMigration).toHaveBeenCalledWith("p1");
|
||||||
|
expect(result.current.report).toBeNull();
|
||||||
|
await waitFor(() => expect(result.current.staleness).toEqual(FRESH));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says out loud that a rollback left the volumes alone", async () => {
|
||||||
|
rollbackMigration.mockResolvedValue(undefined);
|
||||||
|
const { result } = renderHook(() => useContainerMigration(project));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.rollback();
|
||||||
|
});
|
||||||
|
expect(rollbackMigration).toHaveBeenCalledWith("p1");
|
||||||
|
expect(pushToast).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "success",
|
||||||
|
detail: expect.stringMatching(/Volumes were not touched/i),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("crash recovery", () => {
|
||||||
|
it("adopts a run that was still in progress, and polls it to a report", async () => {
|
||||||
|
getMigrationState.mockResolvedValue(state());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useContainerMigration(project));
|
||||||
|
await waitFor(() => expect(result.current.running).toBe(true));
|
||||||
|
expect(result.current.recovered).toBe(true);
|
||||||
|
|
||||||
|
getMigrationState.mockResolvedValue(
|
||||||
|
state({ phase: "awaiting-confirmation", report: CLEAN }),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(result.current.report).toEqual(CLEAN), {
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
expect(result.current.running).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces a finished migration that was never acknowledged", async () => {
|
||||||
|
getMigrationState.mockResolvedValue(
|
||||||
|
state({ phase: "awaiting-confirmation", report: CLEAN }),
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useContainerMigration(project));
|
||||||
|
await waitFor(() => expect(result.current.report).toEqual(CLEAN));
|
||||||
|
expect(result.current.running).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces an interrupted migration instead of leaving it invisible", async () => {
|
||||||
|
getMigrationState.mockResolvedValue(state({ phase: "interrupted" }));
|
||||||
|
const { result } = renderHook(() => useContainerMigration(project));
|
||||||
|
await waitFor(() => expect(result.current.interrupted).not.toBeNull());
|
||||||
|
// Nothing is driving it, so it is not "running" and has no report.
|
||||||
|
expect(result.current.running).toBe(false);
|
||||||
|
expect(result.current.report).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resumes an interrupted migration with the options it was given", async () => {
|
||||||
|
getMigrationState.mockResolvedValue(state({ phase: "interrupted" }));
|
||||||
|
migrateProjectToBase.mockResolvedValue(CLEAN);
|
||||||
|
const { result } = renderHook(() => useContainerMigration(project));
|
||||||
|
await waitFor(() => expect(result.current.interrupted).not.toBeNull());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.resume();
|
||||||
|
});
|
||||||
|
// The deltas cannot be recomputed after the swap, so the recorded plan's
|
||||||
|
// options are replayed verbatim rather than re-derived.
|
||||||
|
expect(migrateProjectToBase).toHaveBeenCalledWith("p1", OPTIONS);
|
||||||
|
expect(result.current.interrupted).toBeNull();
|
||||||
|
expect(result.current.report).toEqual(CLEAN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores an unrecognised phase from a future build rather than crashing", async () => {
|
||||||
|
getMigrationState.mockResolvedValue(state({ phase: "quantum-tunnelling" }));
|
||||||
|
const { result } = renderHook(() => useContainerMigration(project));
|
||||||
|
await waitFor(() => expect(result.current.staleness).toEqual(STALE));
|
||||||
|
expect(result.current.running).toBe(false);
|
||||||
|
expect(result.current.interrupted).toBeNull();
|
||||||
|
expect(result.current.report).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type {
|
||||||
|
ContainerStaleness,
|
||||||
|
MigrationOptions,
|
||||||
|
MigrationReport,
|
||||||
|
MigrationState,
|
||||||
|
Project,
|
||||||
|
} from "../lib/types";
|
||||||
|
import {
|
||||||
|
MIGRATION_PHASE_AWAITING_CONFIRMATION,
|
||||||
|
MIGRATION_PHASE_IN_PROGRESS,
|
||||||
|
MIGRATION_PHASE_INTERRUPTED,
|
||||||
|
} from "../lib/types";
|
||||||
|
import * as commands from "../lib/tauri-commands";
|
||||||
|
import { useAppState } from "../store/appState";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsettled phases from `MigrationState.phase` (hyphenated, unlike the
|
||||||
|
* outcome phases on `MigrationReport`). Compared as strings on purpose: the
|
||||||
|
* backend types this loosely so an unrecognised value from a future build
|
||||||
|
* cannot crash the UI, and neither can it here — an unknown phase simply
|
||||||
|
* surfaces nothing rather than throwing.
|
||||||
|
*/
|
||||||
|
const IN_PROGRESS = MIGRATION_PHASE_IN_PROGRESS;
|
||||||
|
const INTERRUPTED = MIGRATION_PHASE_INTERRUPTED;
|
||||||
|
const AWAITING = MIGRATION_PHASE_AWAITING_CONFIRMATION;
|
||||||
|
|
||||||
|
export interface ContainerMigration {
|
||||||
|
/** Null until the first probe returns, or when the container has never been created. */
|
||||||
|
staleness: ContainerStaleness | null;
|
||||||
|
probing: boolean;
|
||||||
|
/** True while a migration is running — whether we started it or found it. */
|
||||||
|
running: boolean;
|
||||||
|
/** True when the run in progress was recovered from disk, not started here. */
|
||||||
|
recovered: boolean;
|
||||||
|
/**
|
||||||
|
* A migration the app died in the middle of. It is not running and it has no
|
||||||
|
* report: the container is mid-swap until someone resumes or rolls it back.
|
||||||
|
*/
|
||||||
|
interrupted: MigrationState | null;
|
||||||
|
/** Re-enter an interrupted migration. The backend continues the same run. */
|
||||||
|
resume: () => Promise<void>;
|
||||||
|
/** The settled report, kept until the user keeps, rolls back or dismisses it. */
|
||||||
|
report: MigrationReport | null;
|
||||||
|
/** Progress lines from `container-progress`, oldest first. */
|
||||||
|
log: string[];
|
||||||
|
/** The most recent progress line, or null before the first one arrives. */
|
||||||
|
phaseMessage: string | null;
|
||||||
|
/** True while confirm/rollback is in flight. */
|
||||||
|
busy: boolean;
|
||||||
|
start: (options: MigrationOptions) => Promise<void>;
|
||||||
|
keep: () => Promise<void>;
|
||||||
|
rollback: () => Promise<void>;
|
||||||
|
/** Clear a report we cannot act on (failed / rolled back). Local only. */
|
||||||
|
dismiss: () => void;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Container base-image migration for one project.
|
||||||
|
*
|
||||||
|
* Three things have to survive a closed modal: the run itself, the progress
|
||||||
|
* log, and the report. A migration takes minutes, so the modal is a *view* onto
|
||||||
|
* this hook rather than the thing that owns the work — closing it must not
|
||||||
|
* cancel anything. The hook lives in `ProjectHome`, above both the modal and
|
||||||
|
* the Overview banner, so either surface can be showing at any point.
|
||||||
|
*
|
||||||
|
* A migration the app died in the middle of is picked up from
|
||||||
|
* `getMigrationState` on mount — as `interrupted`, which is offered for resume,
|
||||||
|
* or as `awaiting-confirmation`, whose report is put back on screen. Without
|
||||||
|
* that, a half-migrated container would look identical to a healthy one, which
|
||||||
|
* is the exact failure mode this whole feature exists to fix.
|
||||||
|
*/
|
||||||
|
export function useContainerMigration(project: Project): ContainerMigration {
|
||||||
|
const projectId = project.id;
|
||||||
|
const [staleness, setStaleness] = useState<ContainerStaleness | null>(null);
|
||||||
|
const [probing, setProbing] = useState(false);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [recovered, setRecovered] = useState(false);
|
||||||
|
const [interrupted, setInterrupted] = useState<MigrationState | null>(null);
|
||||||
|
const [report, setReport] = useState<MigrationReport | null>(null);
|
||||||
|
const [log, setLog] = useState<string[]>([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const pushToast = useAppState((s) => s.pushToast);
|
||||||
|
const progress = useAppState((s) => s.containerProgress[projectId]);
|
||||||
|
|
||||||
|
// Guards a late response from an earlier project overwriting a newer one.
|
||||||
|
const generation = useRef(0);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
const gen = ++generation.current;
|
||||||
|
if (!project.container_id) {
|
||||||
|
setStaleness(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setProbing(true);
|
||||||
|
try {
|
||||||
|
const next = await commands.getContainerStaleness(projectId);
|
||||||
|
if (gen === generation.current) setStaleness(next);
|
||||||
|
} catch {
|
||||||
|
// A probe that cannot reach the container is "we do not know", which is
|
||||||
|
// an absent banner rather than an error one — the same call is retried
|
||||||
|
// whenever the container's status changes.
|
||||||
|
if (gen === generation.current) setStaleness(null);
|
||||||
|
} finally {
|
||||||
|
if (gen === generation.current) setProbing(false);
|
||||||
|
}
|
||||||
|
}, [projectId, project.container_id]);
|
||||||
|
|
||||||
|
// Probe staleness when the container settles into a new state. The probe runs
|
||||||
|
// two filesystem walks and is explicitly not for polling, so it is skipped
|
||||||
|
// mid-transition and mid-run — a reading taken while the container is being
|
||||||
|
// swapped describes neither the old system layer nor the new one.
|
||||||
|
const settled = project.status !== "starting" && project.status !== "stopping";
|
||||||
|
useEffect(() => {
|
||||||
|
if (running || !settled) return;
|
||||||
|
void refresh();
|
||||||
|
}, [refresh, settled, running]);
|
||||||
|
|
||||||
|
// Crash recovery: adopt whatever the backend still has on record.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
commands
|
||||||
|
.getMigrationState(projectId)
|
||||||
|
.then((state) => {
|
||||||
|
if (cancelled || !state) return;
|
||||||
|
if (state.phase === IN_PROGRESS) {
|
||||||
|
// Something is still driving it; watch rather than restart.
|
||||||
|
setRunning(true);
|
||||||
|
setRecovered(true);
|
||||||
|
} else if (state.phase === INTERRUPTED) {
|
||||||
|
// Nothing is driving it. The container is mid-swap and will stay that
|
||||||
|
// way until someone resumes — so this must be visible, not silent.
|
||||||
|
setInterrupted(state);
|
||||||
|
} else if (state.phase === AWAITING && state.report) {
|
||||||
|
setReport(state.report);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* No recorded state is the normal case. */
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
// A recovered run has no promise to await, so poll it to completion.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!running || !recovered) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
commands
|
||||||
|
.getMigrationState(projectId)
|
||||||
|
.then((state: MigrationState | null) => {
|
||||||
|
if (cancelled || state?.phase === IN_PROGRESS) return;
|
||||||
|
setRunning(false);
|
||||||
|
setRecovered(false);
|
||||||
|
// A cleared record means it was confirmed or rolled back elsewhere.
|
||||||
|
if (!state) {
|
||||||
|
void refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.phase === INTERRUPTED) {
|
||||||
|
setInterrupted(state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.report) setReport(state.report);
|
||||||
|
void refresh();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* Keep polling; a transient IPC failure is not an outcome. */
|
||||||
|
});
|
||||||
|
}, 2500);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [running, recovered, projectId, refresh]);
|
||||||
|
|
||||||
|
// Accumulate the shared progress line into a scrollback the modal can show.
|
||||||
|
// The store collapses repeats, so identical consecutive apt lines appear once.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!running || !progress) return;
|
||||||
|
setLog((prev) =>
|
||||||
|
prev[prev.length - 1] === progress ? prev : [...prev, progress],
|
||||||
|
);
|
||||||
|
}, [progress, running]);
|
||||||
|
|
||||||
|
const start = useCallback(
|
||||||
|
async (options: MigrationOptions) => {
|
||||||
|
setLog([]);
|
||||||
|
setReport(null);
|
||||||
|
setRecovered(false);
|
||||||
|
setInterrupted(null);
|
||||||
|
setRunning(true);
|
||||||
|
try {
|
||||||
|
const result = await commands.migrateProjectToBase(projectId, options);
|
||||||
|
setReport(result);
|
||||||
|
} catch (e) {
|
||||||
|
// A rejected call means the backend never produced a report. Synthesise
|
||||||
|
// the failed shape so the report surface — not a toast that scrolls
|
||||||
|
// away — is still what tells the user.
|
||||||
|
setReport({
|
||||||
|
phase: "failed",
|
||||||
|
packages_requested: [],
|
||||||
|
packages_installed: [],
|
||||||
|
packages_failed: [],
|
||||||
|
paths_copied: [],
|
||||||
|
features_restored: [],
|
||||||
|
rollback_available: false,
|
||||||
|
message: String(e),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setRunning(false);
|
||||||
|
useAppState.getState().setContainerProgress(projectId, null);
|
||||||
|
void refresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectId, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-enter an interrupted migration. The backend continues that run rather
|
||||||
|
* than starting a new one, and the recorded options are replayed as-is — the
|
||||||
|
* deltas cannot be recomputed once the container has already been swapped.
|
||||||
|
*/
|
||||||
|
const resume = useCallback(async () => {
|
||||||
|
const pending = interrupted;
|
||||||
|
if (!pending) return;
|
||||||
|
await start(pending.options);
|
||||||
|
}, [interrupted, start]);
|
||||||
|
|
||||||
|
const keep = useCallback(async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await commands.confirmMigration(projectId);
|
||||||
|
setReport(null);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: `Could not discard the rollback image for “${project.name}”`,
|
||||||
|
detail: String(e),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [projectId, project.name, refresh, pushToast]);
|
||||||
|
|
||||||
|
const rollback = useCallback(async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await commands.rollbackMigration(projectId);
|
||||||
|
setReport(null);
|
||||||
|
setInterrupted(null);
|
||||||
|
pushToast({
|
||||||
|
kind: "success",
|
||||||
|
message: `“${project.name}” is back on its previous system layer.`,
|
||||||
|
detail:
|
||||||
|
"Volumes were not touched, so anything written to your home directory or workspace during the update is still there.",
|
||||||
|
});
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: `Rollback failed for “${project.name}”`,
|
||||||
|
detail: String(e),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [projectId, project.name, refresh, pushToast]);
|
||||||
|
|
||||||
|
const dismiss = useCallback(() => setReport(null), []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
staleness,
|
||||||
|
probing,
|
||||||
|
running,
|
||||||
|
recovered,
|
||||||
|
interrupted,
|
||||||
|
report,
|
||||||
|
log,
|
||||||
|
phaseMessage: log.length > 0 ? log[log.length - 1] : null,
|
||||||
|
busy,
|
||||||
|
start,
|
||||||
|
resume,
|
||||||
|
keep,
|
||||||
|
rollback,
|
||||||
|
dismiss,
|
||||||
|
refresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
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
|
// Docker
|
||||||
export const checkDocker = () => invoke<boolean>("check_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 cancelClaudeToken = () => invoke<void>("cancel_claude_token");
|
||||||
export const hasClaudeToken = () => invoke<boolean>("has_claude_token");
|
export const hasClaudeToken = () => invoke<boolean>("has_claude_token");
|
||||||
export const clearClaudeToken = () => invoke<void>("clear_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;
|
project_id: string;
|
||||||
chunk: 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,16 @@
|
|||||||
# NOTE: set -e is intentionally omitted. A failing usermod/groupmod must not
|
# NOTE: set -e is intentionally omitted. A failing usermod/groupmod must not
|
||||||
# kill the entire entrypoint — SSH setup, git config, and the final exec
|
# kill the entire entrypoint — SSH setup, git config, and the final exec
|
||||||
# must still run so the container is usable even if remapping fails.
|
# must still run so the container is usable even if remapping fails.
|
||||||
|
#
|
||||||
|
# NOTE: /home/claude is the mount point of the named volume
|
||||||
|
# triple-c-home-{projectId}, so the *image's* copy of that directory is
|
||||||
|
# seed-only: after a project's first start it is masked permanently. Anything
|
||||||
|
# this script writes under /home/claude on **every** start does reach existing
|
||||||
|
# projects (that is why the CLAUDE.md, git config and Mission Control skill
|
||||||
|
# copies are written here rather than baked into the image). Anything added to
|
||||||
|
# /home/claude in the Dockerfile reaches new projects only, forever. Put
|
||||||
|
# upgradable content in /usr/local/bin or /opt, or seed it from here.
|
||||||
|
# See "Container Lifecycle" in the repo's CLAUDE.md.
|
||||||
|
|
||||||
# ── UID/GID remapping ──────────────────────────────────────────────────────
|
# ── UID/GID remapping ──────────────────────────────────────────────────────
|
||||||
# Match the container's claude user to the host user's UID/GID so that
|
# Match the container's claude user to the host user's UID/GID so that
|
||||||
|
|||||||
Reference in New Issue
Block a user