Fix review findings: secrets in snapshots, URL spoofing, migration data loss
Adversarial review of the branch produced findings across four areas. This addresses them, plus the Windows CI environment. Secrets. commit_container_snapshot baked the container's full env into the per-project snapshot image, so the shared OAuth token — and the AWS keys, git token and gateway master key — outlived revocation and were readable via docker inspect. Verified against Engine 29.6 that a commit body's config merges over the container's: keys cannot be dropped but can be overwritten, so all of them now commit as KEY=. clear_claude_token additionally rewrites images from earlier builds and reports honestly when a tag could not be rewritten. The recommendation to move the token out of env entirely was not taken, with reasoning: apiKeyHelper is a different auth method that outranks CLAUDE_CODE_OAUTH_TOKEN rather than a transport for it, and no file-based delivery exists. The durable exposure — the image — is what is closed here. Separately noted, not fixed: entrypoint.sh captures the token into the scheduler's .env inside the persisted volume. URL spoofing. Three call sites reached openUrl with container-controlled strings, one of which the review missed (the WebLinksAddon handler). The sign-in URL was scraped from container output with a longest-match tie-break and no userinfo check, so claude.ai@evil.tld rendered as "claude.ai…" in a truncating element. There is now one sanitizer in front of every sink — scheme allowlist, no userinfo, C0/C1 and quote rejection, host allowlist for the sign-in case, first-match — and the origin renders un-truncated. The toast is keyed so a changed URL remounts, closing a bait-and-switch where the user read one URL and clicked another. Migration. The rollback pin was best-effort: a tag failure was logged and the migration continued past remove_container, after which the final commit overwrote the only copy of the old system layer. It now aborts before anything destructive and reads the tag back. /var was destroyed while the ordinary recreate path preserves it — making the "safe" alternative to Reset more destructive than Reset's alternative; data-bearing subtrees are now detected and disclosed in the pre-flight rather than copied, since tarring a live database onto a different base's packages is a corruption risk. resume_migration now verifies the migration-state label instead of reporting success for a container that never swapped. dismiss actually resolves the record rather than leaving the feature permanently refusing to migrate. Start and Reset are guarded while a migration is live. Lifecycle. The gateway no longer publishes on 0.0.0.0 — bind address and advertised URL are derived together so they cannot drift. Disabling it now stops it. App exit runs teardown concurrently under a budget with a visible shutting-down state instead of blocking for minutes. Auto-starts retry when Docker is not up yet, and the polling-recovery path now reconciles, so interrupted migrations are still recovered. Auth-bridge forwards are capped, closing a container-driven fd exhaustion. Windows CI. build-windows failed on this branch with "linker link.exe not found". The runner had no MSVC build tools and the workflow assumed a hand-provisioned machine, so a bare runner registers, accepts jobs and fails at link time after downloading the whole crate graph. The job now installs the VC++ workload when vswhere cannot find it, matching how it already conditionally installs Rust and Node. 192 Rust tests, 274 frontend tests, both builds clean, zero warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -60,8 +60,8 @@ use crate::docker;
|
||||
use crate::docker::migration::{self as mig, Recovery};
|
||||
use crate::models::{
|
||||
ContainerStaleness, MigrationOptions, MigrationPhase, MigrationPlan, MigrationReport,
|
||||
MigrationState, PackageFailure, Project, ProjectStatus, MIGRATION_PHASE_AWAITING,
|
||||
MIGRATION_PHASE_INTERRUPTED,
|
||||
MigrationState, PackageFailure, Project, ProjectStatus, UnpreservedData,
|
||||
MIGRATION_PHASE_AWAITING, MIGRATION_PHASE_INTERRUPTED,
|
||||
};
|
||||
use crate::storage::migration_store;
|
||||
use crate::AppState;
|
||||
@@ -159,6 +159,20 @@ pub async fn get_container_staleness(
|
||||
&mig::bind_mount_exclusions(&project.paths),
|
||||
);
|
||||
out.outdated_package_count = mig::outdated_package_count(&from_manifest, &base_manifest);
|
||||
// Reported, never copied, and never silent — see `unpreserved_data`.
|
||||
out.unpreserved_data = mig::unpreserved_data(&from_manifest, &base_manifest);
|
||||
if !out.unpreserved_data.is_empty() {
|
||||
log::warn!(
|
||||
"Project {}: {} data-bearing subtree(s) under /var would not survive a migration: {}",
|
||||
project_id,
|
||||
out.unpreserved_data.len(),
|
||||
out.unpreserved_data
|
||||
.iter()
|
||||
.map(|d| d.path.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
let (etc_only_container, etc_only_base) = mig::etc_deltas(&from_manifest, &base_manifest);
|
||||
if !etc_only_container.is_empty() || !etc_only_base.is_empty() {
|
||||
@@ -207,7 +221,12 @@ fn active_migrations() -> &'static std::sync::Mutex<std::collections::HashSet<St
|
||||
ACTIVE_MIGRATIONS.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
|
||||
}
|
||||
|
||||
fn is_migrating(project_id: &str) -> bool {
|
||||
/// Whether a migration for this project is running **in this process right
|
||||
/// now**. Every command that stops, removes or recreates the project's
|
||||
/// container has to consult it: the window between `remove_container` and the
|
||||
/// create that follows looks exactly like "no container", and an ordinary
|
||||
/// Start landing in it creates a second container under the same name.
|
||||
pub(crate) fn is_migrating(project_id: &str) -> bool {
|
||||
active_migrations()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
@@ -335,6 +354,22 @@ async fn fresh_migration(
|
||||
let npm_delta = mig::set_delta(&from_manifest.npm_global, &base_manifest.npm_global);
|
||||
let (missing_paths, _) = mig::missing_features(&from_manifest, &base_manifest);
|
||||
let payload_bytes = mig::verbatim_payload_bytes(&from_manifest, &verbatim);
|
||||
// Measured here as well as in the pre-flight probe, because this is the one
|
||||
// reading taken against the container that is about to be destroyed. It is
|
||||
// frozen into the plan so the finished report can still name it.
|
||||
let unpreserved = mig::unpreserved_data(&from_manifest, &base_manifest);
|
||||
if !unpreserved.is_empty() {
|
||||
log::warn!(
|
||||
"Project {}: migrating destroys {} data-bearing subtree(s) under /var: {}",
|
||||
project_id,
|
||||
unpreserved.len(),
|
||||
unpreserved
|
||||
.iter()
|
||||
.map(|d| format!("{} ({} files)", d.path, d.file_count))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
emit_progress(&app_handle, &project_id, "Checking network and disk...");
|
||||
let env = mig::preflight_environment(&base_image).await?;
|
||||
@@ -367,6 +402,7 @@ async fn fresh_migration(
|
||||
npm_packages: npm_delta.clone(),
|
||||
verbatim_paths: verbatim.clone(),
|
||||
missing_paths,
|
||||
unpreserved_data: unpreserved,
|
||||
});
|
||||
migration_store::save(&project_id, &mstate)?;
|
||||
|
||||
@@ -408,22 +444,63 @@ async fn fresh_migration(
|
||||
let _ = docker::stop_container(&container_id).await;
|
||||
|
||||
emit_progress(&app_handle, &project_id, "Saving the current image...");
|
||||
docker::commit_container_snapshot(&container_id, &project).await?;
|
||||
if let Err(e) = docker::commit_container_snapshot(&container_id, &project).await {
|
||||
// The container is stopped but intact and `:latest` is untouched, so
|
||||
// the only repair needed is to stop claiming a migration is in flight.
|
||||
abandon_before_swap(&project_id, &state, None).await;
|
||||
return Ok(MigrationReport::failed_preflight(format!(
|
||||
"Could not save the container's current image: {}. Nothing was changed — the container is still on its previous image and can be started as usual.",
|
||||
e
|
||||
)));
|
||||
}
|
||||
|
||||
// The rollback pin. `docker tag` of a 5.49 GB image was measured at 0.036 s
|
||||
// and 0 bytes, so this is free to take and only costs disk if it is kept.
|
||||
//
|
||||
// It is *not* best-effort. The commit above is now the only copy of the old
|
||||
// system layer, the next step removes the container, and `finish_migration`
|
||||
// repoints `:latest` at the new lineage — so a migration that carries on
|
||||
// without a usable pin has quietly made itself irreversible while the UI
|
||||
// goes on offering "Roll back". Tag, read it back, and abort while aborting
|
||||
// still costs nothing.
|
||||
emit_progress(&app_handle, &project_id, "Pinning a rollback image...");
|
||||
let (repo, _) = mig::split_image_ref(&snapshot_image);
|
||||
let tag = mig::rollback_tag(&chrono::Utc::now());
|
||||
let rollback_ref = format!("{}:{}", repo, tag);
|
||||
if let Err(e) = mig::tag_image(&snapshot_image, &repo, &tag).await {
|
||||
log::warn!("Could not create the rollback tag: {} — continuing", e);
|
||||
let tag_result = mig::tag_image(&snapshot_image, &repo, &tag).await;
|
||||
let resolved = if tag_result.is_ok() {
|
||||
mig::image_id(&rollback_ref).await.unwrap_or(None)
|
||||
} else {
|
||||
mstate.rollback_image = Some(rollback_ref);
|
||||
migration_store::save(&project_id, &mstate)?;
|
||||
None
|
||||
};
|
||||
if let Err(why) = rollback_pin_verdict(
|
||||
tag_result.as_ref().err().map(|e| e.as_str()),
|
||||
resolved.as_deref(),
|
||||
) {
|
||||
abandon_before_swap(&project_id, &state, Some(&rollback_ref)).await;
|
||||
return Ok(MigrationReport::failed_preflight(why));
|
||||
}
|
||||
mstate.rollback_image = Some(rollback_ref.clone());
|
||||
if let Err(e) = migration_store::save(&project_id, &mstate) {
|
||||
// A pin nothing has recorded is a pin no recovery path can find, which
|
||||
// is the same hole as not having one. Still pre-swap, so stop here.
|
||||
abandon_before_swap(&project_id, &state, Some(&rollback_ref)).await;
|
||||
return Ok(MigrationReport::failed_preflight(format!(
|
||||
"Could not record the rollback image before replacing the container: {}. Nothing was changed — the container is still on its previous image.",
|
||||
e
|
||||
)));
|
||||
}
|
||||
|
||||
emit_progress(&app_handle, &project_id, "Recreating on the new base image...");
|
||||
docker::remove_container(&container_id).await?;
|
||||
if let Err(e) = docker::remove_container(&container_id).await {
|
||||
// Still pre-swap: the old container is the one in place and `:latest`
|
||||
// still holds its lineage.
|
||||
abandon_before_swap(&project_id, &state, Some(&rollback_ref)).await;
|
||||
return Ok(MigrationReport::failed_preflight(format!(
|
||||
"Could not remove the old container: {}. Nothing was changed — the container is still on its previous image and can be started as usual.",
|
||||
e
|
||||
)));
|
||||
}
|
||||
|
||||
let docker_socket = settings
|
||||
.docker_socket_path
|
||||
@@ -487,9 +564,63 @@ async fn resume_migration(
|
||||
));
|
||||
}
|
||||
};
|
||||
// The container being *there* is not evidence that the swap happened. If
|
||||
// `commit_container_snapshot` failed, the old, unmigrated container is
|
||||
// still in place — and resuming into it would replay the deltas onto the
|
||||
// container that already had them, commit it as the migrated image and
|
||||
// report success. `reconcile_migration` has always checked this label; the
|
||||
// path the Migrate button reaches did not.
|
||||
let labelled = container_label(&container_id, mig::LABEL_MIGRATION_STATE)
|
||||
.await
|
||||
.as_deref()
|
||||
== Some(mig::MIGRATION_LABEL_IN_PROGRESS);
|
||||
match resume_verdict(&mstate.phase, labelled) {
|
||||
ResumeVerdict::Proceed => {}
|
||||
ResumeVerdict::RefuseUnswapped => {
|
||||
// Exactly the `SelfHeal` case `reconcile_migration` handles, so
|
||||
// handle it the same way: the record describes work that never
|
||||
// landed, `:latest` still holds the old lineage, and clearing it
|
||||
// turns a dead end into "just run the update again".
|
||||
if let Some(ref reference) = mstate.rollback_image {
|
||||
let _ = mig::untag_image(reference).await;
|
||||
}
|
||||
let _ = migration_store::clear_staging(&project_id);
|
||||
let _ = migration_store::clear(&project_id);
|
||||
return Ok(MigrationReport::failed_preflight(
|
||||
"This project's container is still the original one — the interrupted update never got as far as replacing it, so there was nothing to resume. Nothing was changed and the record has been cleared: start the project as usual, or run the update again from the beginning.",
|
||||
));
|
||||
}
|
||||
ResumeVerdict::RefuseNotResumable => {
|
||||
return Ok(MigrationReport::failed_preflight(format!(
|
||||
"This project's migration record is in the '{}' state, which cannot be resumed. Confirm it or roll it back first.",
|
||||
mstate.phase
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
if !docker::is_container_running(&container_id).await.unwrap_or(false) {
|
||||
emit_progress(&app_handle, &project_id, "Starting the migrated container...");
|
||||
docker::start_container(&container_id).await?;
|
||||
if let Err(e) = docker::start_container(&container_id).await {
|
||||
// Leave the record alone — the container is still mid-swap and the
|
||||
// user's choices are unchanged — but do not leave the project
|
||||
// parked at a transitional status nothing will ever revisit.
|
||||
let _ = state
|
||||
.projects_store
|
||||
.update_status(&project_id, ProjectStatus::Stopped);
|
||||
return Ok(MigrationReport {
|
||||
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: mstate.rollback_image.is_some(),
|
||||
message: format!(
|
||||
"The half-migrated container would not start ({}), so the update could not be resumed. Nothing was lost — your home directory and Claude config live in volumes that were never touched. Try again, or roll back.",
|
||||
e
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
emit_progress(&app_handle, &project_id, "Resuming the interrupted migration...");
|
||||
finish_migration(project, mstate, container_id, app_handle, state).await
|
||||
@@ -592,6 +723,12 @@ async fn finish_migration(
|
||||
};
|
||||
mstate.report = Some(report.clone());
|
||||
let _ = migration_store::save(&project_id, &mstate);
|
||||
// The container really is up, so say so. Left at `Stopping` this
|
||||
// project would never be re-examined: `reconcile_project_statuses`
|
||||
// only looks at `Running` and `Error`.
|
||||
let _ = state
|
||||
.projects_store
|
||||
.update_status(&project_id, ProjectStatus::Running);
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
@@ -630,6 +767,7 @@ async fn finish_migration(
|
||||
&features_restored,
|
||||
¬es,
|
||||
rollback_available,
|
||||
&plan.unpreserved_data,
|
||||
),
|
||||
phase,
|
||||
packages_requested,
|
||||
@@ -642,11 +780,13 @@ async fn finish_migration(
|
||||
|
||||
mstate.phase = MIGRATION_PHASE_AWAITING.to_string();
|
||||
mstate.report = Some(report.clone());
|
||||
migration_store::save(&project_id, &mstate)?;
|
||||
|
||||
// Before the save, not after: a failed save must not leave the project
|
||||
// parked at `Stopping` with a container that is plainly running.
|
||||
let _ = state
|
||||
.projects_store
|
||||
.update_status(&project_id, ProjectStatus::Running);
|
||||
migration_store::save(&project_id, &mstate)?;
|
||||
|
||||
emit_progress(&app_handle, &project_id, "Migration finished.");
|
||||
Ok(report)
|
||||
}
|
||||
@@ -663,9 +803,28 @@ pub async fn confirm_migration(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let _ = &state;
|
||||
// Confirming drops the only way back. Doing that underneath a running
|
||||
// migration would delete the pin it is relying on mid-flight.
|
||||
let Some(_guard) = ActiveGuard::acquire(&project_id) else {
|
||||
return Err(
|
||||
"A container base update is running for this project right now. Wait for it to finish."
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
let Some(mstate) = migration_store::load(&project_id)? else {
|
||||
return Ok(());
|
||||
};
|
||||
// An unfinished migration is not a thing that can be accepted: `:latest`
|
||||
// still points at the old lineage, so dropping the pin and the record here
|
||||
// would strand a container the app can no longer reason about.
|
||||
if mstate.phase == MIGRATION_PHASE_INTERRUPTED
|
||||
|| mstate.phase == crate::models::MIGRATION_PHASE_IN_PROGRESS
|
||||
{
|
||||
return Err(
|
||||
"This project's container base update never finished, so there is nothing to accept yet. Resume it or roll it back."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if let Some(ref reference) = mstate.rollback_image {
|
||||
if let Err(e) = mig::untag_image(reference).await {
|
||||
log::warn!("Could not drop the rollback tag {}: {}", reference, e);
|
||||
@@ -688,6 +847,13 @@ pub async fn rollback_migration(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let Some(_guard) = ActiveGuard::acquire(&project_id) else {
|
||||
return Err(
|
||||
"A container base update is running for this project right now. Wait for it to finish."
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
|
||||
let mut project = state
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
@@ -704,6 +870,16 @@ pub async fn rollback_migration(
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
// Check the image is really there *before* removing the container. The
|
||||
// record only says a tag was created; a prune, a `docker rmi` or a failed
|
||||
// tag from an older build can all leave the reference dangling, and by the
|
||||
// time the container is gone there is nothing left to put back.
|
||||
if mig::image_id(&rollback_ref).await?.is_none() {
|
||||
return Err(format!(
|
||||
"The rollback image '{}' no longer exists, so this update cannot be undone — it may have been pruned. Nothing was changed: the container is still running on the new base.",
|
||||
rollback_ref
|
||||
));
|
||||
}
|
||||
|
||||
emit_progress(&app_handle, &project_id, "Rolling back...");
|
||||
state.auth_bridge.stop(&project_id).await;
|
||||
@@ -842,6 +1018,112 @@ pub async fn reconcile_migration(project: &Project, app_handle: &tauri::AppHandl
|
||||
// Internals
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Whether the rollback pin can be relied on for the rest of the migration.
|
||||
///
|
||||
/// Pure so the decision is testable without Docker. Both failure shapes matter:
|
||||
/// `docker tag` can fail outright, and it can also "succeed" against an image
|
||||
/// that is not there any more (a concurrent prune), in which case reading the
|
||||
/// new reference back is the only thing that catches it. Either way the answer
|
||||
/// is to stop *before* `remove_container`, which is the last moment at which
|
||||
/// stopping is free.
|
||||
fn rollback_pin_verdict(
|
||||
tag_error: Option<&str>,
|
||||
resolved_image_id: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
if let Some(e) = tag_error {
|
||||
return Err(format!(
|
||||
"Could not pin a rollback image, so the update was stopped before it could become irreversible: {}. Nothing was changed — the container is still on its previous image. Free some disk space or check the Docker daemon, then try again.",
|
||||
e
|
||||
));
|
||||
}
|
||||
match resolved_image_id {
|
||||
Some(id) if !id.is_empty() => Ok(()),
|
||||
_ => Err(
|
||||
"The rollback image was tagged but could not be read back, so rolling this update back could not be guaranteed. The update was stopped before it could become irreversible — nothing was changed, and the container is still on its previous image."
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Undo the *bookkeeping* of a migration that gave up before the container
|
||||
/// swap. Everything real is still in place: the container exists, `:latest`
|
||||
/// still holds the old lineage, and the volumes were never involved.
|
||||
///
|
||||
/// The status reset is the load-bearing part. `reconcile_project_statuses`
|
||||
/// only ever re-examines projects it finds in `Running` or `Error`, so a
|
||||
/// project abandoned at `Stopping` stays there for good — the Start button
|
||||
/// stays disabled and nothing in the app ever puts it right.
|
||||
async fn abandon_before_swap(
|
||||
project_id: &str,
|
||||
state: &State<'_, AppState>,
|
||||
rollback_ref: Option<&str>,
|
||||
) {
|
||||
if let Some(reference) = rollback_ref {
|
||||
let _ = mig::untag_image(reference).await;
|
||||
}
|
||||
let _ = migration_store::clear_staging(project_id);
|
||||
let _ = migration_store::clear(project_id);
|
||||
let _ = state
|
||||
.projects_store
|
||||
.update_status(project_id, ProjectStatus::Stopped);
|
||||
}
|
||||
|
||||
/// What a user-initiated resume is allowed to do.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ResumeVerdict {
|
||||
/// The swap landed — the container in place is the migrated one.
|
||||
Proceed,
|
||||
/// The container in place is the **original**. The swap never happened
|
||||
/// (typically `commit_container_snapshot` failed), so replaying into it and
|
||||
/// committing the result would silently stamp the unmigrated container as
|
||||
/// migrated and report success.
|
||||
RefuseUnswapped,
|
||||
/// The record is not one a resume can act on at all.
|
||||
RefuseNotResumable,
|
||||
}
|
||||
|
||||
/// Gate `resume_migration` on the same two signals `reconcile_migration` uses.
|
||||
///
|
||||
/// `reconcile_migration` has always checked the container's
|
||||
/// `triple-c.migration-state` label; the frontend-callable resume path did not,
|
||||
/// and that asymmetry is the bug: clicking Migrate on a record left behind by a
|
||||
/// failed commit "resumed" into the old container.
|
||||
fn resume_verdict(phase: &str, container_has_in_progress_label: bool) -> ResumeVerdict {
|
||||
match mig::decide_recovery(Some(phase), container_has_in_progress_label) {
|
||||
Recovery::OfferResumeOrRollback => ResumeVerdict::Proceed,
|
||||
Recovery::SelfHeal => ResumeVerdict::RefuseUnswapped,
|
||||
Recovery::None | Recovery::OfferConfirmOrRollback => ResumeVerdict::RefuseNotResumable,
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop every host- and Docker-side trace of a migration for a project that is
|
||||
/// being destroyed or reset.
|
||||
///
|
||||
/// Reset and Remove both delete the snapshot image and the volumes, so a
|
||||
/// surviving migration record can only describe things that no longer exist —
|
||||
/// and its `:pre-migration-<ts>` tag would hold an entire snapshot image (3.8
|
||||
/// to 12.3 GB measured) alive with nothing left that could ever use it. The
|
||||
/// staged payload tar is the same story at a smaller scale.
|
||||
pub(crate) async fn purge_migration_artifacts(project_id: &str) {
|
||||
match migration_store::load(project_id) {
|
||||
Ok(Some(state)) => {
|
||||
if let Some(ref reference) = state.rollback_image {
|
||||
if let Err(e) = mig::untag_image(reference).await {
|
||||
log::warn!("Could not drop the rollback tag {}: {}", reference, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => return,
|
||||
Err(e) => log::warn!(
|
||||
"Could not read the migration record for {} while cleaning up: {}",
|
||||
project_id,
|
||||
e
|
||||
),
|
||||
}
|
||||
let _ = migration_store::clear_staging(project_id);
|
||||
let _ = migration_store::clear(project_id);
|
||||
}
|
||||
|
||||
fn default_docker_socket() -> String {
|
||||
if cfg!(target_os = "windows") {
|
||||
"//./pipe/docker_engine".to_string()
|
||||
@@ -865,6 +1147,23 @@ fn human_bytes(n: u64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create (or truncate) a file that only the current user can read.
|
||||
///
|
||||
/// On Unix the mode goes on at `open(2)` time so there is no window in which
|
||||
/// the file exists world-readable. Windows has no equivalent bit and inherits
|
||||
/// the directory ACL, which is already per-user under `%APPDATA%`.
|
||||
async fn create_private_file(path: &std::path::Path) -> std::io::Result<tokio::fs::File> {
|
||||
let mut opts = tokio::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// `tokio::fs::OpenOptions::mode` is the inherent unix method — no
|
||||
// `OpenOptionsExt` import needed, and it is applied at `open(2)` time.
|
||||
opts.mode(0o600);
|
||||
}
|
||||
opts.open(path).await
|
||||
}
|
||||
|
||||
/// Tar the verbatim set out of the running container into a host file.
|
||||
///
|
||||
/// Mirrors `download_container_backup`'s stream-an-exec's-stdout-to-a-file
|
||||
@@ -913,7 +1212,12 @@ async fn stage_payload(
|
||||
let exec = docker::exec::create_attached_exec_as(container_id, cmd, false, "root", "/").await?;
|
||||
let mut output = exec.output;
|
||||
|
||||
let file = tokio::fs::File::create(&host_path)
|
||||
// 0600, not the default umask. This tar holds the whole of `/usr/local`,
|
||||
// `/opt`, `/srv` and every loose `/workspace` file — private keys, tokens
|
||||
// in scripts, whatever the user put there — sitting in a predictable path
|
||||
// under the data directory, possibly for the length of a long migration.
|
||||
// World-readable is the wrong default for that.
|
||||
let file = create_private_file(&host_path)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create the staging file: {}", e))?;
|
||||
let mut writer = tokio::io::BufWriter::new(file);
|
||||
@@ -1168,9 +1472,23 @@ fn shell_quote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', r#"'\''"#))
|
||||
}
|
||||
|
||||
/// The last ~400 bytes of a package manager's output, for a failure reason.
|
||||
///
|
||||
/// The offset is a **byte** offset into arbitrary apt-get/npm output, which
|
||||
/// routinely contains multi-byte UTF-8 (mirror names, `‘quoted’` package names,
|
||||
/// progress glyphs). Slicing straight at `len - 400` panics the moment that
|
||||
/// lands inside a character — turning a reportable per-package failure into a
|
||||
/// crash of the whole migration. Walk forward to the next boundary instead.
|
||||
fn tail(s: &str) -> String {
|
||||
const LIMIT: usize = 400;
|
||||
let t = s.trim();
|
||||
let start = t.len().saturating_sub(400);
|
||||
if t.len() <= LIMIT {
|
||||
return t.to_string();
|
||||
}
|
||||
let mut start = t.len() - LIMIT;
|
||||
while start < t.len() && !t.is_char_boundary(start) {
|
||||
start += 1;
|
||||
}
|
||||
t[start..].to_string()
|
||||
}
|
||||
|
||||
@@ -1221,11 +1539,27 @@ async fn auto_rollback(
|
||||
let _ = state
|
||||
.projects_store
|
||||
.set_container_id(&project.id, Some(id));
|
||||
// Report what is actually true: a container that was recreated but
|
||||
// would not start is Stopped, not Running.
|
||||
let _ = state.projects_store.update_status(
|
||||
&project.id,
|
||||
if restored {
|
||||
ProjectStatus::Running
|
||||
} else {
|
||||
ProjectStatus::Stopped
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Could not recreate the previous container: {}", e);
|
||||
// The status is `Stopping` at this point and nothing else will
|
||||
// revisit it — `reconcile_project_statuses` only re-examines
|
||||
// `Running` and `Error`. There is no container, so `Stopped` is
|
||||
// both true and the state the Start button needs.
|
||||
let _ = state
|
||||
.projects_store
|
||||
.update_status(&project.id, ProjectStatus::Running);
|
||||
.update_status(&project.id, ProjectStatus::Stopped);
|
||||
}
|
||||
Err(e) => log::error!("Could not recreate the previous container: {}", e),
|
||||
}
|
||||
|
||||
if let Some(ref reference) = mstate.rollback_image {
|
||||
@@ -1264,6 +1598,7 @@ fn summarize(
|
||||
features: &[String],
|
||||
notes: &[String],
|
||||
rollback_available: bool,
|
||||
unpreserved: &[UnpreservedData],
|
||||
) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
parts.push(match phase {
|
||||
@@ -1291,6 +1626,25 @@ fn summarize(
|
||||
for note in notes {
|
||||
parts.push(note.clone());
|
||||
}
|
||||
// Stated in the outcome as well as the pre-flight. A user who clicked
|
||||
// through the warning still has to be told, in the record that persists,
|
||||
// which directories are now empty — silence here is how someone discovers
|
||||
// an empty database a week later.
|
||||
if !unpreserved.is_empty() {
|
||||
parts.push(format!(
|
||||
"Service data under {} was not carried across and cannot be restored by reinstalling the package{}.",
|
||||
unpreserved
|
||||
.iter()
|
||||
.map(|d| d.path.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
if rollback_available {
|
||||
" — roll back if you needed it"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
));
|
||||
}
|
||||
parts.push(
|
||||
"Your home directory and Claude config live in volumes that were never touched, so the login, skills, transcripts and scheduled tasks are exactly as they were."
|
||||
.to_string(),
|
||||
@@ -1330,6 +1684,7 @@ mod tests {
|
||||
&["Auth bridge tunnel (socat)".to_string()],
|
||||
&[],
|
||||
true,
|
||||
&[],
|
||||
);
|
||||
assert!(msg.contains("never touched"));
|
||||
assert!(msg.contains("Auth bridge tunnel (socat)"));
|
||||
@@ -1346,12 +1701,115 @@ mod tests {
|
||||
&[],
|
||||
&[],
|
||||
false,
|
||||
&[],
|
||||
);
|
||||
assert!(msg.contains("obsolete-pkg"));
|
||||
assert!(msg.contains("cannot be undone"));
|
||||
assert!(msg.contains("never touched"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_summary_names_the_var_data_the_migration_destroyed() {
|
||||
// The pre-flight warned about it; the record that persists has to as
|
||||
// well, or the only trace of an emptied database is a dialog the user
|
||||
// clicked through minutes ago.
|
||||
let msg = summarize(
|
||||
MigrationPhase::Succeeded,
|
||||
&["postgresql".to_string()],
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
true,
|
||||
&[UnpreservedData {
|
||||
path: "/var/lib/postgresql".to_string(),
|
||||
bytes: 41_000_000,
|
||||
file_count: 912,
|
||||
}],
|
||||
);
|
||||
assert!(msg.contains("/var/lib/postgresql"));
|
||||
assert!(msg.contains("cannot be restored by reinstalling the package"));
|
||||
}
|
||||
|
||||
// ── The rollback pin is not best-effort ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn a_migration_refuses_to_continue_without_a_verified_rollback_pin() {
|
||||
// By this point the commit is the only copy of the old system layer and
|
||||
// `remove_container` is next, so anything short of a tag that reads
|
||||
// back has to stop the migration rather than warn and carry on.
|
||||
assert!(rollback_pin_verdict(None, Some("sha256:abc")).is_ok());
|
||||
|
||||
let err = rollback_pin_verdict(Some("no space left on device"), None).unwrap_err();
|
||||
assert!(err.contains("no space left on device"));
|
||||
assert!(err.contains("Nothing was changed"));
|
||||
|
||||
// Tagged, but the reference does not resolve — a concurrent prune, or a
|
||||
// daemon that accepted the call and did nothing.
|
||||
let err = rollback_pin_verdict(None, None).unwrap_err();
|
||||
assert!(err.contains("could not be read back"));
|
||||
assert!(err.contains("nothing was changed"));
|
||||
assert!(rollback_pin_verdict(None, Some("")).is_err());
|
||||
}
|
||||
|
||||
// ── Resume must prove the swap actually happened ────────────────────────
|
||||
|
||||
#[test]
|
||||
fn resuming_refuses_when_the_container_is_still_the_unmigrated_one() {
|
||||
use crate::models::MIGRATION_PHASE_IN_PROGRESS;
|
||||
|
||||
// The label is the only evidence the swap landed. Without it the
|
||||
// container in place is the original: replaying into it and committing
|
||||
// would stamp an unmigrated container as migrated and report success.
|
||||
assert_eq!(
|
||||
resume_verdict(MIGRATION_PHASE_INTERRUPTED, false),
|
||||
ResumeVerdict::RefuseUnswapped
|
||||
);
|
||||
assert_eq!(
|
||||
resume_verdict(MIGRATION_PHASE_IN_PROGRESS, false),
|
||||
ResumeVerdict::RefuseUnswapped
|
||||
);
|
||||
assert_eq!(
|
||||
resume_verdict(MIGRATION_PHASE_INTERRUPTED, true),
|
||||
ResumeVerdict::Proceed
|
||||
);
|
||||
assert_eq!(
|
||||
resume_verdict(MIGRATION_PHASE_IN_PROGRESS, true),
|
||||
ResumeVerdict::Proceed
|
||||
);
|
||||
// A finished migration is a confirm/rollback decision, never a resume.
|
||||
assert_eq!(
|
||||
resume_verdict(MIGRATION_PHASE_AWAITING, true),
|
||||
ResumeVerdict::RefuseNotResumable
|
||||
);
|
||||
assert_eq!(
|
||||
resume_verdict("who-knows", true),
|
||||
ResumeVerdict::RefuseNotResumable
|
||||
);
|
||||
}
|
||||
|
||||
// ── tail() ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn the_failure_tail_never_splits_a_multibyte_character() {
|
||||
// apt-get quotes package names with ‘…’ and mirrors have non-ASCII
|
||||
// names, so the 400-byte cut lands mid-character routinely. The old
|
||||
// slice panicked, turning one reportable package failure into a crash
|
||||
// of the whole migration.
|
||||
for pad in 0..8 {
|
||||
let s = format!("{}{}", "x".repeat(pad), "é".repeat(400));
|
||||
let t = tail(&s);
|
||||
assert!(s.ends_with(&t), "tail must be a suffix of the input");
|
||||
assert!(t.len() <= 400 + 1);
|
||||
}
|
||||
// Short input is returned whole, trimmed.
|
||||
assert_eq!(tail(" apt-get exited 100 "), "apt-get exited 100");
|
||||
assert_eq!(tail(""), "");
|
||||
// A single character wider than the window is still returned intact.
|
||||
let wide = "🐳".repeat(200);
|
||||
assert!(wide.ends_with(&tail(&wide)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_migration_is_distinguishable_from_a_crashed_one() {
|
||||
// The whole point: reconcile cannot tell them apart from the outside,
|
||||
|
||||
Reference in New Issue
Block a user