feat(installer): back up what a run is about to overwrite #11

Merged
whitlocktech merged 1 commits from feat/backup-before-overwrite into edge 2026-08-05 17:53:47 +00:00
8 changed files with 680 additions and 12 deletions

485
src/backup.rs Normal file
View File

@@ -0,0 +1,485 @@
//! Copies of what a run is about to overwrite (PLAN.md §5.3).
//!
//! ## Scoped by what cannot be fetched again
//!
//! Most of what this installer writes is replaceable: the sidecar binary and every overlay file are
//! re-downloadable and hash-named in the bundle, and the sidecar's database is a cache with a schema
//! — `link`'s `store.rs` creates every table `IF NOT EXISTS` and every one of them holds shard state
//! the sweeps repopulate. Backing those up would be bulk with no recovery value, and the bulk is not
//! free: it would bury the two things that matter.
//!
//! What a run can destroy irrecoverably is short:
//!
//! 1. **The operator's own edits to a file the overlay owns.** `Bridge.cfg` is deliberately kept
//! (PLAN.md §5 Phase 1), but every `.cs` file and `Scripts.csproj` is overwritten
//! *unconditionally and by design* — so the one place this tool knowingly discards work is the
//! one place it should keep a copy first.
//! 2. **A stock ServUO file the patch tier edits.** `patches/originals/` already holds the
//! pre-*tier* copy and is never overwritten, which is the right revert target; it is not a
//! record of what the file looked like *this morning*, after the operator's own later edits.
//! 3. **`sidecar.toml`**, whose token the website already holds. Mint a new one and the site's
//! saved configuration starts answering `401` with nothing on the sidecar to explain why.
//!
//! ## What decides whether a backup happens
//!
//! **Whether this run is about to overwrite something** — not which verb was typed and not whether
//! a prior record exists. PLAN.md §5.3 framed it as "`update`, and `install` over an existing
//! record", on the reasoning that a first install overwrites nothing. That reasoning does not
//! survive contact with `INSTALL.md` Appendix A2, which documents deploying the overlay **by hand**:
//! a first `install` over such a tree finds `.cs` files that differ, plans them as `Change`, and
//! overwrites them with no record anywhere of what was there. So the test is the direct one, and a
//! genuine first install onto a clean tree still writes nothing because there is nothing to copy.
//!
//! ## Restoring is printed, not done
//!
//! Same rule as the uninstall report, and for the same reason: the installer cannot know what has
//! changed since, and a restore that puts an old `.cs` file back over a newer overlay eats work
//! rather than saving it. The path and the manifest are what this module hands over.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use crate::paths::Layout;
use crate::util;
/// How many backup directories survive. Older ones are pruned as new ones are written.
///
/// An unbounded directory of ServUO source copies on a shard host is its own support problem, and
/// the value of an old backup falls off a cliff: what an operator reaches for is "before this
/// upgrade", occasionally "before the one before". Three is that, plus one.
pub const KEEP: usize = 3;
/// The `schema` written into `manifest.json`, so a future reader can tell shapes apart.
const SCHEMA: u32 = 1;
/// Why a file was copied. Recorded per entry, because "what did this upgrade touch" is answered
/// very differently by an overlay file and by a stock ServUO file the patch tier edited.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reason {
/// An overlay file whose on-disk content the sync is about to replace.
OverlayChange,
/// A stock ServUO file the patch tier is about to edit.
PatchTarget,
/// A companion `.cs` the tier copies in, which already existed in the tree.
PatchCompanion,
/// `sidecar.toml` — the token the website holds.
SidecarConfig,
}
impl Reason {
fn as_str(self) -> &'static str {
match self {
Self::OverlayChange => "overlay-change",
Self::PatchTarget => "patch-target",
Self::PatchCompanion => "patch-companion",
Self::SidecarConfig => "sidecar-config",
}
}
/// Which sub-directory of the backup the copy lands under.
///
/// The two roots are kept apart because a path is only meaningful relative to one of them, and
/// without the split a state file could collide with a tree file of the same name.
fn root_dir(self) -> &'static str {
match self {
Self::SidecarConfig => "state",
_ => "servuo",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Manifest {
pub schema: u32,
/// RFC 3339, when the backup was taken.
pub taken: String,
/// `install` or `update` — the verb that displaced these files.
pub command: String,
pub installer: String,
/// The bundle in the record before this run, when there was one.
#[serde(skip_serializing_if = "Option::is_none")]
pub bundle_from: Option<String>,
/// The bundle this run is moving to.
pub bundle_to: String,
pub servuo_root: String,
pub files: Vec<Entry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Entry {
/// Where the copy sits inside the backup directory, `/`-separated.
pub path: String,
/// Where it was copied from, absolute, as it was on this host.
pub source: String,
pub sha256: String,
pub reason: String,
}
/// One run's backup. Created up front and handed to each stage that writes.
///
/// **The directory is created lazily, on the first capture.** A run that overwrites nothing must
/// leave nothing behind — an empty dated directory per run would be indistinguishable from a
/// backup that failed to record anything, and would push real ones out of the retention window.
pub struct Session {
dir: PathBuf,
enabled: bool,
started: bool,
root: PathBuf,
layout: Layout,
command: &'static str,
bundle_from: Option<String>,
bundle_to: String,
taken: String,
entries: Vec<Entry>,
}
impl Session {
/// `enabled` is false for `--verify` (a dry run must not create state, the same rule that keeps
/// it away from `--print-config`) and for `--no-backup`.
pub fn new(
layout: &Layout,
root: &Path,
command: &'static str,
bundle_from: Option<String>,
bundle_to: String,
enabled: bool,
) -> Self {
let taken = chrono::Utc::now();
Self {
// Colons are not legal in a Windows path component, so the stamp is the basic ISO 8601
// form. It still sorts lexicographically, which is what the pruning relies on.
dir: layout
.backups_dir()
.join(taken.format("%Y%m%dT%H%M%SZ").to_string()),
enabled,
started: false,
root: root.to_path_buf(),
layout: layout.clone(),
command,
bundle_from,
bundle_to,
taken: taken.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
entries: Vec::new(),
}
}
/// True once something has actually been copied.
pub fn has_entries(&self) -> bool {
!self.entries.is_empty()
}
/// Copies `source` into this backup, if it exists and backups are enabled.
///
/// A file that does not exist is not an error and not an entry: the caller asks for anything it
/// *may* be about to overwrite, and "there was nothing there" is the common answer on a first
/// install.
pub fn capture(&mut self, source: &Path, reason: Reason) -> Result<()> {
if !self.enabled || !source.exists() {
return Ok(());
}
let rel = self.relative_to_root(source, reason);
let dest = self.dir.join(reason.root_dir()).join(&rel);
if dest.exists() {
// Two stages can name the same file — a patch target that is also a companion path, or
// a re-entrant caller. First copy wins: it is the one taken furthest from any write.
return Ok(());
}
if !self.started {
fs::create_dir_all(&self.dir).with_context(|| {
format!("cannot create the backup directory {}", self.dir.display())
})?;
self.started = true;
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("cannot create {}", parent.display()))?;
}
fs::copy(source, &dest).with_context(|| {
format!(
"cannot back up {} before overwriting it. Re-run with --no-backup to proceed \
without a copy",
source.display()
)
})?;
self.entries.push(Entry {
path: format!(
"{}/{}",
reason.root_dir(),
rel.replace(std::path::MAIN_SEPARATOR, "/")
),
source: source.display().to_string(),
sha256: util::sha256_file(source)?,
reason: reason.as_str().to_string(),
});
Ok(())
}
/// Writes `manifest.json` and prunes older backups. Returns the directory when one was written.
///
/// The manifest is written **last**, so a directory carrying one is a complete backup. Pruning
/// only considers directories that have one, for the same reason: a run interrupted mid-copy
/// must not be able to evict a good backup by being newer than it.
pub fn finish(mut self) -> Result<Option<PathBuf>> {
if !self.started {
return Ok(None);
}
self.entries.sort_by(|a, b| a.path.cmp(&b.path));
let manifest = Manifest {
schema: SCHEMA,
taken: self.taken.clone(),
command: self.command.to_string(),
installer: env!("CARGO_PKG_VERSION").to_string(),
bundle_from: self.bundle_from.clone(),
bundle_to: self.bundle_to.clone(),
servuo_root: self.root.display().to_string(),
files: self.entries.clone(),
};
let body = serde_json::to_string_pretty(&manifest)? + "\n";
util::write_atomic(&manifest_path(&self.dir), body.as_bytes())
.context("cannot write the backup manifest")?;
prune(&self.layout, KEEP)?;
Ok(Some(self.dir.clone()))
}
/// The path a captured file takes inside the backup, relative to the root it belongs to.
fn relative_to_root(&self, source: &Path, reason: Reason) -> String {
let rel = match reason.root_dir() {
"servuo" => source.strip_prefix(&self.root).unwrap_or(source),
_ => source
.strip_prefix(&self.layout.state_dir)
.unwrap_or(source),
};
// An absolute path outside the root it was filed under would escape the backup directory
// when joined. Falling back to the file name keeps the copy inside; the manifest still
// records exactly where it came from.
if rel.is_absolute() || rel.as_os_str().is_empty() {
return source
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "file".to_string());
}
rel.to_string_lossy().to_string()
}
}
fn manifest_path(dir: &Path) -> PathBuf {
dir.join("manifest.json")
}
/// Every complete backup on this host, newest first.
pub fn list(layout: &Layout) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = match fs::read_dir(layout.backups_dir()) {
Ok(entries) => entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| manifest_path(p).is_file())
.collect(),
Err(_) => Vec::new(),
};
// The stamp is fixed-width and zero-padded, so lexicographic order is chronological order.
dirs.sort();
dirs.reverse();
dirs
}
/// Reads one backup's manifest.
pub fn read_manifest(dir: &Path) -> Result<Manifest> {
let body = fs::read_to_string(manifest_path(dir))
.with_context(|| format!("cannot read {}", manifest_path(dir).display()))?;
serde_json::from_str(&body)
.with_context(|| format!("{} is not a backup manifest", manifest_path(dir).display()))
}
/// Removes all but the `keep` newest complete backups.
pub fn prune(layout: &Layout, keep: usize) -> Result<()> {
for old in list(layout).into_iter().skip(keep) {
fs::remove_dir_all(&old)
.with_context(|| format!("cannot remove the old backup {}", old.display()))?;
}
Ok(())
}
/// Deletes every backup. Reached only from `uninstall --purge`, alongside the config, the database
/// and the cached patch set — they are all the same kind of thing: the only offline record of what
/// was here before.
pub fn remove_all(layout: &Layout) -> Result<()> {
let dir = layout.backups_dir();
if dir.exists() {
fs::remove_dir_all(&dir).with_context(|| format!("cannot remove {}", dir.display()))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::TempDir;
fn layout_in(root: &Path) -> Layout {
let mut layout = crate::paths::layout();
layout.state_dir = root.join("state");
layout
}
fn write(path: &Path, body: &str) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, body).unwrap();
}
#[test]
fn a_run_that_overwrites_nothing_leaves_nothing_behind() {
let tmp = TempDir::new("backup-empty").unwrap();
let layout = layout_in(tmp.path());
let root = tmp.path().join("ServUO");
let mut session = Session::new(&layout, &root, "install", None, "2026.08.05".into(), true);
// Nothing on disk to copy — the common first-install case.
session
.capture(
&root.join("Scripts/Custom/Bridge/BridgeLink.cs"),
Reason::OverlayChange,
)
.unwrap();
assert!(session.finish().unwrap().is_none());
assert!(
!layout.backups_dir().exists(),
"an empty dated directory would be indistinguishable from a failed backup"
);
}
#[test]
fn a_captured_file_is_copied_verbatim_and_recorded() {
let tmp = TempDir::new("backup-capture").unwrap();
let layout = layout_in(tmp.path());
let root = tmp.path().join("ServUO");
let source = root.join("Scripts/Custom/Bridge/BridgeLink.cs");
write(&source, "the operator's own edit\n");
write(&layout.sidecar_config(), "[web]\nauth_token = \"secret\"\n");
let mut session = Session::new(
&layout,
&root,
"update",
Some("2026.08.04".into()),
"2026.08.05".into(),
true,
);
session.capture(&source, Reason::OverlayChange).unwrap();
session
.capture(&layout.sidecar_config(), Reason::SidecarConfig)
.unwrap();
let dir = session.finish().unwrap().expect("a backup was taken");
let copy = dir.join("servuo/Scripts/Custom/Bridge/BridgeLink.cs");
assert_eq!(
fs::read_to_string(&copy).unwrap(),
"the operator's own edit\n"
);
assert!(dir.join("state/sidecar.toml").is_file());
let manifest = read_manifest(&dir).unwrap();
assert_eq!(manifest.command, "update");
assert_eq!(manifest.bundle_from.as_deref(), Some("2026.08.04"));
assert_eq!(manifest.files.len(), 2);
let overlay = manifest
.files
.iter()
.find(|f| f.reason == "overlay-change")
.unwrap();
// The path inside the backup is always `/`-separated, so a manifest written on Windows
// reads the same as one written on Linux.
assert_eq!(overlay.path, "servuo/Scripts/Custom/Bridge/BridgeLink.cs");
assert_eq!(overlay.sha256, util::sha256_file(&source).unwrap());
assert!(overlay.source.contains("BridgeLink.cs"));
}
#[test]
fn the_first_copy_of_a_file_wins() {
// Two stages can name the same path. The earlier capture is the one taken furthest from
// any write, so a later one must not overwrite it with content that has already changed.
let tmp = TempDir::new("backup-twice").unwrap();
let layout = layout_in(tmp.path());
let root = tmp.path().join("ServUO");
let source = root.join("Server/EventSink.cs");
write(&source, "before\n");
let mut session = Session::new(&layout, &root, "install", None, "2026.08.05".into(), true);
session.capture(&source, Reason::PatchTarget).unwrap();
write(&source, "after\n");
session.capture(&source, Reason::PatchTarget).unwrap();
let dir = session.finish().unwrap().unwrap();
assert_eq!(
fs::read_to_string(dir.join("servuo/Server/EventSink.cs")).unwrap(),
"before\n"
);
assert_eq!(read_manifest(&dir).unwrap().files.len(), 1);
}
#[test]
fn disabled_sessions_write_nothing() {
let tmp = TempDir::new("backup-off").unwrap();
let layout = layout_in(tmp.path());
let root = tmp.path().join("ServUO");
let source = root.join("Scripts/Custom/Bridge/BridgeLink.cs");
write(&source, "content\n");
let mut session = Session::new(&layout, &root, "install", None, "2026.08.05".into(), false);
session.capture(&source, Reason::OverlayChange).unwrap();
assert!(session.finish().unwrap().is_none());
assert!(!layout.backups_dir().exists());
}
#[test]
fn pruning_keeps_the_newest_and_ignores_incomplete_directories() {
let tmp = TempDir::new("backup-prune").unwrap();
let layout = layout_in(tmp.path());
for stamp in ["20260801T000000Z", "20260802T000000Z", "20260803T000000Z"] {
write(
&layout.backups_dir().join(stamp).join("manifest.json"),
"{\"schema\":1}",
);
}
// A run interrupted before its manifest was written. It must neither be listed nor be able
// to evict a complete backup by being newer.
write(
&layout.backups_dir().join("20260804T000000Z/servuo/x.cs"),
"half a copy\n",
);
assert_eq!(list(&layout).len(), 3);
prune(&layout, 2).unwrap();
let kept: Vec<String> = list(&layout)
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert_eq!(kept, vec!["20260803T000000Z", "20260802T000000Z"]);
assert!(
layout.backups_dir().join("20260804T000000Z").exists(),
"an incomplete directory is left for a human to look at, not silently deleted"
);
}
#[test]
fn purge_removes_every_backup() {
let tmp = TempDir::new("backup-purge").unwrap();
let layout = layout_in(tmp.path());
write(
&layout.backups_dir().join("20260801T000000Z/manifest.json"),
"{\"schema\":1}",
);
remove_all(&layout).unwrap();
assert!(!layout.backups_dir().exists());
// Removing what is not there is not an error: `uninstall --purge` runs on hosts that never
// took a backup.
remove_all(&layout).unwrap();
}
}

