1use crate::cmd::{Output, run};
25use crate::systemd::unit_name;
26use planarian_schema::{Outcome, Timestamp, UnitId};
27use std::collections::BTreeSet;
28use std::path::{Path, PathBuf};
29use std::time::Duration;
30
31pub const HOLD_DIR: &str = "/run/planarian/hold";
32pub const RELEASED_DIR: &str = "/run/planarian/hold-released";
33const TIMEOUT: Duration = Duration::from_secs(30);
34
35pub fn instance(unit: &UnitId) -> String {
37 let n = unit_name(unit);
38 n.strip_suffix(".service").map(str::to_string).unwrap_or(n)
39}
40
41fn hold_unit(unit: &UnitId) -> String {
42 format!("planarian-hold@{}.service", instance(unit))
43}
44
45fn outcome(verb: &str, unit: &UnitId, o: Output) -> Outcome {
46 if o.timed_out {
47 Outcome::Unknown(format!("systemctl {verb} {} did not finish", hold_unit(unit)))
48 } else if o.ok() {
49 Outcome::Succeeded
50 } else {
51 Outcome::Failed(o.tail())
52 }
53}
54
55pub fn apply(unit: &UnitId, wait: Duration) -> Outcome {
60 outcome("start", unit, run(&["systemctl", "start", &hold_unit(unit)], wait.min(TIMEOUT)))
61}
62
63pub fn reapply(unit: &UnitId, wait: Duration) -> Outcome {
70 reapply_with(unit, |argv| run(argv, wait.min(TIMEOUT)))
71}
72
73fn reapply_with(unit: &UnitId, run: impl Fn(&[&str]) -> Output) -> Outcome {
74 let name = hold_unit(unit);
75 let active = run(&["systemctl", "is-active", "--quiet", &name]);
76 if active.timed_out {
77 return Outcome::Unknown(format!("systemctl is-active {name} did not finish"));
78 }
79 let verb = if active.ok() { "reload" } else { "start" };
80 outcome(verb, unit, run(&["systemctl", verb, &name]))
81}
82
83pub fn table_present(wait: Duration) -> Result<bool, Outcome> {
85 let o = run(&["nft", "list", "table", "inet", "planarian-hold"], wait.min(TIMEOUT));
86 if o.ok() {
87 Ok(true)
88 } else if !o.timed_out && o.stderr.contains("No such file or directory") {
89 Ok(false)
90 } else {
91 Err(Outcome::Failed(o.tail()))
92 }
93}
94
95pub fn lift(unit: &UnitId, wait: Duration) -> Outcome {
96 outcome("stop", unit, run(&["systemctl", "stop", &hold_unit(unit)], wait.min(TIMEOUT)))
97}
98
99pub fn held_in(dir: &Path) -> Result<BTreeSet<UnitId>, Outcome> {
103 match std::fs::read_dir(dir) {
104 Ok(entries) => Ok(entries
105 .flatten()
106 .filter_map(|e| e.file_name().to_str().map(str::to_string))
107 .map(|n| UnitId::service(&n))
108 .collect()),
109 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BTreeSet::new()),
110 Err(e) => Err(Outcome::Failed(format!("{}: {e}", dir.display()))),
111 }
112}
113
114pub fn marker(until: Option<Timestamp>, wall_now: Timestamp, boottime_now_s: u64) -> String {
119 match until {
120 Some(t) => {
121 let left = (t.0 / 1000).saturating_sub(wall_now.0 / 1000);
122 format!("{} {}\n", t.0 / 1000, boottime_now_s.saturating_add(left))
123 }
124 None => "boot\n".into(),
125 }
126}
127
128fn boottime_s() -> u64 {
129 rustix::time::clock_gettime(rustix::time::ClockId::Boottime).tv_sec as u64
130}
131
132fn wall_now() -> Timestamp {
133 let d = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
134 Timestamp(d.as_millis() as u64)
135}
136
137fn marker_path(dir: &Path, unit: &UnitId) -> PathBuf {
138 dir.join(instance(unit))
139}
140
141pub fn release_in(dir: &Path, unit: &UnitId, until: Option<Timestamp>, wait: Duration) -> Outcome {
144 if let Err(e) = std::fs::create_dir_all(dir)
145 .and_then(|_| std::fs::write(marker_path(dir, unit), marker(until, wall_now(), boottime_s())))
146 {
147 return Outcome::Failed(format!("{}: {e}", dir.display()));
148 }
149 lift(unit, wait)
150}
151
152pub fn end_release_in(dir: &Path, unit: &UnitId) -> Outcome {
153 match std::fs::remove_file(marker_path(dir, unit)) {
154 Ok(()) => Outcome::Succeeded,
155 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Outcome::Succeeded,
156 Err(e) => Outcome::Failed(format!("{}: {e}", dir.display())),
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn instance_names_drop_the_service_suffix() {
166 assert_eq!(instance(&UnitId::service("wireguard-gym")), "wireguard-gym");
167 assert_eq!(hold_unit(&UnitId::service("wg-quick-wg0")), "planarian-hold@wg-quick-wg0.service");
168 assert_eq!(hold_unit(&UnitId::service("wg-quick@wg0")), "planarian-hold@wg-quick@wg0.service");
170 }
171
172 #[test]
173 fn the_marker_is_boot_or_wall_and_boot_deadlines() {
174 let now = Timestamp(1_799_999_000_900);
175 assert_eq!(marker(Some(Timestamp(1_800_000_000_123)), now, 5_000), "1800000000 6000\n");
177 assert_eq!(marker(Some(Timestamp(1_000)), now, 5_000), "1 5000\n");
179 assert_eq!(marker(None, now, 5_000), "boot\n");
180 }
181
182 #[test]
185 fn reapply_reloads_an_active_hold_and_starts_an_inactive_one() {
186 let u = UnitId::service("wireguard-gym");
187 for (active, verb) in [(true, "reload"), (false, "start")] {
188 let calls = std::cell::RefCell::new(Vec::new());
189 let o = reapply_with(&u, |argv| {
190 calls.borrow_mut().push(argv.join(" "));
191 let status = if argv[1] == "is-active" && !active { 3 } else { 0 };
192 Output {
193 status: Some(status),
194 stdout: String::new(),
195 stderr: String::new(),
196 timed_out: false,
197 truncated: false,
198 }
199 });
200 assert_eq!(o, Outcome::Succeeded);
201 assert_eq!(
202 calls.into_inner(),
203 [
204 "systemctl is-active --quiet planarian-hold@wireguard-gym.service".to_string(),
205 format!("systemctl {verb} planarian-hold@wireguard-gym.service"),
206 ]
207 );
208 }
209 }
210
211 #[test]
212 fn held_reads_the_state_directory_and_absent_is_none() {
213 let d = std::env::temp_dir().join(format!("planarian-hold-{}", std::process::id()));
214 assert_eq!(held_in(&d), Ok(BTreeSet::new()), "never applied: none");
215 std::fs::create_dir_all(&d).unwrap();
216 std::fs::write(d.join("wireguard-gym"), "").unwrap();
217 assert_eq!(held_in(&d), Ok([UnitId::service("wireguard-gym")].into()));
218 let f = d.join("not-a-dir");
220 std::fs::write(&f, "").unwrap();
221 assert!(held_in(&f).is_err());
222 std::fs::remove_dir_all(&d).unwrap();
223 }
224
225 #[test]
226 fn a_release_writes_its_marker_and_ending_it_removes_it() {
227 let d = std::env::temp_dir().join(format!("planarian-hold-released-{}", std::process::id()));
228 let u = UnitId::service("wireguard-gym");
229 let _ = release_in(&d, &u, None, Duration::from_secs(1));
231 assert_eq!(std::fs::read_to_string(d.join("wireguard-gym")).unwrap(), "boot\n");
232 assert_eq!(end_release_in(&d, &u), Outcome::Succeeded);
233 assert!(!d.join("wireguard-gym").exists());
234 assert_eq!(end_release_in(&d, &u), Outcome::Succeeded, "idempotent");
235 std::fs::remove_dir_all(&d).unwrap();
236 }
237}