Skip to main content

regent_sdk/state/attribute/container/
service_unit.rs

1//! Systemd service units running a container in the foreground
2//!
3//! Shared by the [`apptainer`](super::apptainer) and [`enroot`](super::enroot) attributes:
4//! these engines have no daemon keeping containers alive, so a long-running container is
5//! managed as a systemd service unit running it in the foreground. Systemd then provides the
6//! restart policy, the start at boot and the logs (journald).
7//!
8//! # Unit scope
9//!
10//! The scope is decided at assessment time by running `id -u` with the attribute's privilege:
11//!
12//! - uid 0: **system unit** in `/etc/systemd/system/`, managed with `systemctl`.
13//! - any other uid: **user unit** in `${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/`, managed
14//!   with `systemctl --user`. Lingering is required so that the service runs without an open
15//!   session and starts at boot: it is enabled (`loginctl enable-linger`) when the service must
16//!   be running.
17//!
18//! # Files
19//!
20//! - The unit: `regent-<engine>-<name>.service`. It holds no environment variable value, as unit
21//!   files are world-readable and exposed by `systemctl show`.
22//! - The environment file, mode 0600, referenced by `EnvironmentFile=`:
23//!   `/etc/regent-sdk/<engine>/<name>.env` (system) or
24//!   `${XDG_CONFIG_HOME:-$HOME/.config}/regent-sdk/<engine>/<name>.env` (user).
25//!
26//! Drift is detected by comparing both files with their expected content, rendered
27//! deterministically. Values of environment variables never appear in remediation messages.
28
29use crate::error::RegentError;
30use crate::hosts::handlers::shell_quote;
31use crate::hosts::managed_host::InternalApiCallOutcome;
32use crate::state::attribute::HostHandler;
33use crate::state::attribute::Privilege;
34use serde::{Deserialize, Serialize};
35use std::fmt;
36use std::str::FromStr;
37
38use super::container_spec::{ContainerName, invalid, quoted_command};
39
40/// Seconds systemd waits for the container to stop before killing it
41const TIMEOUT_STOP_SEC: u32 = 60;
42/// Seconds systemd waits before restarting a failed container
43const RESTART_SEC: u32 = 5;
44
45const HEADER: &str = "# Managed by regent-sdk: manual changes will be overwritten";
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "PascalCase")]
49pub enum ServiceEngine {
50    Apptainer,
51    Enroot,
52}
53
54impl ServiceEngine {
55    pub fn as_str(&self) -> &'static str {
56        match self {
57            ServiceEngine::Apptainer => "apptainer",
58            ServiceEngine::Enroot => "enroot",
59        }
60    }
61
62    fn display_name(&self) -> &'static str {
63        match self {
64            ServiceEngine::Apptainer => "Apptainer",
65            ServiceEngine::Enroot => "Enroot",
66        }
67    }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "PascalCase")]
72pub enum UnitScope {
73    /// System manager (`systemctl`), when running as root
74    System,
75    /// User manager of the connected user (`systemctl --user`)
76    User,
77}
78
79/// `Restart=`
80#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "PascalCase")]
82pub enum UnitRestartPolicy {
83    No,
84    #[default]
85    OnFailure,
86    Always,
87}
88
89impl UnitRestartPolicy {
90    fn as_directive(&self) -> &'static str {
91        match self {
92            UnitRestartPolicy::No => "no",
93            UnitRestartPolicy::OnFailure => "on-failure",
94            UnitRestartPolicy::Always => "always",
95        }
96    }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(rename_all = "PascalCase")]
101pub struct ServiceUnitId {
102    engine: ServiceEngine,
103    name: ContainerName,
104    scope: UnitScope,
105}
106
107/// As a shell expression
108const USER_CONFIG_ROOT: &str = "\"${XDG_CONFIG_HOME:-$HOME/.config}\"";
109
110impl ServiceUnitId {
111    pub fn new(engine: ServiceEngine, name: ContainerName, scope: UnitScope) -> Self {
112        Self {
113            engine,
114            name,
115            scope,
116        }
117    }
118
119    pub fn name(&self) -> &ContainerName {
120        &self.name
121    }
122
123    pub fn scope(&self) -> UnitScope {
124        self.scope
125    }
126
127    /// `regent-<engine>-<name>.service`
128    pub fn unit_name(&self) -> String {
129        format!("regent-{}-{}.service", self.engine.as_str(), self.name)
130    }
131
132    fn config_root(&self) -> &'static str {
133        match self.scope {
134            UnitScope::System => "/etc",
135            UnitScope::User => USER_CONFIG_ROOT,
136        }
137    }
138
139    /// As a shell expression
140    pub(super) fn unit_path(&self) -> String {
141        let directory = match self.scope {
142            UnitScope::System => "/etc/systemd/system".to_string(),
143            UnitScope::User => format!("{}/systemd/user", USER_CONFIG_ROOT),
144        };
145        format!("{}/{}", directory, shell_quote(&self.unit_name()))
146    }
147
148    /// As a shell expression
149    pub(super) fn regent_dir(&self) -> String {
150        format!("{}/regent-sdk/{}", self.config_root(), self.engine.as_str())
151    }
152
153    /// As a shell expression
154    pub(super) fn regent_file(&self, extension: &str) -> String {
155        format!(
156            "{}/{}",
157            self.regent_dir(),
158            shell_quote(&format!("{}.{}", self.name, extension))
159        )
160    }
161
162    /// As a shell expression
163    pub(super) fn env_path(&self) -> String {
164        self.regent_file("env")
165    }
166
167    /// As written in the unit (`%E` is the configuration directory of the user manager)
168    fn env_path_in_unit(&self) -> String {
169        let root = match self.scope {
170            UnitScope::System => "/etc",
171            UnitScope::User => "%E",
172        };
173        format!(
174            "{}/regent-sdk/{}/{}.env",
175            root,
176            self.engine.as_str(),
177            self.name
178        )
179    }
180
181    /// `systemctl [--user] <args>` as a shell command
182    pub(super) fn systemctl(&self, args: &[&str]) -> String {
183        match self.scope {
184            UnitScope::System => {
185                let mut command = vec!["systemctl"];
186                command.extend_from_slice(args);
187                quoted_command(&command)
188            }
189            UnitScope::User => {
190                let mut command = vec!["systemctl", "--user"];
191                command.extend_from_slice(args);
192                // The user manager is found through XDG_RUNTIME_DIR, which is not set in every
193                // kind of session (su, sudo)
194                format!(
195                    "XDG_RUNTIME_DIR=\"${{XDG_RUNTIME_DIR:-/run/user/$(id -u)}}\" {}",
196                    quoted_command(&command)
197                )
198            }
199        }
200    }
201}
202
203impl fmt::Display for ServiceUnitId {
204    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205        match self.scope {
206            UnitScope::System => write!(f, "{}", self.unit_name()),
207            UnitScope::User => write!(f, "{} (user unit)", self.unit_name()),
208        }
209    }
210}
211
212pub(super) struct UnitSpec {
213    /// Command line run in the foreground (absolute executable path first)
214    pub(super) exec_start: Vec<String>,
215    /// Variables set with `Environment=` (never secrets: the unit is world-readable)
216    pub(super) environment: Vec<(String, String)>,
217    pub(super) restart: UnitRestartPolicy,
218}
219
220/// Deterministic: the same spec always gives the same content
221pub(super) fn render_unit(id: &ServiceUnitId, spec: &UnitSpec) -> String {
222    let mut unit = String::new();
223    let mut line = |text: String| {
224        unit.push_str(&text);
225        unit.push('\n');
226    };
227
228    line(HEADER.to_string());
229    line("[Unit]".to_string());
230    line(format!(
231        "Description={} container {} (regent-sdk)",
232        id.engine.display_name(),
233        id.name
234    ));
235    // network-online.target only exists in the system manager
236    if id.scope == UnitScope::System {
237        line("Wants=network-online.target".to_string());
238        line("After=network-online.target".to_string());
239    }
240    line(String::new());
241    line("[Service]".to_string());
242    line("Type=simple".to_string());
243    for (key, value) in &spec.environment {
244        line(format!(
245            "Environment={}",
246            systemd_quote(&format!("{}={}", key, value))
247        ));
248    }
249    line(format!("EnvironmentFile={}", id.env_path_in_unit()));
250    line(format!(
251        "ExecStart={}",
252        spec.exec_start
253            .iter()
254            .map(|arg| systemd_quote(arg))
255            .collect::<Vec<String>>()
256            .join(" ")
257    ));
258    line(format!("Restart={}", spec.restart.as_directive()));
259    line(format!("RestartSec={}", RESTART_SEC));
260    line(format!("TimeoutStopSec={}", TIMEOUT_STOP_SEC));
261    line(String::new());
262    line("[Install]".to_string());
263    line(format!(
264        "WantedBy={}",
265        match id.scope {
266            UnitScope::System => "multi-user.target",
267            UnitScope::User => "default.target",
268        }
269    ));
270
271    unit
272}
273
274/// Quote a word of a systemd command line (`ExecStart=`, `Environment=`).
275///
276/// Words made of safe characters are left as is, the others are double-quoted with `\` and `"`
277/// escaped. Specifiers (`%`) and variable expansion (`$`) are disabled by doubling them.
278pub(super) fn systemd_quote(arg: &str) -> String {
279    let escaped = arg.replace('%', "%%").replace('$', "$$");
280    let is_safe = !arg.is_empty()
281        && arg
282            .chars()
283            .all(|c| c.is_ascii_alphanumeric() || "/._-+=:,@".contains(c));
284    if is_safe {
285        return escaped;
286    }
287    let mut quoted = String::with_capacity(escaped.len() + 2);
288    quoted.push('"');
289    for c in escaped.chars() {
290        match c {
291            '\\' => quoted.push_str("\\\\"),
292            '"' => quoted.push_str("\\\""),
293            '\n' => quoted.push_str("\\n"),
294            '\t' => quoted.push_str("\\t"),
295            c => quoted.push(c),
296        }
297    }
298    quoted.push('"');
299    quoted
300}
301
302/// Render the environment file read through `EnvironmentFile=` (values double-quoted, with
303/// `\`, `"`, `$` and `` ` `` escaped so they are taken literally)
304pub(super) fn render_env_file(entries: &[(String, String)]) -> String {
305    let mut content = format!("{}\n", HEADER);
306    for (key, value) in entries {
307        content.push_str(&format!("{}={}\n", key, env_file_quote(value)));
308    }
309    content
310}
311
312fn env_file_quote(value: &str) -> String {
313    let mut quoted = String::with_capacity(value.len() + 2);
314    quoted.push('"');
315    for c in value.chars() {
316        if matches!(c, '\\' | '"' | '$' | '`') {
317            quoted.push('\\');
318        }
319        quoted.push(c);
320    }
321    quoted.push('"');
322    quoted
323}
324
325/// Name of an environment variable passed to a service: `[A-Za-z_][A-Za-z0-9_]*`.
326///
327/// Stricter than the container engines' variable names, as it must be valid in a systemd
328/// environment file.
329#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
330#[serde(try_from = "String", into = "String")]
331pub struct ServiceEnvVarName(String);
332
333impl TryFrom<String> for ServiceEnvVarName {
334    type Error = RegentError;
335
336    fn try_from(name: String) -> Result<Self, Self::Error> {
337        let is_valid = name
338            .chars()
339            .next()
340            .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
341            && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
342        if is_valid {
343            Ok(Self(name))
344        } else {
345            Err(invalid(
346                "environment variable name",
347                &name,
348                "allowed characters are [A-Za-z_][A-Za-z0-9_]*",
349            ))
350        }
351    }
352}
353
354impl FromStr for ServiceEnvVarName {
355    type Err = RegentError;
356
357    fn from_str(name: &str) -> Result<Self, Self::Err> {
358        Self::try_from(name.to_string())
359    }
360}
361
362impl From<ServiceEnvVarName> for String {
363    fn from(name: ServiceEnvVarName) -> String {
364        name.0
365    }
366}
367
368impl fmt::Display for ServiceEnvVarName {
369    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
370        write!(f, "{}", self.0)
371    }
372}
373
374impl ServiceEnvVarName {
375    pub fn as_str(&self) -> &str {
376        &self.0
377    }
378}
379
380/// Absolute path on the host (no trailing `/`, no line break)
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
382#[serde(try_from = "String", into = "String")]
383pub struct AbsolutePath(String);
384
385impl TryFrom<String> for AbsolutePath {
386    type Error = RegentError;
387
388    fn try_from(path: String) -> Result<Self, Self::Error> {
389        if !path.starts_with('/') {
390            Err(invalid("path", &path, "must be absolute"))
391        } else if path.len() > 1 && path.ends_with('/') {
392            Err(invalid("path", &path, "must not end with '/'"))
393        } else if path.chars().any(char::is_control) {
394            Err(invalid(
395                "path",
396                &path,
397                "must not contain control characters",
398            ))
399        } else {
400            Ok(Self(path))
401        }
402    }
403}
404
405impl FromStr for AbsolutePath {
406    type Err = RegentError;
407
408    fn from_str(path: &str) -> Result<Self, Self::Err> {
409        Self::try_from(path.to_string())
410    }
411}
412
413impl From<AbsolutePath> for String {
414    fn from(path: AbsolutePath) -> String {
415        path.0
416    }
417}
418
419impl fmt::Display for AbsolutePath {
420    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
421        write!(f, "{}", self.0)
422    }
423}
424
425impl AbsolutePath {
426    pub fn as_str(&self) -> &str {
427        &self.0
428    }
429
430    /// Parent directory (`/` for a file at the root)
431    pub(super) fn parent(&self) -> &str {
432        match self.0.rfind('/') {
433            Some(0) | None => "/",
434            Some(index) => &self.0[..index],
435        }
436    }
437}
438
439/// Host directory or file mounted in the container: `source[:target[:ro|rw]]`.
440///
441/// Both paths are absolute, the target defaults to the source and the mount is read-write
442/// unless `ro` is given. Paths can't contain `:`, `,` or whitespace, as the engines use them as
443/// separators.
444#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
445#[serde(try_from = "String", into = "String")]
446pub struct BindMount {
447    source: String,
448    target: String,
449    read_only: bool,
450}
451
452impl TryFrom<String> for BindMount {
453    type Error = RegentError;
454
455    fn try_from(spec: String) -> Result<Self, Self::Error> {
456        let error = |details: &str| invalid("bind mount", &spec, details);
457        let parts: Vec<&str> = spec.split(':').collect();
458        let (source, target, read_only) = match parts.as_slice() {
459            [source] => (*source, *source, false),
460            [source, target] => (*source, *target, false),
461            [source, target, "ro"] => (*source, *target, true),
462            [source, target, "rw"] => (*source, *target, false),
463            [_, _, _] => return Err(error("options must be ro or rw")),
464            _ => return Err(error("expected source[:target[:ro|rw]]")),
465        };
466        for path in [source, target] {
467            if !path.starts_with('/') {
468                return Err(error("paths must be absolute"));
469            }
470            if path.chars().any(|c| c == ',' || c.is_whitespace()) {
471                return Err(error("paths can't contain ',' or whitespace"));
472            }
473        }
474        Ok(Self {
475            source: source.to_string(),
476            target: target.to_string(),
477            read_only,
478        })
479    }
480}
481
482impl FromStr for BindMount {
483    type Err = RegentError;
484
485    fn from_str(spec: &str) -> Result<Self, Self::Err> {
486        Self::try_from(spec.to_string())
487    }
488}
489
490impl From<BindMount> for String {
491    fn from(mount: BindMount) -> String {
492        mount.to_string()
493    }
494}
495
496impl fmt::Display for BindMount {
497    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
498        write!(
499            f,
500            "{}:{}:{}",
501            self.source,
502            self.target,
503            if self.read_only { "ro" } else { "rw" }
504        )
505    }
506}
507
508impl BindMount {
509    /// `--bind` value of Apptainer: `src:dest:ro|rw`
510    pub(super) fn apptainer_arg(&self) -> String {
511        self.to_string()
512    }
513
514    /// `--mount` value of Enroot: an fstab entry with colon-separated fields
515    pub(super) fn enroot_arg(&self) -> String {
516        format!(
517            "{}:{}:none:x-create=auto,rbind{}",
518            self.source,
519            self.target,
520            if self.read_only { ",ro" } else { "" }
521        )
522    }
523}
524
525/// State of the unit, from `systemctl show`
526#[derive(Debug, Clone, Default, PartialEq)]
527pub(super) struct UnitStatus {
528    pub(super) load_state: String,
529    pub(super) active_state: String,
530    pub(super) unit_file_state: String,
531    pub(super) need_daemon_reload: bool,
532}
533
534impl UnitStatus {
535    /// Running, or about to
536    pub(super) fn is_active(&self) -> bool {
537        matches!(
538            self.active_state.as_str(),
539            "active" | "activating" | "reloading"
540        )
541    }
542
543    pub(super) fn is_enabled(&self) -> bool {
544        self.unit_file_state == "enabled"
545    }
546
547    /// Parse `Key=Value` lines (other lines, such as warnings, are ignored)
548    pub(super) fn parse(output: &str) -> Self {
549        let mut status = UnitStatus::default();
550        for line in output.lines() {
551            match line.trim().split_once('=') {
552                Some(("LoadState", value)) => status.load_state = value.to_string(),
553                Some(("ActiveState", value)) => status.active_state = value.to_string(),
554                Some(("UnitFileState", value)) => status.unit_file_state = value.to_string(),
555                Some(("NeedDaemonReload", value)) => status.need_daemon_reload = value == "yes",
556                _ => {}
557            }
558        }
559        status
560    }
561}
562
563#[derive(Debug, Clone, Default, PartialEq)]
564pub(super) struct ObservedService {
565    pub(super) unit_file: Option<String>,
566    pub(super) env_file: Option<String>,
567    pub(super) status: UnitStatus,
568    /// Lingering of the user (`None` for system units, or when not needed)
569    pub(super) linger: Option<bool>,
570}
571
572#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
573#[serde(rename_all = "PascalCase")]
574pub enum ServiceUnitAction {
575    /// Let the user manager run without an open session and start at boot
576    EnableLinger,
577    DaemonReload,
578    Enable,
579    Disable,
580    Start,
581    Stop,
582    Restart,
583    Remove,
584}
585
586impl ServiceUnitAction {
587    pub(super) fn command(&self, id: &ServiceUnitId) -> String {
588        let unit_name = id.unit_name();
589        let unit = unit_name.as_str();
590        match self {
591            ServiceUnitAction::EnableLinger => "loginctl enable-linger \"$(id -un)\"".to_string(),
592            ServiceUnitAction::DaemonReload => id.systemctl(&["daemon-reload"]),
593            ServiceUnitAction::Enable => id.systemctl(&["enable", unit]),
594            ServiceUnitAction::Disable => id.systemctl(&["disable", unit]),
595            ServiceUnitAction::Start => id.systemctl(&["start", unit]),
596            ServiceUnitAction::Stop => id.systemctl(&["stop", unit]),
597            ServiceUnitAction::Restart => id.systemctl(&["restart", unit]),
598            ServiceUnitAction::Remove => format!(
599                // Stopping or disabling a unit which is not loaded fails: ignored
600                "{{ {} >/dev/null 2>&1; {} >/dev/null 2>&1; rm -f {} {} && {}; }}",
601                id.systemctl(&["stop", unit]),
602                id.systemctl(&["disable", unit]),
603                id.unit_path(),
604                id.env_path(),
605                id.systemctl(&["daemon-reload"]),
606            ),
607        }
608    }
609
610    pub(super) fn display(&self, id: &ServiceUnitId) -> String {
611        match self {
612            ServiceUnitAction::EnableLinger => {
613                format!("Enable lingering of the user running {}", id)
614            }
615            ServiceUnitAction::DaemonReload => format!("Reload systemd units for {}", id),
616            ServiceUnitAction::Enable => format!("Enable {}", id),
617            ServiceUnitAction::Disable => format!("Disable {}", id),
618            ServiceUnitAction::Start => format!("Start {}", id),
619            ServiceUnitAction::Stop => format!("Stop {}", id),
620            ServiceUnitAction::Restart => format!("Restart {}", id),
621            ServiceUnitAction::Remove => {
622                format!("Remove {} and its environment file", id)
623            }
624        }
625    }
626}
627
628#[derive(Debug, Clone, PartialEq)]
629pub(super) enum ServiceStep {
630    WriteFiles(Vec<String>),
631    Action(ServiceUnitAction),
632}
633
634/// Plan the steps bringing a unit which must exist to its expected state.
635///
636/// `container_changed` tells that the container behind the unit is being replaced (new image,
637/// recreated container), which requires a restart when it must run.
638pub(super) fn plan_service(
639    should_run: bool,
640    expected_unit: &str,
641    expected_env: &str,
642    observed: &ObservedService,
643    container_changed: bool,
644) -> Vec<ServiceStep> {
645    let mut steps = Vec::new();
646
647    if should_run && observed.linger == Some(false) {
648        steps.push(ServiceStep::Action(ServiceUnitAction::EnableLinger));
649    }
650
651    let mut reasons = Vec::new();
652    for (file, current, expected) in [
653        ("unit file", &observed.unit_file, expected_unit),
654        ("environment file", &observed.env_file, expected_env),
655    ] {
656        match current {
657            None => reasons.push(format!("{} is missing", file)),
658            // Never show the content: the environment file holds secrets
659            Some(current) if current != expected => reasons.push(format!("{} differs", file)),
660            Some(_) => {}
661        }
662    }
663    let files_changed = !reasons.is_empty();
664    if files_changed {
665        steps.push(ServiceStep::WriteFiles(reasons));
666    } else if observed.status.need_daemon_reload {
667        steps.push(ServiceStep::Action(ServiceUnitAction::DaemonReload));
668    }
669
670    let is_active = observed.status.is_active();
671    let is_enabled = observed.status.is_enabled();
672    if should_run {
673        if !is_enabled {
674            steps.push(ServiceStep::Action(ServiceUnitAction::Enable));
675        }
676        if !is_active {
677            steps.push(ServiceStep::Action(ServiceUnitAction::Start));
678        } else if files_changed || container_changed {
679            steps.push(ServiceStep::Action(ServiceUnitAction::Restart));
680        }
681    } else {
682        if is_enabled {
683            steps.push(ServiceStep::Action(ServiceUnitAction::Disable));
684        }
685        if is_active {
686            steps.push(ServiceStep::Action(ServiceUnitAction::Stop));
687        }
688    }
689
690    steps
691}
692
693/// A unit which must not exist only needs removing if one of its files is still there
694pub(super) fn needs_removal(observed: &ObservedService) -> bool {
695    observed.unit_file.is_some() || observed.env_file.is_some()
696}
697
698pub(super) async fn detect_scope<Handler: HostHandler>(
699    host_handler: &mut Handler,
700    privilege: &Privilege,
701) -> Result<(UnitScope, String), RegentError> {
702    let result = host_handler
703        .run_command(
704            "printf 'regent-uid=%s\\nregent-home=%s\\n' \"$(id -u)\" \"$HOME\"",
705            privilege,
706        )
707        .await
708        .map_err(|e| {
709            RegentError::FailedDryRunEvaluation(format!("Unable to get the user id: {:?}", e))
710        })?;
711    parse_scope_probe(&result.stdout).ok_or_else(|| {
712        RegentError::FailedDryRunEvaluation(format!("Unable to get the user id: {:?}", result))
713    })
714}
715
716fn parse_scope_probe(output: &str) -> Option<(UnitScope, String)> {
717    let mut uid = None;
718    let mut home = String::new();
719    for line in output.lines() {
720        if let Some(value) = line.strip_prefix("regent-uid=") {
721            uid = value.trim().parse::<u32>().ok();
722        } else if let Some(value) = line.strip_prefix("regent-home=") {
723            home = value.to_string();
724        }
725    }
726    let scope = match uid? {
727        0 => UnitScope::System,
728        _ => UnitScope::User,
729    };
730    Some((scope, home))
731}
732
733/// Whether lingering is enabled for the connected user (a user unknown to logind has none)
734pub(super) async fn is_linger_enabled<Handler: HostHandler>(
735    host_handler: &mut Handler,
736    privilege: &Privilege,
737) -> Result<bool, RegentError> {
738    let result = host_handler
739        .run_command(
740            "loginctl show-user \"$(id -un)\" -p Linger --value",
741            privilege,
742        )
743        .await
744        .map_err(|e| {
745            RegentError::FailedDryRunEvaluation(format!("Unable to check lingering: {:?}", e))
746        })?;
747    Ok(parse_linger(&result.stdout))
748}
749
750fn parse_linger(output: &str) -> bool {
751    output.lines().any(|line| line.trim() == "yes")
752}
753
754const FILE_PRESENT: &str = "regent-file-present\n";
755const FILE_MISSING: &str = "regent-file-missing";
756const FILE_END: &str = "\nregent-file-end";
757
758/// Read a file given as a shell expression. Returns `None` if it doesn't exist.
759///
760/// Error messages never contain the file content (environment files hold secrets).
761pub(super) async fn read_optional_file<Handler: HostHandler>(
762    host_handler: &mut Handler,
763    path: &str,
764    description: &str,
765    privilege: &Privilege,
766) -> Result<Option<String>, RegentError> {
767    let command = format!(
768        "if [ -e {path} ]; then printf 'regent-file-present\\n' && cat {path} && printf '\\nregent-file-end\\n'; else printf 'regent-file-missing\\n'; fi",
769        path = path
770    );
771    let result = host_handler
772        .run_command(&command, privilege)
773        .await
774        .map_err(|e| {
775            RegentError::FailedDryRunEvaluation(format!(
776                "Unable to read the {}: {:?}",
777                description, e
778            ))
779        })?;
780    if result.return_code != 0 {
781        return Err(RegentError::FailedDryRunEvaluation(format!(
782            "Unable to read the {} (return code {})",
783            description, result.return_code
784        )));
785    }
786    parse_file_probe(&result.stdout).ok_or_else(|| {
787        RegentError::FailureToParseContent(format!(
788            "Unexpected output while reading the {}",
789            description
790        ))
791    })
792}
793
794fn parse_file_probe(output: &str) -> Option<Option<String>> {
795    match output.find(FILE_PRESENT) {
796        Some(start) => {
797            let content = &output[start + FILE_PRESENT.len()..];
798            let end = content.rfind(FILE_END)?;
799            Some(Some(content[..end].to_string()))
800        }
801        None if output.contains(FILE_MISSING) => Some(None),
802        None => None,
803    }
804}
805
806/// `systemctl [--user] show` the unit
807pub(super) async fn unit_status<Handler: HostHandler>(
808    host_handler: &mut Handler,
809    id: &ServiceUnitId,
810    privilege: &Privilege,
811) -> Result<UnitStatus, RegentError> {
812    let unit = id.unit_name();
813    let command = id.systemctl(&[
814        "show",
815        &unit,
816        "-p",
817        "LoadState",
818        "-p",
819        "ActiveState",
820        "-p",
821        "UnitFileState",
822        "-p",
823        "NeedDaemonReload",
824    ]);
825    let result = host_handler
826        .run_command(&command, privilege)
827        .await
828        .map_err(|e| {
829            RegentError::FailedDryRunEvaluation(format!("Unable to query {}: {:?}", id, e))
830        })?;
831    if result.return_code != 0 {
832        return Err(RegentError::FailedDryRunEvaluation(format!(
833            "Unable to query {}: {:?}",
834            id, result
835        )));
836    }
837    Ok(UnitStatus::parse(&result.stdout))
838}
839
840pub(super) async fn observe_service<Handler: HostHandler>(
841    host_handler: &mut Handler,
842    id: &ServiceUnitId,
843    check_linger: bool,
844    privilege: &Privilege,
845) -> Result<ObservedService, RegentError> {
846    let unit_file =
847        read_optional_file(host_handler, &id.unit_path(), "unit file", privilege).await?;
848    let env_file =
849        read_optional_file(host_handler, &id.env_path(), "environment file", privilege).await?;
850    let status = unit_status(host_handler, id, privilege).await?;
851    let linger = if check_linger && id.scope == UnitScope::User {
852        Some(is_linger_enabled(host_handler, privilege).await?)
853    } else {
854        None
855    };
856    Ok(ObservedService {
857        unit_file,
858        env_file,
859        status,
860        linger,
861    })
862}
863
864/// Shell command writing the unit and environment files, then reloading systemd.
865///
866/// The environment file is created with mode 0600 in a 0700 directory.
867pub(super) fn write_files_command(id: &ServiceUnitId, unit: &str, env: &str) -> String {
868    let unit_path = id.unit_path();
869    let unit_dir = match id.scope {
870        UnitScope::System => "/etc/systemd/system".to_string(),
871        UnitScope::User => format!("{}/systemd/user", USER_CONFIG_ROOT),
872    };
873    format!(
874        "(umask 077 && mkdir -p {regent_dir} && printf '%s' {env} > {env_path} && chmod 600 {env_path}) && \
875mkdir -p {unit_dir} && printf '%s' {unit} > {unit_path} && chmod 644 {unit_path} && {reload}",
876        regent_dir = id.regent_dir(),
877        env = shell_quote(env),
878        env_path = id.env_path(),
879        unit_dir = unit_dir,
880        unit = shell_quote(unit),
881        unit_path = unit_path,
882        reload = id.systemctl(&["daemon-reload"]),
883    )
884}
885
886/// Resolve the absolute path of an executable (systemd needs it in `ExecStart=`)
887pub(super) async fn resolve_executable<Handler: HostHandler>(
888    host_handler: &mut Handler,
889    executable: &str,
890    privilege: &Privilege,
891) -> Result<Option<String>, RegentError> {
892    let result = host_handler
893        .run_command(
894            &format!("command -v {}", shell_quote(executable)),
895            privilege,
896        )
897        .await
898        .map_err(|e| {
899            RegentError::FailedDryRunEvaluation(format!(
900                "Unable to look for {}: {:?}",
901                executable, e
902            ))
903        })?;
904    if result.return_code != 0 {
905        return Ok(None);
906    }
907    Ok(result
908        .stdout
909        .lines()
910        .map(str::trim)
911        .find(|line| line.starts_with('/'))
912        .map(String::from))
913}
914
915pub(super) async fn file_exists<Handler: HostHandler>(
916    host_handler: &mut Handler,
917    path: &AbsolutePath,
918    privilege: &Privilege,
919) -> Result<bool, RegentError> {
920    let result = host_handler
921        .run_command(
922            &format!("test -f {}", shell_quote(path.as_str())),
923            privilege,
924        )
925        .await
926        .map_err(|e| {
927            RegentError::FailedDryRunEvaluation(format!("Unable to check {}: {:?}", path, e))
928        })?;
929    Ok(result.return_code == 0)
930}
931
932/// Run a remediation command. `show_output` must be false when the command may print secrets.
933pub(super) async fn run_remediation<Handler: HostHandler>(
934    host_handler: &mut Handler,
935    command: &str,
936    privilege: &Privilege,
937    show_output: bool,
938) -> Result<InternalApiCallOutcome, RegentError> {
939    match host_handler.run_command(command, privilege).await {
940        Ok(result) if result.return_code == 0 => Ok(InternalApiCallOutcome::Success(None)),
941        Ok(result) if show_output => Ok(InternalApiCallOutcome::Failure(format!(
942            "RC: {}, STDOUT: {}, STDERR: {}",
943            result.return_code, result.stdout, result.stderr
944        ))),
945        Ok(result) => Ok(InternalApiCallOutcome::Failure(format!(
946            "RC: {} (output hidden as it may contain secrets)",
947            result.return_code
948        ))),
949        Err(e) if show_output => Ok(InternalApiCallOutcome::Failure(format!(
950            "Command execution failed: {:?}",
951            e
952        ))),
953        Err(_) => Ok(InternalApiCallOutcome::Failure(
954            "Command execution failed (details hidden as they may contain secrets)".to_string(),
955        )),
956    }
957}
958
959pub(super) async fn run_action<Handler: HostHandler>(
960    host_handler: &mut Handler,
961    id: &ServiceUnitId,
962    action: ServiceUnitAction,
963    privilege: &Privilege,
964) -> Result<InternalApiCallOutcome, RegentError> {
965    run_remediation(host_handler, &action.command(id), privilege, true).await
966}
967
968/// Write the unit and environment files. Failures don't show the command output.
969pub(super) async fn write_files<Handler: HostHandler>(
970    host_handler: &mut Handler,
971    id: &ServiceUnitId,
972    unit: &str,
973    env: &str,
974    privilege: &Privilege,
975) -> Result<InternalApiCallOutcome, RegentError> {
976    run_remediation(
977        host_handler,
978        &write_files_command(id, unit, env),
979        privilege,
980        false,
981    )
982    .await
983}
984
985#[cfg(test)]
986mod tests {
987    use super::*;
988
989    fn id(scope: UnitScope) -> ServiceUnitId {
990        ServiceUnitId::new(ServiceEngine::Apptainer, "vllm".parse().unwrap(), scope)
991    }
992
993    fn spec() -> UnitSpec {
994        UnitSpec {
995            exec_start: vec![
996                "/usr/bin/apptainer".to_string(),
997                "run".to_string(),
998                "--nv".to_string(),
999                "/opt/images/vllm 0.6.sif".to_string(),
1000            ],
1001            environment: vec![("HOME".to_string(), "/root".to_string())],
1002            restart: UnitRestartPolicy::OnFailure,
1003        }
1004    }
1005
1006    #[test]
1007    fn system_unit_is_rendered_deterministically() {
1008        assert_eq!(
1009            render_unit(&id(UnitScope::System), &spec()),
1010            "# Managed by regent-sdk: manual changes will be overwritten
1011[Unit]
1012Description=Apptainer container vllm (regent-sdk)
1013Wants=network-online.target
1014After=network-online.target
1015
1016[Service]
1017Type=simple
1018Environment=HOME=/root
1019EnvironmentFile=/etc/regent-sdk/apptainer/vllm.env
1020ExecStart=/usr/bin/apptainer run --nv \"/opt/images/vllm 0.6.sif\"
1021Restart=on-failure
1022RestartSec=5
1023TimeoutStopSec=60
1024
1025[Install]
1026WantedBy=multi-user.target
1027"
1028        );
1029    }
1030
1031    #[test]
1032    fn user_unit_uses_the_user_manager_paths() {
1033        let unit = render_unit(&id(UnitScope::User), &spec());
1034        assert!(unit.contains("EnvironmentFile=%E/regent-sdk/apptainer/vllm.env\n"));
1035        assert!(unit.contains("WantedBy=default.target\n"));
1036        assert!(!unit.contains("network-online.target"));
1037
1038        let user = id(UnitScope::User);
1039        assert_eq!(
1040            user.unit_path(),
1041            "\"${XDG_CONFIG_HOME:-$HOME/.config}\"/systemd/user/'regent-apptainer-vllm.service'"
1042        );
1043        assert_eq!(
1044            user.env_path(),
1045            "\"${XDG_CONFIG_HOME:-$HOME/.config}\"/regent-sdk/apptainer/'vllm.env'"
1046        );
1047        assert_eq!(
1048            user.systemctl(&["start", "x.service"]),
1049            "XDG_RUNTIME_DIR=\"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}\" 'systemctl' '--user' 'start' 'x.service'"
1050        );
1051        assert_eq!(
1052            id(UnitScope::System).unit_path(),
1053            "/etc/systemd/system/'regent-apptainer-vllm.service'"
1054        );
1055    }
1056
1057    #[test]
1058    fn exec_start_arguments_are_escaped_for_systemd() {
1059        assert_eq!(systemd_quote("--port=8000"), "--port=8000");
1060        assert_eq!(systemd_quote("a b"), "\"a b\"");
1061        assert_eq!(systemd_quote("100%"), "\"100%%\"");
1062        assert_eq!(systemd_quote("$HOME"), "\"$$HOME\"");
1063        assert_eq!(systemd_quote("say \"hi\""), "\"say \\\"hi\\\"\"");
1064        assert_eq!(systemd_quote("C:\\dir"), "\"C:\\\\dir\"");
1065        assert_eq!(systemd_quote("line\nbreak"), "\"line\\nbreak\"");
1066        assert_eq!(systemd_quote(""), "\"\"");
1067        assert_eq!(systemd_quote("{\"a\": 1}"), "\"{\\\"a\\\": 1}\"");
1068    }
1069
1070    #[test]
1071    fn env_file_values_are_taken_literally() {
1072        let env = render_env_file(&[
1073            ("HF_TOKEN".to_string(), "hf_$abc\"`x`\\".to_string()),
1074            ("EMPTY".to_string(), String::new()),
1075        ]);
1076        assert_eq!(
1077            env,
1078            "# Managed by regent-sdk: manual changes will be overwritten
1079HF_TOKEN=\"hf_\\$abc\\\"\\`x\\`\\\\\"
1080EMPTY=\"\"
1081"
1082        );
1083    }
1084
1085    #[test]
1086    fn value_types_are_validated() {
1087        assert!("HF_TOKEN".parse::<ServiceEnvVarName>().is_ok());
1088        assert!("_x1".parse::<ServiceEnvVarName>().is_ok());
1089        for name in ["", "1X", "A-B", "A B", "A=B"] {
1090            assert!(name.parse::<ServiceEnvVarName>().is_err(), "{}", name);
1091        }
1092
1093        assert!("/opt/x.sif".parse::<AbsolutePath>().is_ok());
1094        assert_eq!(
1095            "/opt/x.sif".parse::<AbsolutePath>().unwrap().parent(),
1096            "/opt"
1097        );
1098        assert_eq!("/x.sif".parse::<AbsolutePath>().unwrap().parent(), "/");
1099        for path in ["x.sif", "/opt/", "/opt/a\nb", ""] {
1100            assert!(path.parse::<AbsolutePath>().is_err(), "{:?}", path);
1101        }
1102
1103        let mount = |spec: &str| spec.parse::<BindMount>();
1104        assert_eq!(mount("/data").unwrap().to_string(), "/data:/data:rw");
1105        assert_eq!(
1106            mount("/models:/root/.cache:ro").unwrap().apptainer_arg(),
1107            "/models:/root/.cache:ro"
1108        );
1109        assert_eq!(
1110            mount("/models:/models:ro").unwrap().enroot_arg(),
1111            "/models:/models:none:x-create=auto,rbind,ro"
1112        );
1113        assert_eq!(
1114            mount("/data:/data").unwrap().enroot_arg(),
1115            "/data:/data:none:x-create=auto,rbind"
1116        );
1117        for spec in [
1118            "data:/data",
1119            "/data:data",
1120            "/data:/data:Z",
1121            "/a,b:/c",
1122            "/a b:/c",
1123            "/a:/b:ro:x",
1124        ] {
1125            assert!(mount(spec).is_err(), "{}", spec);
1126        }
1127    }
1128
1129    #[test]
1130    fn probes_are_parsed_despite_noise() {
1131        assert_eq!(
1132            parse_scope_probe("sudo: warning\nregent-uid=0\nregent-home=/root\n"),
1133            Some((UnitScope::System, "/root".to_string()))
1134        );
1135        assert_eq!(
1136            parse_scope_probe("regent-uid=1000\nregent-home=/home/ai\n"),
1137            Some((UnitScope::User, "/home/ai".to_string()))
1138        );
1139        assert_eq!(parse_scope_probe("garbage"), None);
1140
1141        assert!(parse_linger("yes\n"));
1142        assert!(!parse_linger("no\n"));
1143        assert!(!parse_linger(
1144            "Failed to get user: User ID 1000 is not logged in or lingering\n"
1145        ));
1146
1147        assert_eq!(
1148            parse_file_probe("warning\nregent-file-present\nline\n\nregent-file-end\n"),
1149            Some(Some("line\n".to_string()))
1150        );
1151        assert_eq!(
1152            parse_file_probe("regent-file-present\n\nregent-file-end\n"),
1153            Some(Some(String::new()))
1154        );
1155        assert_eq!(parse_file_probe("regent-file-missing\n"), Some(None));
1156        assert_eq!(parse_file_probe("weird"), None);
1157
1158        let status = UnitStatus::parse(
1159            "LoadState=loaded\nActiveState=active\nUnitFileState=enabled\nNeedDaemonReload=no\n",
1160        );
1161        assert!(status.is_active() && status.is_enabled() && !status.need_daemon_reload);
1162        let status = UnitStatus::parse(
1163            "LoadState=not-found\nActiveState=inactive\nUnitFileState=\nNeedDaemonReload=no\n",
1164        );
1165        assert!(!status.is_active() && !status.is_enabled());
1166    }
1167
1168    fn observed(unit: &str, env: &str, active: bool, enabled: bool) -> ObservedService {
1169        ObservedService {
1170            unit_file: Some(unit.to_string()),
1171            env_file: Some(env.to_string()),
1172            status: UnitStatus {
1173                load_state: "loaded".to_string(),
1174                active_state: if active { "active" } else { "inactive" }.to_string(),
1175                unit_file_state: if enabled { "enabled" } else { "disabled" }.to_string(),
1176                need_daemon_reload: false,
1177            },
1178            linger: None,
1179        }
1180    }
1181
1182    #[test]
1183    fn service_plan_reaches_the_expected_state() {
1184        use ServiceUnitAction::*;
1185        let act = ServiceStep::Action;
1186
1187        // Nothing installed yet, rootless
1188        let fresh = ObservedService {
1189            linger: Some(false),
1190            ..ObservedService::default()
1191        };
1192        assert_eq!(
1193            plan_service(true, "U", "E", &fresh, false),
1194            vec![
1195                act(EnableLinger),
1196                ServiceStep::WriteFiles(vec![
1197                    "unit file is missing".to_string(),
1198                    "environment file is missing".to_string()
1199                ]),
1200                act(Enable),
1201                act(Start),
1202            ]
1203        );
1204
1205        // Compliant
1206        assert!(plan_service(true, "U", "E", &observed("U", "E", true, true), false).is_empty());
1207        assert!(plan_service(false, "U", "E", &observed("U", "E", false, false), false).is_empty());
1208
1209        // A secret changed while running: rewrite then restart, without showing the value
1210        assert_eq!(
1211            plan_service(true, "U", "E2", &observed("U", "E", true, true), false),
1212            vec![
1213                ServiceStep::WriteFiles(vec!["environment file differs".to_string()]),
1214                act(Restart),
1215            ]
1216        );
1217
1218        // New container behind an unchanged unit
1219        assert_eq!(
1220            plan_service(true, "U", "E", &observed("U", "E", true, true), true),
1221            vec![act(Restart)]
1222        );
1223
1224        // Must be stopped
1225        assert_eq!(
1226            plan_service(false, "U", "E", &observed("U", "E", true, true), true),
1227            vec![act(Disable), act(Stop)]
1228        );
1229
1230        // Daemon reload needed
1231        let mut stale = observed("U", "E", false, true);
1232        stale.status.need_daemon_reload = true;
1233        assert_eq!(
1234            plan_service(true, "U", "E", &stale, false),
1235            vec![act(DaemonReload), act(Start)]
1236        );
1237
1238        assert!(needs_removal(&observed("U", "E", false, false)));
1239        assert!(!needs_removal(&ObservedService::default()));
1240    }
1241
1242    #[test]
1243    fn write_command_protects_the_environment_file() {
1244        let command = write_files_command(&id(UnitScope::System), "UNIT", "A=\"it's\"\n");
1245        assert_eq!(
1246            command,
1247            "(umask 077 && mkdir -p /etc/regent-sdk/apptainer && printf '%s' 'A=\"it'\\''s\"\n' > /etc/regent-sdk/apptainer/'vllm.env' && chmod 600 /etc/regent-sdk/apptainer/'vllm.env') && \
1248mkdir -p /etc/systemd/system && printf '%s' 'UNIT' > /etc/systemd/system/'regent-apptainer-vllm.service' && chmod 644 /etc/systemd/system/'regent-apptainer-vllm.service' && 'systemctl' 'daemon-reload'"
1249        );
1250        assert_eq!(
1251            ServiceUnitAction::Remove.command(&id(UnitScope::System)),
1252            "{ 'systemctl' 'stop' 'regent-apptainer-vllm.service' >/dev/null 2>&1; 'systemctl' 'disable' 'regent-apptainer-vllm.service' >/dev/null 2>&1; rm -f /etc/systemd/system/'regent-apptainer-vllm.service' /etc/regent-sdk/apptainer/'vllm.env' && 'systemctl' 'daemon-reload'; }"
1253        );
1254    }
1255}