Skip to main content

orch/cluster/
config.rs

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