Skip to main content

orch/cluster/
config.rs

1//! The `cluster:` section of the global config
2
3use super::discovery::Tailnet;
4use super::{NodeId, discovery};
5use crate::config;
6use std::collections::BTreeMap;
7
8pub const DEFAULT_PORT: u16 = 7420;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct ClusterConfig {
12    pub secret: String,
13    pub port: u16,
14    /// Node names, as the first label of each machine's MagicDNS name
15    pub voters: Vec<String>,
16    /// Votes but never leads, runs no agents
17    pub witness: bool,
18}
19
20impl ClusterConfig {
21    /// None without a `cluster.secret`, which keeps orch on one machine
22    pub fn load() -> anyhow::Result<Option<Self>> {
23        let Ok(secret) = config::get("cluster.secret") else {
24            return Ok(None);
25        };
26        if secret.is_empty() {
27            return Ok(None);
28        }
29
30        let port = match config::get("cluster.port") {
31            Ok(p) => p.parse()?,
32            Err(_) => DEFAULT_PORT,
33        };
34        let voters = config::get_list("cluster.voters")?
35            .into_iter()
36            .map(|v| v.to_ascii_lowercase())
37            .collect();
38        let witness = config::get("cluster.witness").is_ok_and(|w| w == "true");
39        Ok(Some(Self {
40            secret,
41            port,
42            voters,
43            witness,
44        }))
45    }
46}
47
48/// How this machine takes part, worked out from the config and the tailnet
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct Plan {
51    pub name: String,
52    pub id: NodeId,
53    pub addr: String,
54    /// The voters to initialize with. Empty for a witness or a learner
55    pub voters: BTreeMap<NodeId, String>,
56    pub elect: bool,
57    pub learner: bool,
58}
59
60pub fn plan(cfg: &ClusterConfig, tailnet: &Tailnet) -> anyhow::Result<Plan> {
61    let name = tailnet.me.name.clone();
62    let mut voters = BTreeMap::new();
63    let mut missing = Vec::new();
64    for voter in &cfg.voters {
65        let found = tailnet.all_named(voter);
66        anyhow::ensure!(
67            found.len() <= 1,
68            "cluster.voters names {voter}, and {} machines on the tailnet have that name",
69            found.len()
70        );
71        match found.first() {
72            Some(host) => {
73                voters.insert(
74                    discovery::node_id(voter),
75                    format!("{}:{}", host.ip, cfg.port),
76                );
77            }
78            None => missing.push(voter.as_str()),
79        }
80    }
81
82    anyhow::ensure!(
83        missing.is_empty(),
84        "cluster.voters names machines the tailnet does not know: {}",
85        missing.join(", ")
86    );
87    let is_voter = cfg.voters.contains(&name);
88    anyhow::ensure!(
89        !cfg.witness || is_voter,
90        "{name} has cluster.witness set but is not in cluster.voters"
91    );
92    let elect = is_voter && !cfg.witness;
93    Ok(Plan {
94        id: discovery::node_id(&name),
95        addr: format!("{}:{}", tailnet.me.ip, cfg.port),
96        voters: if elect { voters } else { BTreeMap::new() },
97        elect,
98        learner: !is_voter,
99        name,
100    })
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crate::cluster::discovery::Host;
107
108    fn host(name: &str, ip: &str, online: bool) -> Host {
109        Host {
110            name: name.into(),
111            ip: ip.into(),
112            online,
113        }
114    }
115
116    fn tailnet(me: &str) -> Tailnet {
117        let all = [
118            host("m1-max", "100.1.1.1", true),
119            host("legion", "100.1.1.2", true),
120            host("vpsbr", "100.1.1.3", false),
121            host("m4", "100.1.1.4", true),
122        ];
123        Tailnet {
124            me: all.iter().find(|h| h.name == me).unwrap().clone(),
125            peers: all.into_iter().filter(|h| h.name != me).collect(),
126        }
127    }
128
129    fn cfg(witness: bool) -> ClusterConfig {
130        ClusterConfig {
131            secret: "s".into(),
132            port: 7420,
133            voters: vec!["m1-max".into(), "legion".into(), "vpsbr".into()],
134            witness,
135        }
136    }
137
138    #[test]
139    fn a_voter_initializes_with_every_voter_even_one_that_is_offline() {
140        let p = plan(&cfg(false), &tailnet("m1-max")).unwrap();
141        assert!(p.elect && !p.learner);
142        assert_eq!(p.addr, "100.1.1.1:7420");
143        assert_eq!(p.voters.len(), 3);
144        assert_eq!(p.voters[&discovery::node_id("vpsbr")], "100.1.1.3:7420");
145    }
146
147    #[test]
148    fn a_witness_votes_without_leading_or_initializing() {
149        let p = plan(&cfg(true), &tailnet("vpsbr")).unwrap();
150        assert!(!p.elect && !p.learner);
151        assert!(p.voters.is_empty());
152    }
153
154    #[test]
155    fn a_machine_outside_the_voters_is_a_learner() {
156        let p = plan(&cfg(false), &tailnet("m4")).unwrap();
157        assert!(p.learner && !p.elect);
158        assert!(p.voters.is_empty());
159    }
160
161    #[test]
162    fn plan_rejects_an_unknown_voter_and_a_witness_outside_the_voters() {
163        let mut c = cfg(false);
164        c.voters.push("ghost".into());
165        let err = plan(&c, &tailnet("m1-max")).unwrap_err().to_string();
166        assert!(err.contains("ghost"), "{err}");
167        assert!(plan(&cfg(true), &tailnet("m4")).is_err());
168    }
169
170    #[test]
171    fn plan_rejects_a_voter_name_two_machines_share() {
172        let mut t = tailnet("m1-max");
173        t.peers.push(host("legion", "100.1.1.9", true));
174        let err = plan(&cfg(false), &t).unwrap_err().to_string();
175        assert!(err.contains("2 machines"), "{err}");
176    }
177}