feat(installer): back up what a run is about to overwrite
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m59s
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user