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    let client = sb.client();
252    let name = sb.name();
253    if !root_exec(
254        sb,
255        &["test", "-d", "/run/systemd/system"],
256        Duration::from_secs(30),
257    )?
258    .success()
259    {
260        return Err(Error::invalid(format!(
261            "{name}: restart needs systemd in the guest to supervise command; this image has none (use an OCI image, or drop restart)"
262        )));
263    }
264    wait_for_systemd(sb, Duration::from_secs(120))?;
265    let shell = match (&spec.user, spec.exec.login) {
266        (Some(u), true) => crate::exec::resolve_user(client, name, u)?.shell,
267        (None, true) => crate::exec::resolve_user(client, name, "root")?.shell,
268        _ => None,
269    }
270    .filter(|s| !s.ends_with("nologin") && !s.ends_with("/false"));
271    let files = render(service, spec, shell.as_deref(), uses_secrets, secret_env)?;
272    let unit_path = format!("/etc/systemd/system/{}", unit_name(service));
273    let env_file = env_path(service);
274    let same = |path: &str, want: &str| -> Result<bool> {
275        Ok(client.read_file(name, path)?.as_deref() == Some(want.as_bytes()))
276    };
277    let changed = !same(&unit_path, &files.unit)? || !same(&env_file, &files.env)?;
278    let unit = unit_name(service);
279    if changed {
280        client.make_dir(name, "/etc/isb", 0, 0, 0o755)?;
281        client.push_file(name, &env_file, files.env.as_bytes(), 0, 0, 0o600)?;
282        client.push_file(name, &unit_path, files.unit.as_bytes(), 0, 0, 0o644)?;
283    }
284    // Also when unchanged: an earlier attempt may have written the files and
285    // failed before reloading.
286    let loaded = root_exec(
287        sb,
288        &[
289            "systemctl",
290            "show",
291            "--value",
292            "-p",
293            "NeedDaemonReload",
294            &unit,
295        ],
296        Duration::from_secs(30),
297    )?;
298    if changed || loaded.stdout_text().trim() != "no" {
299        check(
300            root_exec(sb, &["systemctl", "daemon-reload"], Duration::from_secs(60))?,
301            "systemctl daemon-reload",
302        )?;
303    }
304    check(
305        root_exec(
306            sb,
307            &["systemctl", "enable", "--quiet", &unit],
308            Duration::from_secs(60),
309        )?,
310        &format!("systemctl enable {unit}"),
311    )?;
312    // --no-block: a unit waiting for its secrets would otherwise hold this.
313    let verb = if changed { "restart" } else { "start" };
314    check(
315        root_exec(
316            sb,
317            &["systemctl", verb, "--no-block", &unit],
318            Duration::from_secs(60),
319        )?,
320        &format!("systemctl {verb} {unit}"),
321    )?;
322    Ok(changed)
323}
324
325/// A just-started guest has /run/systemd/system before systemd answers on
326/// its bus. Wait for boot to finish (`degraded` counts: one failed unit of
327/// the image's own is not ours to judge).
328fn wait_for_systemd(sb: &Sandbox, deadline: Duration) -> Result<()> {
329    let started = Instant::now();
330    loop {
331        let out = root_exec(
332            sb,
333            &["systemctl", "is-system-running", "--wait"],
334            Duration::from_secs(60),
335        )?;
336        let state = out.stdout_text().trim().to_string();
337        if matches!(state.as_str(), "running" | "degraded" | "maintenance") {
338            return Ok(());
339        }
340        if started.elapsed() >= deadline {
341            return Err(Error::NotReady {
342                sandbox: sb.name().to_string(),
343                check: "systemd".into(),
344                detail: format!("{state} {}", out.stderr_text().trim()),
345                waited: started.elapsed(),
346            });
347        }
348        std::thread::sleep(Duration::from_millis(500));
349    }
350}
351
352/// Stop and disable a service's unit, if it is installed.
353pub fn uninstall(sb: &Sandbox, service: &str) -> Result<()> {
354    let unit = unit_name(service);
355    let _ = root_exec(
356        sb,
357        &["systemctl", "disable", "--now", "--quiet", &unit],
358        Duration::from_secs(60),
359    )?;
360    Ok(())
361}
362
363/// Restart the app: the unit for a system image, the instance for OCI.
364pub fn restart_app(sb: &Sandbox, service: &str, oci: bool) -> Result<()> {
365    if oci {
366        return sb.restart();
367    }
368    let unit = unit_name(service);
369    check(
370        root_exec(
371            sb,
372            &["systemctl", "restart", "--no-block", &unit],
373            Duration::from_secs(60),
374        )?,
375        &format!("systemctl restart {unit}"),
376    )
377    .map(|_| ())
378}
379
380/// The unit's state: `active`, `activating`, `failed`, `inactive`, ...
381pub fn unit_state(sb: &Sandbox, service: &str) -> Result<String> {
382    let out = root_exec(
383        sb,
384        &["systemctl", "is-active", &unit_name(service)],
385        Duration::from_secs(30),
386    )?;
387    Ok(out.stdout_text().trim().to_string())
388}
389
390/// Write the service's secrets under `/run/secrets` (or their targets), and
391/// the persisted copies plus the script that restores them after a boot.
392/// `values` maps a top-level secret key to its value. Returns whether a
393/// file was missing or different: an OCI app, already running when its
394/// files arrive, needs a restart to read them.
395pub fn push_secrets(
396    sb: &Sandbox,
397    spec: &SandboxSpec,
398    values: &BTreeMap<String, Vec<u8>>,
399) -> Result<bool> {
400    if spec.secrets.is_empty() {
401        return Ok(false);
402    }
403    let mut changed = false;
404    let client = sb.client();
405    let name = sb.name();
406    let (def_uid, def_gid) = numeric_user(spec.user.as_deref()).unwrap_or((0, 0));
407    make_dirs(client, name, "/var/lib/isb")?;
408    client.make_dir(name, SECRETS_STORE, 0, 0, 0o700)?;
409    let mut script =
410        String::from("#!/bin/sh\n# Written by isb: puts the secrets back after a boot.\nset -e\n");
411    for (n, s) in spec.secrets.iter().enumerate() {
412        let value = values
413            .get(&s.source)
414            .ok_or_else(|| Error::invalid(format!("{name}: no value for secret {:?}", s.source)))?;
415        let path = s.guest_path();
416        let parent = path.rsplit_once('/').map(|(p, _)| p).unwrap_or("/");
417        make_dirs(client, name, parent)?;
418        let mode = s.file_mode().map_err(Error::invalid)?;
419        let (uid, gid) = (s.uid.unwrap_or(def_uid), s.gid.or(s.uid).unwrap_or(def_gid));
420        changed |=
421            client.read_file(name, &path).ok().flatten().as_deref() != Some(value.as_slice());
422        client.push_file(name, &path, value, uid, gid, mode)?;
423        let stored = format!("{SECRETS_STORE}/{n}");
424        client.push_file(name, &stored, value, 0, 0, 0o400)?;
425        script.push_str(&format!(
426            "install -D -m {mode:04o} -o {uid} -g {gid} {} {}\n",
427            sh_quote(&stored),
428            sh_quote(&path)
429        ));
430    }
431    client.push_file(name, SECRETS_RESTORE, script.as_bytes(), 0, 0, 0o700)?;
432    Ok(changed)
433}
434
435fn sh_quote(s: &str) -> String {
436    format!("'{}'", s.replace('\'', "'\\''"))
437}
438
439fn make_dirs(client: &Client, name: &str, path: &str) -> Result<()> {
440    let mut cur = String::new();
441    for part in path.split('/').filter(|p| !p.is_empty()) {
442        cur.push('/');
443        cur.push_str(part);
444        client.make_dir(name, &cur, 0, 0, 0o755)?;
445    }
446    Ok(())
447}
448
449/// `1000` or `1000:1000` as ids; a name needs the guest to resolve it.
450fn numeric_user(user: Option<&str>) -> Option<(u32, u32)> {
451    let u = user?;
452    let (a, b) = u.split_once(':').unwrap_or((u, u));
453    Some((a.parse().ok()?, b.parse().ok()?))
454}
455
456/// Read the secrets the file's services use that come from where the
457/// deployer stands: a host file (relative to `base`) or one of `vars` / the
458/// environment. The others (`external`, `age`, `driver`) come from the org's
459/// store and the daemon's key, and are skipped here.
460pub fn resolve_secret_values(
461    file: &crate::spec::ComposeFile,
462    base: &std::path::Path,
463    lookup: &dyn Fn(&str) -> Option<String>,
464) -> Result<BTreeMap<String, Vec<u8>>> {
465    let used = crate::stack::secrets::used_keys(file);
466    let mut out = BTreeMap::new();
467    for (key, def) in &file.secrets {
468        if !used.contains(key) {
469            continue;
470        }
471        let v = if let Some(f) = &def.file {
472            let p = crate::plan::resolve_host_path(f, base)?;
473            std::fs::read(&p)
474                .map_err(|e| Error::invalid(format!("secret {key:?}: cannot read {p}: {e}")))?
475        } else if let Some(var) = &def.environment {
476            lookup(var)
477                .ok_or_else(|| {
478                    Error::invalid(format!(
479                        "secret {key:?}: environment variable {var} is not set"
480                    ))
481                })?
482                .into_bytes()
483        } else {
484            continue;
485        };
486        out.insert(key.clone(), v);
487    }
488    Ok(out)
489}
490
491/// The outcome of one health probe.
492#[derive(Debug, Clone, PartialEq, serde::Serialize)]
493pub struct Probe {
494    pub ok: bool,
495    /// Exit code, or None when it could not run or timed out.
496    pub exit_code: Option<i32>,
497    /// The tail of its output, for status displays.
498    pub output: String,
499    #[serde(skip)]
500    pub took: Duration,
501}
502
503/// Run a service's healthcheck once.
504pub fn probe(sb: &Sandbox, check: &crate::spec::HealthProbe) -> Probe {
505    let started = Instant::now();
506    let r = sb.exec_with(
507        check.argv.clone(),
508        ExecOptions::default().timeout(check.timeout),
509    );
510    let took = started.elapsed();
511    match r {
512        Ok(out) => {
513            let mut text = format!("{}{}", out.stdout_text(), out.stderr_text());
514            if text.len() > 512 {
515                text = text[text.len() - 512..].to_string();
516            }
517            Probe {
518                ok: out.success(),
519                exit_code: Some(out.exit_code),
520                output: text.trim().to_string(),
521                took,
522            }
523        }
524        Err(e) => Probe {
525            ok: false,
526            exit_code: None,
527            output: e.to_string(),
528            took,
529        },
530    }
531}
532
533/// The last `lines` lines of a service's output: its journal for a system
534/// image, the console log for an OCI image.
535pub fn logs(sb: &Sandbox, service: &str, oci: bool, lines: usize) -> Result<String> {
536    if oci {
537        let raw = console_log(sb.client(), sb.name())?;
538        let text = String::from_utf8_lossy(&raw);
539        let all: Vec<&str> = text.lines().collect();
540        return Ok(all[all.len().saturating_sub(lines)..].join("\n"));
541    }
542    let n = lines.to_string();
543    let out = root_exec(
544        sb,
545        &[
546            "journalctl",
547            "-u",
548            &unit_name(service),
549            "-n",
550            &n,
551            "-o",
552            "short-iso",
553            "--no-pager",
554        ],
555        Duration::from_secs(60),
556    )?;
557    Ok(check(out, "journalctl")?.stdout_text())
558}
559
560/// The argv that follows a unit's journal from now on, for foreground `up`.
561pub fn follow_argv(service: &str) -> Vec<String> {
562    ["journalctl", "-f", "-n", "0", "-o", "cat", "-u"]
563        .iter()
564        .map(|s| s.to_string())
565        .chain([unit_name(service)])
566        .collect()
567}
568
569/// An instance's console log (an OCI app's stdout and stderr).
570pub fn console_log(client: &Client, name: &str) -> Result<Vec<u8>> {
571    client.console_log(name)
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577
578    fn spec(y: &str) -> SandboxSpec {
579        serde_yaml_ng::from_str(y).unwrap()
580    }
581
582    #[test]
583    fn renders_a_unit() {
584        let s = spec(
585            "image: x\nuser: dev\nworking_dir: /srv\nrestart: always\ncommand: bun run dev --port=$PORT\nenvironment: {A: 'x \"y\" $z'}\n",
586        );
587        let f = render("web", &s, None, false, &BTreeMap::new()).unwrap();
588        assert!(f.unit.contains("User=dev\n"), "{}", f.unit);
589        assert!(f.unit.contains("WorkingDirectory=/srv\n"));
590        assert!(f.unit.contains("Restart=always\n"));
591        assert!(f.unit.contains("RestartSec=5000ms\n"));
592        assert!(f.unit.contains("StartLimitIntervalSec=0\n"));
593        assert!(
594            f.unit.contains(
595                "ExecStart=\"/bin/sh\" \"-c\" \"exec \\\"$$@\\\"\" \"isb\" \"bun\" \"run\" \"dev\" \"--port=$$PORT\"\n"
596            ),
597            "{}",
598            f.unit
599        );
600        assert!(!f.unit.contains("ExecStartPre"));
601        assert_eq!(f.env, "# Written by isb.\nA=\"x \\\"y\\\" \\$z\"\n");
602    }
603
604    #[test]
605    fn absolute_command_runs_directly_and_policy_maps() {
606        let s = spec(
607            "image: x\nrestart: on-failure\ncommand: [/usr/bin/app, '50%']\ndeploy: {restart_policy: {delay: 2s, max_attempts: 3, window: 1m}}\n",
608        );
609        let f = render("api", &s, None, true, &BTreeMap::new()).unwrap();
610        assert!(
611            f.unit.contains("ExecStart=\"/usr/bin/app\" \"50%%\"\n"),
612            "{}",
613            f.unit
614        );
615        assert!(f.unit.contains("Restart=on-failure\n"));
616        assert!(f.unit.contains("RestartSec=2000ms\n"));
617        assert!(
618            f.unit
619                .contains("StartLimitIntervalSec=60\nStartLimitBurst=3\n")
620        );
621        assert!(
622            f.unit
623                .contains("ExecStartPre=+/bin/sh /var/lib/isb/secrets/restore\n")
624        );
625        assert!(!f.unit.contains("User="));
626    }
627
628    #[test]
629    fn login_shell_wraps() {
630        let s = spec("image: x\nrestart: always\ncommand: [bun, dev]\nexec: {login: true}\n");
631        assert_eq!(
632            effective_argv(&s, Some("/bin/bash")).unwrap(),
633            ["/bin/bash", "-l", "-c", "exec \"$@\"", "isb", "bun", "dev"]
634        );
635        assert!(
636            render(
637                "x",
638                &spec("image: x\nrestart: always\n"),
639                None,
640                false,
641                &BTreeMap::new()
642            )
643            .is_err()
644        );
645    }
646
647    #[test]
648    fn secret_variables_go_to_the_env_file_only() {
649        let s = spec(
650            "image: x\nrestart: always\ncommand: [/usr/bin/app]\nenvironment: {A: plain, TOKEN: {secret: tok}, DB: {secret: db}}\nexec: {env: {B: two}}\n",
651        );
652        let values = BTreeMap::from([
653            ("tok".to_string(), b"s3cr$t \"x\"".to_vec()),
654            ("db".to_string(), b"pw".to_vec()),
655        ]);
656        let env = secret_env(&s, &values).unwrap();
657        assert_eq!(env["TOKEN"], "s3cr$t \"x\"");
658        let f = render("app", &s, None, false, &env).unwrap();
659        assert_eq!(
660            f.env,
661            "# Written by isb.\nA=\"plain\"\nB=\"two\"\nDB=\"pw\"\nTOKEN=\"s3cr\\$t \\\"x\\\"\"\n"
662        );
663        assert!(!f.unit.contains("s3cr"), "{}", f.unit);
664        // Not text: refused, pointing at a file mount instead.
665        let bad = BTreeMap::from([
666            ("tok".to_string(), vec![0xff, 0x00]),
667            ("db".to_string(), b"pw".to_vec()),
668        ]);
669        let e = secret_env(&s, &bad).unwrap_err().to_string();
670        assert!(e.contains("not text") && e.contains("TOKEN"), "{e}");
671        let e = secret_env(&s, &BTreeMap::new()).unwrap_err().to_string();
672        assert!(e.contains("no value"), "{e}");
673    }
674
675    #[test]
676    fn numeric_users() {
677        assert_eq!(numeric_user(Some("1000")), Some((1000, 1000)));
678        assert_eq!(numeric_user(Some("1000:44")), Some((1000, 44)));
679        assert_eq!(numeric_user(Some("dev")), None);
680    }
681}