deploy/upcloud/src/site.rs
//! **The site facts** — the four things this verb needs that are properties of
//! one deployment rather than of gunnar, kept OUT of the source tree.
//!
//! `reinstall` re-images a named machine at a named address and then restarts
//! the services on a named front. Those three names are not gunnar's; they
//! belong to whoever runs the appliance, and a public repository that carries
//! them hands a reader the uuid and the addresses of somebody's live box. So
//! they are **required input**, with no real default anywhere in the code:
//!
//! * `--server-uuid <uuid>` · `--verify-addr <host:port>` · `--front
//! [user@]host[:port]`
//! * or one file, named by `--site <path>` or `$GUNNAR_SITE`, holding the same
//! keys.
//!
//! A run that names none of them **refuses by name** rather than falling back
//! to anything ([`Site::require`]), which is the whole point: the old default
//! was a live appliance, and a default that is a live appliance is a default
//! that eventually re-images the wrong box.
//!
//! # The file
//!
//! ```toml
//! # ~/gunnar-site.toml — outside the repository, like the token file
//! server_uuid = "11111111-2222-3333-4444-555555555555"
//! server_title = "gunnar-appliance"
//! verify_addr = "203.0.113.55:2222"
//! front = "root@203.0.113.203:22"
//! front_key = "~/.ssh/id_ed25519"
//! zone = "se-sto1"
//! ```
//!
//! The parser is twenty lines of `std` on purpose: this crate reads exactly six
//! flat `key = "value"` pairs, and a toml dependency to read six strings is a
//! dependency for its own sake. Anything it does not understand is an error
//! rather than a silence — a misspelled `server_uuid` that parsed as "absent"
//! would put the refusal one step away from the operator who caused it.
use std::path::Path;
/// The environment variable that names the file, when no `--site` is given.
pub const ENV: &str = "GUNNAR_SITE";
/// The keys a site file may carry. Anything else is a refusal that names it.
pub const KEYS: &[&str] = &[
"server_uuid",
"server_title",
"verify_addr",
"front",
"front_key",
"zone",
];
/// One deployment's facts, all optional here: what is REQUIRED is decided by
/// [`Site::require`] once flags and file have been merged.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Site {
pub server_uuid: Option<String>,
pub server_title: Option<String>,
pub verify_addr: Option<String>,
pub front: Option<String>,
pub front_key: Option<String>,
pub zone: Option<String>,
}
impl Site {
/// Read a site file. A named file that is absent is an error: an operator
/// who typed `--site` meant it, and silently continuing to the refusal
/// about a missing uuid would hide the typo.
pub fn read(path: &Path) -> Result<Site, String> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("reading the site file {}: {e}", path.display()))?;
Site::parse(&text).map_err(|e| format!("{}: {e}", path.display()))
}
/// `--site`, else `$GUNNAR_SITE`, else nothing at all — which is legal, as
/// long as the flags carry the facts.
pub fn discover(explicit: Option<&Path>) -> Result<Site, String> {
if let Some(p) = explicit {
return Site::read(p);
}
match std::env::var(ENV) {
Ok(v) if !v.trim().is_empty() => Site::read(Path::new(v.trim())),
_ => Ok(Site::default()),
}
}
/// `key = "value"`, `#` comments, blank lines. Nothing else.
pub fn parse(text: &str) -> Result<Site, String> {
let mut site = Site::default();
for (n, raw) in text.lines().enumerate() {
let line = match raw.split_once('#') {
Some((before, _)) => before.trim(),
None => raw.trim(),
};
if line.is_empty() {
continue;
}
let (key, value) = line
.split_once('=')
.ok_or_else(|| format!("line {}: not `key = \"value\"`: {raw:?}", n + 1))?;
let key = key.trim();
let value = value.trim().trim_matches('"').trim().to_string();
let slot = match key {
"server_uuid" => &mut site.server_uuid,
"server_title" => &mut site.server_title,
"verify_addr" => &mut site.verify_addr,
"front" => &mut site.front,
"front_key" => &mut site.front_key,
"zone" => &mut site.zone,
other => {
return Err(format!(
"line {}: unknown key {other:?}. This file carries only: {}",
n + 1,
KEYS.join(", ")
))
}
};
if value.is_empty() {
return Err(format!("line {}: {key} is empty", n + 1));
}
*slot = Some(value);
}
Ok(site)
}
/// A flag wins over the file, and the file wins over nothing. `self` is the
/// file; `flag` is what the operator typed.
pub fn or_flag(file: Option<String>, flag: Option<String>) -> Option<String> {
flag.or(file)
}
/// **The refusal.** `what` is the fact, `flag` the argument that supplies
/// it. Named, so an operator reads which one is missing and how to give it
/// rather than a stack of `None`s.
/// **A server fact a MEDIUM-ONLY run may go without** (2026-10-09, the shop's
/// release medium). `--medium-only` reads, touches and dials no server, so a run
/// that makes a medium for NO machine — a release medium, cloned per sold
/// machine by the shop — names none: absent is `None` there. Absent on any
/// other run is still [`Site::require`]'s refusal.
pub fn require_unless_medium_only(
value: Option<String>,
medium_only: bool,
what: &str,
flag: &str,
) -> Result<Option<String>, String> {
match value {
Some(v) => Ok(Some(v)),
None if medium_only => Ok(None),
None => Site::require(None, what, flag).map(Some),
}
}
pub fn require(value: Option<String>, what: &str, flag: &str) -> Result<String, String> {
value.ok_or_else(|| {
format!(
"{what} is not known: pass {flag}, or name a site file with --site <path> or \
${ENV}. There is NO default — the appliance this verb destroys is a property \
of your deployment, not of gunnar"
)
})
}
}
/// `[user@]host[:port]`, the way an operator writes a front.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FrontSpec {
pub user: String,
pub host: String,
pub port: u16,
}
/// The user a front is reached as when the spec does not say. Not a site fact:
/// it is the ordinary ssh default, and the HOST — the part that identifies
/// somebody's box — has no default at all.
pub const DEFAULT_FRONT_USER: &str = "root";
pub const DEFAULT_FRONT_PORT: u16 = 22;
impl FrontSpec {
pub fn parse(spec: &str) -> Result<FrontSpec, String> {
let spec = spec.trim();
if spec.is_empty() {
return Err("--front is empty; it reads [user@]host[:port]".to_string());
}
let (user, rest) = match spec.split_once('@') {
Some((u, r)) => (u.to_string(), r),
None => (DEFAULT_FRONT_USER.to_string(), spec),
};
let (host, port) = match rest.rsplit_once(':') {
Some((h, p)) => (
h,
p.parse::<u16>()
.map_err(|e| format!("--front {spec:?}: port {p:?}: {e}"))?,
),
None => (rest, DEFAULT_FRONT_PORT),
};
if host.is_empty() || user.is_empty() {
return Err(format!("--front {spec:?}: it reads [user@]host[:port]"));
}
Ok(FrontSpec {
user,
host: host.to_string(),
port,
})
}
}
/// `~/.ssh/id_ed25519`, expanded — the ordinary key path, overridable by
/// `front_key` / `--front-key`.
///
/// A re-export since 2026-09-10, not a body. The four-line function that stood
/// here was the fourth copy in this repository (`xtask/src/lib.rs`,
/// `bench/src/lib.rs`, `installtest/src/lib.rs` and this one; the first three
/// byte-identical), which `nornir mimir analyze duplicates` clustered and put
/// in `edda/crates/nornir-xtask-support` beside the other std-only path
/// primitives. Behaviour is pinned there by
/// `expand_home_expands_only_a_leading_tilde_slash`, including the two
/// properties this door depends on and no caller ever stated: an ABSOLUTE
/// `--front-key` passes through untouched, and `$HOME` unset falls back to
/// `/root` — which is what a systemd unit running this deploy would hit.
pub use nornir_xtask_support::expand_home;
#[cfg(test)]
mod tests {
use super::*;
const FILE: &str = r#"
# a deployment's own facts
server_uuid = "11111111-2222-3333-4444-555555555555"
verify_addr = "203.0.113.55:2222" # where the banner is read
front = "root@203.0.113.203"
"#;
#[test]
fn a_site_file_carries_the_three_facts() {
let s = Site::parse(FILE).unwrap();
assert_eq!(
s.server_uuid.as_deref(),
Some("11111111-2222-3333-4444-555555555555")
);
assert_eq!(s.verify_addr.as_deref(), Some("203.0.113.55:2222"));
assert_eq!(s.front.as_deref(), Some("root@203.0.113.203"));
assert_eq!(s.server_title, None);
}
/// **Red-first, and the reason this module exists.** With the uuid and the
/// verify address as constants in `main.rs`, a run that named no site at
/// all still had a target: a live appliance. Now every one of the three is
/// a refusal that names the flag.
#[test]
fn a_run_with_no_site_facts_refuses_by_name() {
let empty = Site::default();
let err =
Site::require(empty.server_uuid, "the appliance's uuid", "--server-uuid").unwrap_err();
assert!(err.contains("--server-uuid"), "{err}");
assert!(err.contains("There is NO default"), "{err}");
assert!(err.contains(ENV), "{err}");
let err = Site::require(None, "where the SSH banner is read", "--verify-addr").unwrap_err();
assert!(err.contains("--verify-addr"), "{err}");
let err = Site::require(None, "the front", "--front").unwrap_err();
assert!(err.contains("--front"), "{err}");
}
/// A medium-only run (the release medium) needs no server; every other run still refuses.
#[test]
fn only_a_medium_only_run_goes_without_the_server_facts() {
assert_eq!(
Site::require_unless_medium_only(None, true, "the uuid", "--server-uuid"),
Ok(None)
);
let err =
Site::require_unless_medium_only(None, false, "the uuid", "--server-uuid").unwrap_err();
assert!(
err.contains("--server-uuid") && err.contains("There is NO default"),
"{err}"
);
assert_eq!(
Site::require_unless_medium_only(Some("u".into()), true, "the uuid", "--server-uuid"),
Ok(Some("u".into())),
"a named server is kept: the receipt still says which"
);
}
/// A flag beats the file, so one file can serve several appliances.
#[test]
fn a_flag_wins_over_the_file() {
let file = Site::parse(FILE).unwrap();
assert_eq!(
Site::or_flag(file.server_uuid.clone(), Some("other".into())).as_deref(),
Some("other")
);
assert_eq!(
Site::or_flag(file.verify_addr, None).as_deref(),
Some("203.0.113.55:2222")
);
}
/// A misspelled key is a refusal that quotes it. Read as "absent" it would
/// surface as the missing-uuid refusal, one step away from its cause.
#[test]
fn an_unknown_key_is_named_and_not_ignored() {
let err = Site::parse("server_uid = \"x\"\n").unwrap_err();
assert!(err.contains("server_uid"), "{err}");
assert!(
err.contains("server_uuid"),
"the known keys are listed: {err}"
);
}
#[test]
fn a_line_that_is_not_a_pair_is_a_refusal() {
assert!(Site::parse("server_uuid\n").is_err());
assert!(Site::parse("front = \"\"\n").is_err());
}
#[test]
fn a_front_spec_reads_user_host_and_port() {
assert_eq!(
FrontSpec::parse("203.0.113.203").unwrap(),
FrontSpec {
user: "root".into(),
host: "203.0.113.203".into(),
port: 22
}
);
assert_eq!(
FrontSpec::parse("deploy@203.0.113.203:2200").unwrap(),
FrontSpec {
user: "deploy".into(),
host: "203.0.113.203".into(),
port: 2200
}
);
assert!(FrontSpec::parse("").is_err());
assert!(FrontSpec::parse("host:notaport").is_err());
}
}