//! 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 { 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:?}"); } }