feat(installer): back up what a run is about to overwrite
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m59s

PLAN.md §5.3. Before anything is written, every file this run will
replace is copied into <state>/backups/<utc-stamp>/ with a manifest
naming where each came from. --no-backup opts out; --verify takes none.

Scoped by what cannot be fetched again. The sidecar binary and the
overlay files are re-downloadable and hash-named in the bundle, and the
database is a cache with a schema -- link's store.rs creates every table
IF NOT EXISTS over shard state the sweeps repopulate. What a run can
destroy for good is an operator's edits to a deployed .cs file, which
Phase 1 overwrites unconditionally and by design, and sidecar.toml,
whose token the website already holds.

Two deviations from §5.3 as written, both found by building it:

- The trigger is "this run is about to overwrite something", not "an
  update, or an install over an existing record". §5.3 justified the
  latter with "a first install overwrites nothing" -- which is not true
  of a tree deployed by hand per INSTALL.md Appendix A2, a documented
  path. There the first install finds .cs files that differ, plans them
  as Change, and overwrites them with no record anywhere. The direct
  test covers that case and still writes nothing for a genuine first
  install, because there is nothing to copy.
- sidecar.toml joins a backup that is already being taken and is never
  the reason for one. Nothing here rewrites it, so making it a trigger
  would put a dated directory on disk after every no-op update; it is
  copied so a restored set of files comes with the token that matches
  them.

The directory is created lazily and the manifest is written last, so a
directory carrying one is a complete backup -- and pruning only
considers those, so a run interrupted mid-copy cannot evict a good
backup by being newer than it. Three are kept. uninstall keeps them and
names them in its report; --purge removes them, alongside the config,
the database and the cached patch set. doctor reports the newest.

Restoring stays printed rather than done, as the uninstall report is:
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.

Verified live against two scratch ServUO trees built from the real 57.4
files: a clean first install leaving no backups directory at all, an
update after editing a deployed .cs (copy holds the edit, tree gets the
release's file, manifest lists both it and sidecar.toml), a no-op update
taking none, --no-backup and --verify each taking none, a fourth backup
pruning the oldest, doctor's row, uninstall keeping three and listing
them, --purge removing them, and a --patches run capturing the
pre-patch Logging.cs while the two rung-0 patches correctly captured
nothing. fmt, clippy -D warnings and 144 tests on both Linux and
Windows.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-05 05:47:16 -05:00
parent 14f65d50a0
commit 82900da939
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"));