Skip to main content

regent_sdk/state/attribute/container/
container_spec.rs

1//! Value types shared by the container attributes ([`docker`](super::docker),
2//! [`podman`](super::podman), [`apptainer`](super::apptainer) and [`enroot`](super::enroot))
3//!
4//! The Docker and Podman CLIs share the same syntax for ports, volumes, devices, memory sizes... Each type below
5//! can only hold a valid value: it is (de)serialized through its string form (`try_from`/`into`),
6//! so YAML/JSON input is validated while parsing, and Rust users build it with `FromStr`
7//! (`"...".parse()`) or `TryFrom`.
8
9use crate::error::RegentError;
10use crate::hosts::handlers::shell_quote;
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13use std::collections::BTreeSet;
14use std::fmt;
15use std::net::IpAddr;
16use std::str::FromStr;
17
18#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
19#[serde(rename_all = "PascalCase")]
20pub enum PullPolicy {
21    #[default]
22    Missing,
23    /// Never pull: a missing image makes the attribute non-compliant (fatal)
24    Never,
25}
26
27/// `--restart`
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29#[serde(rename_all = "PascalCase")]
30pub enum RestartPolicy {
31    /// `no`
32    No,
33    /// `always`
34    Always,
35    /// `unless-stopped`
36    UnlessStopped,
37    /// `on-failure[:max-retries]` (0 means unlimited retries)
38    OnFailure(u32),
39}
40
41impl RestartPolicy {
42    pub(super) fn as_cli_arg(&self) -> String {
43        match self {
44            RestartPolicy::No => "no".to_string(),
45            RestartPolicy::Always => "always".to_string(),
46            RestartPolicy::UnlessStopped => "unless-stopped".to_string(),
47            RestartPolicy::OnFailure(0) => "on-failure".to_string(),
48            RestartPolicy::OnFailure(max_retries) => format!("on-failure:{}", max_retries),
49        }
50    }
51
52    pub(super) fn matches(&self, name: &str, max_retries: u64) -> bool {
53        match self {
54            RestartPolicy::No => name.is_empty() || name == "no",
55            RestartPolicy::Always => name == "always",
56            RestartPolicy::UnlessStopped => name == "unless-stopped",
57            RestartPolicy::OnFailure(expected_retries) => {
58                name == "on-failure" && max_retries == *expected_retries as u64
59            }
60        }
61    }
62}
63
64pub(crate) fn quoted_command(args: &[&str]) -> String {
65    args.iter()
66        .map(|arg| shell_quote(arg))
67        .collect::<Vec<String>>()
68        .join(" ")
69}
70
71pub(super) fn pointer<'a>(value: &'a Value, path: &str) -> &'a Value {
72    value.pointer(path).unwrap_or(&Value::Null)
73}
74
75pub(super) fn string_list(value: &Value) -> Vec<String> {
76    value
77        .as_array()
78        .map(|items| {
79            items
80                .iter()
81                .filter_map(|item| item.as_str().map(String::from))
82                .collect()
83        })
84        .unwrap_or_default()
85}
86
87pub(super) fn to_strings(items: &[&str]) -> Vec<String> {
88    items.iter().map(|item| item.to_string()).collect()
89}
90
91pub(super) fn compare_sets(
92    reasons: &mut Vec<String>,
93    option_name: &str,
94    expected: BTreeSet<String>,
95    current: BTreeSet<String>,
96) {
97    if expected != current {
98        reasons.push(format!(
99            "{} are {:?}, expected {:?}",
100            option_name, current, expected
101        ));
102    }
103}
104
105/// Docker and Podman accept capabilities with or without the `CAP_` prefix, in any case
106pub(super) fn normalize_capability(capability: &str) -> String {
107    let capability = capability.to_uppercase();
108    match capability.trim_start_matches("CAP_") {
109        "ALL" => "ALL".to_string(),
110        name => format!("CAP_{}", name),
111    }
112}
113
114pub(crate) fn invalid(what: &str, value: &str, details: &str) -> RegentError {
115    RegentError::IncoherentExpectedState(format!("Invalid {} {:?}: {}", what, value, details))
116}
117
118/// Implements `FromStr`, `Display` and `From<Self> for String` for a type implementing
119/// `TryFrom<String>` and `as_string()`
120macro_rules! string_backed_type {
121    ($($type:ty),* $(,)?) => {$(
122        impl FromStr for $type {
123            type Err = RegentError;
124            fn from_str(value: &str) -> Result<Self, Self::Err> {
125                Self::try_from(value.to_string())
126            }
127        }
128
129        impl fmt::Display for $type {
130            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131                write!(f, "{}", self.as_string())
132            }
133        }
134
135        impl From<$type> for String {
136            fn from(value: $type) -> String {
137                value.as_string()
138            }
139        }
140    )*};
141}
142
143string_backed_type!(
144    ContainerName,
145    ContainerImage,
146    EnvVarName,
147    NetworkName,
148    PublishedPort,
149    ContainerVolume,
150    Capability,
151    MemorySize,
152    ExtraHost,
153    HostDevice,
154    GpuId,
155);
156
157/// Container name: `[a-zA-Z0-9][a-zA-Z0-9_.-]*`
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(try_from = "String", into = "String")]
160pub struct ContainerName(String);
161
162impl TryFrom<String> for ContainerName {
163    type Error = RegentError;
164
165    fn try_from(name: String) -> Result<Self, Self::Error> {
166        if is_valid_object_name(&name) {
167            Ok(Self(name))
168        } else {
169            Err(invalid(
170                "container name",
171                &name,
172                "allowed characters are [a-zA-Z0-9][a-zA-Z0-9_.-]*",
173            ))
174        }
175    }
176}
177
178/// Container (and pod) names: `[a-zA-Z0-9][a-zA-Z0-9_.-]*`
179pub(super) fn is_valid_object_name(name: &str) -> bool {
180    name.chars()
181        .next()
182        .is_some_and(|c| c.is_ascii_alphanumeric())
183        && name
184            .chars()
185            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-')
186}
187
188impl ContainerName {
189    pub fn as_str(&self) -> &str {
190        &self.0
191    }
192
193    fn as_string(&self) -> String {
194        self.0.clone()
195    }
196}
197
198/// Image reference (`[registry/]repository[:tag][@digest]`)
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(try_from = "String", into = "String")]
201pub struct ContainerImage(String);
202
203impl TryFrom<String> for ContainerImage {
204    type Error = RegentError;
205
206    fn try_from(image: String) -> Result<Self, Self::Error> {
207        if image.is_empty() || image.chars().any(char::is_whitespace) {
208            Err(invalid(
209                "image",
210                &image,
211                "must be non-empty and without whitespace",
212            ))
213        } else {
214            Ok(Self(image))
215        }
216    }
217}
218
219impl ContainerImage {
220    pub fn as_str(&self) -> &str {
221        &self.0
222    }
223
224    fn as_string(&self) -> String {
225        self.0.clone()
226    }
227}
228
229/// Omit the command to use the image default
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231#[serde(try_from = "Vec<String>", into = "Vec<String>")]
232pub struct ContainerCommand(Vec<String>);
233
234impl TryFrom<Vec<String>> for ContainerCommand {
235    type Error = RegentError;
236
237    fn try_from(command: Vec<String>) -> Result<Self, Self::Error> {
238        if command.is_empty() {
239            Err(RegentError::IncoherentExpectedState(
240                "Command can't be empty, omit it to use the image default command".to_string(),
241            ))
242        } else {
243            Ok(Self(command))
244        }
245    }
246}
247
248impl From<ContainerCommand> for Vec<String> {
249    fn from(command: ContainerCommand) -> Vec<String> {
250        command.0
251    }
252}
253
254impl ContainerCommand {
255    pub fn new(command: &[&str]) -> Result<Self, RegentError> {
256        Self::try_from(to_strings(command))
257    }
258
259    pub fn args(&self) -> &[String] {
260        &self.0
261    }
262}
263
264/// Environment variable name (non-empty, without `=`)
265#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
266#[serde(try_from = "String", into = "String")]
267pub struct EnvVarName(String);
268
269impl TryFrom<String> for EnvVarName {
270    type Error = RegentError;
271
272    fn try_from(name: String) -> Result<Self, Self::Error> {
273        if name.is_empty() || name.contains('=') {
274            Err(invalid(
275                "environment variable name",
276                &name,
277                "must be non-empty and without '='",
278            ))
279        } else {
280            Ok(Self(name))
281        }
282    }
283}
284
285impl EnvVarName {
286    fn as_string(&self) -> String {
287        self.0.clone()
288    }
289}
290
291/// Name of a network to attach the container to.
292///
293/// Network modes (`host`, `none`, `container:...`, and Podman's `slirp4netns`, `pasta`,
294/// `private`, `ns:...`) are not network names: `host` and `none` are expressed with the `Host`
295/// and `Isolated` networking variants of each attribute, the others are not supported.
296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
297#[serde(try_from = "String", into = "String")]
298pub struct NetworkName(String);
299
300impl TryFrom<String> for NetworkName {
301    type Error = RegentError;
302
303    fn try_from(name: String) -> Result<Self, Self::Error> {
304        if name.is_empty() {
305            Err(invalid("network name", &name, "must be non-empty"))
306        } else if name == "host" || name == "none" {
307            Err(invalid(
308                "network name",
309                &name,
310                "use the Host or Isolated networking instead",
311            ))
312        } else if ["slirp4netns", "pasta", "private"].contains(&name.as_str())
313            || name.starts_with("slirp4netns:")
314            || name.starts_with("pasta:")
315            || name.starts_with("ns:")
316            || name.starts_with("container:")
317        {
318            Err(invalid(
319                "network name",
320                &name,
321                "this is a network mode, not a network, and is not supported",
322            ))
323        } else {
324            Ok(Self(name))
325        }
326    }
327}
328
329impl NetworkName {
330    pub fn as_str(&self) -> &str {
331        &self.0
332    }
333
334    fn as_string(&self) -> String {
335        self.0.clone()
336    }
337}
338
339/// Non-empty list of networks (the first one is used at creation, the others are connected afterwards)
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
341#[serde(try_from = "Vec<NetworkName>", into = "Vec<NetworkName>")]
342pub struct NetworkList(Vec<NetworkName>);
343
344impl TryFrom<Vec<NetworkName>> for NetworkList {
345    type Error = RegentError;
346
347    fn try_from(networks: Vec<NetworkName>) -> Result<Self, Self::Error> {
348        if networks.is_empty() {
349            Err(RegentError::IncoherentExpectedState(
350                "Networks can't be empty, omit it to keep the default network".to_string(),
351            ))
352        } else {
353            Ok(Self(networks))
354        }
355    }
356}
357
358impl From<NetworkList> for Vec<NetworkName> {
359    fn from(networks: NetworkList) -> Vec<NetworkName> {
360        networks.0
361    }
362}
363
364impl NetworkList {
365    pub fn networks(&self) -> &[NetworkName] {
366        &self.0
367    }
368
369    /// Appends a network to a list, which is created if needed
370    pub(super) fn add(networks: Option<NetworkList>, network: NetworkName) -> NetworkList {
371        let mut networks = networks.map(Vec::from).unwrap_or_default();
372        networks.push(network);
373        NetworkList(networks)
374    }
375}
376
377#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
378pub(super) struct PortBinding {
379    /// As found in `HostConfig.PortBindings` keys, e.g. `80/tcp`
380    pub(super) container_port: String,
381    pub(super) host_ip: String,
382    /// Empty when the engine picks a random port
383    pub(super) host_port: String,
384}
385
386/// Published port(s), CLI syntax: `[ip:][host_port:]container_port[/protocol]`.
387/// Port ranges (`8000-8010:8000-8010`) are supported.
388#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
389#[serde(try_from = "String", into = "String")]
390pub struct PublishedPort {
391    spec: String,
392    /// Bindings as the engines store them (ranges expanded)
393    bindings: Vec<PortBinding>,
394}
395
396impl TryFrom<String> for PublishedPort {
397    type Error = RegentError;
398
399    fn try_from(spec: String) -> Result<Self, Self::Error> {
400        let error = |details: &str| invalid("port", &spec, details);
401
402        let (mapping, protocol) = match spec.rsplit_once('/') {
403            Some((mapping, protocol)) => (mapping, protocol.to_lowercase()),
404            None => (spec.as_str(), "tcp".to_string()),
405        };
406        if !["tcp", "udp", "sctp"].contains(&protocol.as_str()) {
407            return Err(error("protocol must be tcp, udp or sctp"));
408        }
409
410        let (host_part, container_part) = match mapping.rsplit_once(':') {
411            Some((host_part, container_part)) => (Some(host_part), container_part),
412            None => (None, mapping),
413        };
414        let (host_ip, host_port_part) = match host_part {
415            Some(host_part) => match host_part.rsplit_once(':') {
416                Some((host_ip, host_port)) => (
417                    host_ip.trim_start_matches('[').trim_end_matches(']'),
418                    host_port,
419                ),
420                None => ("", host_part),
421            },
422            None => ("", ""),
423        };
424        if !host_ip.is_empty() && host_ip.parse::<IpAddr>().is_err() {
425            return Err(error("invalid host IP address"));
426        }
427
428        let parse_range = |range: &str| -> Result<(u16, u16), RegentError> {
429            let (start, end) = range.split_once('-').unwrap_or((range, range));
430            let start: u16 = start.parse().map_err(|_| error("invalid port number"))?;
431            let end: u16 = end.parse().map_err(|_| error("invalid port number"))?;
432            if start == 0 || end < start {
433                return Err(error("invalid port range"));
434            }
435            Ok((start, end))
436        };
437
438        let (container_start, container_end) = parse_range(container_part)?;
439        let container_ports: Vec<u16> = (container_start..=container_end).collect();
440
441        let host_ports: Vec<String> = if host_port_part.is_empty() {
442            vec![String::new(); container_ports.len()]
443        } else {
444            let (host_start, host_end) = parse_range(host_port_part)?;
445            if host_end - host_start != container_end - container_start {
446                return Err(error(
447                    "host and container port ranges must have the same size",
448                ));
449            }
450            (host_start..=host_end)
451                .map(|port| port.to_string())
452                .collect()
453        };
454
455        let bindings = container_ports
456            .into_iter()
457            .zip(host_ports)
458            .map(|(container_port, host_port)| PortBinding {
459                container_port: format!("{}/{}", container_port, protocol),
460                host_ip: host_ip.to_string(),
461                host_port,
462            })
463            .collect();
464
465        Ok(Self { spec, bindings })
466    }
467}
468
469impl PublishedPort {
470    pub(super) fn bindings(&self) -> &[PortBinding] {
471        &self.bindings
472    }
473
474    fn as_string(&self) -> String {
475        self.spec.clone()
476    }
477}
478
479/// Volume, CLI syntax: `source:target[:options]` (bind mount or named volume) or
480/// `target` (anonymous volume). The target must be an absolute path.
481#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
482#[serde(try_from = "String", into = "String")]
483pub enum ContainerVolume {
484    Bind {
485        source: String,
486        target: String,
487        options: Option<String>,
488    },
489    Anonymous {
490        target: String,
491    },
492}
493
494impl TryFrom<String> for ContainerVolume {
495    type Error = RegentError;
496
497    fn try_from(spec: String) -> Result<Self, Self::Error> {
498        let parts: Vec<&str> = spec.split(':').collect();
499        let volume = match parts.as_slice() {
500            [target] => ContainerVolume::Anonymous {
501                target: target.to_string(),
502            },
503            [source, target] => ContainerVolume::Bind {
504                source: source.to_string(),
505                target: target.to_string(),
506                options: None,
507            },
508            [source, target, options] if !options.is_empty() => ContainerVolume::Bind {
509                source: source.to_string(),
510                target: target.to_string(),
511                options: Some(options.to_string()),
512            },
513            _ => {
514                return Err(invalid(
515                    "volume",
516                    &spec,
517                    "expected source:target[:options] or target",
518                ));
519            }
520        };
521
522        let (source, target) = match &volume {
523            ContainerVolume::Bind { source, target, .. } => (Some(source), target),
524            ContainerVolume::Anonymous { target } => (None, target),
525        };
526        if source.is_some_and(|source| source.is_empty()) {
527            return Err(invalid("volume", &spec, "source can't be empty"));
528        }
529        if !target.starts_with('/') {
530            return Err(invalid("volume", &spec, "target must be an absolute path"));
531        }
532
533        Ok(volume)
534    }
535}
536
537impl ContainerVolume {
538    fn as_string(&self) -> String {
539        match self {
540            ContainerVolume::Bind {
541                source,
542                target,
543                options: Some(options),
544            } => format!("{}:{}:{}", source, target, options),
545            ContainerVolume::Bind {
546                source,
547                target,
548                options: None,
549            } => format!("{}:{}", source, target),
550            ContainerVolume::Anonymous { target } => target.clone(),
551        }
552    }
553}
554
555/// Linux capability, normalized with the `CAP_` prefix (`CAP_NET_ADMIN`, `ALL`).
556/// `net_admin`, `NET_ADMIN` and `CAP_NET_ADMIN` are all accepted.
557#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
558#[serde(try_from = "String", into = "String")]
559pub struct Capability(String);
560
561impl TryFrom<String> for Capability {
562    type Error = RegentError;
563
564    fn try_from(capability: String) -> Result<Self, Self::Error> {
565        let normalized = normalize_capability(&capability);
566        let name = normalized.trim_start_matches("CAP_");
567        if name.is_empty()
568            || !name
569                .chars()
570                .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
571        {
572            Err(invalid(
573                "capability",
574                &capability,
575                "expected a name like NET_ADMIN",
576            ))
577        } else {
578            Ok(Self(normalized))
579        }
580    }
581}
582
583impl Capability {
584    fn as_string(&self) -> String {
585        self.0.clone()
586    }
587}
588
589/// Memory size, CLI syntax (binary units, case-insensitive): `512m`, `1.5g`, `1048576`...
590#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
591#[serde(try_from = "String", into = "String")]
592pub struct MemorySize {
593    spec: String,
594    bytes: i64,
595}
596
597impl TryFrom<String> for MemorySize {
598    type Error = RegentError;
599
600    fn try_from(spec: String) -> Result<Self, Self::Error> {
601        let error = || invalid("memory size", &spec, "expected e.g. 512m, 2g");
602        let lowercase = spec.trim().to_lowercase();
603        let unit_start = lowercase
604            .find(|c: char| !(c.is_ascii_digit() || c == '.'))
605            .unwrap_or(lowercase.len());
606        let (number, unit) = lowercase.split_at(unit_start);
607        let number: f64 = number.parse().map_err(|_| error())?;
608        if unit.len() > 2 {
609            return Err(error());
610        }
611        let multiplier: f64 = match unit.trim_end_matches('b') {
612            "" => 1.0,
613            "k" => 1024.0,
614            "m" => 1024.0_f64.powi(2),
615            "g" => 1024.0_f64.powi(3),
616            "t" => 1024.0_f64.powi(4),
617            "p" => 1024.0_f64.powi(5),
618            _ => return Err(error()),
619        };
620        let bytes = (number * multiplier) as i64;
621        if bytes <= 0 {
622            return Err(error());
623        }
624        Ok(Self { spec, bytes })
625    }
626}
627
628impl MemorySize {
629    pub fn bytes(&self) -> i64 {
630        self.bytes
631    }
632
633    fn as_string(&self) -> String {
634        self.spec.clone()
635    }
636}
637
638/// Number of CPUs, strictly positive (e.g. `1.5`)
639#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
640#[serde(try_from = "f64", into = "f64")]
641pub struct CpuCount(f64);
642
643impl TryFrom<f64> for CpuCount {
644    type Error = RegentError;
645
646    fn try_from(cpus: f64) -> Result<Self, Self::Error> {
647        if cpus.is_finite() && cpus > 0.0 {
648            Ok(Self(cpus))
649        } else {
650            Err(invalid(
651                "number of CPUs",
652                &cpus.to_string(),
653                "must be strictly positive",
654            ))
655        }
656    }
657}
658
659impl From<CpuCount> for f64 {
660    fn from(cpus: CpuCount) -> f64 {
661        cpus.0
662    }
663}
664
665impl fmt::Display for CpuCount {
666    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
667        write!(f, "{}", self.0)
668    }
669}
670
671impl CpuCount {
672    pub(super) fn nano_cpus(&self) -> i64 {
673        (self.0 * 1e9).round() as i64
674    }
675}
676
677/// Requires the NVIDIA Container Toolkit on the host
678#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
679#[serde(rename_all = "PascalCase")]
680pub enum GpuSelection {
681    All,
682    Devices(GpuList),
683}
684
685impl GpuSelection {
686    /// GPU identifiers, `all` included, sorted and deduplicated
687    pub(super) fn ids(&self) -> BTreeSet<String> {
688        match self {
689            GpuSelection::All => BTreeSet::from(["all".to_string()]),
690            GpuSelection::Devices(gpus) => gpus.0.iter().map(|gpu| gpu.0.clone()).collect(),
691        }
692    }
693
694    /// Appends a device to a selection: `All` (or no selection) is replaced by this device
695    pub(super) fn add_device(selection: Option<GpuSelection>, gpu: GpuId) -> GpuSelection {
696        let mut gpus = match selection {
697            Some(GpuSelection::Devices(gpus)) => gpus.0,
698            Some(GpuSelection::All) | None => Vec::new(),
699        };
700        gpus.push(gpu);
701        GpuSelection::Devices(GpuList(gpus))
702    }
703}
704
705/// GPU identifier: an index (`0`), a GPU UUID (`GPU-<uuid>`) or a MIG device (`MIG-<uuid>`)
706///
707/// Indexes can be written as YAML integers (`Devices: [0, 1]`).
708#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
709#[serde(try_from = "GpuIdRepr", into = "String")]
710pub struct GpuId(String);
711
712#[derive(Deserialize)]
713#[serde(untagged)]
714enum GpuIdRepr {
715    Index(u32),
716    Id(String),
717}
718
719impl TryFrom<GpuIdRepr> for GpuId {
720    type Error = RegentError;
721
722    fn try_from(repr: GpuIdRepr) -> Result<Self, Self::Error> {
723        match repr {
724            GpuIdRepr::Index(index) => Ok(Self(index.to_string())),
725            GpuIdRepr::Id(id) => Self::try_from(id),
726        }
727    }
728}
729
730impl TryFrom<String> for GpuId {
731    type Error = RegentError;
732
733    fn try_from(id: String) -> Result<Self, Self::Error> {
734        let is_index = !id.is_empty() && id.chars().all(|c| c.is_ascii_digit());
735        let is_uuid = ["GPU-", "MIG-"].iter().any(|prefix| {
736            id.strip_prefix(prefix).is_some_and(|uuid| {
737                !uuid.is_empty()
738                    && uuid
739                        .chars()
740                        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '/')
741            })
742        });
743        if is_index || is_uuid {
744            Ok(Self(id))
745        } else {
746            Err(invalid(
747                "GPU",
748                &id,
749                "expected an index (0), GPU-<uuid> or MIG-<uuid>",
750            ))
751        }
752    }
753}
754
755impl GpuId {
756    fn as_string(&self) -> String {
757        self.0.clone()
758    }
759}
760
761#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
762#[serde(try_from = "Vec<GpuId>", into = "Vec<GpuId>")]
763pub struct GpuList(Vec<GpuId>);
764
765impl TryFrom<Vec<GpuId>> for GpuList {
766    type Error = RegentError;
767
768    fn try_from(gpus: Vec<GpuId>) -> Result<Self, Self::Error> {
769        if gpus.is_empty() {
770            Err(RegentError::IncoherentExpectedState(
771                "GPU list can't be empty, use All or remove the option".to_string(),
772            ))
773        } else {
774            Ok(Self(gpus))
775        }
776    }
777}
778
779impl From<GpuList> for Vec<GpuId> {
780    fn from(gpus: GpuList) -> Vec<GpuId> {
781        gpus.0
782    }
783}
784
785impl GpuList {
786    pub fn gpus(&self) -> &[GpuId] {
787        &self.0
788    }
789}
790
791/// IPC namespace of the container (`--ipc`)
792///
793/// Inference servers (vLLM, PyTorch dataloaders, Triton) exchange tensors through shared
794/// memory: either share the host IPC namespace, or give the container a bigger `/dev/shm`
795/// (64 MiB by default). The size of `/dev/shm` can only be set on a private namespace.
796#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
797#[serde(rename_all = "PascalCase")]
798pub enum IpcNamespace {
799    /// Share the host IPC namespace and `/dev/shm` (`--ipc host`)
800    Host,
801    /// Own IPC namespace (`--ipc private`)
802    Private(PrivateIpc),
803    /// Own IPC namespace which other containers can join (`--ipc shareable`)
804    Shareable(PrivateIpc),
805}
806
807impl IpcNamespace {
808    pub(super) fn mode(&self) -> &'static str {
809        match self {
810            IpcNamespace::Host => "host",
811            IpcNamespace::Private(_) => "private",
812            IpcNamespace::Shareable(_) => "shareable",
813        }
814    }
815
816    pub(super) fn shm_size(&self) -> Option<&MemorySize> {
817        match self {
818            IpcNamespace::Host => None,
819            IpcNamespace::Private(ipc) | IpcNamespace::Shareable(ipc) => ipc.shm_size.as_ref(),
820        }
821    }
822
823    /// Settings of the container's own namespace, kept when switching between `Private` and
824    /// `Shareable` (default settings when the namespace was `Host` or not set)
825    fn own_settings(ipc: Option<IpcNamespace>) -> PrivateIpc {
826        match ipc {
827            Some(IpcNamespace::Private(ipc) | IpcNamespace::Shareable(ipc)) => ipc,
828            Some(IpcNamespace::Host) | None => PrivateIpc::new(),
829        }
830    }
831
832    pub(super) fn private(ipc: Option<IpcNamespace>) -> IpcNamespace {
833        IpcNamespace::Private(Self::own_settings(ipc))
834    }
835
836    pub(super) fn shareable(ipc: Option<IpcNamespace>) -> IpcNamespace {
837        IpcNamespace::Shareable(Self::own_settings(ipc))
838    }
839
840    /// Sets the size of `/dev/shm`, keeping a `Shareable` namespace shareable: `Host` (or no
841    /// namespace) is replaced by a `Private` namespace
842    pub(super) fn with_shm_size(ipc: Option<IpcNamespace>, shm_size: MemorySize) -> IpcNamespace {
843        match ipc {
844            Some(IpcNamespace::Shareable(ipc)) => {
845                IpcNamespace::Shareable(ipc.with_shm_size(shm_size))
846            }
847            other => IpcNamespace::Private(Self::own_settings(other).with_shm_size(shm_size)),
848        }
849    }
850}
851
852#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
853#[serde(deny_unknown_fields)]
854#[serde(rename_all = "PascalCase")]
855pub struct PrivateIpc {
856    /// Size of `/dev/shm` (`--shm-size`, not managed if omitted)
857    shm_size: Option<MemorySize>,
858}
859
860impl PrivateIpc {
861    pub fn new() -> Self {
862        Self::default()
863    }
864
865    pub fn with_shm_size(mut self, shm_size: MemorySize) -> Self {
866        self.shm_size = Some(shm_size);
867        self
868    }
869}
870
871/// Custom host-to-IP mapping: `hostname:ip` (`ip` can also be `host-gateway`)
872#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
873#[serde(try_from = "String", into = "String")]
874pub struct ExtraHost {
875    hostname: String,
876    /// IP address in canonical form, or `host-gateway`
877    address: String,
878}
879
880impl TryFrom<String> for ExtraHost {
881    type Error = RegentError;
882
883    fn try_from(spec: String) -> Result<Self, Self::Error> {
884        let error = || invalid("extra host", &spec, "expected hostname:ip");
885        let (hostname, address) = spec.split_once(':').ok_or_else(error)?;
886        let address = if address == "host-gateway" {
887            address.to_string()
888        } else {
889            address
890                .trim_start_matches('[')
891                .trim_end_matches(']')
892                .parse::<IpAddr>()
893                .map_err(|_| error())?
894                .to_string()
895        };
896        if hostname.is_empty() {
897            return Err(error());
898        }
899        Ok(Self {
900            hostname: hostname.to_string(),
901            address,
902        })
903    }
904}
905
906impl ExtraHost {
907    fn as_string(&self) -> String {
908        format!("{}:{}", self.hostname, self.address)
909    }
910}
911
912/// Host device, CLI syntax: `host_path[:container_path[:permissions]]`
913/// (container path defaults to the host path, permissions to `rwm`)
914#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
915#[serde(try_from = "String", into = "String")]
916pub struct HostDevice {
917    host_path: String,
918    container_path: String,
919    permissions: String,
920}
921
922impl TryFrom<String> for HostDevice {
923    type Error = RegentError;
924
925    fn try_from(spec: String) -> Result<Self, Self::Error> {
926        let parts: Vec<&str> = spec.split(':').collect();
927        let (host_path, container_path, permissions) = match parts.as_slice() {
928            [host_path] => (*host_path, *host_path, "rwm"),
929            [host_path, container_path] => (*host_path, *container_path, "rwm"),
930            [host_path, container_path, permissions] => (*host_path, *container_path, *permissions),
931            _ => ("", "", ""),
932        };
933        if !host_path.starts_with('/')
934            || !container_path.starts_with('/')
935            || permissions.is_empty()
936            || !permissions.chars().all(|c| "rwm".contains(c))
937        {
938            return Err(invalid(
939                "device",
940                &spec,
941                "expected /host/path[:/container/path[:permissions]]",
942            ));
943        }
944        Ok(Self {
945            host_path: host_path.to_string(),
946            container_path: container_path.to_string(),
947            permissions: permissions.to_string(),
948        })
949    }
950}
951
952impl HostDevice {
953    fn as_string(&self) -> String {
954        format!(
955            "{}:{}:{}",
956            self.host_path, self.container_path, self.permissions
957        )
958    }
959}
960
961/// `docker|podman container inspect` returns a JSON array. Anything before it (warnings) is skipped.
962pub(super) fn parse_inspect_output(output: &str) -> Option<Value> {
963    let start = output.find('[')?;
964    let array: Value = serde_json::Deserializer::from_str(&output[start..])
965        .into_iter::<Value>()
966        .next()?
967        .ok()?;
968    array.as_array()?.first().cloned()
969}
970
971#[cfg(test)]
972mod tests {
973    use super::*;
974
975    #[test]
976    fn port_specs_are_parsed_like_the_cli() {
977        let bindings = |spec: &str| spec.parse::<PublishedPort>().map(|port| port.bindings);
978        let binding = |container_port: &str, host_ip: &str, host_port: &str| PortBinding {
979            container_port: container_port.to_string(),
980            host_ip: host_ip.to_string(),
981            host_port: host_port.to_string(),
982        };
983        assert_eq!(bindings("80").unwrap(), vec![binding("80/tcp", "", "")]);
984        assert_eq!(
985            bindings("8080:80/udp").unwrap(),
986            vec![binding("80/udp", "", "8080")]
987        );
988        assert_eq!(
989            bindings("127.0.0.1::80").unwrap(),
990            vec![binding("80/tcp", "127.0.0.1", "")]
991        );
992        assert_eq!(
993            bindings("[::1]:8080:80").unwrap(),
994            vec![binding("80/tcp", "::1", "8080")]
995        );
996        assert_eq!(
997            bindings("8000-8001:9000-9001").unwrap(),
998            vec![
999                binding("9000/tcp", "", "8000"),
1000                binding("9001/tcp", "", "8001")
1001            ]
1002        );
1003        assert!(bindings("8000-8002:9000-9001").is_err());
1004        assert!(bindings("80:80/icmp").is_err());
1005        assert!(bindings("http").is_err());
1006        assert!(bindings("localhost:8080:80").is_err());
1007    }
1008
1009    #[test]
1010    fn value_types_are_normalized() {
1011        let bytes = |spec: &str| spec.parse::<MemorySize>().map(|memory| memory.bytes());
1012        assert_eq!(bytes("1024").unwrap(), 1024);
1013        assert_eq!(bytes("512m").unwrap(), 512 * 1024 * 1024);
1014        assert_eq!(bytes("512MB").unwrap(), 512 * 1024 * 1024);
1015        assert_eq!(bytes("1.5g").unwrap(), 1536 * 1024 * 1024);
1016        assert!(bytes("12x").is_err());
1017        assert!(bytes("").is_err());
1018
1019        let capability = |spec: &str| spec.parse::<Capability>().unwrap().to_string();
1020        assert_eq!(capability("net_admin"), "CAP_NET_ADMIN");
1021        assert_eq!(capability("CAP_NET_ADMIN"), "CAP_NET_ADMIN");
1022        assert_eq!(capability("all"), "ALL");
1023
1024        let device = |spec: &str| spec.parse::<HostDevice>().unwrap().to_string();
1025        assert_eq!(device("/dev/snd"), "/dev/snd:/dev/snd:rwm");
1026        assert_eq!(device("/dev/sda:/dev/xvda:r"), "/dev/sda:/dev/xvda:r");
1027    }
1028
1029    #[test]
1030    fn inspect_output_is_parsed_despite_warnings() {
1031        let output = "WARNING: something\n[{\"Name\": \"/web\"}]\n";
1032        assert_eq!(
1033            parse_inspect_output(output).unwrap(),
1034            serde_json::json!({ "Name": "/web" })
1035        );
1036        assert!(parse_inspect_output("[]").is_none());
1037    }
1038}