Skip to main content

isb_core/
supervise.rs

1//! Long-running services: the app is supervised inside the guest, never held
2//! open by an isb process, so it outlives `isb up`, a daemon restart or an isb
3//! upgrade.
4//!
5//! - A system image runs `command` as a systemd unit, `isb-<service>.service`,
6//!   with docker's restart policy mapped to `Restart=` and its environment in a
7//!   0600 `EnvironmentFile`. Logs go to the guest's journal.
8//! - An OCI image's process is the instance's init (`oci.entrypoint`), so
9//!   incus itself restarts it (`boot.autorestart`). Logs are the console log.
10//!
11//! Secrets are files under `/run/secrets`, a tmpfs in a systemd guest. A
12//! root-only copy is kept in `/var/lib/isb/secrets` with a script that puts
13//! them back, which the unit runs before every start: after a reboot the app
14//! has its secrets even when no isb is around to push them.
15
16use std::collections::BTreeMap;
17use std::time::{Duration, Instant};
18
19use crate::client::Client;
20use crate::error::{Error, Result};
21use crate::exec::{ExecOptions, ExecOutput};
22use crate::sandbox::Sandbox;
23use crate::spec::{RestartCondition, RestartMode, SandboxSpec};
24
25/// The persisted copies of a guest's secrets, and the script restoring them.
26pub const SECRETS_STORE: &str = "/var/lib/isb/secrets";
27pub const SECRETS_RESTORE: &str = "/var/lib/isb/secrets/restore";
28
29/// The systemd unit for a service.
30pub fn unit_name(service: &str) -> String {
31    format!("isb-{}.service", crate::compose::sanitize_name(service))
32}
33
34fn env_path(service: &str) -> String {
35    format!("/etc/isb/{}.env", crate::compose::sanitize_name(service))
36}
37
38/// The argv systemd should run: `command`, through the user's login shell
39/// when `exec.login` is set, else through `/bin/sh` when the program is not an
40/// absolute path, so `$PATH` from the environment file applies (systemd
41/// itself only searches a fixed path).
42pub fn effective_argv(spec: &SandboxSpec, login_shell: Option<&str>) -> Option<Vec<String>> {
43    let argv = spec.command.clone().filter(|a| !a.is_empty())?;
44    let wrap = |shell: &str, login: bool| {
45        let mut v = vec![shell.to_string()];
46        if login {
47            v.push("-l".into());
48        }
49        v.extend(["-c".into(), "exec \"$@\"".into(), "isb".into()]);
50        v.extend(argv.clone());
51        v
52    };
53    Some(if spec.exec.login {
54        wrap(login_shell.unwrap_or("/bin/sh"), true)
55    } else if !argv[0].starts_with('/') {
56        wrap("/bin/sh", false)
57    } else {
58        argv
59    })
60}
61
62/// Quote one ExecStart word: systemd expands `$VAR` and `%` specifiers and
63/// takes C escapes inside double quotes.
64fn unit_word(s: &str) -> String {
65    let mut out = String::from("\"");
66    for c in s.chars() {
67        match c {
68            '\\' => out.push_str("\\\\"),
69            '"' => out.push_str("\\\""),
70            '$' => out.push_str("$$"),
71            '%' => out.push_str("%%"),
72            '\n' => out.push_str("\\n"),
73            c => out.push(c),
74        }
75    }
76    out.push('"');
77    out
78}
79
80/// One `KEY="VALUE"` line of an EnvironmentFile, escaped as systemd's
81/// shell-like parser expects.
82fn env_line(k: &str, v: &str) -> String {
83    let mut out = format!("{k}=\"");
84    for c in v.chars() {
85        if matches!(c, '\\' | '"' | '`' | '$') {
86            out.push('\\');
87        }
88        out.push(c);
89    }
90    out.push_str("\"\n");
91    out
92}
93
94/// What to write into the guest for a systemd-supervised service.
95#[derive(Debug, Clone, PartialEq)]
96pub struct UnitFiles {
97    pub unit: String,
98    pub env: String,
99}
100
101/// The variables a service gets from secrets (`KEY: {secret: NAME}`), from
102/// the values of its top-level secrets. A variable's value must be text.
103pub fn secret_env(
104    spec: &SandboxSpec,
105    values: &BTreeMap<String, Vec<u8>>,
106) -> Result<BTreeMap<String, String>> {
107    let mut out = BTreeMap::new();
108    for (var, key) in &spec.env.secrets {
109        let v = values
110            .get(key)
111            .ok_or_else(|| Error::invalid(format!("no value for secret {key:?}")))?;
112        let text = String::from_utf8(v.clone())
113            .ok()
114            .filter(|t| !t.contains('\0'))
115            .ok_or_else(|| {
116                Error::invalid(format!(
117                    "secret {key:?} cannot be the variable {var}: it is not text (NUL or invalid UTF-8); mount it as a file instead"
118                ))
119            })?;
120        out.insert(var.clone(), text);
121    }
122    Ok(out)
123}
124
125/// Render the unit and its environment file. `secret_env` (from
126/// [`secret_env`]) lands in the 0600 environment file only, after the
127/// plain variables.
128pub fn render(
129    service: &str,
130    spec: &SandboxSpec,
131    login_shell: Option<&str>,
132    uses_secrets: bool,
133    secret_env: &BTreeMap<String, String>,
134) -> Result<UnitFiles> {
135    let argv = effective_argv(spec, login_shell).ok_or_else(|| {
136        Error::invalid(format!("{service}: restart needs a command to supervise"))
137    })?;
138    let policy = spec.deploy.as_ref().and_then(|d| d.restart_policy.clone());
139    let restart = match (
140        spec.restart.unwrap_or_default(),
141        policy.as_ref().and_then(|p| p.condition),
142    ) {
143        (_, Some(RestartCondition::None)) => "no",
144        (_, Some(RestartCondition::OnFailure)) => "on-failure",
145        (_, Some(RestartCondition::Any)) => "always",
146        (RestartMode::OnFailure, None) => "on-failure",
147        (RestartMode::No, None) => "no",
148        _ => "always",
149    };
150    let delay = match policy.as_ref().and_then(|p| p.delay.as_deref()) {
151        Some(d) => crate::flex::parse_duration(d).map_err(Error::invalid)?,
152        None => Duration::from_secs(5),
153    };
154    let (burst, interval) = match policy.as_ref().and_then(|p| p.max_attempts) {
155        Some(n) => {
156            let window = match policy.as_ref().and_then(|p| p.window.as_deref()) {
157                Some(w) => crate::flex::parse_duration(w).map_err(Error::invalid)?,
158                // docker counts forever; a day is systemd's nearest useful window.
159                None => Duration::from_secs(86400),
160            };
161            (Some(n.max(1)), window.as_secs().max(1))
162        }
163        None => (None, 0),
164    };
165
166    let mut u = String::new();
167    u.push_str("# Written by isb; overwritten on the next `isb up` or deploy.\n");
168    u.push_str(&format!("[Unit]\nDescription=isb service {service}\n"));
169    u.push_str("After=network-online.target\nWants=network-online.target\n");
170    u.push_str(&format!("StartLimitIntervalSec={interval}\n"));
171    if let Some(b) = burst {
172        u.push_str(&format!("StartLimitBurst={b}\n"));
173    }
174    u.push_str("\n[Service]\nType=simple\n");
175    if let Some(user) = &spec.user {
176        match user.split_once(':') {
177            Some((name, group)) => {
178                u.push_str(&format!("User={name}\nGroup={group}\n"));
179            }
180            None => u.push_str(&format!("User={user}\n")),
181        }
182    }
183    match &spec.working_dir {
184        Some(w) => u.push_str(&format!("WorkingDirectory={w}\n")),
185        None if spec.user.is_some() => u.push_str("WorkingDirectory=~\n"),
186        None => {}
187    }
188    u.push_str(&format!("EnvironmentFile={}\n", env_path(service)));
189    if uses_secrets {
190        // As root (+), whatever the service's user: the store is root-only.
191        u.push_str(&format!("ExecStartPre=+/bin/sh {SECRETS_RESTORE}\n"));
192    }
193    u.push_str("ExecStart=");
194    u.push_str(
195        &argv
196            .iter()
197            .map(|a| unit_word(a))
198            .collect::<Vec<_>>()
199            .join(" "),
200    );
201    u.push('\n');
202    u.push_str(&format!(
203        "Restart={restart}\nRestartSec={}ms\n",
204        delay.as_millis()
205    ));
206    u.push_str("KillMode=mixed\nTimeoutStopSec=10\n");
207    u.push_str("\n[Install]\nWantedBy=multi-user.target\n");
208
209    // incus' environment.* reaches exec, not systemd's services: repeat it.
210    let mut env: BTreeMap<String, String> = spec.env.vars.clone();
211    env.extend(spec.exec.env.clone());
212    env.extend(secret_env.clone());
213    let mut e = String::from("# Written by isb.\n");
214    for (k, v) in &env {
215        e.push_str(&env_line(k, v));
216    }
217    Ok(UnitFiles { unit: u, env: e })
218}
219
220fn root_exec(sb: &Sandbox, argv: &[&str], timeout: Duration) -> Result<ExecOutput> {
221    sb.exec_with(
222        argv.iter().map(|s| s.to_string()),
223        ExecOptions::default()
224            .user("root")
225            .cwd("/")
226            .timeout(timeout),
227    )
228}
229
230fn check(out: ExecOutput, what: &str) -> Result<ExecOutput> {
231    if out.success() {
232        return Ok(out);
233    }
234    let detail = format!("{}{}", out.stdout_text(), out.stderr_text());
235    Err(Error::OperationFailed {
236        step: what.to_string(),
237        message: format!("exit {}: {}", out.exit_code, detail.trim()),
238    })
239}
240
241/// Install (or update) the unit for a long-running service and make sure it
242/// is enabled and running. Returns true when the unit or its environment
243/// changed, in which case the app was restarted.
244pub fn install(
245    sb: &Sandbox,
246    service: &str,
247    spec: &SandboxSpec,
248    uses_secrets: bool,
249    secret_env: &BTreeMap<String, String>,
250) -> Result<bool> {
251    install_with(sb, service, spec, uses_secrets, secret_env, true)
252}
253
254/// [`install`], restarting the app for a changed environment file only when
255/// `restart_on_env`: a secret variable with `on_change: none` is written for
256/// the next start and left alone. A changed unit always restarts it.
257pub fn install_with(
258    sb: &Sandbox,
259    service: &str,
260    spec: &SandboxSpec,
261    uses_secrets: bool,
262    secret_env: &BTreeMap<String, String>,
263    restart_on_env: bool,
264) -> Result<bool> {
265    let client = sb.client();
266    let name = sb.name();
267    if !root_exec(
268        sb,
269        &["test", "-d", "/run/systemd/system"],
270        Duration::from_secs(30),
271    )?
272    .success()
273    {
274        return Err(Error::invalid(format!(
275            "{name}: restart needs systemd in the guest to supervise command; this image has none (use an OCI image, or drop restart)"
276        )));
277    }
278    wait_for_systemd(sb, Duration::from_secs(120))?;
279    let shell = match (&spec.user, spec.exec.login) {
280        (Some(u), true) => crate::exec::resolve_user(client, name, u)?.shell,
281        (None, true) => crate::exec::resolve_user(client, name, "root")?.shell,
282        _ => None,
283    }
284    .filter(|s| !s.ends_with("nologin") && !s.ends_with("/false"));
285    let files = render(service, spec, shell.as_deref(), uses_secrets, secret_env)?;
286    let unit_path = format!("/etc/systemd/system/{}", unit_name(service));
287    let env_file = env_path(service);
288    let same = |path: &str, want: &str| -> Result<bool> {
289        Ok(client.read_file(name, path)?.as_deref() == Some(want.as_bytes()))
290    };
291    let unit_changed = !same(&unit_path, &files.unit)?;
292    let env_changed = !same(&env_file, &files.env)?;
293    let changed = unit_changed || env_changed;
294    let unit = unit_name(service);
295    if changed {
296        client.make_dir(name, "/etc/isb", 0, 0, 0o755)?;
297        client.push_file(name, &env_file, files.env.as_bytes(), 0, 0, 0o600)?;
298        client.push_file(name, &unit_path, files.unit.as_bytes(), 0, 0, 0o644)?;
299    }
300    // Also when unchanged: an earlier attempt may have written the files and
301    // failed before reloading.
302    let loaded = root_exec(
303        sb,
304        &[
305            "systemctl",
306            "show",
307            "--value",
308            "-p",
309            "NeedDaemonReload",
310            &unit,
311        ],
312        Duration::from_secs(30),
313    )?;
314    if changed || loaded.stdout_text().trim() != "no" {
315        check(
316            root_exec(sb, &["systemctl", "daemon-reload"], Duration::from_secs(60))?,
317            "systemctl daemon-reload",
318        )?;
319    }
320    check(
321        root_exec(
322            sb,
323            &["systemctl", "enable", "--quiet", &unit],
324            Duration::from_secs(60),
325        )?,
326        &format!("systemctl enable {unit}"),
327    )?;
328    // --no-block: a unit waiting for its secrets would otherwise hold this.
329    let restart = unit_changed || (env_changed && restart_on_env);
330    let verb = if restart { "restart" } else { "start" };
331    check(
332        root_exec(
333            sb,
334            &["systemctl", verb, "--no-block", &unit],
335            Duration::from_secs(60),
336        )?,
337        &format!("systemctl {verb} {unit}"),
338    )?;
339    Ok(restart)
340}
341
342/// A just-started guest has /run/systemd/system before systemd answers on
343/// its bus. Wait for boot to finish (`degraded` counts: one failed unit of
344/// the image's own is not ours to judge).
345fn wait_for_systemd(sb: &Sandbox, deadline: Duration) -> Result<()> {
346    let started = Instant::now();
347    loop {
348        let out = root_exec(
349            sb,
350            &["systemctl", "is-system-running", "--wait"],
351            Duration::from_secs(60),
352        )?;
353        let state = out.stdout_text().trim().to_string();
354        if matches!(state.as_str(), "running" | "degraded" | "maintenance") {
355            return Ok(());
356        }
357        if started.elapsed() >= deadline {
358            return Err(Error::NotReady {
359                sandbox: sb.name().to_string(),
360                check: "systemd".into(),
361                detail: format!("{state} {}", out.stderr_text().trim()),
362                waited: started.elapsed(),
363            });
364        }
365        std::thread::sleep(Duration::from_millis(500));
366    }
367}
368
369/// Stop and disable a service's unit, if it is installed.
370pub fn uninstall(sb: &Sandbox, service: &str) -> Result<()> {
371    let unit = unit_name(service);
372    let _ = root_exec(
373        sb,
374        &["systemctl", "disable", "--now", "--quiet", &unit],
375        Duration::from_secs(60),
376    )?;
377    Ok(())
378}
379
380/// Restart the app: the unit for a system image, the instance for OCI.
381pub fn restart_app(sb: &Sandbox, service: &str, oci: bool) -> Result<()> {
382    if oci {
383        return sb.restart();
384    }
385    let unit = unit_name(service);
386    check(
387        root_exec(
388            sb,
389            &["systemctl", "restart", "--no-block", &unit],
390            Duration::from_secs(60),
391        )?,
392        &format!("systemctl restart {unit}"),
393    )
394    .map(|_| ())
395}
396
397/// The unit's state: `active`, `activating`, `failed`, `inactive`, ...
398pub fn unit_state(sb: &Sandbox, service: &str) -> Result<String> {
399    let out = root_exec(
400        sb,
401        &["systemctl", "is-active", &unit_name(service)],
402        Duration::from_secs(30),
403    )?;
404    Ok(out.stdout_text().trim().to_string())
405}
406
407/// Write the service's secrets under `/run/secrets` (or their targets), and
408/// the persisted copies plus the script that restores them after a boot.
409/// `values` maps a top-level secret key to its value. Returns whether a
410/// file was missing or different: an OCI app, already running when its
411/// files arrive, needs a restart to read them.
412pub fn push_secrets(
413    sb: &Sandbox,
414    spec: &SandboxSpec,
415    values: &BTreeMap<String, Vec<u8>>,
416) -> Result<bool> {
417    push_secrets_detailed(sb, spec, values).map(|p| p.missing || !p.changed.is_empty())
418}
419
420/// What [`push_secrets_detailed`] found in the guest before writing.
421#[derive(Debug, Default)]
422pub struct Pushed {
423    /// A file was not there at all: the app started without it.
424    pub missing: bool,
425    /// The top-level secrets whose file held another value.
426    pub changed: std::collections::BTreeSet<String>,
427}
428
429/// [`push_secrets`], saying which secrets' files were missing or different,
430/// so a caller can restart the app only for the ones that warrant it.
431pub fn push_secrets_detailed(
432    sb: &Sandbox,
433    spec: &SandboxSpec,
434    values: &BTreeMap<String, Vec<u8>>,
435) -> Result<Pushed> {
436    push_secret_files(sb.client(), sb.name(), spec, values)
437}
438
439/// [`push_secrets_detailed`] by instance name. The files API works on a
440/// stopped container too, so a new OCI instance gets its files before its
441/// first start (see `plan::Desired::before_start`).
442pub fn push_secret_files(
443    client: &Client,
444    name: &str,
445    spec: &SandboxSpec,
446    values: &BTreeMap<String, Vec<u8>>,
447) -> Result<Pushed> {
448    let mut pushed = Pushed::default();
449    if !spec.has_secret_files() {
450        return Ok(pushed);
451    }
452    let (def_uid, def_gid) = numeric_user(spec.user.as_deref()).unwrap_or((0, 0));
453    let refs = file_refs(client, name, spec)?;
454    make_dirs(client, name, "/var/lib/isb")?;
455    client.make_dir(name, SECRETS_STORE, 0, 0, 0o700)?;
456    let mut script =
457        String::from("#!/bin/sh\n# Written by isb: puts the secrets back after a boot.\nset -e\n");
458    for (n, s) in refs.iter().enumerate() {
459        let value = values
460            .get(&s.source)
461            .ok_or_else(|| Error::invalid(format!("{name}: no value for secret {:?}", s.source)))?;
462        let path = s.guest_path();
463        let parent = path.rsplit_once('/').map(|(p, _)| p).unwrap_or("/");
464        make_dirs(client, name, parent)?;
465        let mode = s.file_mode().map_err(Error::invalid)?;
466        let (uid, gid) = (s.uid.unwrap_or(def_uid), s.gid.or(s.uid).unwrap_or(def_gid));
467        match client.read_file(name, &path).ok().flatten() {
468            None => pushed.missing = true,
469            Some(v) if v != *value => {
470                pushed.changed.insert(s.source.clone());
471            }
472            Some(_) => {}
473        }
474        client.push_file(name, &path, value, uid, gid, mode)?;
475        let stored = format!("{SECRETS_STORE}/{n}");
476        client.push_file(name, &stored, value, 0, 0, 0o400)?;
477        script.push_str(&format!(
478            "install -D -m {mode:04o} -o {uid} -g {gid} {} {}\n",
479            sh_quote(&stored),
480            sh_quote(&path)
481        ));
482    }
483    client.push_file(name, SECRETS_RESTORE, script.as_bytes(), 0, 0, 0o700)?;
484    Ok(pushed)
485}
486
487/// The files to write: `secrets:` as given, then each `as: file` variable's
488/// secret at `/run/secrets/NAME`, 0400 and owned by the user the app starts
489/// as (the numeric `user:`, else an OCI image's own `oci.uid`/`oci.gid`), so
490/// an image that runs as non-root can read it. A path `secrets:` already
491/// writes is left to it.
492fn file_refs(
493    client: &Client,
494    name: &str,
495    spec: &SandboxSpec,
496) -> Result<Vec<crate::spec::SecretRef>> {
497    let mut refs = spec.secrets.clone();
498    if spec.env.files.is_empty() {
499        return Ok(refs);
500    }
501    let (uid, gid) = match numeric_user(spec.user.as_deref()) {
502        Some(ids) => ids,
503        None => oci_ids(client, name)?,
504    };
505    let taken: std::collections::BTreeSet<String> = refs.iter().map(|r| r.guest_path()).collect();
506    let keys: std::collections::BTreeSet<&String> = spec.env.files.values().collect();
507    for key in keys {
508        let r = crate::spec::SecretRef {
509            source: key.clone(),
510            uid: Some(uid),
511            gid: Some(gid),
512            mode: Some("0400".into()),
513            ..Default::default()
514        };
515        if !taken.contains(&r.guest_path()) {
516            refs.push(r);
517        }
518    }
519    Ok(refs)
520}
521
522/// An OCI instance's `oci.uid`/`oci.gid` (incus fills them from the image's
523/// `USER`); 0 when unset, as for a system image.
524fn oci_ids(client: &Client, name: &str) -> Result<(u32, u32)> {
525    let inst = client.get(&format!(
526        "/1.0/instances/{}",
527        crate::client::encode_segment(name)
528    ))?;
529    let id = |k: &str| {
530        inst.pointer(&format!("/config/{}", k.replace('/', "~1")))
531            .and_then(serde_json::Value::as_str)
532            .and_then(|v| v.trim().parse().ok())
533            .unwrap_or(0)
534    };
535    Ok((id("oci.uid"), id("oci.gid")))
536}
537
538/// Set an OCI instance's secret variables in its config (`environment.KEY`),
539/// where its next start reads them; the running app keeps what it has.
540pub fn set_oci_env(sb: &Sandbox, env: &BTreeMap<String, String>) -> Result<()> {
541    if env.is_empty() {
542        return Ok(());
543    }
544    let config: serde_json::Map<String, serde_json::Value> = env
545        .iter()
546        .map(|(k, v)| {
547            (
548                format!("environment.{k}"),
549                serde_json::Value::from(v.as_str()),
550            )
551        })
552        .collect();
553    sb.client().mutate(
554        "PATCH",
555        &format!(
556            "/1.0/instances/{}",
557            crate::client::encode_segment(sb.name())
558        ),
559        Some(&serde_json::json!({ "config": config })),
560        "set secret variables",
561        Duration::from_secs(60),
562    )?;
563    Ok(())
564}
565
566fn sh_quote(s: &str) -> String {
567    format!("'{}'", s.replace('\'', "'\\''"))
568}
569
570fn make_dirs(client: &Client, name: &str, path: &str) -> Result<()> {
571    let mut cur = String::new();
572    for part in path.split('/').filter(|p| !p.is_empty()) {
573        cur.push('/');
574        cur.push_str(part);
575        client.make_dir(name, &cur, 0, 0, 0o755)?;
576    }
577    Ok(())
578}
579
580/// `1000` or `1000:1000` as ids; a name needs the guest to resolve it.
581fn numeric_user(user: Option<&str>) -> Option<(u32, u32)> {
582    let u = user?;
583    let (a, b) = u.split_once(':').unwrap_or((u, u));
584    Some((a.parse().ok()?, b.parse().ok()?))
585}
586
587/// Read the secrets the file's services use that come from where the
588/// deployer stands: a host file (relative to `base`) or one of `vars` / the
589/// environment. The others (`external`, `age`, `driver`) come from the org's
590/// store and the daemon's key, and are skipped here.
591pub fn resolve_secret_values(
592    file: &crate::spec::ComposeFile,
593    base: &std::path::Path,
594    lookup: &dyn Fn(&str) -> Option<String>,
595) -> Result<BTreeMap<String, Vec<u8>>> {
596    let used = crate::stack::secrets::used_keys(file);
597    let mut out = BTreeMap::new();
598    for (key, def) in &file.secrets {
599        if !used.contains(key) {
600            continue;
601        }
602        let v = if let Some(f) = &def.file {
603            let p = crate::plan::resolve_host_path(f, base)?;
604            std::fs::read(&p)
605                .map_err(|e| Error::invalid(format!("secret {key:?}: cannot read {p}: {e}")))?
606        } else if let Some(var) = &def.environment {
607            lookup(var)
608                .ok_or_else(|| {
609                    Error::invalid(format!(
610                        "secret {key:?}: environment variable {var} is not set"
611                    ))
612                })?
613                .into_bytes()
614        } else {
615            continue;
616        };
617        out.insert(key.clone(), v);
618    }
619    Ok(out)
620}
621
622/// The outcome of one health probe.
623#[derive(Debug, Clone, PartialEq, serde::Serialize)]
624pub struct Probe {
625    pub ok: bool,
626    /// Exit code, or None when it could not run or timed out.
627    pub exit_code: Option<i32>,
628    /// The tail of its output, for status displays.
629    pub output: String,
630    #[serde(skip)]
631    pub took: Duration,
632}
633
634/// Run a service's healthcheck once.
635pub fn probe(sb: &Sandbox, check: &crate::spec::HealthProbe) -> Probe {
636    let started = Instant::now();
637    let r = sb.exec_with(
638        check.argv.clone(),
639        ExecOptions::default().timeout(check.timeout),
640    );
641    let took = started.elapsed();
642    match r {
643        Ok(out) => {
644            let mut text = format!("{}{}", out.stdout_text(), out.stderr_text());
645            if text.len() > 512 {
646                text = text[text.len() - 512..].to_string();
647            }
648            Probe {
649                ok: out.success(),
650                exit_code: Some(out.exit_code),
651                output: text.trim().to_string(),
652                took,
653            }
654        }
655        Err(e) => Probe {
656            ok: false,
657            exit_code: None,
658            output: e.to_string(),
659            took,
660        },
661    }
662}
663
664/// The last `lines` lines of a service's output: its journal for a system
665/// image, the console log for an OCI image.
666pub fn logs(sb: &Sandbox, service: &str, oci: bool, lines: usize) -> Result<String> {
667    if oci {
668        let raw = console_log(sb.client(), sb.name())?;
669        let text = String::from_utf8_lossy(&raw);
670        let all: Vec<&str> = text.lines().collect();
671        return Ok(all[all.len().saturating_sub(lines)..].join("\n"));
672    }
673    let n = lines.to_string();
674    let out = root_exec(
675        sb,
676        &[
677            "journalctl",
678            "-u",
679            &unit_name(service),
680            "-n",
681            &n,
682            "-o",
683            "short-iso",
684            "--no-pager",
685        ],
686        Duration::from_secs(60),
687    )?;
688    Ok(check(out, "journalctl")?.stdout_text())
689}
690
691/// The argv that follows a unit's journal from now on, for foreground `up`.
692pub fn follow_argv(service: &str) -> Vec<String> {
693    ["journalctl", "-f", "-n", "0", "-o", "cat", "-u"]
694        .iter()
695        .map(|s| s.to_string())
696        .chain([unit_name(service)])
697        .collect()
698}
699
700/// An instance's console log (an OCI app's stdout and stderr).
701pub fn console_log(client: &Client, name: &str) -> Result<Vec<u8>> {
702    client.console_log(name)
703}
704
705/// The services supervised in `sb`, by the (sanitized) name their units
706/// carry: what [`logs`] takes when the caller does not know the compose
707/// service an instance was made from.
708pub fn supervised(sb: &Sandbox) -> Result<Vec<String>> {
709    let out = root_exec(
710        sb,
711        &[
712            "find",
713            "/etc/systemd/system",
714            "-maxdepth",
715            "1",
716            "-name",
717            "isb-*.service",
718        ],
719        Duration::from_secs(30),
720    )?;
721    Ok(services_of(&check(out, "find")?.stdout_text()))
722}
723
724fn services_of(listing: &str) -> Vec<String> {
725    let mut out: Vec<String> = listing
726        .lines()
727        .filter_map(|l| l.trim().rsplit('/').next())
728        .filter_map(|f| f.strip_prefix("isb-")?.strip_suffix(".service"))
729        .filter(|s| !s.is_empty())
730        .map(String::from)
731        .collect();
732    out.sort();
733    out.dedup();
734    out
735}
736
737#[cfg(test)]
738mod tests {
739    use super::*;
740
741    fn spec(y: &str) -> SandboxSpec {
742        serde_yaml_ng::from_str(y).unwrap()
743    }
744
745    #[test]
746    fn supervised_services_are_read_back_from_their_unit_files() {
747        let ls = "/etc/systemd/system/isb-web.service\n/etc/systemd/system/isb-api-v2.service\n\n/etc/systemd/system/isb-.service\n";
748        assert_eq!(services_of(ls), ["api-v2", "web"]);
749        // What logs() is given names the same unit.
750        assert_eq!(unit_name("api-v2"), "isb-api-v2.service");
751        assert!(services_of("").is_empty());
752    }
753
754    #[test]
755    fn renders_a_unit() {
756        let s = spec(
757            "image: x\nuser: dev\nworking_dir: /srv\nrestart: always\ncommand: bun run dev --port=$PORT\nenvironment: {A: 'x \"y\" $z'}\n",
758        );
759        let f = render("web", &s, None, false, &BTreeMap::new()).unwrap();
760        assert!(f.unit.contains("User=dev\n"), "{}", f.unit);
761        assert!(f.unit.contains("WorkingDirectory=/srv\n"));
762        assert!(f.unit.contains("Restart=always\n"));
763        assert!(f.unit.contains("RestartSec=5000ms\n"));
764        assert!(f.unit.contains("StartLimitIntervalSec=0\n"));
765        assert!(
766            f.unit.contains(
767                "ExecStart=\"/bin/sh\" \"-c\" \"exec \\\"$$@\\\"\" \"isb\" \"bun\" \"run\" \"dev\" \"--port=$$PORT\"\n"
768            ),
769            "{}",
770            f.unit
771        );
772        assert!(!f.unit.contains("ExecStartPre"));
773        assert_eq!(f.env, "# Written by isb.\nA=\"x \\\"y\\\" \\$z\"\n");
774    }
775
776    #[test]
777    fn absolute_command_runs_directly_and_policy_maps() {
778        let s = spec(
779            "image: x\nrestart: on-failure\ncommand: [/usr/bin/app, '50%']\ndeploy: {restart_policy: {delay: 2s, max_attempts: 3, window: 1m}}\n",
780        );
781        let f = render("api", &s, None, true, &BTreeMap::new()).unwrap();
782        assert!(
783            f.unit.contains("ExecStart=\"/usr/bin/app\" \"50%%\"\n"),
784            "{}",
785            f.unit
786        );
787        assert!(f.unit.contains("Restart=on-failure\n"));
788        assert!(f.unit.contains("RestartSec=2000ms\n"));
789        assert!(
790            f.unit
791                .contains("StartLimitIntervalSec=60\nStartLimitBurst=3\n")
792        );
793        assert!(
794            f.unit
795                .contains("ExecStartPre=+/bin/sh /var/lib/isb/secrets/restore\n")
796        );
797        assert!(!f.unit.contains("User="));
798    }
799
800    #[test]
801    fn login_shell_wraps() {
802        let s = spec("image: x\nrestart: always\ncommand: [bun, dev]\nexec: {login: true}\n");
803        assert_eq!(
804            effective_argv(&s, Some("/bin/bash")).unwrap(),
805            ["/bin/bash", "-l", "-c", "exec \"$@\"", "isb", "bun", "dev"]
806        );
807        assert!(
808            render(
809                "x",
810                &spec("image: x\nrestart: always\n"),
811                None,
812                false,
813                &BTreeMap::new()
814            )
815            .is_err()
816        );
817    }
818
819    #[test]
820    fn secret_variables_go_to_the_env_file_only() {
821        let s = spec(
822            "image: x\nrestart: always\ncommand: [/usr/bin/app]\nenvironment: {A: plain, TOKEN: {secret: tok}, DB: {secret: db}}\nexec: {env: {B: two}}\n",
823        );
824        let values = BTreeMap::from([
825            ("tok".to_string(), b"s3cr$t \"x\"".to_vec()),
826            ("db".to_string(), b"pw".to_vec()),
827        ]);
828        let env = secret_env(&s, &values).unwrap();
829        assert_eq!(env["TOKEN"], "s3cr$t \"x\"");
830        let f = render("app", &s, None, false, &env).unwrap();
831        assert_eq!(
832            f.env,
833            "# Written by isb.\nA=\"plain\"\nB=\"two\"\nDB=\"pw\"\nTOKEN=\"s3cr\\$t \\\"x\\\"\"\n"
834        );
835        assert!(!f.unit.contains("s3cr"), "{}", f.unit);
836        // Not text: refused, pointing at a file mount instead.
837        let bad = BTreeMap::from([
838            ("tok".to_string(), vec![0xff, 0x00]),
839            ("db".to_string(), b"pw".to_vec()),
840        ]);
841        let e = secret_env(&s, &bad).unwrap_err().to_string();
842        assert!(e.contains("not text") && e.contains("TOKEN"), "{e}");
843        let e = secret_env(&s, &BTreeMap::new()).unwrap_err().to_string();
844        assert!(e.contains("no value"), "{e}");
845    }
846
847    #[test]
848    fn numeric_users() {
849        assert_eq!(numeric_user(Some("1000")), Some((1000, 1000)));
850        assert_eq!(numeric_user(Some("1000:44")), Some((1000, 44)));
851        assert_eq!(numeric_user(Some("dev")), None);
852    }
853}