Files
Triple-C/app/src-tauri/src/docker/migration.rs
T
shadow-testandClaude Opus 5 dd2894cc60 Stop Docker disk growth, and fix the Claude Code settings that never worked
Two independent sets of fixes.

## Disk: stop the growth, no UI this round

The dangling-snapshot sweep was already correct and was never the leak. The
leak is that every `docker commit` **stacks** a layer and nothing compacts one:
a file deleted after it has been committed becomes a whiteout, not free bytes.
24 conditions trigger recreation+commit, so changing one settings field costs a
multi-gigabyte layer for the life of the project. One project was measured with
14 stacked commit layers, ~5.1 GB above its base.

* **Scrub the writable layer before every commit** (`docker/container.rs`,
  `SNAPSHOT_SCRUB_PATHS` / `scrub_writable_layer`). The one moment those bytes
  are still free to drop is before the commit that captures them. Measured on
  one container's 4.48 GB pending layer: 3.0 GB of agent scratchpad under
  `/tmp/claude-*`, the terminal drag-drop staging area (256 MiB per file, with
  no `rm` for it anywhere in the repo), a PNG per pasted image, and the apt
  lists/cache/logs that `browser_view/install.rs` and `triple-c-playwright-heal`
  leave behind with no `apt-get clean`. A hardcoded list, never a heuristic:
  `/workspace/{mount_name}` is a host bind mount and nothing here may reach one,
  and the three `/tmp` globs cannot select the read-only `.host-ca`/`.host-aws`
  mounts. Failure is a log line — a scrub must never block a snapshot.

* **Cap container logs** (`capped_log_config`). There was no `LogConfig`
  anywhere, so containers ran on the daemon's unbounded `json-file` default.
  Deliberately *not* wired into `container_needs_recreation`: participating
  would recreate every project once, and a recreation costs a commit, which is
  the thing being fixed. Picked up on the next natural recreation.

* **Make superseded base images sweepable** (`container/Dockerfile`). It carried
  no `LABEL` at all, so `orphan_sweep_filters`' `dangling` + `triple-c.managed`
  pair provably could not match one — ~11.9 GB observed stranded. Stamping
  `triple-c.managed=true` is the whole fix; the sweep needed no change.
  `create_container` writes the new `triple-c.base` key explicitly empty, or
  Docker's label inheritance plus `docker commit` would make every snapshot
  claim to be a base image. `force: false` stays, and now says why.

* **Sweep at startup** (`lib.rs`), not only after recreation: probes first
  (a probe pins an image the unforced sweep then refuses), pins second, sweep
  last. `sweep_orphaned_snapshots_logged` exists because all three callers threw
  the report away — `reclaimed_bytes`, `failed` and `unavailable` included.

* **Reap migration leftovers.** `rollback_migration` retagged and orphaned the
  migrated snapshot with no sweep. Stale `pre-migration-*` pins are now
  age-reaped by scanning the tag pattern rather than trusting the state file —
  `migration_store::load` reports an unparseable record as absent, which
  stranded a 4-12 GB pin nothing could name again; `load` now moves a corrupt
  record aside so `has_record` is trustworthy. A pin whose migration is still
  awaiting confirmation is never reaped at any age. The probe container's
  removal was a plain statement after an await, so a dropped future (an app quit
  mid-migration) leaked a container pinning a multi-gigabyte image; it is a
  `Drop` guard now, with `reap_probe_containers` for the case where the process
  itself dies.

* **Prune scheduler logs.** `remove` deleted a task's JSON but never its log
  directory, and the task runner appended uncapped `claude -p` output.

* **Fix the delete copy.** It said "the container, config volume, and stored
  credentials"; it removes *both* volumes and the snapshot image.

No prune UI, and no unfiltered `prune_images`/`prune_volumes` anywhere — the
daemon is shared with the user's unrelated work.

## Claude Code settings: two invented keys, one inverted default, one sticky bug

Verified against code.claude.com/docs/en/settings-reference.md and env-vars.md.

* `effort` -> **`effortLevel`**, the key Claude Code actually reads; the old one
  was written and silently ignored. `xhigh` added to the dropdown.
* `focusMode` -> **`viewMode: "focus"`**. `focusMode` was invented. The real key
  does exactly what the existing UI hint already described.
* **Session recap was inverted.** Claude Code's recap is on by default, so
  `CLAUDE_CODE_ENABLE_AWAY_SUMMARY=1`-when-enabled was a no-op and the control
  could never turn the recap *off*. The field is renamed to
  `session_recap_disabled` rather than reused: reusing the name with the
  opposite meaning would have read every stored `enable_session_recap: false` —
  which is every project that never touched the control — as "the user turned
  this off".
* **The stickiness, which is the important one.** Keys were emitted only when
  non-default, and the entrypoint *merges* into a settings.json on a persisted
  volume, so switching a setting off omitted its key, the merge preserved the
  stale on-value, and the setting stayed on until a destructive Reset. The fix
  already existed in the same file — the sandbox block is emitted
  unconditionally for exactly this reason — and is now applied to all five keys.
  A key whose neutral state is *unset* (`tui`, `effortLevel`, `viewMode`,
  `awaySummaryEnabled`) is emitted as JSON `null` and the entrypoint deletes it,
  because a stand-in value is not neutral: `tui: "default"` pins the classic
  renderer where unset lets Claude Code choose, and `viewMode: "default"`
  overrides the user's own sticky `/focus` choice.
* The same stickiness existed, unnoticed, in the **env vars**: `docker commit`
  bakes container env into the snapshot image, so a `=1` written once rode it
  forever. All four are now emitted on every create, extracted into
  `claude_code_env_vars` and unit tested. Two use an empty value for "off"
  rather than `0`, because they outrank a setting the user can change from
  inside their own container and Triple-C's default must not overrule a
  `/config` choice it never asked about.
* TUI mode is now a genuine three-way choice (automatic / classic / fullscreen),
  which the always-emitted key makes both necessary and possible.

`merge_claude_code_settings` is untouched by choice: a project-level OFF still
cannot override a globally-ON setting.

Tests: 364 frontend (+5), 308 Rust (+23), covering the scrub path list and
script, log rotation, pin reaping, and that toggling a setting off actually
clears a previously-set ON value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
2026-08-23 08:35:10 -07:00

