Projects were pinned to the image they were first created from. Both create paths preferred triple-c-snapshot-<id>:latest whenever it existed, and container_needs_recreation compared the container's live image against the triple-c.image label — which create_container wrote from the same image it created from. A tautology that could never fire. The only escape was Reset, which calls remove_project_volumes and destroys the login, skills and transcripts. Measured consequences on this host: real projects are missing socat (so the auth bridge cannot tunnel) and bubblewrap (so sandbox mode does not work), plus Mission Control and triple-c-sso-refresh, and sit 61 packages behind the base including ca-certificates, openssl and curl. Detection. create_container now writes triple-c.base-image-id (the image ID, not RepoDigests, which local-built and custom images do not have) and triple-c.create-image. container_needs_recreation takes the expected create-image and compares against the latter, so the check means something. base-image-id is deliberately NOT compared: a base bump would otherwise silently recreate from the snapshot, consuming the "you should migrate" signal without migrating. Staleness is surfaced, never acted on automatically. Migration keeps the volumes. /home/claude and ~/.claude are volumes and the image's copy is seed-only — permanently masked after first mount — so the login, ~/.claude.json, skills, transcripts, scheduler tasks, SSH keys, cargo, uv, ruff and Claude Code itself re-attach untouched. Only root-level state is rebuilt: apt packages are replayed against the new base rather than copied, so no stale libc is dragged forward, and /usr/local, /opt and the non-bind-mounted parts of /workspace are copied verbatim with tar --skip-old-files so they can never clobber a newer base binary. docker diff is not used: on a snapshot-derived container it reports only changes since the last commit. Raw image-vs-image diffing is filtered through dpkg ownership because it otherwise lies — 8,677 raw path differences on a real project reduced to 2 genuinely user-authored files, both loose /workspace-root files. Crash safety. snapshot:latest keeps pointing at the old image until the final commit, so any crash before it self-heals on next start. Later crashes are caught by reconcile_project_statuses. The rollback pin is a docker tag: 0.057s and 0 bytes. Rollback restores the system layer only — volumes are never touched — and the UI says so rather than implying a time machine. Fixes an infinite recreation loop shipped with the MCP removal. docker commit propagates labels to the image, so a container created from a snapshot inherited its non-empty triple-c.mcp-fingerprint and the one-shot shim recreated it again on every start, forever. Lineage labels are now always written explicitly. Documents the second, separate bug this uncovered: Dockerfile changes under /home/claude never reach an existing project, migration or not, because the volume masks them. Anything that must stay upgradable belongs in /usr/local/bin or /opt, or must be seeded by entrypoint.sh. 145 Rust tests, 227 frontend tests, both builds clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1420 lines
54 KiB
Rust
1420 lines
54 KiB
Rust
//! 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;
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 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.
|
|
pub const MANIFEST_ROOTS: &[&str] = &["/usr/local", "/opt", "/srv", "/workspace"];
|
|
|
|
/// 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";
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 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()
|
|
}
|
|
|
|
/// 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),
|
|
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);
|
|
}
|
|
|
|
// ── 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'"#);
|
|
}
|
|
|
|
}
|