Merge pull request 'feat(installer): back up what a run is about to overwrite' (#11) from feat/backup-before-overwrite into edge
Reviewed-on: #11
This commit is contained in:
485
src/backup.rs
Normal file
485
src/backup.rs
Normal 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(©).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();
|
||||
}
|
||||
}
|
||||
16
src/cli.rs
16
src/cli.rs
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
10
src/paths.rs
10
src/paths.rs
@@ -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 {
|
||||
|
||||
20
src/tier.rs
20
src/tier.rs
@@ -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()))?;
|
||||
|
||||
@@ -121,12 +121,25 @@ 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() {
|
||||
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);
|
||||
|
||||
for line in &done {
|
||||
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user