1use std::path::{Path, PathBuf};
18
19use anyhow::Result;
20
21pub use appliance_pid1::net::DhcpSession;
22
23pub const NET_VAR: &str = "GUNNAR_NET";
26pub const T1_OVERRIDE_VAR: &str = "GUNNAR_DHCP_T1_OVERRIDE_SECS";
28pub const HOSTNAME_PREFIX: &str = "gunnar";
30pub const RESOLVER_PROBE: &str = "github.com";
32pub const RESOLVER_PROBE_IN_THIS_BUILD: Option<&str> = if cfg!(feature = "resolver-probe") {
36 Some(RESOLVER_PROBE)
37} else {
38 None
39};
40pub const PANIC_CODE: &str = "GN-99";
42
43pub fn net_plans() -> Result<Vec<appliance_pid1::net::NetConfig>> {
45 appliance_pid1::net::NetConfig::from_env(NET_VAR)
46}
47
48pub fn role_bound(
52 spec: gunnar_common::bindspec::BindSpec,
53 net: Option<&str>,
54 exists: impl Fn(&str) -> bool,
55) -> Result<gunnar_common::bindspec::BindSpec> {
56 if !matches!(spec, gunnar_common::bindspec::BindSpec::Role { .. }) {
57 return Ok(spec);
58 }
59 let declared: Vec<String> = match net.map(str::trim).filter(|n| !n.is_empty()) {
60 Some(n) => appliance_pid1::net::NetConfig::parse_list(n)?
61 .iter()
62 .map(|c| c.interface().to_string())
63 .collect(),
64 None => Vec::new(),
65 };
66 spec.for_this_box(&declared, exists)
67 .map_err(|why| anyhow::anyhow!("{NET_VAR}: {why}"))
68}
69
70pub fn role_bound_here(
73 spec: gunnar_common::bindspec::BindSpec,
74) -> Result<gunnar_common::bindspec::BindSpec> {
75 let net = std::env::var(NET_VAR).ok();
76 role_bound(spec, net.as_deref(), |i| {
77 std::path::Path::new("/sys/class/net").join(i).exists()
78 })
79}
80
81pub fn bring_up_network() -> Result<Vec<DhcpSession>> {
88 let narrate = |note: &str| ship_boot_narration(note);
89 let how = appliance_pid1::net::Bringup {
90 hostname_prefix: HOSTNAME_PREFIX,
91 resolver_probe: RESOLVER_PROBE_IN_THIS_BUILD,
92 narrate: &narrate,
93 };
94 appliance_pid1::net::bring_up_network_with(net_plans()?, &how, late_lease())
95}
96
97pub fn late_lease() -> appliance_pid1::net::LateLease {
100 appliance_pid1::net::LateLease {
101 narrate: ship_boot_narration,
102 t1_override_var: Some(T1_OVERRIDE_VAR),
103 }
104}
105
106pub fn ship_boot_narration(network_note: &str) {
109 appliance_pid1::narration::ship_boot_narration(network_note, &|l: &str| {
110 gunnar_common::logqueue::tap_line(l)
111 });
112}
113
114#[cfg(feature = "dhcp-client")]
117pub async fn dhcp_renewal(session: DhcpSession) {
118 appliance_pid1::runtime::dhcp_renewal(
119 session,
120 appliance_pid1::runtime::dhcp_t1_override(T1_OVERRIDE_VAR),
121 )
122 .await
123}
124
125#[cfg(not(feature = "dhcp-client"))]
128pub async fn dhcp_renewal(session: DhcpSession) {
129 match session {}
130}
131
132pub fn announce_install_mode(data_root: &Path) {
138 if !Path::new(appliance_pid1::install::INSTALL_RECORD).exists() {
139 return;
140 }
141 let set = crate::dashboard::probe::disk_kind(data_root)
142 .and_then(|k| k.set)
143 .map(|s| {
144 format!(
145 "{}\u{d7}{}",
146 s.members,
147 crate::dashboard::fmt_bytes(s.member_bytes)
148 )
149 });
150 appliance_pid1::install::announce_install_mode(set.as_deref(), &install_mode_event);
151}
152
153pub fn install_mode_event(line: &str) {
156 let f = install_mode_fields(line);
157 tracing::info!(
158 event = "install.mode",
159 mode = %f[0],
160 kept = %f[1],
161 kept_partno = %f[2],
162 kept_gib = %f[3],
163 kept_set = %f[4],
164 "install record read off {}",
165 appliance_pid1::install::INSTALL_RECORD
166 );
167}
168
169pub fn install_mode_fields(line: &str) -> [String; 5] {
172 let get = |k: &str| {
173 line.split_whitespace()
174 .find_map(|t| t.strip_prefix(k).and_then(|r| r.strip_prefix('=')))
175 .unwrap_or("-")
176 .to_owned()
177 };
178 [
179 get("mode"),
180 get("kept"),
181 get("kept_partno"),
182 get("kept_gib"),
183 get("kept_set"),
184 ]
185}
186
187pub fn install_panic_fault(data: Option<PathBuf>, shout: fn(&str)) {
197 appliance_pid1::fault::narration_from(crate::narrate::memory);
200 let p = appliance_pid1::fault::PanicFault {
201 code: PANIC_CODE,
202 meaning: crate::narrate::code_meaning(PANIC_CODE).unwrap_or("the app panicked"),
203 data,
204 };
205 appliance_pid1::fault::install_panic_fault(
206 p,
207 || appliance_pid1::net::is_init() && crate::narrate::narrator().is_some(),
208 move |line: &str| say_panic_line(line, shout),
209 );
210}
211
212fn say_panic_line(line: &str, shout: fn(&str)) {
214 if let Some(text) = line.strip_prefix("PANIC: ") {
215 let text = format!("\ngunnar PANIC (exit 101): {text}\n");
216 shout(&text);
217 if let Some(path) = crate::slow::record_crash(&text) {
221 shout(&format!(
222 "gunnar: crash record written to {}\n",
223 path.display()
224 ));
225 }
226 return;
227 }
228 match crate::narrate::narrator() {
229 Some(n) => n.say(line),
230 None => shout(&format!("gunnar: {line}\n")),
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
242 fn the_utility_role_becomes_the_nic_this_box_has() {
243 use gunnar_common::bindspec::BindSpec;
244 let role: BindSpec = "utility:50051".parse().unwrap();
245 let iface = |i: &str| BindSpec::Interface {
246 interface: i.into(),
247 port: 50051,
248 };
249 assert_eq!(
250 role_bound(role.clone(), Some("eth0:dhcp,eth1:dhcp"), |_| true).unwrap(),
251 iface("eth1")
252 );
253 assert_eq!(
254 role_bound(role.clone(), Some("eth0:dhcp,eth1:dhcp"), |i| i == "eth0").unwrap(),
255 iface("eth0")
256 );
257 assert!(role_bound(role.clone(), None, |_| true)
258 .unwrap_err()
259 .to_string()
260 .contains(NET_VAR));
261 let kept: BindSpec = "127.0.0.1:50051".parse().unwrap();
262 assert_eq!(role_bound(kept.clone(), None, |_| false).unwrap(), kept);
263 }
264
265 #[test]
268 fn gunnar_names_its_network_variable_its_host_and_its_panic() {
269 assert_eq!(NET_VAR, "GUNNAR_NET");
270 assert_eq!(T1_OVERRIDE_VAR, "GUNNAR_DHCP_T1_OVERRIDE_SECS");
271 assert_eq!(
272 appliance_pid1::net::hostname_for(
273 HOSTNAME_PREFIX,
274 None,
275 [0, 0x11, 0x22, 0xaa, 0xbb, 0xcc]
276 ),
277 "gunnar-aabbcc"
278 );
279 assert_eq!(
280 appliance_pid1::net::hostname_for(HOSTNAME_PREFIX, Some("vm1.example"), [0; 6]),
281 "vm1.example"
282 );
283 assert_eq!(RESOLVER_PROBE, "github.com");
284 assert_eq!(PANIC_CODE, "GN-99");
285 assert_eq!(
286 crate::narrate::code_meaning(PANIC_CODE),
287 Some("the app panicked")
288 );
289 }
290
291 #[test]
294 fn the_install_line_becomes_the_dashboards_fields() {
295 use appliance_pid1::install::InstallRecord;
296 let p = InstallRecord::parse(
297 "mode=upgrade\nkept=korp-data\nkept_partno=3\nkept_bytes=241591910400\n",
298 )
299 .line(None);
300 assert_eq!(
301 install_mode_fields(&p),
302 ["upgrade", "korp-data", "3", "225.0", "-"].map(String::from)
303 );
304 let s = InstallRecord::parse("mode=upgrade\nkept=data-set\n").line(Some("4\u{d7}75G"));
305 assert_eq!(
306 install_mode_fields(&s),
307 ["upgrade", "data-set", "-", "-", "4\u{d7}75G"].map(String::from)
308 );
309 let s = InstallRecord::parse("mode=upgrade\nkept=data-set\n").line(None);
310 assert_eq!(
311 install_mode_fields(&s),
312 ["upgrade", "data-set", "-", "-", "-"].map(String::from)
313 );
314 let f = InstallRecord::parse("mode=fresh\n").line(None);
315 assert_eq!(
316 install_mode_fields(&format!("{f} (install record read off the ESP)")),
317 ["fresh", "-", "-", "-", "-"].map(String::from)
318 );
319 }
320
321 #[test]
329 fn a_late_lease_is_narrated_into_the_installers_boot_log() {
330 let late = late_lease();
331 assert_eq!(late.t1_override_var, Some(T1_OVERRIDE_VAR));
332 #[cfg(feature = "wg-client")]
334 {
335 crate::installer_listener::capture();
336 (late.narrate)("network up: eth0 10.0.2.15/24 gw Some(10.0.2.2) via DHCP from 10.0.2.2 (lease 3600s) — after the boot's failure, 7 asks l10-late");
337 let log = crate::installer_listener::BOOT_LOG
338 .since(0, usize::MAX)
339 .lines
340 .join("\n");
341 assert!(
342 log.contains("after the boot's failure, 7 asks l10-late"),
343 "the late line reaches the boot log: {log}"
344 );
345 }
346 let src = include_str!("boot.rs");
347 let up = &src[src.find("pub fn bring_up_network()").unwrap()..];
348 let up = &up[..up.find("\n}\n").unwrap()];
349 assert!(
350 up.contains("bring_up_network_with(net_plans()?, &how, late_lease())"),
351 "{up}"
352 );
353 assert!(
354 up.contains("resolver_probe: RESOLVER_PROBE_IN_THIS_BUILD,"),
355 "{up}"
356 );
357 }
358
359 #[test]
362 fn gunnar_net_is_read_from_its_own_variable() {
363 if std::env::var_os(NET_VAR).is_none() {
365 assert!(net_plans().unwrap().is_empty());
366 }
367 let plans = appliance_pid1::net::NetConfig::parse_list(
368 "eth0:81.27.107.63/23:81.27.106.1,eth1:10.13.8.78/22",
369 )
370 .unwrap();
371 assert_eq!(
372 plans.iter().map(|p| p.interface()).collect::<Vec<_>>(),
373 ["eth0", "eth1"]
374 );
375 }
376}
377
378#[cfg(test)]
382mod airgap {
383 #[test]
384 fn the_bring_up_asks_the_name_this_build_names() {
385 let src = include_str!("boot.rs");
386 let up = &src[src.find("pub fn bring_up_network()").unwrap()..];
387 let up = &up[..up.find("\n}\n").unwrap()];
388 assert!(
389 up.contains("resolver_probe: RESOLVER_PROBE_IN_THIS_BUILD,"),
390 "{up}"
391 );
392 }
393
394 #[cfg(not(feature = "resolver-probe"))]
395 #[test]
396 fn a_default_build_proves_no_resolver_and_asks_no_public_name() {
397 assert_eq!(super::RESOLVER_PROBE_IN_THIS_BUILD, None);
398 }
399
400 #[cfg(feature = "resolver-probe")]
401 #[test]
402 fn a_resolver_probe_build_asks_github_com_as_before() {
403 assert_eq!(super::RESOLVER_PROBE_IN_THIS_BUILD, Some("github.com"));
404 }
405}