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