1944 lines
75 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Container **base-image migration** — the Docker-level machinery.
//!
//! The orchestration (which Tauri command does what, in which order) lives in
//! [`crate::commands::migration_commands`]. This module holds the two things
//! that benefit from being separate: the *pure* delta computation, which is
//! fully unit-tested below, and the small set of Docker operations migration
//! needs that nothing else in the app does.
//!
//! # Why this is a diff of two image manifests and not `docker diff`
//!
//! `docker diff` reports changes since the container's **last commit**. Every
//! Triple-C project container is created from its own snapshot image and
//! re-committed on each recreation, so `docker diff` on one reports only what
//! happened since the most recent commit — measured on a real project: 2,533
//! entries, almost all of them `/tmp` churn, and none of the actual
//! divergence from the base. It is the wrong tool here and is not used.
//!
//! # Why the diff is filtered through dpkg ownership
//!
//! Raw path diffing lies. On a real project, 11,088 paths differed between the
//! snapshot and the current base and approximately **zero** were user-authored:
//! the rest were the base's *own* AWS CLI and pnpm trees at different versions.
//! Two filters make the set honest:
//!
//! 1. **dpkg ownership** — anything listed in `/var/lib/dpkg/info/*.list` in
//! either image belongs to a package, not to the user.
//! 2. **presence in the new base** — if the current base already ships a path,
//! the base's copy wins by definition (that is the point of migrating), so
//! it is never carried across. This is also what makes the extraction's
//! never-clobber guarantee cheap: the payload does not even contain the
//! conflicting files.
//!
//! `pip3 list` is likewise a liar on Ubuntu — its apparent extras are
//! `dist-packages` installed by apt — so Python packages are covered by the apt
//! delta rather than by a pip diff.
use std::collections::{BTreeMap, BTreeSet, HashMap};
use bollard::container::{
Config, CreateContainerOptions, LogOutput, LogsOptions, RemoveContainerOptions,
StartContainerOptions, WaitContainerOptions,
};
use bollard::image::TagImageOptions;
use bollard::models::HostConfig;
use futures_util::StreamExt;
use super::client::get_docker;
use crate::models::{ProjectPath, UnpreservedData};
// ─────────────────────────────────────────────────────────────────────────────
// Policy constants
// ─────────────────────────────────────────────────────────────────────────────
/// Roots whose *non-package, not-in-the-base* contents are carried across
/// verbatim.
///
/// `/usr/local` is narrowed to the four directories that hold executables and
/// data rather than configuration, per the migration design. `/workspace` is
/// here because loose files at the workspace root live in the container's
/// writable layer — they are not on any bind mount and are genuinely lost today
/// when a container is recreated from a different image.
pub const COPY_ROOTS: &[&str] = &[
"/usr/local/bin",
"/usr/local/sbin",
"/usr/local/lib",
"/usr/local/share",
"/opt",
"/srv",
"/workspace",
];
/// Subtrees never copied even though they sit under a [`COPY_ROOTS`] entry.
///
/// Both are the *base image's own* content, shipped by the Dockerfile. Copying
/// them forward would pin the new base to the old base's version of them, which
/// is the exact failure migration exists to fix. (The presence-in-base filter
/// would already catch them; naming them is cheap insurance against a base that
/// relocates one.)
pub const COPY_EXCLUSIONS: &[&str] = &["/usr/local/aws-cli", "/opt/mission-control"];
/// Roots the filesystem manifest walks. Wider than [`COPY_ROOTS`] so the
/// manifest stays useful for debugging; [`compute_verbatim_paths`] applies the
/// narrower policy.
///
/// [`DATA_ROOTS`] are in here for a different reason: they are never copied,
/// but they *are* destroyed by the container swap, so the walk has to see them
/// in order to warn about them.
pub const MANIFEST_ROOTS: &[&str] = &[
"/usr/local",
"/opt",
"/srv",
"/workspace",
"/var/lib",
"/var/www",
];
/// Roots holding **state a base-image swap destroys and no replay can put
/// back**. Reported by [`unpreserved_data`], never copied.
///
/// A container running Postgres, MySQL, Redis or nginx keeps its actual data in
/// `/var/lib/<service>` or `/var/www`. Replaying the apt delta reinstalls the
/// *package* onto the new base and gets an empty data directory back — the
/// database is gone. That is worse than the ordinary recreate path, which
/// creates from the project's snapshot and therefore keeps `/var` intact.
///
/// These are deliberately **not** in [`COPY_ROOTS`]. A live database's on-disk
/// files cannot be tarred out from under a running server and restored into a
/// different base's version of the same package with any confidence — a copy
/// that half-works is worse than a warning that lets the user take a proper
/// dump first. So migration's answer is disclosure, loudly, before anything is
/// touched.
pub const DATA_ROOTS: &[&str] = &["/var/lib", "/var/www"];
/// Base-image capabilities worth telling the user they are missing, as
/// `(path, human label)`.
///
/// A feature is only ever reported as missing when the **current base actually
/// ships it** and the container does not, so this table needs no maintenance
/// when a capability is dropped from the image — it simply stops appearing.
pub const FEATURE_PROBES: &[(&str, &str)] = &[
("/usr/bin/socat", "Auth bridge tunnel (socat)"),
("/usr/bin/bwrap", "Sandbox mode (bubblewrap)"),
("/usr/bin/cron", "Cron daemon (scheduled tasks)"),
("/usr/bin/jq", "JSON tooling (jq)"),
("/usr/bin/rg", "Fast search (ripgrep)"),
("/usr/bin/gh", "GitHub CLI"),
("/usr/bin/git", "git"),
("/usr/bin/docker", "Docker CLI"),
("/usr/bin/node", "Node.js"),
("/usr/bin/python3", "Python 3"),
("/usr/local/bin/triple-c-open", "Host browser URL relay"),
("/usr/local/bin/osc52-clipboard", "Clipboard bridge (OSC 52)"),
("/usr/local/bin/audio-shim", "Voice mode audio capture"),
("/usr/local/bin/triple-c-scheduler", "Scheduled tasks"),
("/usr/local/bin/triple-c-task-runner", "Scheduled task runner"),
("/usr/local/bin/triple-c-sso-refresh", "AWS SSO auto-refresh"),
("/opt/mission-control", "Mission Control (Flight Control)"),
("/usr/bin/wg", "VPN tooling for the VPN Support toggle (WireGuard)"),
("/opt/triple-c-skills", "Bundled skills for the VPN Support toggle (PIA VPN)"),
];
/// Headroom demanded on Docker's storage backend on top of the measured
/// payload, so a migration cannot be the thing that fills the disk. The new
/// snapshot commit is a delta layer over the base (the base itself is already
/// on disk), and a 524 MB commit was measured at 25.6 s — 2 GiB is a generous
/// ceiling for that plus the replayed packages.
pub const DISK_HEADROOM_BYTES: u64 = 2 * 1024 * 1024 * 1024;
/// Label carrying the image ID of the base a container's lineage descends from.
pub const LABEL_BASE_IMAGE_ID: &str = "triple-c.base-image-id";
/// Label carrying the image this container was actually created from.
pub const LABEL_CREATE_IMAGE: &str = "triple-c.create-image";
/// Label stamped on a container created *by* a migration, so a crash between
/// the container swap and the final commit is recognisable on restart.
pub const LABEL_MIGRATION_STATE: &str = "triple-c.migration-state";
/// Value of [`LABEL_MIGRATION_STATE`] while a migration is unfinished.
pub const MIGRATION_LABEL_IN_PROGRESS: &str = "in-progress";
/// Label stamped on the short-lived probe containers [`run_throwaway`] creates.
///
/// They are removed on every path including failure, but a hard crash of the
/// app (or of Docker) between create and remove would otherwise leave a
/// container that carries no `triple-c.*` marking at all — invisible to every
/// cleanup this app has, and unattributable by hand. The label makes
/// `docker ps -a --filter label=triple-c.probe=migration` find them.
pub const LABEL_PROBE: &str = "triple-c.probe";
/// Value of [`LABEL_PROBE`] on a migration manifest/pre-flight probe container.
pub const PROBE_LABEL_MIGRATION: &str = "migration";
// ─────────────────────────────────────────────────────────────────────────────
// Manifests
// ─────────────────────────────────────────────────────────────────────────────
/// One entry from the filesystem walk.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManifestEntry {
/// `find`'s `%y`: `f` regular, `d` directory, `l` symlink, …
pub kind: char,
pub size: u64,
pub path: String,
}
impl ManifestEntry {
pub fn is_dir(&self) -> bool {
self.kind == 'd'
}
}
/// Everything one probe run learned about an image or a running container.
#[derive(Debug, Clone, Default)]
pub struct Manifest {
/// Filesystem walk of [`MANIFEST_ROOTS`].
pub paths: Vec<ManifestEntry>,
/// Paths under those roots that `dpkg` owns.
pub dpkg_owned: BTreeSet<String>,
/// `apt-mark showmanual`.
pub apt_manual: BTreeSet<String>,
/// Globally installed npm package names (scoped names kept intact).
pub npm_global: BTreeSet<String>,
/// Which [`FEATURE_PROBES`] paths exist.
pub features: BTreeSet<String>,
/// Filesystem walk of `/etc`.
pub etc_paths: BTreeSet<String>,
/// `package -> version` for every installed dpkg package.
pub dpkg_versions: BTreeMap<String, String>,
}
impl Manifest {
/// Index of the filesystem walk, for O(log n) presence tests.
fn path_set(&self) -> BTreeSet<&str> {
self.paths.iter().map(|e| e.path.as_str()).collect()
}
}
/// The shell program run inside a throwaway container (or, when the project is
/// running, inside the container itself) to produce a [`Manifest`].
///
/// Sections are separated by sentinel lines so one exec answers every question;
/// on a 5.49 GB image the whole thing takes about three seconds. Every command
/// is failure-tolerant (`2>/dev/null`, no `set -e`) because a missing `npm` or
/// an unreadable directory must degrade one section, not the run.
pub fn manifest_script() -> String {
let feature_paths = FEATURE_PROBES
.iter()
.map(|(p, _)| shell_single_quote(p))
.collect::<Vec<_>>()
.join(" ");
let roots = MANIFEST_ROOTS
.iter()
.map(|p| shell_single_quote(p))
.collect::<Vec<_>>()
.join(" ");
// The dpkg grep is anchored to the manifest roots so the section stays a
// few hundred kB instead of the ~40 MB a full ownership dump would be.
let dpkg_filter = MANIFEST_ROOTS
.iter()
.map(|r| r.trim_start_matches('/'))
.collect::<Vec<_>>()
.join("|");
format!(
r#"
echo '###PATHS'
find {roots} -xdev -printf '%y\t%s\t%p\n' 2>/dev/null
echo '###DPKG'
cat /var/lib/dpkg/info/*.list 2>/dev/null | grep -E '^/({dpkg_filter})(/|$)'
echo '###APT'
apt-mark showmanual 2>/dev/null
echo '###NPM'
npm ls -g --depth=0 --parseable 2>/dev/null
echo '###FEATURES'
for p in {feature_paths}; do
if [ -e "$p" ]; then echo "$p"; fi
done
echo '###ETC'
find /etc -xdev -printf '%y\t%s\t%p\n' 2>/dev/null
echo '###PKGVER'
dpkg-query -W -f='${{Package}}\t${{Version}}\n' 2>/dev/null
echo '###END'
exit 0
"#
)
}
/// Parse the output of [`manifest_script`].
///
/// Unknown sections and malformed lines are skipped rather than failing: the
/// probe runs against images this build has never seen, and one odd line must
/// not cost the whole manifest.
pub fn parse_manifest(raw: &str) -> Manifest {
let mut m = Manifest::default();
let mut section = "";
for line in raw.lines() {
let line = line.strip_suffix('\r').unwrap_or(line);
if let Some(name) = line.strip_prefix("###") {
section = match name {
"PATHS" | "DPKG" | "APT" | "NPM" | "FEATURES" | "ETC" | "PKGVER" | "END" => name,
_ => "",
};
continue;
}
if line.is_empty() {
continue;
}
match section {
"PATHS" => {
if let Some(entry) = parse_find_line(line) {
m.paths.push(entry);
}
}
"DPKG" => {
m.dpkg_owned.insert(line.to_string());
}
"APT" => {
m.apt_manual.insert(line.trim().to_string());
}
"NPM" => {
if let Some(name) = npm_package_from_path(line) {
m.npm_global.insert(name);
}
}
"FEATURES" => {
m.features.insert(line.to_string());
}
"ETC" => {
if let Some(entry) = parse_find_line(line) {
m.etc_paths.insert(entry.path);
}
}
"PKGVER" => {
if let Some((pkg, ver)) = line.split_once('\t') {
m.dpkg_versions.insert(pkg.to_string(), ver.to_string());
}
}
_ => {}
}
}
m
}
fn parse_find_line(line: &str) -> Option<ManifestEntry> {
let mut parts = line.splitn(3, '\t');
let kind = parts.next()?.chars().next()?;
let size = parts.next()?.parse::<u64>().ok()?;
let path = parts.next()?;
if !path.starts_with('/') {
return None;
}
Some(ManifestEntry {
kind,
size,
path: path.to_string(),
})
}
/// `/usr/lib/node_modules/@scope/pkg` → `@scope/pkg`.
///
/// `npm ls -g --parseable` prints the prefix directory on its first line and
/// one path per installed package after it; splitting on the *last*
/// `/node_modules/` is what keeps scoped names intact.
fn npm_package_from_path(line: &str) -> Option<String> {
let idx = line.rfind("/node_modules/")?;
let name = line[idx + "/node_modules/".len()..].trim();
if name.is_empty() {
return None;
}
Some(name.to_string())
}
/// Quote a string for safe interpolation into a single-quoted shell word.
fn shell_single_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r#"'\''"#))
}
// ─────────────────────────────────────────────────────────────────────────────
// Pure delta computation
// ─────────────────────────────────────────────────────────────────────────────
/// Set difference, sorted. Used for both the apt and the `npm -g` delta.
pub fn set_delta(from: &BTreeSet<String>, base: &BTreeSet<String>) -> Vec<String> {
from.difference(base).cloned().collect()
}
/// The `/workspace/<mount_name>` targets a project's bind mounts occupy.
///
/// Everything under one of these belongs to the host filesystem and must never
/// be staged: it is not lost by a container swap, and copying a whole mounted
/// repository into a tar would be both pointless and enormous. Computed from
/// `project.paths` rather than hardcoded, because the mount names are
/// user-chosen.
pub fn bind_mount_exclusions(paths: &[ProjectPath]) -> Vec<String> {
let mut out: Vec<String> = paths
.iter()
.map(|p| format!("/workspace/{}", p.mount_name))
.collect();
out.sort();
out.dedup();
out
}
/// Whether `path` is `root` itself or lives beneath it.
pub fn is_under(path: &str, root: &str) -> bool {
path == root || path.starts_with(&format!("{}/", root))
}
/// Drop every path that already has an ancestor in the set.
///
/// Turns "one entry per file" into "one entry per newly-added subtree", which
/// is what makes both the reported list and the `tar -T` include list small
/// when someone has installed something large into `/usr/local/lib`.
pub fn prune_to_roots(paths: &BTreeSet<String>) -> Vec<String> {
let mut kept: Vec<String> = Vec::new();
// BTreeSet iterates lexicographically, so a parent is always visited before
// any of its children ("/a" < "/a/b"), and checking only the last kept
// entry is not enough — a sibling can intervene. Check all kept roots, but
// short-circuit on the common case.
for p in paths {
if kept.iter().any(|k| is_under(p, k) && k != p) {
continue;
}
kept.push(p.clone());
}
kept
}
/// The set of paths a migration would carry across verbatim.
///
/// A path qualifies when **all** of:
/// * it lives under a [`COPY_ROOTS`] entry,
/// * it is not under a [`COPY_EXCLUSIONS`] entry or a bind-mount target,
/// * neither image's dpkg database owns it,
/// * the current base image does not already have it.
///
/// The result is then pruned to subtree roots. An empty result means the copy
/// step is skipped entirely.
pub fn compute_verbatim_paths(
from: &Manifest,
base: &Manifest,
bind_targets: &[String],
) -> Vec<String> {
let base_paths = base.path_set();
let mut candidates: BTreeSet<String> = BTreeSet::new();
for entry in &from.paths {
let p = entry.path.as_str();
if !COPY_ROOTS.iter().any(|r| is_under(p, r)) {
continue;
}
// A copy root itself is a container for new content, never new content.
if COPY_ROOTS.contains(&p) {
continue;
}
if COPY_EXCLUSIONS.iter().any(|x| is_under(p, x)) {
continue;
}
if bind_targets.iter().any(|t| is_under(p, t)) {
continue;
}
if from.dpkg_owned.contains(p) || base.dpkg_owned.contains(p) {
continue;
}
if base_paths.contains(p) {
continue;
}
candidates.insert(entry.path.clone());
}
let dirs: BTreeSet<&str> = from
.paths
.iter()
.filter(|e| e.is_dir())
.map(|e| e.path.as_str())
.collect();
prune_to_roots(&candidates)
.into_iter()
// Drop empty directory trees. Measured on a real project, these were
// three of the five hits: `/usr/local/share/{fonts,sgml,xml}`, which a
// package's postinst creates and dpkg does not own, so no other filter
// catches them. They carry nothing, and replaying the packages that
// made them recreates them anyway.
.filter(|p| {
!dirs.contains(p.as_str())
|| from
.paths
.iter()
.any(|e| !e.is_dir() && is_under(&e.path, p))
})
.collect()
}
/// Total on-disk size of a verbatim set, for the pre-flight disk estimate.
pub fn verbatim_payload_bytes(from: &Manifest, verbatim: &[String]) -> u64 {
from.paths
.iter()
.filter(|e| !e.is_dir())
.filter(|e| verbatim.iter().any(|root| is_under(&e.path, root)))
.map(|e| e.size)
.sum()
}
/// The reporting unit under a [`DATA_ROOTS`] entry: the first path component
/// below the root, e.g. `/var/lib/postgresql`. Directory-level, because that is
/// the granularity a user can actually act on ("dump this database"), and
/// because a per-file list of a Postgres cluster would be thousands of lines.
fn data_unit(path: &str) -> Option<String> {
for root in DATA_ROOTS {
let prefix = format!("{}/", root);
if let Some(rest) = path.strip_prefix(&prefix) {
let first = rest.split('/').next()?;
if first.is_empty() {
return None;
}
return Some(format!("{}/{}", root, first));
}
}
None
}
/// Data-bearing subtrees under [`DATA_ROOTS`] that the migration will destroy
/// and cannot restore, with the size and file count of what is at risk.
///
/// A subtree qualifies when **all** of:
/// * it is a first-level directory under a [`DATA_ROOTS`] entry,
/// * the current base image does not have that directory **at all** — if the
/// base ships it, it is the base's own machinery (`/var/lib/apt`,
/// `/var/lib/dpkg`, `/var/lib/systemd`, …) and the base's copy is the right
/// one, exactly as for `/etc`,
/// * it contains at least one regular file that neither image's dpkg database
/// owns — a package's own scaffolding is recreated by the apt replay, the
/// data written into it is not.
///
/// That pair of filters is what keeps this quiet on an ordinary container and
/// loud on one running a database: `/var/lib/postgresql` is absent from the
/// base and full of unowned files, while `/var/lib/apt/lists` is present in the
/// base and never reported.
pub fn unpreserved_data(from: &Manifest, base: &Manifest) -> Vec<UnpreservedData> {
let base_paths = base.path_set();
let mut acc: BTreeMap<String, (u64, u32)> = BTreeMap::new();
for entry in &from.paths {
let Some(unit) = data_unit(&entry.path) else {
continue;
};
if base_paths.contains(unit.as_str()) {
continue;
}
if entry.is_dir() {
continue;
}
if from.dpkg_owned.contains(&entry.path) || base.dpkg_owned.contains(&entry.path) {
continue;
}
let slot = acc.entry(unit).or_insert((0, 0));
slot.0 = slot.0.saturating_add(entry.size);
slot.1 += 1;
}
acc.into_iter()
.map(|(path, (bytes, file_count))| UnpreservedData {
path,
bytes,
file_count,
})
.collect()
}
/// Base-image capabilities the container does not have, as
/// `(concrete paths, human labels)`.
///
/// Only paths the base actually ships are considered, so this can never
/// recommend migrating to gain something the new base does not have either.
pub fn missing_features(from: &Manifest, base: &Manifest) -> (Vec<String>, Vec<String>) {
let mut paths = Vec::new();
let mut labels = Vec::new();
for (path, label) in FEATURE_PROBES {
if base.features.contains(*path) && !from.features.contains(*path) {
paths.push((*path).to_string());
labels.push((*label).to_string());
}
}
(paths, labels)
}
/// How many dpkg packages the current base carries at a version the container
/// does not have — either a different version, or a package the container is
/// missing entirely.
///
/// A rough drift measure, deliberately not a claim that every one is *newer*:
/// comparing Debian version strings properly needs `dpkg --compare-versions`,
/// and the number exists to answer "is this container far behind?", which
/// inequality answers just as well.
pub fn outdated_package_count(from: &Manifest, base: &Manifest) -> u32 {
base.dpkg_versions
.iter()
.filter(|(pkg, base_ver)| from.dpkg_versions.get(*pkg) != Some(*base_ver))
.count() as u32
}
/// `/etc` paths the base has that the container does not, and vice versa.
///
/// **Reported, never copied.** The snapshot lineage carries
/// `/etc/apt/sources.list.d/nodesource.sources` where the current base has
/// `nodesource.list`; copying `/etc` wholesale would leave both in place and
/// every `apt-get update` would fail on a duplicate-source conflict. Since
/// `/etc` is also where the base's own configuration lives, the base's copy is
/// always the right one.
pub fn etc_deltas(from: &Manifest, base: &Manifest) -> (Vec<String>, Vec<String>) {
let only_in_container: Vec<String> = from
.etc_paths
.difference(&base.etc_paths)
.cloned()
.collect();
let only_in_base: Vec<String> = base
.etc_paths
.difference(&from.etc_paths)
.cloned()
.collect();
(only_in_container, only_in_base)
}
/// The `tar` member names for a verbatim set: absolute paths made relative to
/// `/`, so the archive extracts with `-C /`.
pub fn tar_member_names(verbatim: &[String]) -> Vec<String> {
verbatim
.iter()
.map(|p| p.trim_start_matches('/').to_string())
.filter(|p| !p.is_empty())
.collect()
}
// ─────────────────────────────────────────────────────────────────────────────
// Crash-recovery state machine
// ─────────────────────────────────────────────────────────────────────────────
/// What to do about a migration state found on startup.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Recovery {
/// Nothing was in flight.
None,
/// The crash happened before the container was swapped. `:latest` still
/// points at the old lineage and `start_project_container` will recreate
/// from it unaided, so the only work is to clear the record.
SelfHeal,
/// The container was swapped but the migration never finished. The user
/// must choose: resume, or roll back.
OfferResumeOrRollback,
/// The migration finished. The user must choose: confirm, or roll back.
OfferConfirmOrRollback,
}
/// Decide the recovery action from the two independent signals.
///
/// The host-side state file says a migration was in flight; the container's
/// `triple-c.migration-state` label says whether the *swap* actually happened.
/// Neither alone is sufficient:
///
/// * state file but no labelled container → the crash predates the swap
/// (or the swapped container never got created), and everything self-heals.
/// * labelled container but no state file → a stale label from a migration that
/// was already confirmed; the label rides the final commit into the snapshot
/// image, so it can outlive its migration. It must not trigger anything.
///
/// `phase` is [`crate::models::MigrationState::phase`].
pub fn decide_recovery(phase: Option<&str>, container_has_in_progress_label: bool) -> Recovery {
use crate::models::{
MIGRATION_PHASE_AWAITING, MIGRATION_PHASE_INTERRUPTED, MIGRATION_PHASE_IN_PROGRESS,
};
match phase {
None => Recovery::None,
Some(MIGRATION_PHASE_AWAITING) => Recovery::OfferConfirmOrRollback,
Some(MIGRATION_PHASE_IN_PROGRESS) | Some(MIGRATION_PHASE_INTERRUPTED) => {
if container_has_in_progress_label {
Recovery::OfferResumeOrRollback
} else {
Recovery::SelfHeal
}
}
// An unrecognised phase is a record we cannot reason about. Treat it
// like a finished migration awaiting a decision rather than silently
// discarding it: the destructive option must always be the user's.
Some(_) => Recovery::OfferConfirmOrRollback,
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Docker operations
// ─────────────────────────────────────────────────────────────────────────────
/// A throwaway container's stdout plus its exit code.
pub struct ThrowawayResult {
pub stdout: String,
pub stderr: String,
pub exit_code: i64,
}
/// Run a shell program in a short-lived container off `image` and collect its
/// output.
///
/// The image's `ENTRYPOINT` is overridden — the Triple-C image's entrypoint
/// ends in `sleep infinity`, so leaving it in place would hang forever. The
/// container is removed on every path, including failure.
pub async fn run_throwaway(image: &str, script: &str) -> Result<ThrowawayResult, String> {
let docker = get_docker()?;
let config = Config {
image: Some(image.to_string()),
entrypoint: Some(vec!["/bin/sh".to_string()]),
cmd: Some(vec!["-c".to_string(), script.to_string()]),
user: Some("root".to_string()),
working_dir: Some("/".to_string()),
tty: Some(false),
// Written explicitly rather than inherited: a probe container that
// outlives a crash has to be findable, and nothing else in the app
// labels these.
labels: Some(HashMap::from([(
LABEL_PROBE.to_string(),
PROBE_LABEL_MIGRATION.to_string(),
)])),
host_config: Some(HostConfig {
// No mounts on purpose: this must observe the *image*, not the
// project's volumes, which are exactly the state migration does
// not need to move.
auto_remove: Some(false),
..Default::default()
}),
..Default::default()
};
let created = docker
.create_container(
None::<CreateContainerOptions<String>>,
config,
)
.await
.map_err(|e| format!("Failed to create probe container for {}: {}", image, e))?;
// From here on the container's removal is owned by a guard rather than by
// the statement that used to sit after the await below. A plain statement
// only runs if this future is *polled to completion*: an `Err(...)?` was
// already handled, but a **dropped** future — the app quitting mid-flight,
// a timeout, any `select!` that loses — skipped it silently and left a
// container behind holding a multi-gigabyte base image open. That image is
// then unsweepable (removal is deliberately unforced) and there is nothing
// in the UI that would ever mention it.
let guard = ProbeContainerGuard::new(created.id);
let result = run_throwaway_inner(guard.id()).await;
// The happy path still removes it *synchronously*, so a caller that goes on
// to `docker rmi` the image it probed does not race the removal.
guard.remove_now().await;
result
}
/// Owns the lifetime of a probe container.
///
/// [`Self::remove_now`] is the normal path and awaits the removal. `Drop` is the
/// safety net for the abnormal one: it cannot await, so it hands the removal to
/// a detached task. That covers a dropped future while the process lives; it
/// cannot cover the process dying, which is what
/// [`reap_probe_containers`] is for.
struct ProbeContainerGuard {
id: String,
/// Cleared by `remove_now` so `Drop` does not queue a second removal.
armed: bool,
}
impl ProbeContainerGuard {
fn new(id: String) -> Self {
Self { id, armed: true }
}
fn id(&self) -> &str {
&self.id
}
async fn remove_now(mut self) {
self.armed = false;
remove_probe_container(&self.id).await;
}
}
impl Drop for ProbeContainerGuard {
fn drop(&mut self) {
if !self.armed {
return;
}
let id = std::mem::take(&mut self.id);
log::warn!("Probe container {} was abandoned; removing it in the background", id);
tauri::async_runtime::spawn(async move {
remove_probe_container(&id).await;
});
}
}
/// Force-remove one probe container. Missing is success — the point is that the
/// container is gone.
async fn remove_probe_container(id: &str) {
let Ok(docker) = get_docker() else {
return;
};
match docker
.remove_container(
id,
Some(RemoveContainerOptions {
force: true,
v: true,
..Default::default()
}),
)
.await
{
Ok(())
| Err(bollard::errors::Error::DockerResponseServerError {
status_code: 404, ..
}) => {}
Err(e) => log::warn!("Failed to remove probe container {}: {}", id, e),
}
}
/// Remove probe containers left behind by a previous run of the app.
///
/// A probe is labelled [`LABEL_PROBE`] precisely so it stays findable after a
/// crash, but until now nothing ever went looking. One leftover probe pins the
/// base image it was created from — several gigabytes that
/// `sweep_orphaned_snapshots` then reports as "in use" and correctly refuses to
/// touch, with no way for the user to find out why.
///
/// Safe to run at startup: a probe is a short-lived `/bin/sh` with no mounts
/// and no volumes, owned entirely by a `run_throwaway` call. If one is running
/// right now it belongs to this process — and this runs before any migration
/// can be started, so there is none to interrupt.
pub async fn reap_probe_containers() {
let Ok(docker) = get_docker() else {
return;
};
let filters: HashMap<String, Vec<String>> = HashMap::from([(
"label".to_string(),
vec![format!("{}={}", LABEL_PROBE, PROBE_LABEL_MIGRATION)],
)]);
let containers = match docker
.list_containers(Some(bollard::container::ListContainersOptions {
all: true,
filters,
..Default::default()
}))
.await
{
Ok(list) => list,
Err(e) => {
log::warn!("Could not list leftover probe containers: {}", e);
return;
}
};
for c in containers {
if let Some(id) = c.id {
log::info!("Removing leftover migration probe container {}", id);
remove_probe_container(&id).await;
}
}
}
async fn run_throwaway_inner(id: &str) -> Result<ThrowawayResult, String> {
let docker = get_docker()?;
docker
.start_container(id, None::<StartContainerOptions<String>>)
.await
.map_err(|e| format!("Failed to start probe container: {}", e))?;
let mut wait = docker.wait_container(
id,
Some(WaitContainerOptions {
condition: "not-running",
}),
);
let mut exit_code: i64 = -1;
while let Some(msg) = wait.next().await {
match msg {
Ok(r) => exit_code = r.status_code,
// A non-zero exit is delivered as an Err by bollard; the status
// code is still what we want, and the logs below carry the detail.
Err(bollard::errors::Error::DockerContainerWaitError { code, .. }) => exit_code = code,
Err(e) => return Err(format!("Probe container wait failed: {}", e)),
}
}
let mut logs = docker.logs(
id,
Some(LogsOptions::<String> {
stdout: true,
stderr: true,
follow: false,
..Default::default()
}),
);
let mut stdout = String::new();
let mut stderr = String::new();
while let Some(chunk) = logs.next().await {
match chunk {
Ok(LogOutput::StdOut { message }) => {
stdout.push_str(&String::from_utf8_lossy(&message))
}
Ok(LogOutput::StdErr { message }) => {
stderr.push_str(&String::from_utf8_lossy(&message))
}
Ok(other) => stdout.push_str(&String::from_utf8_lossy(&other.into_bytes())),
Err(e) => return Err(format!("Probe container log stream failed: {}", e)),
}
}
Ok(ThrowawayResult {
stdout,
stderr,
exit_code,
})
}
/// Capture a [`Manifest`] from an image, via a throwaway container.
pub async fn manifest_from_image(image: &str) -> Result<Manifest, String> {
let out = run_throwaway(image, &manifest_script()).await?;
if !out.stdout.contains("###END") {
return Err(format!(
"Probe of image {} did not complete (exit {}){}",
image,
out.exit_code,
if out.stderr.trim().is_empty() {
String::new()
} else {
format!(": {}", out.stderr.trim())
}
));
}
Ok(parse_manifest(&out.stdout))
}
/// Capture a [`Manifest`] from a *running* container.
///
/// Preferred over [`manifest_from_image`] for the "from" side whenever the
/// project is up: the snapshot image can lag the container by everything
/// installed since the last commit, and a verbatim set computed from a stale
/// manifest would silently fail to carry that work across.
pub async fn manifest_from_container(container_id: &str) -> Result<Manifest, String> {
let (out, code) = super::exec::exec_oneshot_as(
container_id,
"root",
vec!["/bin/sh".to_string(), "-c".to_string(), manifest_script()],
Vec::new(),
)
.await?;
if !out.contains("###END") {
return Err(format!(
"Probe of the running container did not complete (exit {})",
code
));
}
Ok(parse_manifest(&out))
}
/// The image ID (`sha256:…`) of a local image, or `None` if it is not present.
///
/// Deliberately the **ID**, not a repo digest: locally built images and custom
/// images have no `RepoDigests` entry at all, so a digest-based identity would
/// silently be empty for exactly the users most likely to change their base.
pub async fn image_id(image: &str) -> Result<Option<String>, String> {
let docker = get_docker()?;
match docker.inspect_image(image).await {
Ok(info) => Ok(info.id.filter(|s| !s.is_empty())),
Err(bollard::errors::Error::DockerResponseServerError {
status_code: 404, ..
}) => Ok(None),
Err(e) => Err(format!("Failed to inspect image {}: {}", image, e)),
}
}
/// An image's labels, or an empty map when it does not exist.
pub async fn image_labels(image: &str) -> HashMap<String, String> {
let docker = match get_docker() {
Ok(d) => d,
Err(_) => return HashMap::new(),
};
match docker.inspect_image(image).await {
Ok(info) => info
.config
.and_then(|c| c.labels)
.unwrap_or_default(),
Err(_) => HashMap::new(),
}
}
/// An image's `Created` timestamp, if it exists.
pub async fn image_created(image: &str) -> Option<String> {
let docker = get_docker().ok()?;
docker.inspect_image(image).await.ok().and_then(|i| i.created)
}
/// Point a second tag at an existing image.
///
/// Free in both time and space — a 5.49 GB image was measured at 0.036 s and
/// 0 bytes — which is what makes keeping a rollback pin the default-safe
/// choice. (The *image* it pins is not free: snapshots share only 3 of 31
/// layers with the current base, so a retained rollback holds roughly its full
/// size on disk. That is the trade `MigrationOptions::keep_rollback` exposes.)
pub async fn tag_image(source: &str, repo: &str, tag: &str) -> Result<(), String> {
let docker = get_docker()?;
docker
.tag_image(source, Some(TagImageOptions { repo, tag }))
.await
.map_err(|e| format!("Failed to tag {} as {}:{}: {}", source, repo, tag, e))
}
/// Remove an image tag. Missing is success — a rollback tag that is already
/// gone is the state the caller wanted.
pub async fn untag_image(reference: &str) -> Result<(), String> {
let docker = get_docker()?;
match docker
.remove_image(
reference,
Some(bollard::image::RemoveImageOptions {
force: false,
noprune: false,
}),
None,
)
.await
{
Ok(_) => Ok(()),
Err(bollard::errors::Error::DockerResponseServerError {
status_code: 404, ..
}) => Ok(()),
Err(e) => Err(format!("Failed to remove image tag {}: {}", reference, e)),
}
}
/// A pre-migration rollback tag for a project's snapshot repo.
pub fn rollback_tag(now: &chrono::DateTime<chrono::Utc>) -> String {
format!("pre-migration-{}", now.format("%Y%m%d-%H%M%S"))
}
/// How long a rollback pin may sit with no migration record behind it before
/// [`reap_stale_migration_pins`] drops the tag.
///
/// Two weeks, chosen to be far longer than anyone deliberates over a base
/// update and far shorter than "forever", which is what it was.
pub const STALE_PIN_MAX_AGE_DAYS: i64 = 14;
/// Recover the timestamp encoded in a tag produced by [`rollback_tag`].
///
/// `None` for anything that is not one of ours — a tag that merely *starts*
/// with `pre-migration-` but does not carry a parseable timestamp is left alone
/// rather than guessed at, because the consequence of guessing wrong is
/// deleting the only copy of somebody's system layer.
pub fn parse_rollback_tag(tag: &str) -> Option<chrono::DateTime<chrono::Utc>> {
let stamp = tag.strip_prefix("pre-migration-")?;
let naive = chrono::NaiveDateTime::parse_from_str(stamp, "%Y%m%d-%H%M%S").ok()?;
Some(naive.and_utc())
}
/// Split `triple-c-snapshot-<projectId>:<tag>` into the project id and the tag.
///
/// `None` when the reference is not a snapshot repo at all.
pub fn parse_snapshot_reference(reference: &str) -> Option<(String, String)> {
let (repo, tag) = split_image_ref(reference);
let project_id = repo.strip_prefix("triple-c-snapshot-")?.to_string();
if project_id.is_empty() {
return None;
}
Some((project_id, tag))
}
/// Whether a rollback pin is safe to drop, given how old it is and whether the
/// project it belongs to still has a migration record.
///
/// Pure so the decision can be tested without a daemon. The order of the two
/// conditions is the point: **a pin whose migration is still awaiting
/// confirmation is never reaped at any age**, because it is the only copy of
/// the rollback target and the user has not yet said they are happy with the
/// new base.
pub fn pin_is_reapable(
tag: &str,
has_migration_record: bool,
now: &chrono::DateTime<chrono::Utc>,
) -> bool {
if has_migration_record {
return false;
}
let Some(created) = parse_rollback_tag(tag) else {
return false;
};
(*now - created).num_days() >= STALE_PIN_MAX_AGE_DAYS
}
/// Drop `triple-c-snapshot-*:pre-migration-*` tags that no migration record
/// claims any more, so the images behind them become sweepable.
///
/// ## Why this scans tags instead of reading the records
///
/// Every other path to a rollback pin starts from
/// `migration_store::load`, and `load` reports an unparseable state file as
/// *absent* — so a single corrupt record used to strand a 412 GB image that no
/// code could ever name again. Confirming or rolling back both remove the
/// record and drop the tag together, so a `pre-migration-*` tag with no record
/// beside it is by definition one that lost its owner: a crash between the two,
/// a record that was deleted by hand, or the corrupt-file case.
///
/// Scanning the *tag pattern* is the only way to find those. `load` moving a
/// corrupt record aside (see `migration_store::load`) is what stops that case
/// from being permanently invisible here too.
///
/// ## Why it only untags
///
/// Dropping the tag turns the image dangling, and it is already labelled
/// `triple-c.managed=true` because `docker commit` created it — so
/// `sweep_orphaned_snapshots` collects it on the same pass, under the same two
/// safety conditions, with the daemon's "still in use by a container" refusal
/// still in front of it. Nothing here calls `docker rmi` on a reachable image.
pub async fn reap_stale_migration_pins() -> usize {
use bollard::image::ListImagesOptions;
let Ok(docker) = get_docker() else {
return 0;
};
// `reference` matches against `repo:tag`, so this asks the daemon for
// exactly the shape [`rollback_tag`] produces and nothing else.
let filters: HashMap<String, Vec<String>> = HashMap::from([(
"reference".to_string(),
vec!["triple-c-snapshot-*:pre-migration-*".to_string()],
)]);
let images = match docker
.list_images(Some(ListImagesOptions {
all: false,
filters,
..Default::default()
}))
.await
{
Ok(images) => images,
Err(e) => {
log::warn!("Could not list rollback pins: {}", e);
return 0;
}
};
let now = chrono::Utc::now();
let mut reaped = 0usize;
for summary in images {
for reference in &summary.repo_tags {
let Some((project_id, tag)) = parse_snapshot_reference(reference) else {
continue;
};
// Filesystem presence, not `load`: a record we cannot parse must
// still count as "somebody may want this back".
let has_record =
crate::storage::migration_store::has_record(&project_id).unwrap_or(true);
if !pin_is_reapable(&tag, has_record, &now) {
continue;
}
match untag_image(reference).await {
Ok(()) => {
log::info!(
"Dropped stale rollback pin {} ({:.2} GB) — no migration record has claimed it for {} days",
reference,
summary.size as f64 / 1_073_741_824.0,
STALE_PIN_MAX_AGE_DAYS,
);
reaped += 1;
}
Err(e) => log::warn!("Could not drop stale rollback pin {}: {}", reference, e),
}
}
}
reaped
}
/// Split `repo:tag` into its parts, defaulting the tag to `latest`.
pub fn split_image_ref(image: &str) -> (String, String) {
match image.rsplit_once(':') {
// A colon in the *registry host* part is a port, not a tag.
Some((repo, tag)) if !tag.contains('/') => (repo.to_string(), tag.to_string()),
_ => (image.to_string(), "latest".to_string()),
}
}
/// Pre-flight environment checks, run against the **new base** in a throwaway
/// container before anything destructive happens.
pub struct PreflightEnvironment {
/// `apt-get update` succeeded, so package replay has a chance.
pub network_ok: bool,
pub network_detail: String,
/// Bytes available on Docker's storage backend.
///
/// Measured with `df` **inside a container**, not with a host `statvfs`:
/// on Windows the Docker root lives inside the WSL2 VM and is not a path
/// the Tauri process can stat at all.
pub available_bytes: u64,
}
/// Run the network and disk pre-flight checks in one throwaway container.
pub async fn preflight_environment(base_image: &str) -> Result<PreflightEnvironment, String> {
let script = r#"
echo '###DF'
df -P / | tail -n 1
echo '###NET'
if apt-get -o Acquire::Retries=2 update >/dev/null 2>&1; then
echo ok
else
echo failed
fi
echo '###END'
exit 0
"#;
let out = run_throwaway(base_image, script).await?;
if !out.stdout.contains("###END") {
return Err(format!(
"Pre-flight probe of {} did not complete (exit {}){}",
base_image,
out.exit_code,
if out.stderr.trim().is_empty() {
String::new()
} else {
format!(": {}", out.stderr.trim())
}
));
}
Ok(parse_preflight(&out.stdout))
}
/// Parse the pre-flight probe output. `df -P` reports 1024-byte blocks.
pub fn parse_preflight(raw: &str) -> PreflightEnvironment {
let mut section = "";
let mut available_bytes = 0u64;
let mut network_ok = false;
let mut network_detail = "not checked".to_string();
for line in raw.lines() {
if let Some(name) = line.strip_prefix("###") {
section = name;
continue;
}
match section {
"DF" => {
// Filesystem 1024-blocks Used Available Capacity Mounted-on
let cols: Vec<&str> = line.split_whitespace().collect();
if cols.len() >= 4 {
if let Ok(kb) = cols[cols.len() - 3].parse::<u64>() {
available_bytes = kb.saturating_mul(1024);
}
}
}
"NET" => {
if line.trim() == "ok" {
network_ok = true;
network_detail = "apt-get update succeeded".to_string();
} else if line.trim() == "failed" {
network_ok = false;
network_detail =
"apt-get update failed — package replay will be skipped".to_string();
}
}
_ => {}
}
}
PreflightEnvironment {
network_ok,
network_detail,
available_bytes,
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{
MIGRATION_PHASE_AWAITING, MIGRATION_PHASE_INTERRUPTED, MIGRATION_PHASE_IN_PROGRESS,
};
fn manifest(paths: &[(char, u64, &str)], dpkg: &[&str], base_features: &[&str]) -> Manifest {
Manifest {
paths: paths
.iter()
.map(|(k, s, p)| ManifestEntry {
kind: *k,
size: *s,
path: p.to_string(),
})
.collect(),
dpkg_owned: dpkg.iter().map(|s| s.to_string()).collect(),
features: base_features.iter().map(|s| s.to_string()).collect(),
..Default::default()
}
}
fn strs(v: &[&str]) -> BTreeSet<String> {
v.iter().map(|s| s.to_string()).collect()
}
// ── Manifest parsing ────────────────────────────────────────────────────
#[test]
fn the_manifest_parser_reads_every_section() {
let raw = "###PATHS\n\
d\t4096\t/usr/local/bin\n\
f\t128\t/usr/local/bin/mytool\n\
###DPKG\n\
/usr/local/lib/pkgfile\n\
###APT\n\
socat\n\
postgresql-client\n\
###NPM\n\
/usr/lib/node_modules\n\
/usr/lib/node_modules/pnpm\n\
/usr/lib/node_modules/@scope/tool\n\
###FEATURES\n\
/usr/bin/socat\n\
###ETC\n\
f\t10\t/etc/hosts\n\
###PKGVER\n\
curl\t8.5.0-2ubuntu10.6\n\
###END\n";
let m = parse_manifest(raw);
assert_eq!(m.paths.len(), 2);
assert_eq!(m.paths[1].size, 128);
assert!(m.dpkg_owned.contains("/usr/local/lib/pkgfile"));
assert_eq!(m.apt_manual, strs(&["socat", "postgresql-client"]));
// The prefix line has no `/node_modules/` segment and is dropped;
// the scoped name survives intact.
assert_eq!(m.npm_global, strs(&["pnpm", "@scope/tool"]));
assert!(m.features.contains("/usr/bin/socat"));
assert!(m.etc_paths.contains("/etc/hosts"));
assert_eq!(
m.dpkg_versions.get("curl").map(String::as_str),
Some("8.5.0-2ubuntu10.6")
);
}
#[test]
fn malformed_manifest_lines_are_skipped_not_fatal() {
let raw = "###PATHS\ngarbage\nf\tnotanumber\t/x\nf\t1\trelative/path\nf\t2\t/ok\n###END\n";
let m = parse_manifest(raw);
assert_eq!(m.paths.len(), 1);
assert_eq!(m.paths[0].path, "/ok");
}
#[test]
fn unknown_sections_do_not_leak_into_the_previous_one() {
let raw = "###APT\nsocat\n###SOMETHINGNEW\nnoise\nmore-noise\n###END\n";
let m = parse_manifest(raw);
assert_eq!(m.apt_manual, strs(&["socat"]));
}
// ── Deltas ──────────────────────────────────────────────────────────────
#[test]
fn the_apt_delta_is_the_containers_manual_set_minus_the_bases() {
let from = strs(&["socat", "postgresql-client", "redis-tools", "curl"]);
let base = strs(&["socat", "curl", "git"]);
assert_eq!(
set_delta(&from, &base),
vec!["postgresql-client".to_string(), "redis-tools".to_string()]
);
// A base that gained packages does not produce a negative delta.
assert!(set_delta(&base, &from).contains(&"git".to_string()));
}
#[test]
fn an_identical_package_set_produces_no_delta() {
let s = strs(&["a", "b"]);
assert!(set_delta(&s, &s).is_empty());
}
#[test]
fn outdated_packages_count_version_differences_and_absences() {
let mut from = Manifest::default();
from.dpkg_versions.insert("curl".into(), "8.5.0-1".into());
from.dpkg_versions.insert("git".into(), "2.43.0".into());
from.dpkg_versions.insert("gone".into(), "1.0".into());
let mut base = Manifest::default();
base.dpkg_versions.insert("curl".into(), "8.5.0-2".into()); // newer
base.dpkg_versions.insert("git".into(), "2.43.0".into()); // same
base.dpkg_versions.insert("brandnew".into(), "1.0".into()); // absent
// curl differs + brandnew is missing = 2. `gone` is only in the
// container and is not drift against the base.
assert_eq!(outdated_package_count(&from, &base), 2);
}
// ── dpkg ownership filter ───────────────────────────────────────────────
#[test]
fn dpkg_owned_paths_are_never_treated_as_user_authored() {
// The real-world failure this guards: a path that exists only in the
// container looks user-authored until you notice a package owns it.
let from = manifest(
&[
('f', 10, "/usr/local/lib/libowned.so"),
('f', 10, "/usr/local/bin/mytool"),
],
&["/usr/local/lib/libowned.so"],
&[],
);
let base = Manifest::default();
assert_eq!(
compute_verbatim_paths(&from, &base, &[]),
vec!["/usr/local/bin/mytool".to_string()]
);
}
#[test]
fn ownership_recorded_only_in_the_base_still_filters() {
// A package that moved into the base since the snapshot was taken owns
// the path there but not in the container's older dpkg database.
let from = manifest(&[('f', 10, "/opt/tool/bin/x")], &[], &[]);
let mut base = Manifest::default();
base.dpkg_owned.insert("/opt/tool/bin/x".to_string());
assert!(compute_verbatim_paths(&from, &base, &[]).is_empty());
}
// ── Verbatim set ────────────────────────────────────────────────────────
#[test]
fn the_bases_own_content_is_never_copied_forward() {
// /usr/local/aws-cli and /opt/mission-control are shipped by the
// Dockerfile. Carrying the old copies over would pin the new base to
// the old base's versions — the exact thing migration fixes.
let from = manifest(
&[
('d', 4096, "/usr/local/aws-cli"),
('f', 10, "/usr/local/aws-cli/v2/current/bin/aws"),
('d', 4096, "/opt/mission-control"),
('f', 10, "/opt/mission-control/README.md"),
('d', 4096, "/opt/mine"),
('f', 10, "/opt/mine/keep.txt"),
],
&[],
&[],
);
assert_eq!(
compute_verbatim_paths(&from, &Manifest::default(), &[]),
vec!["/opt/mine".to_string()]
);
}
#[test]
fn a_path_the_new_base_already_has_is_left_to_the_base() {
let from = manifest(
&[
('f', 10, "/usr/local/bin/triple-c-open"),
('f', 10, "/usr/local/bin/mytool"),
],
&[],
&[],
);
let base = manifest(&[('f', 20, "/usr/local/bin/triple-c-open")], &[], &[]);
assert_eq!(
compute_verbatim_paths(&from, &base, &[]),
vec!["/usr/local/bin/mytool".to_string()]
);
}
#[test]
fn copy_roots_are_narrower_than_the_manifest_roots() {
// /usr/local/etc is walked by the manifest but is not a copy root:
// configuration is the base's to own.
let from = manifest(
&[
('f', 10, "/usr/local/etc/some.conf"),
('f', 10, "/usr/local/bin/mytool"),
],
&[],
&[],
);
assert_eq!(
compute_verbatim_paths(&from, &Manifest::default(), &[]),
vec!["/usr/local/bin/mytool".to_string()]
);
}
#[test]
fn empty_directory_trees_are_not_carried_across() {
// Measured on a real project: /usr/local/share/{fonts,sgml,xml} exist
// in the snapshot, do not exist in the current base, and are owned by
// no package — a postinst made them. They carry nothing.
let from = manifest(
&[
('d', 4096, "/usr/local/share/fonts"),
('d', 4096, "/usr/local/share/sgml"),
('d', 4096, "/usr/local/share/sgml/nested"),
('d', 4096, "/opt/real"),
('f', 10, "/opt/real/thing"),
],
&[],
&[],
);
assert_eq!(
compute_verbatim_paths(&from, &Manifest::default(), &[]),
vec!["/opt/real".to_string()]
);
}
#[test]
fn an_empty_verbatim_set_is_the_normal_case() {
// The measured reality: essentially nothing under these roots is
// user-authored, so the copy step must be skippable.
let from = manifest(&[('d', 4096, "/usr/local/bin"), ('d', 4096, "/opt")], &[], &[]);
assert!(compute_verbatim_paths(&from, &Manifest::default(), &[]).is_empty());
}
#[test]
fn subtrees_are_pruned_to_their_root() {
let from = manifest(
&[
('d', 4096, "/opt/mytool"),
('d', 4096, "/opt/mytool/bin"),
('f', 100, "/opt/mytool/bin/run"),
('f', 100, "/opt/mytool/LICENSE"),
('f', 100, "/srv/other.txt"),
],
&[],
&[],
);
assert_eq!(
compute_verbatim_paths(&from, &Manifest::default(), &[]),
vec!["/opt/mytool".to_string(), "/srv/other.txt".to_string()]
);
}
#[test]
fn pruning_keeps_siblings_that_share_a_name_prefix() {
// "/opt/tool2" starts with "/opt/tool" as a *string* but is not under
// it as a *path*.
let set = strs(&["/opt/tool", "/opt/tool2", "/opt/tool/inner"]);
assert_eq!(
prune_to_roots(&set),
vec!["/opt/tool".to_string(), "/opt/tool2".to_string()]
);
}
#[test]
fn payload_size_sums_files_under_the_pruned_roots_only() {
let from = manifest(
&[
('d', 4096, "/opt/mytool"),
('f', 100, "/opt/mytool/a"),
('f', 200, "/opt/mytool/b"),
('f', 999, "/opt/mission-control/big"),
],
&[],
&[],
);
let verbatim = compute_verbatim_paths(&from, &Manifest::default(), &[]);
// Directories contribute their inode size on disk, not their contents;
// counting them would double-count. Excluded subtrees contribute zero.
assert_eq!(verbatim_payload_bytes(&from, &verbatim), 300);
}
// ── Data that migration destroys and cannot restore ─────────────────────
#[test]
fn a_database_under_var_lib_is_reported_because_nothing_replays_it() {
// The regression this exists for: replaying `postgresql` onto the new
// base reinstalls the package and gets an empty cluster. The ordinary
// recreate path keeps /var because it creates from the snapshot, so a
// silent migration would be *more* destructive than the thing it
// replaces.
let from = manifest(
&[
('d', 4096, "/var/lib/postgresql"),
('d', 4096, "/var/lib/postgresql/16/main"),
('f', 8192, "/var/lib/postgresql/16/main/PG_VERSION"),
('f', 1024, "/var/lib/postgresql/16/main/base/1/2"),
('d', 4096, "/var/www"),
('d', 4096, "/var/www/site"),
('f', 500, "/var/www/site/index.html"),
],
&[],
&[],
);
let got = unpreserved_data(&from, &Manifest::default());
assert_eq!(
got.iter().map(|d| d.path.as_str()).collect::<Vec<_>>(),
vec!["/var/lib/postgresql", "/var/www/site"]
);
assert_eq!(got[0].bytes, 9216);
assert_eq!(got[0].file_count, 2);
// And it is emphatically not in the copy set — reporting is the whole
// answer here, not a half-working copy of a live database.
assert!(!COPY_ROOTS.iter().any(|r| is_under("/var/lib/postgresql", r)));
}
#[test]
fn package_machinery_under_var_is_never_reported_as_data_at_risk() {
// /var/lib/apt exists in the base too, so it is the base's to own —
// the same rule /etc gets. Reporting apt's lists would bury the one
// line that matters under noise on every single migration.
let from = manifest(
&[
('d', 4096, "/var/lib/apt"),
('f', 900_000, "/var/lib/apt/lists/some.mirror_InRelease"),
('d', 4096, "/var/lib/dpkg"),
('f', 4096, "/var/lib/dpkg/status"),
],
&[],
&[],
);
let base = manifest(
&[
('d', 4096, "/var/lib/apt"),
('d', 4096, "/var/lib/dpkg"),
('f', 4096, "/var/lib/dpkg/status"),
],
&[],
&[],
);
assert!(unpreserved_data(&from, &base).is_empty());
}
#[test]
fn a_packages_own_scaffolding_under_var_is_not_data() {
// nginx-common ships /var/www/html/index.nginx-debian.html. The apt
// replay puts that back; only what the user wrote is at risk.
let from = manifest(
&[
('d', 4096, "/var/www/html"),
('f', 612, "/var/www/html/index.nginx-debian.html"),
],
&["/var/www/html/index.nginx-debian.html"],
&[],
);
assert!(unpreserved_data(&from, &Manifest::default()).is_empty());
}
#[test]
fn data_is_reported_per_directory_not_per_file() {
assert_eq!(
data_unit("/var/lib/mysql/ibdata1").as_deref(),
Some("/var/lib/mysql")
);
// A first-level directory is its own unit.
assert_eq!(
data_unit("/var/lib/mysql").as_deref(),
Some("/var/lib/mysql")
);
// The root itself is not: it exists in every image.
assert_eq!(data_unit("/var/lib").as_deref(), None);
assert_eq!(data_unit("/var/www").as_deref(), None);
assert_eq!(data_unit("/usr/local/bin/tool"), None);
}
// ── Bind-mount exclusion ────────────────────────────────────────────────
fn pp(mount: &str) -> ProjectPath {
ProjectPath {
host_path: format!("/host/{}", mount),
mount_name: mount.to_string(),
}
}
#[test]
fn bind_mount_targets_are_derived_from_the_projects_own_mount_names() {
let paths = vec![pp("repo"), pp("docs"), pp("repo")];
assert_eq!(
bind_mount_exclusions(&paths),
vec!["/workspace/docs".to_string(), "/workspace/repo".to_string()]
);
assert!(bind_mount_exclusions(&[]).is_empty());
}
#[test]
fn workspace_content_on_a_bind_mount_is_excluded_but_loose_files_are_not() {
// The whole reason /workspace is a copy root: `scratch.md` at the
// workspace root is in the writable layer and is lost today.
let from = manifest(
&[
('d', 4096, "/workspace/repo"),
('f', 10, "/workspace/repo/src/main.rs"),
('f', 10, "/workspace/scratch.md"),
('d', 4096, "/workspace/notes"),
('f', 10, "/workspace/notes/todo.txt"),
],
&[],
&[],
);
let excl = bind_mount_exclusions(&[pp("repo")]);
assert_eq!(
compute_verbatim_paths(&from, &Manifest::default(), &excl),
vec![
"/workspace/notes".to_string(),
"/workspace/scratch.md".to_string()
]
);
}
#[test]
fn a_mount_name_that_prefixes_another_does_not_over_exclude() {
let from = manifest(
&[
('d', 4096, "/workspace/app"),
('f', 10, "/workspace/app/x"),
('d', 4096, "/workspace/app-notes"),
('f', 10, "/workspace/app-notes/y"),
],
&[],
&[],
);
let excl = bind_mount_exclusions(&[pp("app")]);
assert_eq!(
compute_verbatim_paths(&from, &Manifest::default(), &excl),
vec!["/workspace/app-notes".to_string()]
);
}
#[test]
fn tar_member_names_are_relative_so_the_archive_extracts_at_root() {
assert_eq!(
tar_member_names(&["/opt/mytool".to_string(), "/srv".to_string()]),
vec!["opt/mytool".to_string(), "srv".to_string()]
);
assert!(tar_member_names(&["/".to_string()]).is_empty());
}
// ── Missing features ────────────────────────────────────────────────────
#[test]
fn a_feature_is_missing_only_when_the_new_base_actually_has_it() {
let from = manifest(&[], &[], &["/usr/bin/jq"]);
let base = manifest(&[], &[], &["/usr/bin/jq", "/usr/bin/socat"]);
let (paths, labels) = missing_features(&from, &base);
assert_eq!(paths, vec!["/usr/bin/socat".to_string()]);
assert_eq!(labels, vec!["Auth bridge tunnel (socat)".to_string()]);
// A capability the base dropped is never advertised as a reason to
// migrate, even though the container "differs" from the base.
let (paths, _) = missing_features(&base, &from);
assert!(paths.is_empty());
}
// ── /etc ────────────────────────────────────────────────────────────────
#[test]
fn etc_deltas_surface_the_nodesource_rename_rather_than_copying_it() {
let mut from = Manifest::default();
from.etc_paths
.insert("/etc/apt/sources.list.d/nodesource.sources".into());
let mut base = Manifest::default();
base.etc_paths
.insert("/etc/apt/sources.list.d/nodesource.list".into());
let (only_container, only_base) = etc_deltas(&from, &base);
assert_eq!(
only_container,
vec!["/etc/apt/sources.list.d/nodesource.sources".to_string()]
);
assert_eq!(
only_base,
vec!["/etc/apt/sources.list.d/nodesource.list".to_string()]
);
// And /etc is not a copy root, so neither can be carried across —
// having both would break every apt-get update with a duplicate source.
assert!(!COPY_ROOTS.iter().any(|r| is_under("/etc/apt", r)));
}
// ── Crash-state machine ─────────────────────────────────────────────────
#[test]
fn no_state_file_means_no_recovery() {
assert_eq!(decide_recovery(None, false), Recovery::None);
// A stale in-progress label with no state file is a label that rode the
// final commit into the snapshot image. It must not trigger anything.
assert_eq!(decide_recovery(None, true), Recovery::None);
}
#[test]
fn a_crash_before_the_container_swap_self_heals() {
// `:latest` still points at the old lineage, so start_project_container
// recreates from it unaided.
assert_eq!(
decide_recovery(Some(MIGRATION_PHASE_IN_PROGRESS), false),
Recovery::SelfHeal
);
assert_eq!(
decide_recovery(Some(MIGRATION_PHASE_INTERRUPTED), false),
Recovery::SelfHeal
);
}
#[test]
fn a_crash_after_the_container_swap_needs_a_decision() {
assert_eq!(
decide_recovery(Some(MIGRATION_PHASE_IN_PROGRESS), true),
Recovery::OfferResumeOrRollback
);
assert_eq!(
decide_recovery(Some(MIGRATION_PHASE_INTERRUPTED), true),
Recovery::OfferResumeOrRollback
);
}
#[test]
fn a_finished_migration_waits_for_confirm_or_rollback_either_way() {
// The label is irrelevant once the final commit landed: the phase alone
// decides, because the container is the migrated one by definition.
for labelled in [true, false] {
assert_eq!(
decide_recovery(Some(MIGRATION_PHASE_AWAITING), labelled),
Recovery::OfferConfirmOrRollback
);
}
}
#[test]
fn an_unrecognised_phase_never_silently_discards_the_record() {
assert_eq!(
decide_recovery(Some("who-knows"), false),
Recovery::OfferConfirmOrRollback
);
}
// ── Misc ────────────────────────────────────────────────────────────────
#[test]
fn image_refs_split_on_the_tag_not_a_registry_port() {
assert_eq!(
split_image_ref("triple-c-snapshot-abc:latest"),
("triple-c-snapshot-abc".to_string(), "latest".to_string())
);
assert_eq!(
split_image_ref("ghcr.io/shadowdao/triple-c-sandbox:latest"),
(
"ghcr.io/shadowdao/triple-c-sandbox".to_string(),
"latest".to_string()
)
);
assert_eq!(
split_image_ref("registry:5000/img"),
("registry:5000/img".to_string(), "latest".to_string())
);
}
#[test]
fn rollback_tags_are_sortable_and_docker_legal() {
let t = rollback_tag(
&chrono::DateTime::parse_from_rfc3339("2026-08-09T17:04:05Z")
.unwrap()
.with_timezone(&chrono::Utc),
);
assert_eq!(t, "pre-migration-20260809-170405");
assert!(t
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.'));
}
#[test]
fn the_preflight_parser_reads_df_blocks_as_kibibytes() {
let raw = "###DF\n/dev/sdc 1055762868 12345 900000000 2% /\n###NET\nok\n###END\n";
let p = parse_preflight(raw);
assert_eq!(p.available_bytes, 900_000_000u64 * 1024);
assert!(p.network_ok);
let raw = "###DF\n###NET\nfailed\n###END\n";
let p = parse_preflight(raw);
assert_eq!(p.available_bytes, 0);
assert!(!p.network_ok);
}
#[test]
fn the_manifest_script_emits_every_section_the_parser_expects() {
let s = manifest_script();
for section in [
"###PATHS", "###DPKG", "###APT", "###NPM", "###FEATURES", "###ETC", "###PKGVER",
"###END",
] {
assert!(s.contains(section), "script is missing {}", section);
}
// Every probed feature path must reach the script, or the missing-
// feature report would silently under-report.
for (path, _) in FEATURE_PROBES {
assert!(s.contains(path), "script is missing probe {}", path);
}
}
#[test]
fn shell_quoting_survives_an_apostrophe() {
assert_eq!(shell_single_quote("/opt/a'b"), r#"'/opt/a'\''b'"#);
}
// ── Stale rollback pins (A5) ─────────────────────────────────────────────
fn at(y: i32, m: u32, d: u32) -> chrono::DateTime<chrono::Utc> {
chrono::NaiveDate::from_ymd_opt(y, m, d)
.unwrap()
.and_hms_opt(12, 0, 0)
.unwrap()
.and_utc()
}
#[test]
fn a_rollback_tag_round_trips_through_its_parser() {
let made = at(2026, 3, 14);
assert_eq!(parse_rollback_tag(&rollback_tag(&made)), Some(made));
}
#[test]
fn only_a_real_rollback_tag_parses() {
assert_eq!(parse_rollback_tag("latest"), None);
assert_eq!(parse_rollback_tag("pre-migration-"), None);
// Looks like ours but carries no timestamp we produced. Guessing here
// would mean deleting the only copy of somebody's system layer.
assert_eq!(parse_rollback_tag("pre-migration-keepme"), None);
assert_eq!(parse_rollback_tag("pre-migration-20260231-000000"), None);
}
#[test]
fn a_snapshot_reference_yields_its_project_id() {
assert_eq!(
parse_snapshot_reference("triple-c-snapshot-abc-123:pre-migration-20260101-101500"),
Some(("abc-123".to_string(), "pre-migration-20260101-101500".to_string()))
);
// Not ours: a base image, and a repo that merely shares a prefix.
assert_eq!(parse_snapshot_reference("triple-c-sandbox:latest"), None);
assert_eq!(parse_snapshot_reference("triple-c-snapshot-:latest"), None);
}
#[test]
fn a_pin_awaiting_confirmation_is_never_reaped_at_any_age() {
// The one rule that cannot bend: while a migration record exists, this
// image is the only copy of the rollback target and the user has not
// yet said they are happy on the new base.
let ancient = rollback_tag(&at(2020, 1, 1));
assert!(!pin_is_reapable(&ancient, true, &at(2026, 8, 23)));
}
#[test]
fn an_unclaimed_pin_is_reaped_only_once_it_is_old() {
let made = at(2026, 8, 1);
let tag = rollback_tag(&made);
assert!(!pin_is_reapable(&tag, false, &at(2026, 8, 2)));
assert!(!pin_is_reapable(
&tag,
false,
&(made + chrono::Duration::days(STALE_PIN_MAX_AGE_DAYS - 1))
));
assert!(pin_is_reapable(
&tag,
false,
&(made + chrono::Duration::days(STALE_PIN_MAX_AGE_DAYS))
));
}
#[test]
fn a_tag_we_cannot_date_is_left_alone() {
assert!(!pin_is_reapable("pre-migration-handmade", false, &at(2026, 8, 23)));
assert!(!pin_is_reapable("latest", false, &at(2026, 8, 23)));
}
}