1use 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 pub voters: Vec<String>,
16 pub witness: bool,
18}
19
20impl ClusterConfig {
21 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#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct Plan {
51 pub name: String,
52 pub id: NodeId,
53 pub addr: String,
54 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}