Skip to main content

isb_apps/volume_backup/
hook.rs

1//! The pre-snapshot hook: before isb snapshots a volume, each running
2//! instance using it runs its executable `/etc/isb/pre-snapshot` (if it has
3//! one), as root, with a timeout, so an image can make its own state
4//! consistent first (a SQLite checkpoint, a flushed session file). Its
5//! output goes to the run's log. A failure is reported; it stops the
6//! snapshot only when the volume's settings say `hook_required`.
7
8use std::time::Duration;
9
10use serde::Serialize;
11
12use super::model::HOOK_PATH;
13use crate::error::{Error, Result};
14use crate::exec::{ExecEvent, ExecOptions};
15use crate::jobs::RunLog;
16
17/// How one instance's hook went.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
19#[serde(rename_all = "snake_case")]
20pub enum HookStatus {
21    /// No hook in the instance.
22    Absent,
23    Ok,
24    Failed,
25    TimedOut,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
29pub struct HookResult {
30    pub instance: String,
31    pub status: HookStatus,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub error: Option<String>,
34}
35
36/// Where hooks run: incus, or a fake in tests.
37pub trait HookRunner {
38    /// Does the instance have the hook file?
39    fn present(&self, instance: &str) -> Result<bool>;
40    /// Run it; its exit code (a timeout is [`Error::ExecTimeout`]).
41    fn run(
42        &self,
43        instance: &str,
44        env: &[(&str, &str)],
45        timeout: Duration,
46        log: &mut RunLog,
47    ) -> Result<i32>;
48}
49
50/// Run the hook in each of `instances` (the running ones using the volume).
51/// `Err` only when a hook failed and `required` is set: the snapshot must
52/// not be taken.
53pub fn run_hooks(
54    r: &dyn HookRunner,
55    instances: &[String],
56    env: &[(&str, &str)],
57    (timeout, required): (Duration, bool),
58    log: &mut RunLog,
59) -> Result<Vec<HookResult>> {
60    let mut out = Vec::new();
61    for inst in instances {
62        let res = one(r, inst, env, timeout, log);
63        if let Some(e) = &res.error {
64            log.line(&format!("isb: pre-snapshot hook in {inst}: {e}"));
65            if required {
66                return Err(Error::invalid(format!(
67                    "the pre-snapshot hook in {inst} failed ({e}); hook_required is set, so no snapshot was taken"
68                )));
69            }
70            log.line("isb: continuing without it (hook_required is off)");
71        }
72        out.push(res);
73    }
74    Ok(out)
75}
76
77fn one(
78    r: &dyn HookRunner,
79    inst: &str,
80    env: &[(&str, &str)],
81    timeout: Duration,
82    log: &mut RunLog,
83) -> HookResult {
84    let res = |status, error: Option<String>| HookResult {
85        instance: inst.to_string(),
86        status,
87        error,
88    };
89    match r.present(inst) {
90        Ok(false) => {
91            log.line(&format!("isb: {inst} has no {HOOK_PATH}"));
92            return res(HookStatus::Absent, None);
93        }
94        Ok(true) => {}
95        Err(e) => return res(HookStatus::Failed, Some(format!("looking for it: {e}"))),
96    }
97    log.line(&format!(
98        "isb: running {HOOK_PATH} in {inst} (timeout {timeout:?})"
99    ));
100    match r.run(inst, env, timeout, log) {
101        Ok(0) => {
102            log.line(&format!("isb: {HOOK_PATH} in {inst} done"));
103            res(HookStatus::Ok, None)
104        }
105        Ok(126) => res(
106            HookStatus::Failed,
107            Some(format!("{HOOK_PATH} is not executable")),
108        ),
109        Ok(code) => res(HookStatus::Failed, Some(format!("exited {code}"))),
110        Err(Error::ExecTimeout { .. }) => res(
111            HookStatus::TimedOut,
112            Some(format!("timed out after {timeout:?} (killed)")),
113        ),
114        Err(e) => res(HookStatus::Failed, Some(e.to_string())),
115    }
116}
117
118/// Hooks run through incus, in the org's project.
119pub struct IncusHooks<'a>(pub &'a crate::client::Client);
120
121impl HookRunner for IncusHooks<'_> {
122    fn present(&self, instance: &str) -> Result<bool> {
123        Ok(self.0.read_file(instance, HOOK_PATH)?.is_some())
124    }
125
126    fn run(
127        &self,
128        instance: &str,
129        env: &[(&str, &str)],
130        timeout: Duration,
131        log: &mut RunLog,
132    ) -> Result<i32> {
133        let sb = crate::sandbox::Sandbox::get(self.0, instance)?;
134        let mut opts = ExecOptions::default().timeout(timeout).user("0");
135        for (k, v) in env {
136            opts = opts.env(*k, *v);
137        }
138        let mut s = sb.exec_stream([HOOK_PATH], opts)?;
139        while let Some(ev) = s.next_event() {
140            match ev {
141                ExecEvent::Stdout(b) | ExecEvent::Stderr(b) => log.write(&b),
142            }
143        }
144        s.wait()
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use std::collections::BTreeMap;
152
153    /// Per instance: `None` no hook, `Some(Ok(code))`, or a timeout.
154    struct Fake(BTreeMap<&'static str, Option<std::result::Result<i32, ()>>>);
155
156    impl HookRunner for Fake {
157        fn present(&self, i: &str) -> Result<bool> {
158            Ok(self.0[i].is_some())
159        }
160        fn run(&self, i: &str, env: &[(&str, &str)], t: Duration, log: &mut RunLog) -> Result<i32> {
161            assert_eq!(env, [("ISB_VOLUME", "home")]);
162            log.line("hook output");
163            match self.0[i] {
164                Some(Ok(c)) => Ok(c),
165                _ => Err(Error::ExecTimeout {
166                    argv: HOOK_PATH.into(),
167                    timeout: t,
168                }),
169            }
170        }
171    }
172
173    fn insts(names: &[&str]) -> Vec<String> {
174        names.iter().map(|s| s.to_string()).collect()
175    }
176
177    #[test]
178    fn hooks_report_and_block_only_when_required() {
179        let f = Fake(BTreeMap::from([
180            ("none", None),
181            ("good", Some(Ok(0))),
182            ("bad", Some(Ok(3))),
183            ("noexec", Some(Ok(126))),
184            ("slow", Some(Err(()))),
185        ]));
186        let env = [("ISB_VOLUME", "home")];
187        let t = Duration::from_secs(2);
188        let mut log = RunLog::sink();
189        let r = run_hooks(
190            &f,
191            &insts(&["none", "good", "bad", "noexec", "slow"]),
192            &env,
193            (t, false),
194            &mut log,
195        )
196        .unwrap();
197        let st: Vec<_> = r.iter().map(|x| x.status.clone()).collect();
198        assert_eq!(
199            st,
200            [
201                HookStatus::Absent,
202                HookStatus::Ok,
203                HookStatus::Failed,
204                HookStatus::Failed,
205                HookStatus::TimedOut
206            ]
207        );
208        assert!(
209            r[4].error
210                .as_deref()
211                .unwrap()
212                .contains("timed out after 2s")
213        );
214        assert!(r[3].error.as_deref().unwrap().contains("not executable"));
215        // Required: the first failure stops it.
216        let e = run_hooks(
217            &f,
218            &insts(&["good", "slow", "bad"]),
219            &env,
220            (t, true),
221            &mut log,
222        )
223        .unwrap_err()
224        .to_string();
225        assert!(e.contains("slow") && e.contains("hook_required"), "{e}");
226        // Required, and nothing fails: fine.
227        assert_eq!(
228            run_hooks(&f, &insts(&["good", "none"]), &env, (t, true), &mut log)
229                .unwrap()
230                .len(),
231            2
232        );
233    }
234}