Files
Triple-C/app/src-tauri/src/docker/migration.rs
T

1624 lines
62 KiB
Rust
Raw Normal View History

//! 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)"),
];
/// 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))?;
let id = created.id;
let result = run_throwaway_inner(&id).await;
if let Err(e) = docker
.remove_container(
&id,
Some(RemoveContainerOptions {
force: true,
v: true,
..Default::default()
}),
)
.await
{
log::warn!("Failed to remove probe container {}: {}", id, e);
}
result
}
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"))
}
/// 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'"#);
}
}