View File

@@ -81,8 +81,11 @@ pub struct Cli {
pub site_url: Option<String>,
/// `--yes`: assume the default answer to every prompt.
pub assume_yes: bool,
/// `--purge`: on uninstall, also delete `sidecar.toml` and `uo-link.db`.
/// `--purge`: on uninstall, also delete `sidecar.toml`, `uo-link.db`, the cached patch set
/// and every backup.
pub purge: bool,
/// `--no-backup`: do not copy what this run is about to overwrite (PLAN.md §5.3).
pub no_backup: bool,
}
impl Default for Cli {
@@ -98,6 +101,7 @@ impl Default for Cli {
site_url: None,
assume_yes: false,
purge: false,
no_backup: false,
}
}
}
@@ -138,8 +142,13 @@ Options:
On uninstall it means yes: that prompt
defaults to no, and typing `uninstall
--yes` is not an accident.
--purge uninstall. Also delete sidecar.toml and
uo-link.db, which are otherwise kept.
--no-backup install, update. Do not copy the files
this run is about to overwrite. They are
otherwise saved under <state>/backups/,
newest 3 kept.
--purge uninstall. Also delete sidecar.toml,
uo-link.db, the cached patch set and every
backup, all of which are otherwise kept.
-V, --version Print the installer version and exit.
-h, --help Print this help and exit.
@@ -182,6 +191,7 @@ pub fn parse<I: IntoIterator<Item = String>>(args: I) -> Result<Cli, String> {
"--verify" => cli.verify = true,
"--yes" | "-y" => cli.assume_yes = true,
"--purge" => cli.purge = true,
"--no-backup" => cli.no_backup = true,
"--patches" => cli.patches = PatchChoice::Yes,
"--no-patches" => cli.patches = PatchChoice::No,
"--patches-unsupported-servuo" => cli.patches_unsupported_servuo = true,

View File

@@ -158,6 +158,9 @@ pub fn run(cli: &Cli) -> Result<i32> {
// ── The bundle ───────────────────────────────────────────────────────────
rows.push(bundle_row(&record));
// ── Backups ──────────────────────────────────────────────────────────────
rows.push(backup_row(&layout));
// ── Report ───────────────────────────────────────────────────────────────
println!();
for row in &rows {
@@ -673,6 +676,35 @@ fn shard_row(root: Result<&ServUoRoot, &anyhow::Error>, health: Option<&Health>)
/// Offline is a `⚠`, never a `✗`. A shard host with no outbound route to Gitea is a supported way
/// to run this — the operator downloads artifacts elsewhere — and failing a health check over it
/// would report a working deployment as broken.
/// The most recent backup, so "can I go back?" is answerable without knowing the layout.
///
/// Always `✓`, never a failure: having no backup is the correct state on a host that has never
/// overwritten anything, and a shard that is running fine does not become broken because nothing
/// has displaced a file yet.
fn backup_row(layout: &paths::Layout) -> Row {
let backups = crate::backup::list(layout);
let Some(newest) = backups.first() else {
return Row::ok("Backups", "none taken — no run has replaced a file yet");
};
let detail = match crate::backup::read_manifest(newest) {
Ok(manifest) => format!(
"{}{} file(s) replaced by {} to bundle {}",
manifest.taken,
manifest.files.len(),
manifest.command,
manifest.bundle_to
),
// A directory with an unreadable manifest is still a directory of the operator's files, so
// it is reported rather than skipped.
Err(_) => format!("{} — manifest unreadable", newest.display()),
};
Row::ok("Backups", detail).note(format!(
"{} kept in {}",
backups.len(),
layout.backups_dir().display()
))
}
fn bundle_row(record: &InstallRecord) -> Row {
let url = bundle::url_for(None);
let current = match net::get_text_within(&url, BUNDLE_TIMEOUT).and_then(|b| bundle::parse(&b)) {

View File

@@ -38,7 +38,7 @@ use crate::record::{
};
use crate::servuo::ServUoRoot;
use crate::util::TempDir;
use crate::{bundle, net, overlay, paths, service, servuo, sidecar, tier, ui};
use crate::{backup, bundle, net, overlay, paths, service, servuo, sidecar, tier, ui};
/// Which verb is driving the pipeline.
///
@@ -185,6 +185,23 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
let planned = overlay::plan(&unpacked, &root.path, prior_files)?;
let summary = overlay::summarize(&planned);
// ── Backup ───────────────────────────────────────────────────────────────
// Created before the first write and handed to every stage that overwrites, so each copy is
// taken while the file is still the operator's (PLAN.md §5.3). The directory is created lazily:
// a run that displaces nothing leaves nothing behind.
let mut backup = backup::Session::new(
&layout,
&root.path,
if mode.is_update() {
"update"
} else {
"install"
},
prior.as_ref().map(|p| p.bundle.tag.clone()),
bundle.bundle.clone(),
!cli.verify && !cli.no_backup,
);
ui::heading("Overlay sync");
let lines = overlay::render(&planned);
if lines.is_empty() {
@@ -200,6 +217,15 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
summary.add, summary.change, summary.unchanged, summary.kept
);
} else {
// `Change` only. An `Add` has nothing underneath it, `Unchanged` is byte-identical to what
// would replace it, and `KeptOperatorModified` is not written at all — copying those three
// would bury the files that are actually being displaced.
for file in planned
.iter()
.filter(|f| f.action == overlay::Action::Change)
{
backup.capture(&file.dst, backup::Reason::OverlayChange)?;
}
overlay::apply(&planned)?;
// "deployed" is claimed only when something actually moved. A run that copied nothing
// reporting "deployed" would read as a fresh install to anyone skimming the output.
@@ -246,8 +272,18 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
.as_ref()
.map(|p| p.patch_records())
.unwrap_or_default(),
&mut backup,
)?;
// The config joins a backup that is already being taken; it is never the reason for one. The
// installer never rewrites `sidecar.toml`, so nothing here displaces it — it is copied so that
// a restored set of files comes with the token that matches them, rather than an operator
// restoring a tree and then finding the website pointed at a token that has moved on.
if backup.has_entries() {
backup.capture(&layout.sidecar_config(), backup::Reason::SidecarConfig)?;
}
let backup_dir = backup.finish()?;
// ── The sidecar and its service ──────────────────────────────────────────
let sidecar = install_sidecar(
cli,
@@ -294,6 +330,19 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
}
}
// Named after the record rather than at the moment it was taken, because that is where an
// operator looks when a run has finished and something is wrong. Restoring is theirs to do:
// the installer cannot know what has changed since, and putting an old `.cs` file back over a
// newer overlay eats work rather than saving it.
if let Some(dir) = &backup_dir {
println!("\n Backed up {}", dir.display());
println!(
" the files this run replaced, with a manifest naming each one.\n\
\x20 The newest {} backups are kept; `uninstall --purge` removes them.",
backup::KEEP
);
}
// ── Closing notes ────────────────────────────────────────────────────────
println!();
if cli.verify {

View File

@@ -28,6 +28,7 @@
//! PLAN.md §3, and `[[bin]] test = false` keeps Cargo from building a harness under the triggering
//! name. Nothing an operator sees changes.
pub mod backup;
pub mod bundle;
pub mod cli;
pub mod diff;

View File

@@ -68,6 +68,16 @@ impl Layout {
self.patches_dir().join("originals")
}
/// `/etc/runicgateway/backups` — one dated directory per run that overwrote something
/// (PLAN.md §5.3).
///
/// Beside the cached patch set rather than inside it: both survive an uninstall and both go
/// with `--purge`, but a backup is a copy of what *this host* had, while `patches/` is a copy
/// of what the *release* shipped.
pub fn backups_dir(&self) -> PathBuf {
self.state_dir.join("backups")
}
/// The unit file a systemd host gets. Meaningless elsewhere, and unused under a relocated
/// layout, where no service is registered at all.
pub fn systemd_unit(&self) -> PathBuf {

View File

@@ -95,6 +95,7 @@ pub fn run(
declared: Option<&Tier>,
layout: &paths::Layout,
prior: &[FeatureRecord],
backup: &mut crate::backup::Session,
) -> Result<Outcome> {
let declared_tier = Tier::resolve(declared);
if declared_tier.features.is_empty() {
@@ -131,7 +132,7 @@ pub fn run(
),
);
announce_new_features(&declared_tier, &tier);
return apply_tier(cli, root, unpacked, &tier, layout, prior, supported);
return apply_tier(cli, root, unpacked, &tier, layout, prior, supported, backup);
}
match consent(cli, root, supported, &tier)? {
@@ -157,7 +158,7 @@ pub fn run(
}
}
apply_tier(cli, root, unpacked, &tier, layout, prior, supported)
apply_tier(cli, root, unpacked, &tier, layout, prior, supported, backup)
}
/// The subset of a release's tier that a previous run actually applied.
@@ -322,6 +323,7 @@ fn apply_tier(
layout: &paths::Layout,
prior: &[FeatureRecord],
supported: bool,
backup: &mut crate::backup::Session,
) -> Result<Outcome> {
let previous = patch::index_records(prior);
let mut records: Vec<FeatureRecord> = Vec::new();
@@ -353,7 +355,7 @@ fn apply_tier(
}
if !cli.verify {
write_feature(root, unpacked, feature, &resolved, layout)?;
write_feature(root, unpacked, feature, &resolved, layout, backup)?;
}
applied_patches += resolved.len();
@@ -521,9 +523,18 @@ fn write_feature(
feature: &Feature,
resolved: &[Resolved],
layout: &paths::Layout,
backup: &mut crate::backup::Session,
) -> Result<()> {
for r in resolved {
if let Resolution::Applicable { edits, .. } = &r.resolution {
// `patches/originals/` holds the pre-*tier* copy and is never overwritten, which is the
// right thing to revert to. It is not a copy of what this file looked like before *this*
// run, though — on a second tier pass the operator's own later edits are only in the
// backup (PLAN.md §5.3).
backup.capture(
&patch::join(&root.path, &r.target),
crate::backup::Reason::PatchTarget,
)?;
let original = patch::join(&layout.patch_originals_dir(), &r.target);
if !original.exists() {
write_atomic(&original, &r.content)
@@ -542,6 +553,9 @@ fn write_feature(
for companion in &feature.companions {
let src = patch::join(unpacked, &companion.file);
let dst = patch::join(&root.path, &companion.install_to);
// Copied unconditionally, like every other `.cs` the overlay owns — so an operator who
// edited one loses it here unless a copy is taken first.
backup.capture(&dst, crate::backup::Reason::PatchCompanion)?;
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("cannot create {}", parent.display()))?;

View File

@@ -121,11 +121,24 @@ pub fn run(cli: &Cli) -> Result<i32> {
if cli.purge {
remove_dir(&layout.patches_dir(), &mut done, &mut problems);
} else if layout.patches_dir().exists() {
done.push(format!(
"kept {} — the cached patches and the pre-patch originals you need to revert by hand",
layout.patches_dir().display()
));
remove_dir(&layout.backups_dir(), &mut done, &mut problems);
} else {
if layout.patches_dir().exists() {
done.push(format!(
"kept {} — the cached patches and the pre-patch originals you need to revert by hand",
layout.patches_dir().display()
));
}
// Same rule and the same reason as the patch cache: a backup is the only copy of what this
// host had before an upgrade replaced it, and it outlives the deployment that took it.
let backups = crate::backup::list(&layout);
if !backups.is_empty() {
done.push(format!(
"kept {}{} backup(s) of files earlier runs replaced",
layout.backups_dir().display(),
backups.len()
));
}
}
remove_file(&record_path, &mut done, &mut problems);
@@ -181,6 +194,7 @@ fn print_intent(
println!(" · {}", layout.install_record().display());
if purge {
println!(" · {} [--purge]", layout.patches_dir().display());
println!(" · {} [--purge]", layout.backups_dir().display());
}
println!();
@@ -199,6 +213,14 @@ fn print_intent(
" · {} (cached patches and pre-patch originals)",
layout.patches_dir().display()
);
let backups = crate::backup::list(layout);
if !backups.is_empty() {
println!(
" · {} ({} backup(s) of files earlier runs replaced)",
layout.backups_dir().display(),
backups.len()
);
}
}
let _ = record;
println!();
@@ -241,6 +263,7 @@ fn render_report(
render_overlay_section(&mut out, record);
render_patch_section(&mut out, record, layout, purge);
render_backup_section(&mut out, layout, purge);
let _ = writeln!(
out,
@@ -373,6 +396,50 @@ fn render_patch_section(
}
}
/// The backups earlier runs took, since this report is the durable record of what was left behind.
///
/// Listed rather than summarized: a backup is only useful to someone who knows it exists, and by
/// the time this report is read the run that took it is long out of the scrollback.
fn render_backup_section(out: &mut String, layout: &paths::Layout, purge: bool) {
let backups = crate::backup::list(layout);
if backups.is_empty() {
return;
}
if purge {
let _ = writeln!(
out,
"
── Backups ──────────────────────────────────────────────────────────────────
{} backup(s) of files earlier runs replaced were removed by --purge.
",
backups.len()
);
return;
}
let _ = writeln!(
out,
"
── Backups ──────────────────────────────────────────────────────────────────
Copies of the files earlier runs replaced, newest first. These are kept:
"
);
for dir in &backups {
let count = crate::backup::read_manifest(dir)
.map(|m| m.files.len())
.unwrap_or(0);
let _ = writeln!(out, " {} ({} file(s))", dir.display(), count);
}
let _ = writeln!(
out,
"
Each carries a manifest.json naming where every file came from. Restoring is yours to
do — this tool will not put an old file back over a newer one. `--purge` removes them.
"
);
}
/// Renders one cached patch's added and removed lines, indented for the report.
fn render_hunks(layout: &paths::Layout, name: &str, sha256: &str) -> Option<String> {
let path = layout.patches_dir().join(format!("{name}.patch"));