All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m39s
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>
219 lines
9.3 KiB
Rust
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:?}");
|
|
}
|
|
}
|