feat(installer): implement Phase 3 — the patch tier
Some checks failed
PR Checks / rust-gates (pull_request) Failing after 46s
Some checks failed
PR Checks / rust-gates (pull_request) Failing after 46s
Two features need edits to stock ServUO sources, because the events they depend on do not exist. This adds the rung ladder of PLAN.md §2.2.1, the unsupported-version path of §2.2.2, and the record and cache Phase 4 will read. Three decisions were not settled by the plan: * The engine is fully native, with no `git`. §2.2.1 wrote rung 1 as "apply verbatim with git apply", but §1 chose the release tarball specifically so there would be no git on the shard host, and rung 2 needs a native applier regardless. Rung 1 keeps its distinct, stronger verdict — the whole file reproduced the diff's `index` pre-image, computed as a git blob SHA1 in process — while the write goes through the same code path as rung 2. On the real trees here that is not academic: the shipped .patch files are CRLF in a Windows checkout and two of their three targets are LF, so `git apply` refuses patches this applies correctly. * Per-patch metadata is declared by the release, with a built-in fallback. Which patches form one all-or-nothing unit, which companion .cs follows which, whether a CORE rebuild is needed and what declining costs are not derivable from a diff. servuo-plugins now declares them; overlay v0.1.1 is in the current bundle and declares nothing, so a built-in copy stands in for it. A checked-in fixture of the release workflow's own jq output asserts the two descriptions are identical, so the repos cannot drift quietly. * Pre-images are cached in the state directory. The tier edits files the operator owns, and `/etc/runicgateway/patches/originals/` is what turns "here are the hunks we added" into a revert anyone can verify — kept out of the ServUO tree, which uninstall has promised never to clean up. Everything else follows §2.2.1: exact matching with only line-ending and trailing-whitespace normalization, exactly one occurrence or it fails, all-or-nothing per patch file and again per feature, and a byte-preserving splice so nothing outside a hunk can be reformatted. Verified against the ServUO 57.4 tree on this machine across four scratch roots: a hand-patched tree (rung 0), a reverse-applied stock one (rung 1 on the real EventSink.cs, its blob matching the patch's declared pre-image), a mixed-rung feature, a tree with edits inside two patched regions (rung 3 — nothing written, nothing held back applied, no companions copied), and a non-57.4 tree both with and without the extra consent flag. Three consecutive runs left install.json byte-identical and the cached pre-image still pre-patch. Three reporting defects the live runs caught are fixed with tests: a dry run and a held-back patch both claimed to be "applied", the core-rebuild warning fired when nothing had been written and named a Scripts file as core, and a declined tier announced the loss of features install.json showed as applied. Refused patches are now cached too, since the refusal message names that path. Refs: docs/installer/PLAN.md §2.2, §5 Phase 3 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
144
src/install.rs
144
src/install.rs
@@ -1,19 +1,17 @@
|
||||
//! The `install` command.
|
||||
//!
|
||||
//! Phases 1 and 2 of `docs/installer/PLAN.md`: resolve the bundle, validate the ServUO root, sync
|
||||
//! the overlay, install the sidecar and register its service, record what was deployed, and print
|
||||
//! the values the website needs. The patch tier (Phase 3) is not in this build, and the run says so
|
||||
//! in as many words rather than ending on a success line that would read as a finished install. An
|
||||
//! operator who cannot tell which half ran is the failure this whole tool exists to remove.
|
||||
//! Phases 1 to 3 of `docs/installer/PLAN.md`: resolve the bundle, validate the ServUO root, sync
|
||||
//! the overlay, run the optional patch tier, install the sidecar and register its service, record
|
||||
//! what was deployed, and print the values the website needs.
|
||||
//!
|
||||
//! The order of the run is not incidental:
|
||||
//!
|
||||
//! 1. **Resolve everything that can fail cheaply first** — the bundle, the sidecar asset for this
|
||||
//! platform, the ServUO root, and whether this process can write where it must. A run that
|
||||
//! cannot finish should end before a single file enters the ServUO tree.
|
||||
//! 2. **Overlay, then sidecar.** The sidecar is what the shard dials out to, but the shard is
|
||||
//! stopped throughout; deploying code the shard will compile is the step with a running-process
|
||||
//! hazard attached, so it happens while the check that guards it is freshest.
|
||||
//! 2. **Overlay, then the patch tier, then the sidecar.** Everything that edits the ServUO tree
|
||||
//! happens together, on the near side of the running-shard check that guards it — and the tier
|
||||
//! comes second because a feature's companion `.cs` lands in a directory the overlay creates.
|
||||
//! 3. **Provision the config before registering the service** (PLAN.md §5): `--print-config` writes
|
||||
//! the file the service definition points at, so the service is never started against a config
|
||||
//! that does not exist yet.
|
||||
@@ -24,14 +22,14 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
use crate::cli::{Cli, PatchChoice};
|
||||
use crate::cli::Cli;
|
||||
use crate::record::{
|
||||
now_rfc3339, BinaryRef, BundleRef, InstallRecord, InstallerInfo, LinkRecord, OverlayRecord,
|
||||
ServUoRef, ServiceRecord, SCHEMA,
|
||||
};
|
||||
use crate::servuo::ServUoRoot;
|
||||
use crate::util::TempDir;
|
||||
use crate::{bundle, net, overlay, paths, service, servuo, sidecar, ui};
|
||||
use crate::{bundle, net, overlay, paths, service, servuo, sidecar, tier, ui};
|
||||
|
||||
pub fn run(cli: &Cli) -> Result<()> {
|
||||
let layout = paths::layout();
|
||||
@@ -192,8 +190,21 @@ pub fn run(cli: &Cli) -> Result<()> {
|
||||
));
|
||||
}
|
||||
|
||||
// ── What this build does not do ──────────────────────────────────────────
|
||||
report_patch_tier(cli);
|
||||
// ── The patch tier ───────────────────────────────────────────────────────
|
||||
// After the overlay, because a feature's companion `.cs` lands in the directory the overlay
|
||||
// creates, and before the sidecar, because everything that edits the ServUO tree belongs on the
|
||||
// near side of the running-shard check that guards it.
|
||||
let tier = tier::run(
|
||||
cli,
|
||||
&root,
|
||||
&unpacked,
|
||||
manifest.patch_tier.as_ref(),
|
||||
&layout,
|
||||
&prior
|
||||
.as_ref()
|
||||
.map(|p| p.patch_records())
|
||||
.unwrap_or_default(),
|
||||
)?;
|
||||
|
||||
// ── The sidecar and its service ──────────────────────────────────────────
|
||||
let sidecar = install_sidecar(cli, &bundle, &sidecar_asset, &layout, scratch.path())?;
|
||||
@@ -201,12 +212,16 @@ pub fn run(cli: &Cli) -> Result<()> {
|
||||
// ── Record ───────────────────────────────────────────────────────────────
|
||||
let record = build_record(
|
||||
prior.as_ref(),
|
||||
&bundle,
|
||||
&bundle_url,
|
||||
&root,
|
||||
&manifest,
|
||||
&planned,
|
||||
sidecar.as_ref(),
|
||||
&Deployment {
|
||||
bundle: &bundle,
|
||||
bundle_url: &bundle_url,
|
||||
root: &root,
|
||||
manifest: &manifest,
|
||||
planned: &planned,
|
||||
sidecar: sidecar.as_ref(),
|
||||
tier: &tier,
|
||||
verify: cli.verify,
|
||||
},
|
||||
);
|
||||
|
||||
if cli.verify {
|
||||
@@ -232,7 +247,17 @@ pub fn run(cli: &Cli) -> Result<()> {
|
||||
|
||||
// ── Closing notes ────────────────────────────────────────────────────────
|
||||
println!();
|
||||
if summary.writes_anything() && !cli.verify {
|
||||
if cli.verify {
|
||||
println!("Nothing was written. Re-run without --verify to deploy.");
|
||||
} else if summary.writes_anything() || tier.core_rebuild {
|
||||
if tier.core_rebuild {
|
||||
// Said again here, after everything else, because it is the one step whose omission
|
||||
// produces a shard that boots perfectly and never emits the events it was patched for.
|
||||
println!(
|
||||
"A CORE ServUO file was patched — rebuild the solution before starting:\n \
|
||||
dotnet build ServUO.sln\n"
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"Scripts changed — ServUO rebuilds Scripts.dll on next boot.\n\
|
||||
Start your shard when ready; the installer does not start it for you.\n\
|
||||
@@ -240,8 +265,6 @@ pub fn run(cli: &Cli) -> Result<()> {
|
||||
the plugin compiled:\n watch for \"[Bridge] enabled=True\" in the boot output, or \
|
||||
run `[bridge status` in game (INSTALL.md §6)."
|
||||
);
|
||||
} else if cli.verify {
|
||||
println!("Nothing was written. Re-run without --verify to deploy.");
|
||||
} else {
|
||||
println!("The ServUO tree already has this overlay — nothing was changed there.");
|
||||
}
|
||||
@@ -306,32 +329,6 @@ fn prior_overlay_files<'a>(
|
||||
prior.overlay_files()
|
||||
}
|
||||
|
||||
fn report_patch_tier(cli: &Cli) {
|
||||
println!();
|
||||
match cli.patches {
|
||||
// --patches must never pass silently: an operator who asked for the tier and got a clean
|
||||
// run would reasonably conclude that EventSink.cs had been patched.
|
||||
PatchChoice::Yes => {
|
||||
ui::warn(
|
||||
"Patch tier REQUESTED BUT NOT APPLIED — it is not implemented in this \
|
||||
build (Phase 3).\n \
|
||||
No stock ServUO file has been touched. Apply the patches by hand if you need \
|
||||
them: INSTALL.md Appendix A2.",
|
||||
);
|
||||
}
|
||||
PatchChoice::No => {
|
||||
ui::row("Patch tier", "skipped (--no-patches)");
|
||||
}
|
||||
PatchChoice::Ask => {
|
||||
ui::row(
|
||||
"Patch tier",
|
||||
"skipped (not implemented in this build — Phase 3)",
|
||||
);
|
||||
}
|
||||
}
|
||||
println!(" Without it: no vendor.sale events, no in-game moderation audit forwarding.");
|
||||
}
|
||||
|
||||
/// The sidecar half of a run: binary, config, service. `None` under `--verify`.
|
||||
struct SidecarOutcome {
|
||||
record: LinkRecord,
|
||||
@@ -568,15 +565,36 @@ fn resolve_host(cli: &Cli) -> String {
|
||||
answer.unwrap_or(detected)
|
||||
}
|
||||
|
||||
fn build_record(
|
||||
prior: Option<&InstallRecord>,
|
||||
bundle: &bundle::Bundle,
|
||||
bundle_url: &str,
|
||||
root: &ServUoRoot,
|
||||
manifest: &overlay::Manifest,
|
||||
planned: &[overlay::PlannedFile],
|
||||
sidecar: Option<&SidecarOutcome>,
|
||||
) -> InstallRecord {
|
||||
/// Everything one run produced, gathered so the record can be built from a single value.
|
||||
///
|
||||
/// The three halves are assembled at different points and the record needs all of them, which is
|
||||
/// how this grew a parameter per step. A struct keeps the call site readable and, more usefully,
|
||||
/// makes it obvious at a glance that nothing else feeds `install.json`.
|
||||
struct Deployment<'a> {
|
||||
bundle: &'a bundle::Bundle,
|
||||
bundle_url: &'a str,
|
||||
root: &'a ServUoRoot,
|
||||
manifest: &'a overlay::Manifest,
|
||||
planned: &'a [overlay::PlannedFile],
|
||||
sidecar: Option<&'a SidecarOutcome>,
|
||||
tier: &'a tier::Outcome,
|
||||
/// A dry run reports everything and records nothing, so every "carry the previous value
|
||||
/// through" branch below turns on it.
|
||||
verify: bool,
|
||||
}
|
||||
|
||||
fn build_record(prior: Option<&InstallRecord>, run: &Deployment<'_>) -> InstallRecord {
|
||||
let Deployment {
|
||||
bundle,
|
||||
bundle_url,
|
||||
root,
|
||||
manifest,
|
||||
planned,
|
||||
sidecar,
|
||||
tier,
|
||||
verify,
|
||||
} = *run;
|
||||
|
||||
InstallRecord {
|
||||
schema: SCHEMA,
|
||||
installer: InstallerInfo {
|
||||
@@ -608,7 +626,19 @@ fn build_record(
|
||||
Some(outcome) => serde_json::to_value(&outcome.record).ok(),
|
||||
None => prior.and_then(|p| p.link.clone()),
|
||||
},
|
||||
patches: prior.map(|p| p.patches.clone()).unwrap_or_default(),
|
||||
// The tier's own records replace the section only when it actually ran and wrote. A
|
||||
// declined tier, a refused one, and a `--verify` dry run all leave the previous record
|
||||
// exactly as it was — an install where the operator said "not this time" must not erase
|
||||
// the evidence of patches applied on an earlier one.
|
||||
patches: match (tier.ran && !verify, prior) {
|
||||
(true, _) => tier
|
||||
.records
|
||||
.iter()
|
||||
.filter_map(|r| serde_json::to_value(r).ok())
|
||||
.collect(),
|
||||
(false, Some(previous)) => previous.patches.clone(),
|
||||
(false, None) => Vec::new(),
|
||||
},
|
||||
extra: prior.map(|p| p.extra.clone()).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user