1use 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,
25}
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29#[serde(rename_all = "PascalCase")]
30pub enum RestartPolicy {
31 No,
33 Always,
35 UnlessStopped,
37 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
105pub(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
118macro_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#[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
178pub(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#[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#[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#[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#[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#[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 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 pub(super) container_port: String,
381 pub(super) host_ip: String,
382 pub(super) host_port: String,
384}
385
386#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
389#[serde(try_from = "String", into = "String")]
390pub struct PublishedPort {
391 spec: String,
392 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#[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#[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#[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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
679#[serde(rename_all = "PascalCase")]
680pub enum GpuSelection {
681 All,
682 Devices(GpuList),
683}
684
685impl GpuSelection {
686 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 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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
797#[serde(rename_all = "PascalCase")]
798pub enum IpcNamespace {
799 Host,
801 Private(PrivateIpc),
803 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
873#[serde(try_from = "String", into = "String")]
874pub struct ExtraHost {
875 hostname: String,
876 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#[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
961pub(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}