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") { Some(RESOLVER_PROBE) } else { None };
36pub const PANIC_CODE: &str = "GN-99";
38
39pub fn net_plans() -> Result<Vec<appliance_pid1::net::NetConfig>> {
41 appliance_pid1::net::NetConfig::from_env(NET_VAR)
42}
43
44pub fn role_bound(spec: gunnar_common::bindspec::BindSpec, net: Option<&str>, exists: impl Fn(&str) -> bool) -> Result<gunnar_common::bindspec::BindSpec> {
48 if !matches!(spec, gunnar_common::bindspec::BindSpec::Role { .. }) {
49 return Ok(spec);
50 }
51 let declared: Vec<String> = match net.map(str::trim).filter(|n| !n.is_empty()) {
52 Some(n) => appliance_pid1::net::NetConfig::parse_list(n)?.iter().map(|c| c.interface().to_string()).collect(),
53 None => Vec::new(),
54 };
55 spec.for_this_box(&declared, exists).map_err(|why| anyhow::anyhow!("{NET_VAR}: {why}"))
56}
57
58pub fn role_bound_here(spec: gunnar_common::bindspec::BindSpec) -> Result<gunnar_common::bindspec::BindSpec> {
61 let net = std::env::var(NET_VAR).ok();
62 role_bound(spec, net.as_deref(), |i| std::path::Path::new("/sys/class/net").join(i).exists())
63}
64
65pub fn bring_up_network() -> Result<Vec<DhcpSession>> {
72 let narrate = |note: &str| ship_boot_narration(note);
73 let how = appliance_pid1::net::Bringup {
74 hostname_prefix: HOSTNAME_PREFIX,
75 resolver_probe: RESOLVER_PROBE_IN_THIS_BUILD,
76 narrate: &narrate,
77 };
78 appliance_pid1::net::bring_up_network_with(net_plans()?, &how, late_lease())
79}
80
81pub fn late_lease() -> appliance_pid1::net::LateLease {
84 appliance_pid1::net::LateLease {
85 narrate: ship_boot_narration,
86 t1_override_var: Some(T1_OVERRIDE_VAR),
87 }
88}
89
90pub fn ship_boot_narration(network_note: &str) {
93 appliance_pid1::narration::ship_boot_narration(network_note, &|l: &str| gunnar_common::logqueue::tap_line(l));
94}
95
96#[cfg(feature = "dhcp-client")]
99pub async fn dhcp_renewal(session: DhcpSession) {
100 appliance_pid1::runtime::dhcp_renewal(session, appliance_pid1::runtime::dhcp_t1_override(T1_OVERRIDE_VAR)).await
101}
102
103#[cfg(not(feature = "dhcp-client"))]
106pub async fn dhcp_renewal(session: DhcpSession) {
107 match session {}
108}
109
110pub fn announce_install_mode(data_root: &Path) {
116 if !Path::new(appliance_pid1::install::INSTALL_RECORD).exists() {
117 return;
118 }
119 let set = crate::dashboard::probe::disk_kind(data_root)
120 .and_then(|k| k.set)
121 .map(|s| format!("{}\u{d7}{}", s.members, crate::dashboard::fmt_bytes(s.member_bytes)));
122 appliance_pid1::install::announce_install_mode(set.as_deref(), &install_mode_event);
123}
124
125pub fn install_mode_event(line: &str) {
128 let f = install_mode_fields(line);
129 tracing::info!(
130 event = "install.mode",
131 mode = %f[0],
132 kept = %f[1],
133 kept_partno = %f[2],
134 kept_gib = %f[3],
135 kept_set = %f[4],
136 "install record read off {}",
137 appliance_pid1::install::INSTALL_RECORD
138 );
139}
140
141pub fn install_mode_fields(line: &str) -> [String; 5] {
144 let get = |k: &str| {
145 line.split_whitespace()
146 .find_map(|t| t.strip_prefix(k).and_then(|r| r.strip_prefix('=')))
147 .unwrap_or("-")
148 .to_owned()
149 };
150 [get("mode"), get("kept"), get("kept_partno"), get("kept_gib"), get("kept_set")]
151}
152
153pub fn install_panic_fault(data: Option<PathBuf>, shout: fn(&str)) {
163 appliance_pid1::fault::narration_from(crate::narrate::memory);
166 let p = appliance_pid1::fault::PanicFault {
167 code: PANIC_CODE,
168 meaning: crate::narrate::code_meaning(PANIC_CODE).unwrap_or("the app panicked"),
169 data,
170 };
171 appliance_pid1::fault::install_panic_fault(
172 p,
173 || appliance_pid1::net::is_init() && crate::narrate::narrator().is_some(),
174 move |line: &str| say_panic_line(line, shout),
175 );
176}
177
178fn say_panic_line(line: &str, shout: fn(&str)) {
180 if let Some(text) = line.strip_prefix("PANIC: ") {
181 let text = format!("\ngunnar PANIC (exit 101): {text}\n");
182 shout(&text);
183 if let Some(path) = crate::slow::record_crash(&text) {
187 shout(&format!("gunnar: crash record written to {}\n", path.display()));
188 }
189 return;
190 }
191 match crate::narrate::narrator() {
192 Some(n) => n.say(line),
193 None => shout(&format!("gunnar: {line}\n")),
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[test]
205 fn the_utility_role_becomes_the_nic_this_box_has() {
206 use gunnar_common::bindspec::BindSpec;
207 let role: BindSpec = "utility:50051".parse().unwrap();
208 let iface = |i: &str| BindSpec::Interface { interface: i.into(), port: 50051 };
209 assert_eq!(role_bound(role.clone(), Some("eth0:dhcp,eth1:dhcp"), |_| true).unwrap(), iface("eth1"));
210 assert_eq!(role_bound(role.clone(), Some("eth0:dhcp,eth1:dhcp"), |i| i == "eth0").unwrap(), iface("eth0"));
211 assert!(role_bound(role.clone(), None, |_| true).unwrap_err().to_string().contains(NET_VAR));
212 let kept: BindSpec = "127.0.0.1:50051".parse().unwrap();
213 assert_eq!(role_bound(kept.clone(), None, |_| false).unwrap(), kept);
214 }
215
216 #[test]
219 fn gunnar_names_its_network_variable_its_host_and_its_panic() {
220 assert_eq!(NET_VAR, "GUNNAR_NET");
221 assert_eq!(T1_OVERRIDE_VAR, "GUNNAR_DHCP_T1_OVERRIDE_SECS");
222 assert_eq!(appliance_pid1::net::hostname_for(HOSTNAME_PREFIX, None, [0, 0x11, 0x22, 0xaa, 0xbb, 0xcc]), "gunnar-aabbcc");
223 assert_eq!(appliance_pid1::net::hostname_for(HOSTNAME_PREFIX, Some("vm1.example"), [0; 6]), "vm1.example");
224 assert_eq!(RESOLVER_PROBE, "github.com");
225 assert_eq!(PANIC_CODE, "GN-99");
226 assert_eq!(crate::narrate::code_meaning(PANIC_CODE), Some("the app panicked"));
227 }
228
229 #[test]
232 fn the_install_line_becomes_the_dashboards_fields() {
233 use appliance_pid1::install::InstallRecord;
234 let p = InstallRecord::parse("mode=upgrade\nkept=korp-data\nkept_partno=3\nkept_bytes=241591910400\n").line(None);
235 assert_eq!(install_mode_fields(&p), ["upgrade", "korp-data", "3", "225.0", "-"].map(String::from));
236 let s = InstallRecord::parse("mode=upgrade\nkept=data-set\n").line(Some("4\u{d7}75G"));
237 assert_eq!(install_mode_fields(&s), ["upgrade", "data-set", "-", "-", "4\u{d7}75G"].map(String::from));
238 let s = InstallRecord::parse("mode=upgrade\nkept=data-set\n").line(None);
239 assert_eq!(install_mode_fields(&s), ["upgrade", "data-set", "-", "-", "-"].map(String::from));
240 let f = InstallRecord::parse("mode=fresh\n").line(None);
241 assert_eq!(install_mode_fields(&format!("{f} (install record read off the ESP)")), ["fresh", "-", "-", "-", "-"].map(String::from));
242 }
243
244 #[test]
252 fn a_late_lease_is_narrated_into_the_installers_boot_log() {
253 let late = late_lease();
254 assert_eq!(late.t1_override_var, Some(T1_OVERRIDE_VAR));
255 #[cfg(feature = "wg-client")]
257 {
258 crate::installer_listener::capture();
259 (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");
260 let log = crate::installer_listener::BOOT_LOG.since(0, usize::MAX).lines.join("\n");
261 assert!(log.contains("after the boot's failure, 7 asks l10-late"), "the late line reaches the boot log: {log}");
262 }
263 let src = include_str!("boot.rs");
264 let up = &src[src.find("pub fn bring_up_network()").unwrap()..];
265 let up = &up[..up.find("\n}\n").unwrap()];
266 assert!(up.contains("bring_up_network_with(net_plans()?, &how, late_lease())"), "{up}");
267 assert!(up.contains("resolver_probe: RESOLVER_PROBE_IN_THIS_BUILD,"), "{up}");
268 }
269
270 #[test]
273 fn gunnar_net_is_read_from_its_own_variable() {
274 if std::env::var_os(NET_VAR).is_none() {
276 assert!(net_plans().unwrap().is_empty());
277 }
278 let plans = appliance_pid1::net::NetConfig::parse_list("eth0:81.27.107.63/23:81.27.106.1,eth1:10.13.8.78/22").unwrap();
279 assert_eq!(plans.iter().map(|p| p.interface()).collect::<Vec<_>>(), ["eth0", "eth1"]);
280 }
281}
282
283#[cfg(test)]
287mod airgap {
288 #[test]
289 fn the_bring_up_asks_the_name_this_build_names() {
290 let src = include_str!("boot.rs");
291 let up = &src[src.find("pub fn bring_up_network()").unwrap()..];
292 let up = &up[..up.find("\n}\n").unwrap()];
293 assert!(up.contains("resolver_probe: RESOLVER_PROBE_IN_THIS_BUILD,"), "{up}");
294 }
295
296 #[cfg(not(feature = "resolver-probe"))]
297 #[test]
298 fn a_default_build_proves_no_resolver_and_asks_no_public_name() {
299 assert_eq!(super::RESOLVER_PROBE_IN_THIS_BUILD, None);
300 }
301
302 #[cfg(feature = "resolver-probe")]
303 #[test]
304 fn a_resolver_probe_build_asks_github_com_as_before() {
305 assert_eq!(super::RESOLVER_PROBE_IN_THIS_BUILD, Some("github.com"));
306 }
307}