Files
installer/src/update.rs
wtclaude 6da385425e
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m39s
fix(handoff): print the shard screen's real path
The end-of-run block told operators to paste the four values at
`<site>/admin/shard`. That page moved when the shard screens became part of the
`uo` module: a module owns one path segment wherever it appears (website
`MODULE_SYSTEM.md` §2.8), so it is `/admin/uo/link`, labelled "Shard (uo-link)".

The old path is worse than a 404. The SPA has no route for it, so it sends the
operator to the dashboard — the link looks like it worked, and the values they
were told to paste have nowhere to go.

- The path is now a named constant, `ADMIN_SHARD_PATH`, carrying why it is not
  the obvious string and the fact that API routes are NOT affected by the module
  namespacing rule (they keep `/api/v1/admin/shard/*`).
- Both handoff tests assert the new path, so this cannot regress quietly.
- The two user-facing labels that name the screen — the `--site-url` help text
  and `update`'s protocol-change instruction — say "Admin → Shard (uo-link)",
  matching what the sidebar actually reads.

Found while writing the runicgateway.com installation journey, by pasting the
printed link into a real deployment and landing on the dashboard.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 11:22:45 -05:00

219 lines
9.3 KiB
Rust

//! The `update` command — move an existing deployment to the current bundle.
//!
//! The deployment itself is [`crate::install`] in [`Mode::Update`]; this module holds only what is
//! genuinely different, which is smaller than it looks:
//!
//! - **A prior record is required.** `update` on a host that has never been installed is a typo, or
//! a state directory the run cannot see — never a reason to perform a first install under a verb
//! that promises to preserve what is already there.
//! - **Both halves move together.** The bundle is the compat matrix (PLAN.md §7.1): resolving it
//! and taking both components from it is what stops an update from landing two independently
//! latest artifacts whose protocol versions disagree. That property comes free from reusing the
//! install pipeline — it is stated here because it is the whole reason `update` is not simply
//! "download the newest sidecar".
//! - **The close is a diff, not a handoff.** What moved, what the operator must now do (restart the
//! shard; and, only if the protocol number changed, edit one field in Admin → Shard), and nothing
//! else. The auth token is not reprinted: it has not changed, the website already has it, and a
//! secret that requires no action does not belong in another terminal scrollback.
//!
//! What `update` deliberately does **not** do is restart the shard (the installer never owns
//! another process's lifecycle — PLAN.md §8) or widen the patch tier on its own. The tier's scope
//! under this verb is `[crate::tier]`'s business: features a previous run recorded are re-resolved
//! against the new release, and anything new is named but not applied without `--patches`.
use std::path::Path;
use anyhow::{bail, Result};
use crate::bundle::Bundle;
use crate::cli::Cli;
use crate::install::{self, Mode};
use crate::record::InstallRecord;
use crate::{paths, ui};
pub fn run(cli: &Cli) -> Result<()> {
install::deploy(cli, Mode::Update)
}
/// Refuses an update on a host with nothing recorded.
///
/// Phrased around the state directory rather than the verb, because the overwhelmingly likely cause
/// is a run that cannot see the state it is looking for: a re-run without the `RUNICGATEWAY_STATE_DIR`
/// that the install used, or an unelevated shell on Windows.
pub fn require_prior(prior: Option<&InstallRecord>, record_path: &Path) -> Result<()> {
if prior.is_some() {
return Ok(());
}
bail!(
"there is no deployment to update — {} does not exist.\n\
Run `install` to deploy for the first time. If this host *is* installed, this run cannot \
see its record: check that you are running as root/Administrator, and that {} is set to \
the same value the install used (if any).",
record_path.display(),
paths::STATE_DIR_ENV
)
}
/// The end of an update: what moved, and what the operator has to do about it.
pub fn closing(prior: Option<&InstallRecord>, bundle: &Bundle, now: &InstallRecord, verify: bool) {
let moves = describe_moves(prior, now);
println!();
ui::heading(if verify {
"Would move [--verify]"
} else {
"Updated"
});
if moves.is_empty() {
println!(" Both halves were already on bundle {}.", bundle.bundle);
} else {
for line in &moves {
println!(" {line}");
}
}
// The one thing an update can change that the *website* has to be told about. The token, the
// URLs and the ports are all unchanged, so this is the only reason to reopen Admin → Shard —
// and it must be said plainly, because a stale number there is answered with 409 by the
// sidecar rather than mis-parsed, which looks to an operator like the shard going offline.
let previous_protocol = prior.map(|p| p.bundle.protocol);
if previous_protocol.is_some_and(|p| p != bundle.protocol) {
println!();
ui::warn(&format!(
"The protocol version changed: {} → {}.\n \
Update the Protocol version field in Admin → Shard (uo-link) on your website. Nothing else \
changed —\n the URLs and the auth token are the same, and the sidecar answers a \
website still set to\n {} with 409 rather than mis-parsing it.",
previous_protocol.unwrap_or(bundle.protocol),
bundle.protocol,
previous_protocol.unwrap_or(bundle.protocol),
));
}
// No "nothing was written" line here: the shared closing in `install::deploy` has already said
// it, in the wording of the verb that was typed. Saying it twice reads like two dry runs.
}
/// The version moves between two records, as printed lines.
///
/// Compared per component rather than by bundle tag: a new bundle whose components happen to be
/// unchanged is not something to report as an upgrade, and the tag alone cannot say which half
/// actually moved.
fn describe_moves(prior: Option<&InstallRecord>, now: &InstallRecord) -> Vec<String> {
let Some(prior) = prior else {
return Vec::new();
};
let mut moves = Vec::new();
if prior.bundle.tag != now.bundle.tag {
moves.push(format!(
"bundle {} → {}",
prior.bundle.tag, now.bundle.tag
));
}
match (prior.link_record(), now.link_record()) {
(Some(before), Some(after)) if before.version != after.version => moves.push(format!(
"uo-link {} → {} (service restarted)",
before.version, after.version
)),
_ => {}
}
match (&prior.overlay, &now.overlay) {
(Some(before), Some(after)) if before.version != after.version => moves.push(format!(
"overlay {} → {} (ServUO must be restarted to compile it)",
before.version, after.version
)),
_ => {}
}
moves
}
#[cfg(test)]
mod tests {
use super::*;
use crate::record::{
BinaryRef, BundleRef, InstallerInfo, LinkRecord, OverlayRecord, ServUoRef, SCHEMA,
};
use std::collections::BTreeMap;
fn record(bundle_tag: &str, protocol: u32, link: &str, overlay: &str) -> InstallRecord {
InstallRecord {
schema: SCHEMA,
installer: InstallerInfo {
version: "0.1.0".into(),
},
updated: "2026-08-05T10:00:00Z".into(),
bundle: BundleRef {
tag: bundle_tag.into(),
protocol,
url: "https://example/current.json".into(),
},
servuo: ServUoRef {
path: "/opt/ServUO".into(),
version: Some("57.4".into()),
},
overlay: Some(OverlayRecord {
repo: "RunicGateway/servuo-plugins".into(),
tag: format!("v{overlay}"),
version: overlay.into(),
commit: "3a52abb".into(),
protocol,
files: BTreeMap::new(),
}),
link: serde_json::to_value(LinkRecord {
repo: "RunicGateway/link".into(),
tag: format!("v{link}"),
version: link.into(),
protocol,
binary: BinaryRef {
path: "/usr/bin/runicgateway-link".into(),
sha256: "aa".into(),
},
config_path: "/etc/runicgateway/sidecar.toml".into(),
db_path: "/var/lib/runicgateway/uo-link.db".into(),
service: None,
})
.ok(),
patches: Vec::new(),
extra: BTreeMap::new(),
}
}
#[test]
fn an_update_with_nothing_recorded_is_refused_with_the_state_dir_named() {
// The failure this message exists for is a run that cannot *see* an install, not one that
// has none — so the text has to point at the state directory, not just say "run install".
let error = require_prior(None, Path::new("/etc/runicgateway/install.json")).unwrap_err();
let message = error.to_string();
assert!(message.contains("install.json"), "{message}");
assert!(message.contains(paths::STATE_DIR_ENV), "{message}");
assert!(require_prior(Some(&record("a", 3, "1.1.0", "0.1.1")), Path::new("x")).is_ok());
}
#[test]
fn only_components_that_actually_moved_are_reported() {
let before = record("2026.08.04", 3, "1.1.0", "0.1.1");
let after = record("2026.09.01", 3, "1.2.0", "0.1.1");
let moves = describe_moves(Some(&before), &after);
assert!(
moves.iter().any(|m| m.contains("uo-link 1.1.0 → 1.2.0")),
"{moves:?}"
);
// The overlay did not move, so nothing may tell the operator to restart their shard for it.
assert!(!moves.iter().any(|m| m.contains("overlay")), "{moves:?}");
assert!(moves.iter().any(|m| m.contains("bundle")), "{moves:?}");
}
#[test]
fn a_new_bundle_with_unchanged_components_reports_only_the_bundle() {
// The nightly cron can publish a new tag whose matrix is identical; calling that an upgrade
// would send an operator looking for a change that does not exist.
let before = record("2026.08.04", 3, "1.1.0", "0.1.1");
let after = record("2026.08.05", 3, "1.1.0", "0.1.1");
let moves = describe_moves(Some(&before), &after);
assert_eq!(moves.len(), 1, "{moves:?}");
assert!(moves[0].contains("bundle"), "{moves:?}");
}
}