feat(installer): implement Phase 1 — the installer core
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m31s
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m31s
Adds the Rust crate at the repo root and implements `install` end to end for the overlay half of a deployment: resolve the published bundle, find and validate the ServUO root, refuse to deploy under a running shard, sync the plugin overlay, and record what was deployed in install.json. `doctor`, `update` and `uninstall` parse and answer with the phase they arrive in rather than "unrecognized command", and the run states plainly that the uo-link sidecar (Phase 2) and the patch tier (Phase 3) were not installed — `--patches` in particular reports REQUESTED BUT NOT APPLIED, since a quiet completion would be read as a patched shard. Landing on `edge` rather than `main`: release.yml publishes a binary on every push to main, and an installer that deploys the overlay but cannot install the sidecar is not something to hand an operator. pr-checks.yml now gates PRs into edge on the same rules, so the branch the work happens on is not the ungated one. Notable decisions, all documented in docs/installer/PLAN.md §5 Phase 1: - The code lives in a library called `rgdeploy` with a thin binary that keeps the published name. Windows' UAC installer detection refuses to launch an unsigned executable whose file name contains "install" (os error 740), and Cargo names test harnesses after their target — so a target under that name makes `cargo test` unrunnable on Windows. - The running-shard check matches processes by path, not by process name: on Linux a live shard is `mono`/`dotnet` with ServUO.exe as an argument, and a name match would report "not running" for a shard that is running. - install.json records a state (`deployed` / `kept-operator-modified`), not the run's verb, so an unchanged re-run produces an identical record and writes nothing. - The Bridge.cfg keep rule compares against the hash the installer last deployed, not the last hash it saw — otherwise a kept file is overwritten on the very next run. - Downloads are verified against the bundle's SHA256 while being written, then every extracted file is re-hashed against the release's own manifest.json, whose protocol and version are cross-checked against the bundle. Verified against a real ServUO 57.4 tree and end to end into a scratch tree: 24 files deployed, an unchanged re-run that writes nothing, an edited Bridge.cfg kept across repeated runs while code files are overwritten, bundle pinning, and a refusal with a shard running out of the tree. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
127
src/lib.rs
Normal file
127
src/lib.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
//! Runic Gateway installer.
|
||||
//!
|
||||
//! Takes a working ServUO installation and connects it to a Runic Gateway website. The design of
|
||||
//! record is `docs/installer/PLAN.md`; the operator-facing contract, written before this binary
|
||||
//! existed, is `docs/installer/INSTALL.md`.
|
||||
//!
|
||||
//! **This build implements Phase 1 (installer core):** bundle resolution, ServUO detection and
|
||||
//! validation, the overlay sync, and `install.json`. The uo-link sidecar and its service (Phase 2),
|
||||
//! the patch tier (Phase 3), and `doctor`/`update`/`uninstall` (Phase 4) are not implemented, and
|
||||
//! every one of them says so when reached rather than failing as though it were a typo.
|
||||
//!
|
||||
//! Exit codes: `0` success, `1` the run failed, `2` the arguments were unusable — the same
|
||||
//! convention as the sidecar's CLI.
|
||||
//!
|
||||
//! ## Why the library target is called `rgdeploy`
|
||||
//!
|
||||
//! Windows applies **UAC installer detection** to unsigned executables whose file name contains
|
||||
//! `install`, `setup`, `update` or `patch`: it decides the program is a legacy installer and
|
||||
//! demands elevation before the process starts. That is tolerable for the shipped binary, which
|
||||
//! needs Administrator anyway and is documented as being run from an elevated shell — but Cargo
|
||||
//! names test harnesses after their target, so a target called `runicgateway_installer` produces
|
||||
//! `runicgateway_installer-<hash>.exe`, which Windows refuses to launch (`os error 740`) and
|
||||
//! `cargo test` cannot run at all on a developer's machine.
|
||||
//!
|
||||
//! So the code lives in a neutrally-named library, the binary target keeps the published name from
|
||||
//! PLAN.md §3, and `[[bin]] test = false` keeps Cargo from building a harness under the triggering
|
||||
//! name. Nothing an operator sees changes.
|
||||
|
||||
pub mod bundle;
|
||||
pub mod cli;
|
||||
pub mod install;
|
||||
pub mod net;
|
||||
pub mod overlay;
|
||||
pub mod paths;
|
||||
pub mod record;
|
||||
pub mod servuo;
|
||||
pub mod ui;
|
||||
pub mod util;
|
||||
|
||||
use cli::{Command, Mode};
|
||||
|
||||
/// The whole program. Returns the process exit code rather than calling `exit` itself, so the
|
||||
/// entry point stays a one-liner and this stays callable from a test.
|
||||
pub fn run() -> i32 {
|
||||
ui::init_console();
|
||||
|
||||
let parsed = match cli::parse(std::env::args().skip(1)) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(message) => {
|
||||
eprintln!("error: {message}\n");
|
||||
eprint!("{}", cli::USAGE);
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
|
||||
let result = match parsed.mode {
|
||||
Mode::Help => {
|
||||
print!("{}", cli::USAGE);
|
||||
Ok(())
|
||||
}
|
||||
Mode::Version => {
|
||||
println!("runicgateway-installer {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
}
|
||||
Mode::Run(Command::Install) => install::run(&parsed),
|
||||
Mode::Run(command) => Err(not_implemented(command)),
|
||||
};
|
||||
|
||||
if let Err(error) = result {
|
||||
// The chain is printed, not just the outermost message: "cannot write
|
||||
// /etc/runicgateway/install.json" is only actionable with the OS error still attached.
|
||||
eprintln!("\nerror: {error}");
|
||||
for cause in error.chain().skip(1) {
|
||||
eprintln!(" caused by: {cause}");
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// A command the contract documents but this phase has not built.
|
||||
///
|
||||
/// Exit `1`, not `2`: the operator typed something valid, and the tool is what is unfinished.
|
||||
fn not_implemented(command: Command) -> anyhow::Error {
|
||||
let (phase, workaround) = match command {
|
||||
Command::Doctor => (
|
||||
"Phase 4",
|
||||
"Check the deployment by hand: `[bridge status` in game, and \
|
||||
`curl -s http://127.0.0.1:8080/health` on the shard host (INSTALL.md §6).",
|
||||
),
|
||||
Command::Update => (
|
||||
"Phase 4",
|
||||
"Re-run `install` to move the overlay to the current bundle; replace the sidecar \
|
||||
binary by hand (INSTALL.md Appendix A6).",
|
||||
),
|
||||
Command::Uninstall => (
|
||||
"Phase 4",
|
||||
"Remove the sidecar service and binary by hand; the overlay files this installer \
|
||||
deployed are listed in install.json.",
|
||||
),
|
||||
Command::Install => unreachable!("install is implemented"),
|
||||
};
|
||||
anyhow::anyhow!(
|
||||
"`{command}` is not implemented in this build — it arrives in {phase} \
|
||||
(see docs/installer/PLAN.md §5).\n{workaround}"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unfinished_commands_name_their_phase_and_a_way_through() {
|
||||
// An operator who runs `doctor` today must not be left thinking they typed it wrong, and
|
||||
// must not be left with nothing to do either.
|
||||
for command in [Command::Doctor, Command::Update, Command::Uninstall] {
|
||||
let message = not_implemented(command).to_string();
|
||||
assert!(message.contains(&command.to_string()), "{message}");
|
||||
assert!(message.contains("Phase 4"), "{message}");
|
||||
assert!(
|
||||
message.contains("INSTALL.md") || message.contains("install.json"),
|
||||
"{message}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user