Skip to main content

regent_sdk/state/attribute/container/
enroot.rs

1//! Enroot service attribute
2//!
3//! This module provides the `EnrootExpectedState` type for running an
4//! [Enroot](https://github.com/NVIDIA/enroot) container as a long-running service, such as an
5//! inference server.
6//!
7//! **Compatible OS:** Linux with systemd
8//!
9//! An Enroot container is a root filesystem unpacked (`enroot create`) from a squashfs image,
10//! itself imported (`enroot import`) from a registry or a local engine. Enroot has no daemon
11//! keeping containers alive across reboots: the container runs in the foreground
12//! (`enroot start`) of a **systemd service unit** (`regent-enroot-<name>.service`), which brings
13//! the restart policy, the start at boot and the logs (`journalctl -u regent-enroot-<name>`).
14//!
15//! **System or user unit:** the attribute's `Privilege` decides. When it runs commands as root
16//! (`!WithSudo`, or connected as root), a system unit is installed in `/etc/systemd/system` and
17//! the containers of root are used. Otherwise (`!None` as a regular user), a user unit is
18//! installed in `~/.config/systemd/user`, the containers of the user are used, and lingering is
19//! enabled for the user so that the service runs without an open session and starts at boot.
20//!
21//! # Invalid states are unrepresentable
22//!
23//! - The service configuration only exists in the `Running` and `Stopped` states: an `Absent`
24//!   service has nothing to configure.
25//! - The image is either a `Local` squashfs file, or `Imported` from a source URI (with a
26//!   scheme Enroot supports) into a squashfs file.
27//! - GPUs are either `All` or a non-empty list of `Devices` with valid IDs. The variables they
28//!   set (`NVIDIA_VISIBLE_DEVICES`, `NVIDIA_DRIVER_CAPABILITIES`) can't also be set in `Env`.
29//! - `Env` can't set the variables configuring Enroot itself (`ENROOT_*`, `HOME`, `PATH`,
30//!   `XDG_*`), as the environment file is also the environment of `enroot start`.
31//! - Values with a syntax (name, paths, image URI, mounts, environment variable names, GPU IDs)
32//!   have dedicated types which can only be built from a valid value.
33//!
34//! # How compliance is assessed
35//!
36//! - `enroot` must be available (`FailedDryRunEvaluation` otherwise). Its absolute path is
37//!   written in the unit.
38//! - A `Local` image must exist (`NonCompliantFatal` otherwise). An `Imported` image is imported
39//!   (`enroot import -o <path> <source>`) **only if its file is missing**: to change the image,
40//!   change `Path` too (put the version in the file name, e.g. `/opt/images/vllm-0.6.3.sqsh`).
41//!   Importing a large image can be long: the default timeouts are generous, set `Timeout` on
42//!   the attribute if needed.
43//! - The container is created (`enroot create`) if it doesn't exist (`enroot list`). Enroot keeps
44//!   no record of the image a container comes from, so regent-sdk records the image path and
45//!   identity (size and modification time) in a marker file next to the environment file
46//!   (`.../regent-sdk/enroot/<name>.image`). The container is **recreated** when the marker
47//!   differs, or is missing (a container which was not created by Regent can't be verified).
48//! - The unit file and the environment file are rendered and compared with the files on the
49//!   host: if they differ, they are rewritten. The service is restarted if it must run and its
50//!   files or its container changed.
51//! - The service is then enabled and started (`Running`) or disabled and stopped (`Stopped`).
52//! - `Absent` stops and disables the service, deletes its files, and removes the container and
53//!   its marker. The image file is kept.
54//!
55//! `Env` values can be secrets. They are written in an environment file only readable by its
56//! owner (mode 0600) and passed by name to the container (`--env NAME`). They never appear in
57//! the unit file nor in remediation messages, but they are on the command line writing the
58//! environment file.
59//!
60//! # Examples
61//!
62//! ## Rust API
63//!
64//! ```no_run
65//! use regent_sdk::state::attribute::container::container_spec::ContainerCommand;
66//! use regent_sdk::state::attribute::container::enroot::{EnrootExpectedState, EnrootServiceConfig};
67//! use regent_sdk::state::expected_state::Parameter;
68//! use regent_sdk::secrets::SecretReference;
69//! use regent_sdk::{Attribute, ExpectedState, Privilege, RegentError};
70//!
71//! fn inference_server() -> Result<ExpectedState, RegentError> {
72//!     let config = EnrootServiceConfig::from_imported_image(
73//!         "docker://vllm/vllm-openai:v0.6.3".parse()?,
74//!         "/opt/images/vllm-0.6.3.sqsh".parse()?,
75//!     )
76//!     .with_command(ContainerCommand::new(&[
77//!         "vllm", "serve", "meta-llama/Llama-3.1-8B-Instruct", "--port", "8000",
78//!     ])?)
79//!     .with_all_gpus()
80//!     .with_mounts(vec!["/srv/models:/root/.cache/huggingface".parse()?])
81//!     .with_env(
82//!         "HF_TOKEN".parse()?,
83//!         Parameter::Secret(SecretReference::from("hf_token", Some("files".to_string()))),
84//!     );
85//!
86//!     let vllm = EnrootExpectedState::running("vllm".parse()?, config);
87//!
88//!     Ok(ExpectedState::new()
89//!         .with_attribute(Attribute::enroot(vllm, Privilege::None, None))
90//!         .build())
91//! }
92//! ```
93//!
94//! ## YAML API
95//!
96//! ```yaml
97//! Attributes:
98//!   - Name: vLLM inference server must be running
99//!     Privilege: !None              # user unit of the connected user; !WithSudo for a system unit
100//!     Detail: !Enroot
101//!       Name: vllm
102//!       State: !Running             # !Running | !Stopped (same configuration) | Absent
103//!         Image: !Imported          # !Local /opt/images/app.sqsh | !Imported
104//!           Source: docker://vllm/vllm-openai:v0.6.3   # docker://[USER@][REGISTRY#]IMAGE[:TAG] | dockerd://IMAGE[:TAG] | podman://IMAGE[:TAG]
105//!           Path: /opt/images/vllm-0.6.3.sqsh
106//!         Command:                  # optional, image default command if omitted
107//!           - vllm
108//!           - serve
109//!           - meta-llama/Llama-3.1-8B-Instruct
110//!           - --port
111//!           - "8000"
112//!         Gpus: !Devices [0, 1]     # All | !Devices [index | GPU-<uuid> | MIG-<uuid>, ...]
113//!         Mounts:                   # source[:target[:ro|rw]]
114//!           - /srv/models:/root/.cache/huggingface
115//!         Env:
116//!           HF_TOKEN:               # secret retrieved from a secret provider
117//!             SecRef: hf_token
118//!             Provider: files
119//!         Root: false               # --root
120//!         ReadWrite: false          # --rw
121//!         RestartPolicy: OnFailure  # No | OnFailure (default) | Always
122//! ```
123//!
124//! To make sure a service and its container do not exist:
125//!
126//! ```yaml
127//! Attributes:
128//!   - Name: Old inference server must be removed
129//!     Privilege: !None
130//!     Detail: !Enroot
131//!       Name: legacy-llm
132//!       State: Absent
133//! ```
134
135use crate::error::RegentError;
136use crate::hosts::handlers::shell_quote;
137use crate::hosts::managed_host::InternalApiCallOutcome;
138use crate::hosts::managed_host::{AssessCompliance, ReachCompliance, Timeout};
139use crate::hosts::properties::{HostProperties, OsKind};
140use crate::secrets::SecretProvidersPool;
141use crate::state::Check;
142use crate::state::attribute::HostHandler;
143use crate::state::attribute::Privilege;
144use crate::state::attribute::Remediation;
145use crate::state::attribute::RemediationsList;
146use crate::state::compliance::AttributeComplianceAssessment;
147use crate::state::expected_state::Parameter;
148use serde::{Deserialize, Serialize};
149use std::collections::BTreeMap;
150use std::fmt;
151use std::str::FromStr;
152use std::time::Duration;
153
154use super::container_spec::{ContainerCommand, ContainerName, GpuId, GpuSelection, invalid};
155use super::service_unit::{
156    AbsolutePath, BindMount, ServiceEngine, ServiceEnvVarName, ServiceStep, ServiceUnitAction,
157    ServiceUnitId, UnitRestartPolicy, UnitScope, UnitSpec, file_exists, needs_removal,
158    observe_service, plan_service, read_optional_file, render_env_file, render_unit,
159    resolve_executable, run_action, run_remediation, write_files,
160};
161
162const NVIDIA_VISIBLE_DEVICES: &str = "NVIDIA_VISIBLE_DEVICES";
163const NVIDIA_DRIVER_CAPABILITIES: &str = "NVIDIA_DRIVER_CAPABILITIES";
164const GPU_DRIVER_CAPABILITIES: &str = "compute,utility";
165
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
167#[serde(deny_unknown_fields)]
168#[serde(rename_all = "PascalCase")]
169pub struct EnrootExpectedState {
170    /// The unit is `regent-enroot-<name>.service`
171    name: ContainerName,
172    state: EnrootServiceState,
173}
174
175#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
176#[serde(rename_all = "PascalCase")]
177pub enum EnrootServiceState {
178    Running(Box<EnrootServiceConfig>),
179    Stopped(Box<EnrootServiceConfig>),
180    Absent,
181}
182
183impl EnrootExpectedState {
184    pub fn running(name: ContainerName, config: EnrootServiceConfig) -> Self {
185        Self {
186            name,
187            state: EnrootServiceState::Running(Box::new(config)),
188        }
189    }
190
191    pub fn stopped(name: ContainerName, config: EnrootServiceConfig) -> Self {
192        Self {
193            name,
194            state: EnrootServiceState::Stopped(Box::new(config)),
195        }
196    }
197
198    pub fn absent(name: ContainerName) -> Self {
199        Self {
200            name,
201            state: EnrootServiceState::Absent,
202        }
203    }
204}
205
206#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
207#[serde(rename_all = "PascalCase")]
208pub enum EnrootImage {
209    /// Not managed: the file must be present
210    Local(AbsolutePath),
211    Imported(EnrootImportedImage),
212}
213
214impl EnrootImage {
215    /// Squashfs file which must be present on the host (not managed)
216    pub fn local(path: AbsolutePath) -> Self {
217        EnrootImage::Local(path)
218    }
219
220    /// Squashfs file imported from `source` (`enroot import`) if it is missing
221    pub fn imported(source: EnrootImageUri, path: AbsolutePath) -> Self {
222        EnrootImage::Imported(EnrootImportedImage { source, path })
223    }
224
225    pub fn path(&self) -> &AbsolutePath {
226        match self {
227            EnrootImage::Local(path) => path,
228            EnrootImage::Imported(imported) => &imported.path,
229        }
230    }
231}
232
233#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
234#[serde(deny_unknown_fields)]
235#[serde(rename_all = "PascalCase")]
236pub struct EnrootImportedImage {
237    source: EnrootImageUri,
238    /// Squashfs file to create (include the version in the file name: an existing file is
239    /// never imported again)
240    path: AbsolutePath,
241}
242
243/// Source of `enroot import`: `docker://[USER@][REGISTRY#]IMAGE[:TAG]`, `dockerd://IMAGE[:TAG]`
244/// or `podman://IMAGE[:TAG]`
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246#[serde(try_from = "String", into = "String")]
247pub struct EnrootImageUri(String);
248
249const ENROOT_URI_SCHEMES: [&str; 3] = ["docker://", "dockerd://", "podman://"];
250
251impl TryFrom<String> for EnrootImageUri {
252    type Error = RegentError;
253
254    fn try_from(uri: String) -> Result<Self, Self::Error> {
255        let rest = ENROOT_URI_SCHEMES
256            .iter()
257            .find_map(|scheme| uri.strip_prefix(scheme));
258        match rest {
259            None => Err(invalid(
260                "image URI",
261                &uri,
262                &format!("supported schemes are {}", ENROOT_URI_SCHEMES.join(", ")),
263            )),
264            Some(rest) if rest.is_empty() || uri.chars().any(char::is_whitespace) => Err(invalid(
265                "image URI",
266                &uri,
267                "must name an image, without whitespace",
268            )),
269            Some(_) => Ok(Self(uri)),
270        }
271    }
272}
273
274impl FromStr for EnrootImageUri {
275    type Err = RegentError;
276
277    fn from_str(uri: &str) -> Result<Self, Self::Err> {
278        Self::try_from(uri.to_string())
279    }
280}
281
282impl From<EnrootImageUri> for String {
283    fn from(uri: EnrootImageUri) -> String {
284        uri.0
285    }
286}
287
288impl fmt::Display for EnrootImageUri {
289    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
290        write!(f, "{}", self.0)
291    }
292}
293
294impl EnrootImageUri {
295    pub fn as_str(&self) -> &str {
296        &self.0
297    }
298}
299
300#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
301#[serde(try_from = "EnrootServiceConfigFields")]
302#[serde(rename_all = "PascalCase")]
303pub struct EnrootServiceConfig {
304    image: EnrootImage,
305    /// Image default command if omitted
306    command: Option<ContainerCommand>,
307    gpus: Option<GpuSelection>,
308    mounts: Option<Vec<BindMount>>,
309    /// Environment variables of the container (values can be secrets)
310    env: Option<BTreeMap<ServiceEnvVarName, Parameter<String>>>,
311    /// Remap the user to root inside the container (`--root`)
312    root: bool,
313    /// Writable root filesystem (`--rw`)
314    read_write: bool,
315    restart_policy: UnitRestartPolicy,
316}
317
318/// Fields of `EnrootServiceConfig` before the checks between them
319#[derive(Deserialize)]
320#[serde(deny_unknown_fields)]
321#[serde(rename_all = "PascalCase")]
322struct EnrootServiceConfigFields {
323    image: EnrootImage,
324    command: Option<ContainerCommand>,
325    gpus: Option<GpuSelection>,
326    mounts: Option<Vec<BindMount>>,
327    env: Option<BTreeMap<ServiceEnvVarName, Parameter<String>>>,
328    #[serde(default)]
329    root: bool,
330    #[serde(default)]
331    read_write: bool,
332    #[serde(default)]
333    restart_policy: UnitRestartPolicy,
334}
335
336impl TryFrom<EnrootServiceConfigFields> for EnrootServiceConfig {
337    type Error = RegentError;
338
339    fn try_from(fields: EnrootServiceConfigFields) -> Result<Self, Self::Error> {
340        let config = Self {
341            image: fields.image,
342            command: fields.command,
343            gpus: fields.gpus,
344            mounts: fields.mounts,
345            env: fields.env,
346            root: fields.root,
347            read_write: fields.read_write,
348            restart_policy: fields.restart_policy,
349        };
350        config.validate()?;
351        Ok(config)
352    }
353}
354
355impl EnrootServiceConfig {
356    pub fn new(image: EnrootImage) -> Self {
357        Self {
358            image,
359            command: None,
360            gpus: None,
361            mounts: None,
362            env: None,
363            root: false,
364            read_write: false,
365            restart_policy: UnitRestartPolicy::default(),
366        }
367    }
368
369    /// Service running a local squashfs file, which must be present on the host (not managed)
370    pub fn from_local_image(path: AbsolutePath) -> Self {
371        Self::new(EnrootImage::local(path))
372    }
373
374    /// Service running a squashfs file imported from `source` (`enroot import`) if it is missing
375    pub fn from_imported_image(source: EnrootImageUri, path: AbsolutePath) -> Self {
376        Self::new(EnrootImage::imported(source, path))
377    }
378
379    pub fn with_command(mut self, command: ContainerCommand) -> Self {
380        self.command = Some(command);
381        self
382    }
383
384    pub fn with_gpus(mut self, gpus: GpuSelection) -> Self {
385        self.gpus = Some(gpus);
386        self
387    }
388
389    /// Give all the GPUs to the container (`NVIDIA_VISIBLE_DEVICES=all`). Replaces the devices
390    /// added before.
391    pub fn with_all_gpus(self) -> Self {
392        self.with_gpus(GpuSelection::All)
393    }
394
395    /// Give a GPU to the container, in addition to the devices added before. Replaces
396    /// `with_all_gpus()`.
397    pub fn with_gpu_device(mut self, gpu: GpuId) -> Self {
398        self.gpus = Some(GpuSelection::add_device(self.gpus.take(), gpu));
399        self
400    }
401
402    pub fn with_mounts(mut self, mounts: Vec<BindMount>) -> Self {
403        self.mounts = Some(mounts);
404        self
405    }
406
407    /// Add an environment variable (the value may be a secret reference)
408    pub fn with_env(mut self, key: ServiceEnvVarName, value: Parameter<String>) -> Self {
409        self.env
410            .get_or_insert_with(BTreeMap::new)
411            .insert(key, value);
412        self
413    }
414
415    pub fn with_root(mut self, root: bool) -> Self {
416        self.root = root;
417        self
418    }
419
420    pub fn with_read_write(mut self, read_write: bool) -> Self {
421        self.read_write = read_write;
422        self
423    }
424
425    pub fn with_restart_policy(mut self, restart_policy: UnitRestartPolicy) -> Self {
426        self.restart_policy = restart_policy;
427        self
428    }
429
430    /// Never restart the service (`Restart=no`)
431    pub fn with_restart_never(self) -> Self {
432        self.with_restart_policy(UnitRestartPolicy::No)
433    }
434
435    /// Restart the service when it fails (`Restart=on-failure`, the default)
436    pub fn with_restart_on_failure(self) -> Self {
437        self.with_restart_policy(UnitRestartPolicy::OnFailure)
438    }
439
440    /// Always restart the service (`Restart=always`)
441    pub fn with_restart_always(self) -> Self {
442        self.with_restart_policy(UnitRestartPolicy::Always)
443    }
444
445    /// Checks between fields (run while parsing, and by `check()` for the Rust API)
446    fn validate(&self) -> Result<(), RegentError> {
447        for key in self.env.iter().flat_map(BTreeMap::keys) {
448            let key = key.as_str();
449            if key.starts_with("ENROOT_")
450                || key.starts_with("XDG_")
451                || key == "HOME"
452                || key == "PATH"
453            {
454                return Err(invalid(
455                    "environment variable",
456                    key,
457                    "it would also configure enroot itself",
458                ));
459            }
460            if self.gpus.is_some()
461                && (key == NVIDIA_VISIBLE_DEVICES || key == NVIDIA_DRIVER_CAPABILITIES)
462            {
463                return Err(invalid("environment variable", key, "it is set by Gpus"));
464            }
465        }
466        Ok(())
467    }
468
469    fn gpu_env(&self) -> Vec<(String, String)> {
470        match &self.gpus {
471            Some(gpus) => vec![
472                (
473                    NVIDIA_VISIBLE_DEVICES.to_string(),
474                    gpus.ids().into_iter().collect::<Vec<String>>().join(","),
475                ),
476                (
477                    NVIDIA_DRIVER_CAPABILITIES.to_string(),
478                    GPU_DRIVER_CAPABILITIES.to_string(),
479                ),
480            ],
481            None => Vec::new(),
482        }
483    }
484
485    /// Names of the variables of the environment file, passed with `--env NAME`
486    fn env_names(&self) -> Vec<String> {
487        let mut names: Vec<String> = self
488            .env
489            .iter()
490            .flat_map(BTreeMap::keys)
491            .map(ServiceEnvVarName::to_string)
492            .collect();
493        names.extend(self.gpu_env().into_iter().map(|(name, _)| name));
494        names
495    }
496
497    fn exec_start(&self, enroot_path: &str, name: &ContainerName) -> Vec<String> {
498        let mut args = vec![enroot_path.to_string(), "start".to_string()];
499        if self.root {
500            args.push("--root".to_string());
501        }
502        if self.read_write {
503            args.push("--rw".to_string());
504        }
505        for mount in self.mounts.iter().flatten() {
506            args.push("--mount".to_string());
507            args.push(mount.enroot_arg());
508        }
509        for env_name in self.env_names() {
510            args.push("--env".to_string());
511            args.push(env_name);
512        }
513        args.push(name.to_string());
514        args.extend(
515            self.command
516                .iter()
517                .flat_map(|command| command.args().iter().cloned()),
518        );
519        args
520    }
521
522    /// `home` is the home directory of root, set in system units (Enroot finds its containers
523    /// from it)
524    fn unit_content(&self, id: &ServiceUnitId, enroot_path: &str, home: &Option<String>) -> String {
525        render_unit(
526            id,
527            &UnitSpec {
528                exec_start: self.exec_start(enroot_path, id.name()),
529                environment: home
530                    .iter()
531                    .map(|home| ("HOME".to_string(), home.clone()))
532                    .collect(),
533                restart: self.restart_policy,
534            },
535        )
536    }
537
538    /// Content of the environment file, retrieving secrets if needed
539    async fn env_content(
540        &self,
541        optional_secret_provider: &Option<SecretProvidersPool>,
542    ) -> Result<String, RegentError> {
543        let mut entries = Vec::new();
544        if let Some(env) = &self.env {
545            for (key, value) in env {
546                entries.push((
547                    key.to_string(),
548                    value.clone().inner_raw(optional_secret_provider).await?,
549                ));
550            }
551        }
552        entries.extend(self.gpu_env());
553        Ok(render_env_file(&entries))
554    }
555}
556
557/// Content of the marker recording the image a container was created from
558fn marker_content(image: &AbsolutePath, identity: &str) -> String {
559    format!("image={}\nidentity={}\n", image, identity)
560}
561
562/// Reasons why the container must be recreated, from its marker (`None` if missing) and the
563/// current identity of the image
564fn recreation_reasons(marker: Option<&str>, image: &AbsolutePath, identity: &str) -> Vec<String> {
565    let Some(marker) = marker else {
566        return vec![
567            "image can't be verified on a container which was not created by Regent".to_string(),
568        ];
569    };
570    if marker == marker_content(image, identity) {
571        return Vec::new();
572    }
573    let recorded = |key: &str| {
574        marker
575            .lines()
576            .find_map(|line| line.strip_prefix(key))
577            .unwrap_or_default()
578            .to_string()
579    };
580    let recorded_image = recorded("image=");
581    if recorded_image != image.as_str() {
582        vec![format!(
583            "container was created from {:?}, expected {}",
584            recorded_image, image
585        )]
586    } else {
587        vec![format!(
588            "image {} changed since the container was created",
589            image
590        )]
591    }
592}
593
594/// `size mtime` of a file, as printed by `stat -c '%s %Y'`
595fn parse_identity(output: &str) -> Option<String> {
596    output
597        .lines()
598        .map(str::trim)
599        .find(|line| {
600            let parts: Vec<&str> = line.split(' ').collect();
601            parts.len() == 2
602                && parts
603                    .iter()
604                    .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()))
605        })
606        .map(String::from)
607}
608
609/// Container names of `enroot list` (one per line, other lines ignored by the caller)
610fn is_listed(output: &str, name: &ContainerName) -> bool {
611    output.lines().any(|line| line.trim() == name.as_str())
612}
613
614impl Timeout for EnrootExpectedState {
615    fn default_timeout(&self) -> Duration {
616        Duration::from_secs(120)
617    }
618}
619
620impl Check for EnrootExpectedState {
621    fn check(&self) -> Result<(), RegentError> {
622        // Values are validated when their type is built, only the checks between fields remain
623        // (the Rust API builder can't run them)
624        match &self.state {
625            EnrootServiceState::Running(config) | EnrootServiceState::Stopped(config) => {
626                config.validate()
627            }
628            EnrootServiceState::Absent => Ok(()),
629        }
630    }
631
632    fn check_host_compatibility(
633        &self,
634        host_properties: &HostProperties,
635    ) -> Result<(), RegentError> {
636        check_enroot_host_compatibility(host_properties)
637    }
638}
639
640impl<Handler: HostHandler> AssessCompliance<Handler> for EnrootExpectedState {
641    async fn assess_compliance(
642        &self,
643        host_handler: &mut Handler,
644        host_properties: &Option<HostProperties>,
645        privilege: &Privilege,
646        optional_secret_provider: &Option<SecretProvidersPool>,
647    ) -> Result<AttributeComplianceAssessment, RegentError> {
648        if let Some(props) = host_properties {
649            self.check_host_compatibility(props)?;
650        }
651
652        let (scope, home) = super::service_unit::detect_scope(host_handler, privilege).await?;
653        let id = ServiceUnitId::new(ServiceEngine::Enroot, self.name.clone(), scope);
654        let enroot_path = resolve_executable(host_handler, "enroot", privilege).await?;
655
656        let mut remediations: Vec<Remediation> = Vec::new();
657        let mut push = |api_call: EnrootInternalApiCall| {
658            remediations.push(Remediation::Enroot(EnrootApiCall::from(
659                api_call,
660                privilege.clone(),
661            )));
662        };
663
664        let (config, should_run) = match &self.state {
665            EnrootServiceState::Absent => {
666                let observed = observe_service(host_handler, &id, false, privilege).await?;
667                if needs_removal(&observed) {
668                    push(EnrootInternalApiCall::Service {
669                        unit: id.clone(),
670                        action: ServiceUnitAction::Remove,
671                    });
672                }
673                // Without enroot, there is no container to remove
674                let is_container_present = match &enroot_path {
675                    Some(_) => is_container_listed(host_handler, &self.name, privilege).await?,
676                    None => false,
677                };
678                let marker =
679                    read_optional_file(host_handler, &marker_path(&id), "image marker", privilege)
680                        .await?;
681                if is_container_present || marker.is_some() {
682                    push(EnrootInternalApiCall::RemoveContainer {
683                        unit: id,
684                        is_container_present,
685                    });
686                }
687                return assessment(remediations);
688            }
689            EnrootServiceState::Running(config) => (config, true),
690            EnrootServiceState::Stopped(config) => (config, false),
691        };
692
693        let Some(enroot_path) = enroot_path else {
694            return Err(RegentError::FailedDryRunEvaluation(
695                "Enroot CLI (enroot) is not available on this host".to_string(),
696            ));
697        };
698
699        let image_path = config.image.path();
700        let mut is_image_imported = false;
701        if !file_exists(host_handler, image_path, privilege).await? {
702            match &config.image {
703                EnrootImage::Local(path) => {
704                    return Ok(AttributeComplianceAssessment::NonCompliantFatal(format!(
705                        "image {} is not present on the host",
706                        path
707                    )));
708                }
709                EnrootImage::Imported(imported) => {
710                    push(EnrootInternalApiCall::Import(imported.clone()));
711                    is_image_imported = true;
712                }
713            }
714        }
715
716        let is_container_present = is_container_listed(host_handler, &self.name, privilege).await?;
717        let reasons = if is_image_imported {
718            vec![format!("image {} will be imported", image_path)]
719        } else {
720            let identity = image_identity(host_handler, image_path, privilege).await?;
721            let marker =
722                read_optional_file(host_handler, &marker_path(&id), "image marker", privilege)
723                    .await?;
724            recreation_reasons(marker.as_deref(), image_path, &identity)
725        };
726
727        let container_changed = match (is_container_present, reasons.is_empty()) {
728            (false, _) => {
729                push(EnrootInternalApiCall::CreateContainer {
730                    unit: id.clone(),
731                    image: image_path.clone(),
732                    reasons: Vec::new(),
733                });
734                true
735            }
736            (true, false) => {
737                push(EnrootInternalApiCall::CreateContainer {
738                    unit: id.clone(),
739                    image: image_path.clone(),
740                    reasons,
741                });
742                true
743            }
744            (true, true) => false,
745        };
746
747        let home = match scope {
748            UnitScope::System if !home.is_empty() => Some(home),
749            _ => None,
750        };
751        let observed = observe_service(host_handler, &id, should_run, privilege).await?;
752        let expected_unit = config.unit_content(&id, &enroot_path, &home);
753        let expected_env = config.env_content(optional_secret_provider).await?;
754
755        for step in plan_service(
756            should_run,
757            &expected_unit,
758            &expected_env,
759            &observed,
760            container_changed,
761        ) {
762            push(match step {
763                ServiceStep::WriteFiles(reasons) => EnrootInternalApiCall::WriteServiceFiles {
764                    unit: id.clone(),
765                    config: config.clone(),
766                    enroot_path: enroot_path.clone(),
767                    home: home.clone(),
768                    reasons,
769                },
770                ServiceStep::Action(action) => EnrootInternalApiCall::Service {
771                    unit: id.clone(),
772                    action,
773                },
774            });
775        }
776
777        assessment(remediations)
778    }
779}
780
781fn assessment(
782    remediations: Vec<Remediation>,
783) -> Result<AttributeComplianceAssessment, RegentError> {
784    if remediations.is_empty() {
785        Ok(AttributeComplianceAssessment::Compliant)
786    } else {
787        Ok(AttributeComplianceAssessment::NonCompliant(
788            RemediationsList::from(remediations)?,
789        ))
790    }
791}
792
793fn marker_path(id: &ServiceUnitId) -> String {
794    id.regent_file("image")
795}
796
797async fn is_container_listed<Handler: HostHandler>(
798    host_handler: &mut Handler,
799    name: &ContainerName,
800    privilege: &Privilege,
801) -> Result<bool, RegentError> {
802    let result = host_handler
803        .run_command("enroot list", privilege)
804        .await
805        .map_err(|e| {
806            RegentError::FailedDryRunEvaluation(format!("Unable to list containers: {:?}", e))
807        })?;
808    if result.return_code != 0 {
809        return Err(RegentError::FailedDryRunEvaluation(format!(
810            "Unable to list containers: {:?}",
811            result
812        )));
813    }
814    Ok(is_listed(&result.stdout, name))
815}
816
817async fn image_identity<Handler: HostHandler>(
818    host_handler: &mut Handler,
819    image: &AbsolutePath,
820    privilege: &Privilege,
821) -> Result<String, RegentError> {
822    let result = host_handler
823        .run_command(
824            &format!("stat -c '%s %Y' {}", shell_quote(image.as_str())),
825            privilege,
826        )
827        .await
828        .map_err(|e| {
829            RegentError::FailedDryRunEvaluation(format!("Unable to stat {}: {:?}", image, e))
830        })?;
831    parse_identity(&result.stdout).ok_or_else(|| {
832        RegentError::FailedDryRunEvaluation(format!("Unable to stat {}: {:?}", image, result))
833    })
834}
835
836#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
837#[serde(rename_all = "PascalCase", rename_all_fields = "PascalCase")]
838pub enum EnrootInternalApiCall {
839    Import(EnrootImportedImage),
840    /// Create the container (removing it first if `reasons` is not empty: stop its service,
841    /// remove it) and record its image in the marker
842    CreateContainer {
843        unit: ServiceUnitId,
844        image: AbsolutePath,
845        reasons: Vec<String>,
846    },
847    RemoveContainer {
848        unit: ServiceUnitId,
849        is_container_present: bool,
850    },
851    WriteServiceFiles {
852        unit: ServiceUnitId,
853        config: Box<EnrootServiceConfig>,
854        enroot_path: String,
855        home: Option<String>,
856        reasons: Vec<String>,
857    },
858    Service {
859        unit: ServiceUnitId,
860        action: ServiceUnitAction,
861    },
862}
863
864#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
865pub struct EnrootApiCall {
866    pub api_call: EnrootInternalApiCall,
867    privilege: Privilege,
868}
869
870impl EnrootApiCall {
871    pub fn display(&self) -> String {
872        match &self.api_call {
873            EnrootInternalApiCall::Import(imported) => {
874                format!("Import image {} into {}", imported.source, imported.path)
875            }
876            EnrootInternalApiCall::CreateContainer {
877                unit,
878                image,
879                reasons,
880            } => {
881                if reasons.is_empty() {
882                    format!("Create container {} from {}", unit.name(), image)
883                } else {
884                    format!(
885                        "Recreate container {} from {} ({})",
886                        unit.name(),
887                        image,
888                        reasons.join("; ")
889                    )
890                }
891            }
892            EnrootInternalApiCall::RemoveContainer { unit, .. } => {
893                format!("Remove container {}", unit.name())
894            }
895            EnrootInternalApiCall::WriteServiceFiles { unit, reasons, .. } => format!(
896                "Write {} and its environment file ({})",
897                unit,
898                reasons.join("; ")
899            ),
900            EnrootInternalApiCall::Service { unit, action } => action.display(unit),
901        }
902    }
903
904    fn from(api_call: EnrootInternalApiCall, privilege: Privilege) -> EnrootApiCall {
905        EnrootApiCall {
906            api_call,
907            privilege,
908        }
909    }
910}
911
912impl Timeout for EnrootApiCall {
913    fn default_timeout(&self) -> Duration {
914        match &self.api_call {
915            // Images of inference servers weigh several GB
916            EnrootInternalApiCall::Import(_) => Duration::from_secs(3600),
917            // Unpacking the image
918            EnrootInternalApiCall::CreateContainer { .. } => Duration::from_secs(900),
919            _ => Duration::from_secs(180),
920        }
921    }
922}
923
924impl Check for EnrootApiCall {
925    fn check(&self) -> Result<(), RegentError> {
926        Ok(())
927    }
928
929    fn check_host_compatibility(
930        &self,
931        host_properties: &HostProperties,
932    ) -> Result<(), RegentError> {
933        check_enroot_host_compatibility(host_properties)
934    }
935}
936
937impl<Handler: HostHandler> ReachCompliance<Handler> for EnrootApiCall {
938    async fn call(
939        &self,
940        host_handler: &mut Handler,
941        host_properties: &Option<HostProperties>,
942        optional_secret_provider: &Option<SecretProvidersPool>,
943    ) -> Result<InternalApiCallOutcome, RegentError> {
944        if let Some(props) = host_properties {
945            self.check_host_compatibility(props)?;
946        }
947
948        let command = match &self.api_call {
949            EnrootInternalApiCall::Import(imported) => import_command(imported),
950            EnrootInternalApiCall::CreateContainer {
951                unit,
952                image,
953                reasons,
954            } => create_command(unit, image, !reasons.is_empty()),
955            EnrootInternalApiCall::RemoveContainer {
956                unit,
957                is_container_present,
958            } => remove_command(unit, *is_container_present),
959            EnrootInternalApiCall::WriteServiceFiles {
960                unit,
961                config,
962                enroot_path,
963                home,
964                ..
965            } => {
966                let env = config.env_content(optional_secret_provider).await?;
967                return write_files(
968                    host_handler,
969                    unit,
970                    &config.unit_content(unit, enroot_path, home),
971                    &env,
972                    &self.privilege,
973                )
974                .await;
975            }
976            EnrootInternalApiCall::Service { unit, action } => {
977                return run_action(host_handler, unit, *action, &self.privilege).await;
978            }
979        };
980        run_remediation(host_handler, &command, &self.privilege, true).await
981    }
982}
983
984/// `mkdir -p <parent> && enroot import -o <path> <source>`
985fn import_command(imported: &EnrootImportedImage) -> String {
986    format!(
987        "mkdir -p {} && enroot import -o {} {}",
988        shell_quote(imported.path.parent()),
989        shell_quote(imported.path.as_str()),
990        shell_quote(imported.source.as_str())
991    )
992}
993
994/// Create the container (after removing it, its service stopped, if `recreate`), then record
995/// the image path and identity in the marker
996fn create_command(id: &ServiceUnitId, image: &AbsolutePath, recreate: bool) -> String {
997    let name = shell_quote(id.name().as_str());
998    let image_path = shell_quote(image.as_str());
999    let removal = if recreate {
1000        format!(
1001            "{{ {} >/dev/null 2>&1; true; }} && enroot remove --force {} && ",
1002            id.systemctl(&["stop", &id.unit_name()]),
1003            name
1004        )
1005    } else {
1006        String::new()
1007    };
1008    format!(
1009        "{removal}enroot create --name {name} {image_path} && (umask 077 && mkdir -p {dir} && printf 'image=%s\\nidentity=%s\\n' {image_path} \"$(stat -c '%s %Y' {image_path})\" > {marker})",
1010        removal = removal,
1011        name = name,
1012        image_path = image_path,
1013        dir = id.regent_dir(),
1014        marker = marker_path(id),
1015    )
1016}
1017
1018fn remove_command(id: &ServiceUnitId, is_container_present: bool) -> String {
1019    let marker_removal = format!("rm -f {}", marker_path(id));
1020    if is_container_present {
1021        format!(
1022            "enroot remove --force {} && {}",
1023            shell_quote(id.name().as_str()),
1024            marker_removal
1025        )
1026    } else {
1027        marker_removal
1028    }
1029}
1030
1031fn check_enroot_host_compatibility(host_properties: &HostProperties) -> Result<(), RegentError> {
1032    match host_properties.os_kind() {
1033        OsKind::Linux(_) => Ok(()),
1034        incompatible_os_kind => Err(RegentError::IncompatibleHost(format!(
1035            "Host is {:?} but Enroot services are only supported on Linux",
1036            incompatible_os_kind
1037        ))),
1038    }
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043    use super::*;
1044    use crate::secrets::SecretReference;
1045
1046    use crate::state::attribute::container::container_spec::GpuList;
1047    fn full_yaml() -> &'static str {
1048        "---
1049Name: vllm
1050State: !Running
1051  Image: !Imported
1052    Source: docker://nvcr.io#nvidia/vllm:24.10-py3
1053    Path: /opt/images/vllm-24.10.sqsh
1054  Command:
1055    - vllm
1056    - serve
1057    - meta-llama/Llama-3.1-8B-Instruct
1058    - --port
1059    - '8000'
1060  Gpus: !Devices [0, GPU-4b9d7a3c-2f1e-4d5a-9b8c-7e6f5a4b3c2d]
1061  Mounts:
1062    - /srv/models:/root/.cache/huggingface
1063    - /srv/config:/config:ro
1064  Env:
1065    TZ: Europe/Paris
1066    HF_TOKEN:
1067      SecRef: hf_token
1068      Provider: files
1069  Root: true
1070  ReadWrite: true
1071  RestartPolicy: Always
1072"
1073    }
1074
1075    fn full_config() -> EnrootServiceConfig {
1076        let state: EnrootExpectedState = yaml_serde::from_str(full_yaml()).unwrap();
1077        match state.state {
1078            EnrootServiceState::Running(config) => *config,
1079            other => panic!("unexpected state {:?}", other),
1080        }
1081    }
1082
1083    fn id(scope: UnitScope) -> ServiceUnitId {
1084        ServiceUnitId::new(ServiceEngine::Enroot, "vllm".parse().unwrap(), scope)
1085    }
1086
1087    fn block_on<F: std::future::Future>(future: F) -> F::Output {
1088        tokio::runtime::Builder::new_current_thread()
1089            .build()
1090            .unwrap()
1091            .block_on(future)
1092    }
1093
1094    #[test]
1095    fn parsing_enroot_module_block_from_yaml_str() {
1096        let config = full_config();
1097        assert_eq!(config.image.path().as_str(), "/opt/images/vllm-24.10.sqsh");
1098        assert_eq!(
1099            config
1100                .env
1101                .as_ref()
1102                .unwrap()
1103                .get(&"HF_TOKEN".parse().unwrap()),
1104            Some(&Parameter::Secret(SecretReference::from(
1105                "hf_token",
1106                Some("files".to_string())
1107            )))
1108        );
1109        assert!(config.root && config.read_write);
1110
1111        let raw = "---
1112- Name: legacy
1113  State: Absent
1114- Name: app
1115  State: !Stopped
1116    Image: !Local /opt/images/app.sqsh
1117- Name: llm
1118  State: !Running
1119    Image: !Local /opt/images/llm.sqsh
1120    Gpus: All
1121";
1122        let states: Vec<EnrootExpectedState> = yaml_serde::from_str(raw).unwrap();
1123        assert_eq!(states[0].state, EnrootServiceState::Absent);
1124        match &states[1].state {
1125            EnrootServiceState::Stopped(config) => {
1126                assert_eq!(config.command, None);
1127                assert_eq!(config.restart_policy, UnitRestartPolicy::OnFailure);
1128                assert!(!config.root && !config.read_write);
1129            }
1130            other => panic!("unexpected state {:?}", other),
1131        }
1132        match &states[2].state {
1133            EnrootServiceState::Running(config) => {
1134                assert_eq!(config.gpus, Some(GpuSelection::All))
1135            }
1136            other => panic!("unexpected state {:?}", other),
1137        }
1138    }
1139
1140    #[test]
1141    fn serialization_round_trip_preserves_state() {
1142        // Templating (Attribute::consider_context) relies on a JSON round trip
1143        let state: EnrootExpectedState = yaml_serde::from_str(full_yaml()).unwrap();
1144        let json = serde_json::to_string(&state).unwrap();
1145        assert_eq!(
1146            serde_json::from_str::<EnrootExpectedState>(&json).unwrap(),
1147            state
1148        );
1149    }
1150
1151    #[test]
1152    fn invalid_states_are_rejected_while_parsing() {
1153        let base = "Name: llm\nState: !Running\n  Image: !Local /opt/llm.sqsh\n";
1154        let rejected = [
1155            // Configuration makes no sense for an absent service
1156            (
1157                "Name: old\nState: !Absent\n  Image: !Local /opt/x.sqsh\n",
1158                "expected unit",
1159            ),
1160            // Image is mandatory for a service which must exist
1161            (
1162                "Name: llm\nState: !Running\n  Root: true\n",
1163                "missing field `Image`",
1164            ),
1165            // Schemes of enroot import only
1166            (
1167                "Name: llm\nState: !Running\n  Image: !Imported\n    Source: oras://ghcr.io/x/y\n    Path: /opt/x.sqsh\n",
1168                "supported schemes",
1169            ),
1170            (
1171                "Name: llm\nState: !Running\n  Image: !Imported\n    Source: docker://vllm/vllm-openai\n",
1172                "missing field `Path`",
1173            ),
1174            (
1175                "Name: llm\nState: !Running\n  Image: !Local x.sqsh\n",
1176                "absolute",
1177            ),
1178            (
1179                &format!("{}  Command: []\n", base),
1180                "Command can't be empty",
1181            ),
1182            // GPUs: valid IDs, non-empty list, variables owned by Gpus
1183            (&format!("{}  Gpus: !Devices []\n", base), "can't be empty"),
1184            (&format!("{}  Gpus: !Devices [gpu0]\n", base), "GPU-<uuid>"),
1185            (
1186                &format!(
1187                    "{}  Gpus: All\n  Env:\n    NVIDIA_VISIBLE_DEVICES: '0'\n",
1188                    base
1189                ),
1190                "set by Gpus",
1191            ),
1192            // Variables configuring enroot itself
1193            (
1194                &format!("{}  Env:\n    ENROOT_DATA_PATH: /tmp\n", base),
1195                "configure enroot",
1196            ),
1197            (
1198                &format!("{}  Env:\n    PATH: /bin\n", base),
1199                "configure enroot",
1200            ),
1201            (&format!("{}  Mounts: [/srv:srv]\n", base), "absolute"),
1202            (&format!("{}  Mounts: ['/srv:/srv:z']\n", base), "ro or rw"),
1203            (&format!("{}  Privileged: true\n", base), "unknown field"),
1204        ];
1205        for (raw, reason) in rejected {
1206            let error = yaml_serde::from_str::<EnrootExpectedState>(raw)
1207                .expect_err(&format!("should be rejected: {}", raw))
1208                .to_string();
1209            assert!(
1210                error.contains(reason),
1211                "{:?} rejected for another reason: {}",
1212                raw,
1213                error
1214            );
1215        }
1216
1217        // GPU variables are allowed without Gpus, and checked for the Rust API
1218        assert!(
1219            yaml_serde::from_str::<EnrootExpectedState>(&format!(
1220                "{}  Env:\n    NVIDIA_VISIBLE_DEVICES: '0'\n",
1221                base
1222            ))
1223            .is_ok()
1224        );
1225        let built = EnrootExpectedState::running(
1226            "llm".parse().unwrap(),
1227            EnrootServiceConfig::new(EnrootImage::Local("/opt/llm.sqsh".parse().unwrap()))
1228                .with_gpus(GpuSelection::All)
1229                .with_env(
1230                    "NVIDIA_DRIVER_CAPABILITIES".parse().unwrap(),
1231                    Parameter::Clear("all".to_string()),
1232                ),
1233        );
1234        assert!(built.check().is_err());
1235    }
1236
1237    #[test]
1238    fn exec_start_follows_enroot_cli() {
1239        let name = "vllm".parse().unwrap();
1240        assert_eq!(
1241            full_config().exec_start("/usr/bin/enroot", &name),
1242            vec![
1243                "/usr/bin/enroot",
1244                "start",
1245                "--root",
1246                "--rw",
1247                "--mount",
1248                "/srv/models:/root/.cache/huggingface:none:x-create=auto,rbind",
1249                "--mount",
1250                "/srv/config:/config:none:x-create=auto,rbind,ro",
1251                "--env",
1252                "HF_TOKEN",
1253                "--env",
1254                "TZ",
1255                "--env",
1256                "NVIDIA_VISIBLE_DEVICES",
1257                "--env",
1258                "NVIDIA_DRIVER_CAPABILITIES",
1259                "vllm",
1260                "vllm",
1261                "serve",
1262                "meta-llama/Llama-3.1-8B-Instruct",
1263                "--port",
1264                "8000",
1265            ]
1266        );
1267        let simple = EnrootServiceConfig::new(EnrootImage::Local("/opt/a.sqsh".parse().unwrap()));
1268        assert_eq!(
1269            simple.exec_start("/usr/bin/enroot", &name),
1270            vec!["/usr/bin/enroot", "start", "vllm"]
1271        );
1272    }
1273
1274    #[test]
1275    fn unit_and_environment_file_are_rendered() {
1276        let config = full_config();
1277        let unit = config.unit_content(
1278            &id(UnitScope::System),
1279            "/usr/bin/enroot",
1280            &Some("/root".to_string()),
1281        );
1282        assert!(unit.contains("\nEnvironment=HOME=/root\n"));
1283        assert!(unit.contains("\nEnvironmentFile=/etc/regent-sdk/enroot/vllm.env\n"));
1284        let unit = config.unit_content(&id(UnitScope::User), "/usr/bin/enroot", &None);
1285        assert!(!unit.contains("Environment=HOME"));
1286        assert!(unit.contains("\nEnvironmentFile=%E/regent-sdk/enroot/vllm.env\n"));
1287
1288        let config = EnrootServiceConfig::new(EnrootImage::Local("/opt/a.sqsh".parse().unwrap()))
1289            .with_gpus(GpuSelection::Devices(
1290                vec!["1".parse().unwrap(), "0".parse().unwrap()]
1291                    .try_into()
1292                    .unwrap(),
1293            ))
1294            .with_env(
1295                "HF_TOKEN".parse().unwrap(),
1296                Parameter::Clear("hf_\"x\"".to_string()),
1297            );
1298        assert_eq!(
1299            block_on(config.env_content(&None)).unwrap(),
1300            "# Managed by regent-sdk: manual changes will be overwritten
1301HF_TOKEN=\"hf_\\\"x\\\"\"
1302NVIDIA_VISIBLE_DEVICES=\"0,1\"
1303NVIDIA_DRIVER_CAPABILITIES=\"compute,utility\"
1304"
1305        );
1306    }
1307
1308    #[test]
1309    fn container_drift_is_detected_through_the_marker() {
1310        let image: AbsolutePath = "/opt/images/vllm-24.10.sqsh".parse().unwrap();
1311        let marker = marker_content(&image, "1234 1700000000");
1312        assert_eq!(
1313            marker,
1314            "image=/opt/images/vllm-24.10.sqsh\nidentity=1234 1700000000\n"
1315        );
1316        assert!(recreation_reasons(Some(&marker), &image, "1234 1700000000").is_empty());
1317        assert_eq!(
1318            recreation_reasons(None, &image, "1234 1700000000"),
1319            vec!["image can't be verified on a container which was not created by Regent"]
1320        );
1321        assert_eq!(
1322            recreation_reasons(Some(&marker), &image, "1234 1800000000"),
1323            vec!["image /opt/images/vllm-24.10.sqsh changed since the container was created"]
1324        );
1325        let other: AbsolutePath = "/opt/images/vllm-24.11.sqsh".parse().unwrap();
1326        assert_eq!(
1327            recreation_reasons(Some(&marker), &other, "1234 1700000000"),
1328            vec![
1329                "container was created from \"/opt/images/vllm-24.10.sqsh\", expected /opt/images/vllm-24.11.sqsh"
1330            ]
1331        );
1332
1333        assert_eq!(
1334            parse_identity("stat: warning\n1234 1700000000\n"),
1335            Some("1234 1700000000".to_string())
1336        );
1337        assert_eq!(parse_identity("stat: cannot stat"), None);
1338        let name = "vllm".parse().unwrap();
1339        assert!(is_listed("other\nvllm\n", &name));
1340        assert!(!is_listed("vllm2\n", &name));
1341    }
1342
1343    #[test]
1344    fn container_commands_follow_enroot_cli() {
1345        let image: AbsolutePath = "/opt/images/vllm.sqsh".parse().unwrap();
1346        assert_eq!(
1347            create_command(&id(UnitScope::System), &image, false),
1348            "enroot create --name 'vllm' '/opt/images/vllm.sqsh' && (umask 077 && mkdir -p /etc/regent-sdk/enroot && printf 'image=%s\\nidentity=%s\\n' '/opt/images/vllm.sqsh' \"$(stat -c '%s %Y' '/opt/images/vllm.sqsh')\" > /etc/regent-sdk/enroot/'vllm.image')"
1349        );
1350        assert!(
1351            create_command(&id(UnitScope::System), &image, true).starts_with(
1352                "{ 'systemctl' 'stop' 'regent-enroot-vllm.service' >/dev/null 2>&1; true; } && enroot remove --force 'vllm' && enroot create"
1353            )
1354        );
1355        assert_eq!(
1356            remove_command(&id(UnitScope::User), true),
1357            "enroot remove --force 'vllm' && rm -f \"${XDG_CONFIG_HOME:-$HOME/.config}\"/regent-sdk/enroot/'vllm.image'"
1358        );
1359        assert_eq!(
1360            import_command(&EnrootImportedImage {
1361                source: "docker://nvcr.io#nvidia/vllm:24.10-py3".parse().unwrap(),
1362                path: image,
1363            }),
1364            "mkdir -p '/opt/images' && enroot import -o '/opt/images/vllm.sqsh' 'docker://nvcr.io#nvidia/vllm:24.10-py3'"
1365        );
1366    }
1367
1368    #[test]
1369    fn remediations_never_show_secret_values() {
1370        let call = EnrootApiCall::from(
1371            EnrootInternalApiCall::WriteServiceFiles {
1372                unit: id(UnitScope::User),
1373                config: Box::new(full_config()),
1374                enroot_path: "/usr/bin/enroot".to_string(),
1375                home: None,
1376                reasons: vec!["environment file differs".to_string()],
1377            },
1378            Privilege::None,
1379        );
1380        assert_eq!(
1381            call.display(),
1382            "Write regent-enroot-vllm.service (user unit) and its environment file (environment file differs)"
1383        );
1384    }
1385
1386    #[test]
1387    fn choice_methods_match_the_explicit_enums() {
1388        let source = || {
1389            "docker://vllm/vllm-openai:v0.6.3"
1390                .parse::<EnrootImageUri>()
1391                .unwrap()
1392        };
1393        let path = || {
1394            "/opt/images/vllm-0.6.3.sqsh"
1395                .parse::<AbsolutePath>()
1396                .unwrap()
1397        };
1398        assert_eq!(EnrootImage::local(path()), EnrootImage::Local(path()));
1399        assert_eq!(
1400            EnrootServiceConfig::from_local_image(path()),
1401            EnrootServiceConfig::new(EnrootImage::Local(path()))
1402        );
1403        assert_eq!(
1404            EnrootServiceConfig::from_imported_image(source(), path()),
1405            EnrootServiceConfig::new(EnrootImage::Imported(EnrootImportedImage {
1406                source: source(),
1407                path: path(),
1408            }))
1409        );
1410
1411        let new = || EnrootServiceConfig::from_local_image(path());
1412        assert_eq!(new().with_all_gpus(), new().with_gpus(GpuSelection::All));
1413        let devices = GpuSelection::Devices(
1414            GpuList::try_from(vec!["0".parse().unwrap(), "MIG-1a2b".parse().unwrap()]).unwrap(),
1415        );
1416        assert_eq!(
1417            new()
1418                .with_all_gpus()
1419                .with_gpu_device("0".parse().unwrap())
1420                .with_gpu_device("MIG-1a2b".parse().unwrap()),
1421            new().with_gpus(devices)
1422        );
1423        assert_eq!(
1424            new().with_gpu_device("0".parse().unwrap()).with_all_gpus(),
1425            new().with_gpus(GpuSelection::All)
1426        );
1427
1428        for (config, policy) in [
1429            (new().with_restart_never(), UnitRestartPolicy::No),
1430            (
1431                new().with_restart_always().with_restart_on_failure(),
1432                UnitRestartPolicy::OnFailure,
1433            ),
1434            (new().with_restart_always(), UnitRestartPolicy::Always),
1435        ] {
1436            assert_eq!(config, new().with_restart_policy(policy));
1437        }
1438    }
1439}