Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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());
    }
}