1use crate::error::RegentError;
106use crate::hosts::managed_host::InternalApiCallOutcome;
107use crate::hosts::managed_host::{AssessCompliance, ReachCompliance, Timeout};
108use crate::hosts::properties::{HostProperties, OsKind};
109use crate::secrets::SecretProvidersPool;
110use crate::state::Check;
111use crate::state::attribute::HostHandler;
112use crate::state::attribute::Privilege;
113use crate::state::attribute::Remediation;
114use crate::state::attribute::RemediationsList;
115use crate::state::compliance::AttributeComplianceAssessment;
116use crate::state::expected_state::Parameter;
117use serde::{Deserialize, Serialize};
118use serde_json::Value;
119use std::collections::{BTreeMap, BTreeSet};
120use std::net::IpAddr;
121use std::str::FromStr;
122use std::time::Duration;
123
124use super::container_spec::{
125 Capability, ContainerCommand, ContainerImage, ContainerName, ContainerVolume, CpuCount,
126 EnvVarName, ExtraHost, GpuId, GpuSelection, HostDevice, IpcNamespace, MemorySize, NetworkList,
127 NetworkName, PortBinding, PublishedPort, PullPolicy, RestartPolicy, compare_sets, invalid,
128 normalize_capability, parse_inspect_output, pointer, quoted_command, string_list, to_strings,
129};
130
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132#[serde(deny_unknown_fields)]
133#[serde(rename_all = "PascalCase")]
134pub struct DockerExpectedState {
135 name: ContainerName,
136 state: DockerContainerState,
137}
138
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
140#[serde(rename_all = "PascalCase")]
141pub enum DockerContainerState {
142 Running(Box<DockerContainerConfig>),
143 Stopped(Box<DockerContainerConfig>),
144 Absent,
145}
146
147impl DockerExpectedState {
148 pub fn running(name: ContainerName, config: DockerContainerConfig) -> Self {
149 Self {
150 name,
151 state: DockerContainerState::Running(Box::new(config)),
152 }
153 }
154
155 pub fn stopped(name: ContainerName, config: DockerContainerConfig) -> Self {
156 Self {
157 name,
158 state: DockerContainerState::Stopped(Box::new(config)),
159 }
160 }
161
162 pub fn absent(name: ContainerName) -> Self {
163 Self {
164 name,
165 state: DockerContainerState::Absent,
166 }
167 }
168}
169
170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
171#[serde(rename_all = "PascalCase")]
172pub enum DockerNetworking {
173 Host,
175 Isolated,
177 Attached(DockerAttachedNetworking),
179}
180
181impl DockerNetworking {
182 pub fn attached(networks: Option<NetworkList>, ports: Option<Vec<PublishedPort>>) -> Self {
183 DockerNetworking::Attached(DockerAttachedNetworking { networks, ports })
184 }
185}
186
187#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
188#[serde(deny_unknown_fields)]
189#[serde(rename_all = "PascalCase")]
190pub struct DockerAttachedNetworking {
191 networks: Option<NetworkList>,
192 ports: Option<Vec<PublishedPort>>,
193}
194
195#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
197#[serde(deny_unknown_fields)]
198#[serde(rename_all = "PascalCase")]
199pub struct HealthCheck {
200 test: HealthCheckTest,
201 interval: Option<HealthDuration>,
202 timeout: Option<HealthDuration>,
203 start_period: Option<HealthDuration>,
204 start_interval: Option<HealthDuration>,
205 retries: Option<u32>,
206}
207
208impl HealthCheck {
209 pub fn new(test: HealthCheckTest) -> Self {
210 Self {
211 test,
212 interval: None,
213 timeout: None,
214 start_period: None,
215 start_interval: None,
216 retries: None,
217 }
218 }
219
220 pub fn with_interval(mut self, interval: HealthDuration) -> Self {
221 self.interval = Some(interval);
222 self
223 }
224
225 pub fn with_timeout(mut self, timeout: HealthDuration) -> Self {
226 self.timeout = Some(timeout);
227 self
228 }
229
230 pub fn with_start_period(mut self, start_period: HealthDuration) -> Self {
231 self.start_period = Some(start_period);
232 self
233 }
234
235 pub fn with_start_interval(mut self, start_interval: HealthDuration) -> Self {
236 self.start_interval = Some(start_interval);
237 self
238 }
239
240 pub fn with_retries(mut self, retries: u32) -> Self {
241 self.retries = Some(retries);
242 self
243 }
244}
245
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
247#[serde(try_from = "String", into = "String")]
248pub struct HealthCheckTest(String);
249
250impl TryFrom<String> for HealthCheckTest {
251 type Error = RegentError;
252
253 fn try_from(test: String) -> Result<Self, Self::Error> {
254 if test.trim().is_empty() {
255 Err(invalid("health check test", &test, "must be non-empty"))
256 } else {
257 Ok(Self(test))
258 }
259 }
260}
261
262impl HealthCheckTest {
263 fn as_string(&self) -> String {
264 self.0.clone()
265 }
266}
267
268impl std::fmt::Display for HealthCheckTest {
269 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270 write!(f, "{}", self.as_string())
271 }
272}
273
274impl FromStr for HealthCheckTest {
275 type Err = RegentError;
276
277 fn from_str(test: &str) -> Result<Self, Self::Err> {
278 Self::try_from(test.to_string())
279 }
280}
281
282impl From<HealthCheckTest> for String {
283 fn from(test: HealthCheckTest) -> String {
284 test.as_string()
285 }
286}
287
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290#[serde(try_from = "String", into = "String")]
291pub struct HealthDuration {
292 spec: String,
293 nanos: i64,
294}
295
296impl TryFrom<String> for HealthDuration {
297 type Error = RegentError;
298
299 fn try_from(spec: String) -> Result<Self, Self::Error> {
300 let error = || {
301 invalid(
302 "health check duration",
303 &spec,
304 "expected a Go duration like 30s, 1m30s or 500ms",
305 )
306 };
307 let mut nanos: f64 = 0.0;
309 let mut rest = spec.as_str();
310 let mut pairs = 0;
311 while !rest.is_empty() {
312 let number_len = rest
313 .find(|c: char| !(c.is_ascii_digit() || c == '.'))
314 .unwrap_or(rest.len());
315 if number_len == 0 {
316 return Err(error());
317 }
318 let number: f64 = rest[..number_len].parse().map_err(|_| error())?;
319 rest = &rest[number_len..];
320 let unit_len = rest
321 .find(|c: char| c.is_ascii_digit() || c == '.')
322 .unwrap_or(rest.len());
323 let (unit, tail) = rest.split_at(unit_len);
324 let multiplier: f64 = match unit {
325 "ns" => 1.0,
326 "us" | "µs" | "μs" => 1e3,
327 "ms" => 1e6,
328 "s" => 1e9,
329 "m" => 60e9,
330 "h" => 3600e9,
331 _ => return Err(error()),
332 };
333 nanos += number * multiplier;
334 rest = tail;
335 pairs += 1;
336 }
337 let nanos = nanos.round() as i64;
338 if pairs == 0 || nanos <= 0 {
339 return Err(error());
340 }
341 Ok(Self { spec, nanos })
342 }
343}
344
345impl HealthDuration {
346 fn as_string(&self) -> String {
347 self.spec.clone()
348 }
349
350 fn nanos(&self) -> i64 {
351 self.nanos
352 }
353}
354
355impl std::fmt::Display for HealthDuration {
356 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
357 write!(f, "{}", self.as_string())
358 }
359}
360
361impl FromStr for HealthDuration {
362 type Err = RegentError;
363
364 fn from_str(spec: &str) -> Result<Self, Self::Err> {
365 Self::try_from(spec.to_string())
366 }
367}
368
369impl From<HealthDuration> for String {
370 fn from(duration: HealthDuration) -> String {
371 duration.as_string()
372 }
373}
374
375#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
377#[serde(deny_unknown_fields)]
378#[serde(rename_all = "PascalCase")]
379pub struct DockerContainerConfig {
380 image: ContainerImage,
381 #[serde(default)]
382 pull: PullPolicy,
383 command: Option<ContainerCommand>,
384 entrypoint: Option<String>,
385 env: Option<BTreeMap<EnvVarName, Parameter<String>>>,
386 volumes: Option<Vec<ContainerVolume>>,
387 networking: Option<DockerNetworking>,
388 restart_policy: Option<RestartPolicy>,
389 hostname: Option<String>,
390 user: Option<String>,
392 working_dir: Option<String>,
393 labels: Option<BTreeMap<String, String>>,
394 privileged: Option<bool>,
395 read_only: Option<bool>,
396 init: Option<bool>,
397 cap_add: Option<Vec<Capability>>,
398 cap_drop: Option<Vec<Capability>>,
399 memory: Option<MemorySize>,
400 cpus: Option<CpuCount>,
401 extra_hosts: Option<Vec<ExtraHost>>,
402 dns: Option<Vec<IpAddr>>,
403 devices: Option<Vec<HostDevice>>,
404 gpus: Option<GpuSelection>,
406 ipc: Option<IpcNamespace>,
407 health_check: Option<HealthCheck>,
408 log_driver: Option<String>,
409 log_options: Option<BTreeMap<String, String>>,
410}
411
412impl DockerContainerConfig {
413 pub fn new(image: ContainerImage) -> Self {
414 Self {
415 image,
416 pull: PullPolicy::default(),
417 command: None,
418 entrypoint: None,
419 env: None,
420 volumes: None,
421 networking: None,
422 restart_policy: None,
423 hostname: None,
424 user: None,
425 working_dir: None,
426 labels: None,
427 privileged: None,
428 read_only: None,
429 init: None,
430 cap_add: None,
431 cap_drop: None,
432 memory: None,
433 cpus: None,
434 extra_hosts: None,
435 dns: None,
436 devices: None,
437 gpus: None,
438 ipc: None,
439 health_check: None,
440 log_driver: None,
441 log_options: None,
442 }
443 }
444
445 pub fn with_pull_policy(mut self, pull: PullPolicy) -> Self {
446 self.pull = pull;
447 self
448 }
449
450 pub fn with_pull_if_missing(self) -> Self {
452 self.with_pull_policy(PullPolicy::Missing)
453 }
454
455 pub fn with_pull_never(self) -> Self {
457 self.with_pull_policy(PullPolicy::Never)
458 }
459
460 pub fn with_command(mut self, command: ContainerCommand) -> Self {
461 self.command = Some(command);
462 self
463 }
464
465 pub fn with_entrypoint(mut self, entrypoint: &str) -> Self {
466 self.entrypoint = Some(entrypoint.to_string());
467 self
468 }
469
470 pub fn with_env(mut self, key: EnvVarName, value: Parameter<String>) -> Self {
471 self.env
472 .get_or_insert_with(BTreeMap::new)
473 .insert(key, value);
474 self
475 }
476
477 pub fn with_volumes(mut self, volumes: Vec<ContainerVolume>) -> Self {
478 self.volumes = Some(volumes);
479 self
480 }
481
482 pub fn with_networking(mut self, networking: DockerNetworking) -> Self {
483 self.networking = Some(networking);
484 self
485 }
486
487 pub fn with_host_networking(self) -> Self {
490 self.with_networking(DockerNetworking::Host)
491 }
492
493 pub fn with_isolated_networking(self) -> Self {
496 self.with_networking(DockerNetworking::Isolated)
497 }
498
499 pub fn with_network(mut self, network: NetworkName) -> Self {
502 let mut attached = self.take_attached_networking();
503 attached.networks = Some(NetworkList::add(attached.networks, network));
504 self.with_networking(DockerNetworking::Attached(attached))
505 }
506
507 pub fn with_published_port(mut self, port: PublishedPort) -> Self {
510 let mut attached = self.take_attached_networking();
511 attached.ports.get_or_insert_with(Vec::new).push(port);
512 self.with_networking(DockerNetworking::Attached(attached))
513 }
514
515 fn take_attached_networking(&mut self) -> DockerAttachedNetworking {
517 match self.networking.take() {
518 Some(DockerNetworking::Attached(attached)) => attached,
519 _ => DockerAttachedNetworking {
520 networks: None,
521 ports: None,
522 },
523 }
524 }
525
526 pub fn with_restart_policy(mut self, restart_policy: RestartPolicy) -> Self {
527 self.restart_policy = Some(restart_policy);
528 self
529 }
530
531 pub fn with_restart_never(self) -> Self {
533 self.with_restart_policy(RestartPolicy::No)
534 }
535
536 pub fn with_restart_always(self) -> Self {
538 self.with_restart_policy(RestartPolicy::Always)
539 }
540
541 pub fn with_restart_unless_stopped(self) -> Self {
543 self.with_restart_policy(RestartPolicy::UnlessStopped)
544 }
545
546 pub fn with_restart_on_failure(self, max_retries: u32) -> Self {
549 self.with_restart_policy(RestartPolicy::OnFailure(max_retries))
550 }
551
552 pub fn with_hostname(mut self, hostname: &str) -> Self {
553 self.hostname = Some(hostname.to_string());
554 self
555 }
556
557 pub fn with_user(mut self, user: &str) -> Self {
558 self.user = Some(user.to_string());
559 self
560 }
561
562 pub fn with_working_dir(mut self, working_dir: &str) -> Self {
563 self.working_dir = Some(working_dir.to_string());
564 self
565 }
566
567 pub fn with_label(mut self, key: &str, value: &str) -> Self {
568 self.labels
569 .get_or_insert_with(BTreeMap::new)
570 .insert(key.to_string(), value.to_string());
571 self
572 }
573
574 pub fn with_privileged(mut self, privileged: bool) -> Self {
575 self.privileged = Some(privileged);
576 self
577 }
578
579 pub fn with_read_only(mut self, read_only: bool) -> Self {
580 self.read_only = Some(read_only);
581 self
582 }
583
584 pub fn with_init(mut self, init: bool) -> Self {
585 self.init = Some(init);
586 self
587 }
588
589 pub fn with_cap_add(mut self, capabilities: Vec<Capability>) -> Self {
590 self.cap_add = Some(capabilities);
591 self
592 }
593
594 pub fn with_cap_drop(mut self, capabilities: Vec<Capability>) -> Self {
595 self.cap_drop = Some(capabilities);
596 self
597 }
598
599 pub fn with_memory(mut self, memory: MemorySize) -> Self {
600 self.memory = Some(memory);
601 self
602 }
603
604 pub fn with_cpus(mut self, cpus: CpuCount) -> Self {
605 self.cpus = Some(cpus);
606 self
607 }
608
609 pub fn with_extra_hosts(mut self, extra_hosts: Vec<ExtraHost>) -> Self {
610 self.extra_hosts = Some(extra_hosts);
611 self
612 }
613
614 pub fn with_dns(mut self, dns: Vec<IpAddr>) -> Self {
615 self.dns = Some(dns);
616 self
617 }
618
619 pub fn with_devices(mut self, devices: Vec<HostDevice>) -> Self {
620 self.devices = Some(devices);
621 self
622 }
623
624 pub fn with_gpus(mut self, gpus: GpuSelection) -> Self {
625 self.gpus = Some(gpus);
626 self
627 }
628
629 pub fn with_all_gpus(self) -> Self {
631 self.with_gpus(GpuSelection::All)
632 }
633
634 pub fn with_gpu_device(mut self, gpu: GpuId) -> Self {
637 self.gpus = Some(GpuSelection::add_device(self.gpus.take(), gpu));
638 self
639 }
640
641 pub fn with_ipc(mut self, ipc: IpcNamespace) -> Self {
642 self.ipc = Some(ipc);
643 self
644 }
645
646 pub fn with_host_ipc(self) -> Self {
649 self.with_ipc(IpcNamespace::Host)
650 }
651
652 pub fn with_private_ipc(mut self) -> Self {
655 self.ipc = Some(IpcNamespace::private(self.ipc.take()));
656 self
657 }
658
659 pub fn with_shareable_ipc(mut self) -> Self {
662 self.ipc = Some(IpcNamespace::shareable(self.ipc.take()));
663 self
664 }
665
666 pub fn with_shm_size(mut self, shm_size: MemorySize) -> Self {
670 self.ipc = Some(IpcNamespace::with_shm_size(self.ipc.take(), shm_size));
671 self
672 }
673
674 pub fn with_health_check(mut self, health_check: HealthCheck) -> Self {
675 self.health_check = Some(health_check);
676 self
677 }
678
679 pub fn with_log_driver(mut self, log_driver: &str) -> Self {
680 self.log_driver = Some(log_driver.to_string());
681 self
682 }
683
684 pub fn with_log_option(mut self, key: &str, value: &str) -> Self {
685 self.log_options
686 .get_or_insert_with(BTreeMap::new)
687 .insert(key.to_string(), value.to_string());
688 self
689 }
690
691 fn additional_networks(&self) -> &[NetworkName] {
692 match &self.networking {
693 Some(DockerNetworking::Attached(DockerAttachedNetworking {
694 networks: Some(networks),
695 ..
696 })) => &networks.networks()[1..],
697 _ => &[],
698 }
699 }
700
701 async fn resolve_env(
702 &self,
703 optional_secret_provider: &Option<SecretProvidersPool>,
704 ) -> Result<Vec<(String, String)>, RegentError> {
705 let mut resolved = Vec::new();
706 if let Some(env) = &self.env {
707 for (key, value) in env {
708 resolved.push((
709 key.to_string(),
710 value.clone().inner_raw(optional_secret_provider).await?,
711 ));
712 }
713 }
714 Ok(resolved)
715 }
716
717 fn create_args(&self, name: &ContainerName, env: &[(String, String)]) -> Vec<String> {
718 let mut args = to_strings(&["docker", "create", "--name", name.as_str()]);
719
720 let mut push_option = |flag: &str, value: String| {
721 args.push(flag.to_string());
722 args.push(value);
723 };
724
725 if let Some(hostname) = &self.hostname {
726 push_option("--hostname", hostname.clone());
727 }
728 if let Some(user) = &self.user {
729 push_option("--user", user.clone());
730 }
731 if let Some(working_dir) = &self.working_dir {
732 push_option("--workdir", working_dir.clone());
733 }
734 if let Some(entrypoint) = &self.entrypoint {
735 push_option("--entrypoint", entrypoint.clone());
736 }
737 for (key, value) in env {
738 push_option("--env", format!("{}={}", key, value));
739 }
740 for volume in self.volumes.iter().flatten() {
741 push_option("--volume", volume.to_string());
742 }
743 match &self.networking {
744 Some(DockerNetworking::Host) => push_option("--network", "host".to_string()),
745 Some(DockerNetworking::Isolated) => push_option("--network", "none".to_string()),
746 Some(DockerNetworking::Attached(attached)) => {
747 if let Some(networks) = &attached.networks {
748 push_option("--network", networks.networks()[0].to_string());
749 }
750 for port in attached.ports.iter().flatten() {
751 push_option("--publish", port.to_string());
752 }
753 }
754 None => {}
755 }
756 if let Some(restart_policy) = &self.restart_policy {
757 push_option("--restart", restart_policy.as_cli_arg());
758 }
759 for (key, value) in self.labels.iter().flatten() {
760 push_option("--label", format!("{}={}", key, value));
761 }
762 for capability in self.cap_add.iter().flatten() {
763 push_option("--cap-add", capability.to_string());
764 }
765 for capability in self.cap_drop.iter().flatten() {
766 push_option("--cap-drop", capability.to_string());
767 }
768 if let Some(memory) = &self.memory {
769 push_option("--memory", memory.to_string());
770 }
771 if let Some(cpus) = &self.cpus {
772 push_option("--cpus", cpus.to_string());
773 }
774 for extra_host in self.extra_hosts.iter().flatten() {
775 push_option("--add-host", extra_host.to_string());
776 }
777 for dns in self.dns.iter().flatten() {
778 push_option("--dns", dns.to_string());
779 }
780 for device in self.devices.iter().flatten() {
781 push_option("--device", device.to_string());
782 }
783 match &self.gpus {
784 Some(GpuSelection::All) => push_option("--gpus", "all".to_string()),
785 Some(GpuSelection::Devices(gpus)) => {
786 let ids: Vec<String> = gpus.gpus().iter().map(|gpu| gpu.to_string()).collect();
788 push_option("--gpus", format!("\"device={}\"", ids.join(",")));
789 }
790 None => {}
791 }
792 if let Some(ipc) = &self.ipc {
793 push_option("--ipc", ipc.mode().to_string());
794 if let Some(shm_size) = ipc.shm_size() {
795 push_option("--shm-size", shm_size.to_string());
796 }
797 }
798 if let Some(health_check) = &self.health_check {
799 push_option("--health-cmd", health_check.test.to_string());
800 if let Some(interval) = &health_check.interval {
801 push_option("--health-interval", interval.to_string());
802 }
803 if let Some(timeout) = &health_check.timeout {
804 push_option("--health-timeout", timeout.to_string());
805 }
806 if let Some(start_period) = &health_check.start_period {
807 push_option("--health-start-period", start_period.to_string());
808 }
809 if let Some(start_interval) = &health_check.start_interval {
810 push_option("--health-start-interval", start_interval.to_string());
811 }
812 if let Some(retries) = &health_check.retries {
813 push_option("--health-retries", retries.to_string());
814 }
815 }
816 if let Some(log_driver) = &self.log_driver {
817 push_option("--log-driver", log_driver.clone());
818 }
819 for (key, value) in self.log_options.iter().flatten() {
820 push_option("--log-opt", format!("{}={}", key, value));
821 }
822
823 if self.privileged == Some(true) {
824 args.push("--privileged".to_string());
825 }
826 if self.read_only == Some(true) {
827 args.push("--read-only".to_string());
828 }
829 if self.init == Some(true) {
830 args.push("--init".to_string());
831 }
832
833 args.push(self.image.to_string());
834 args.extend(
835 self.command
836 .iter()
837 .flat_map(|command| command.args().iter().cloned()),
838 );
839
840 args
841 }
842
843 fn recreation_reasons(&self, inspect: &Value, env: &[(String, String)]) -> Vec<String> {
846 let mut reasons: Vec<String> = Vec::new();
847
848 let current_image = pointer(inspect, "/Config/Image")
849 .as_str()
850 .unwrap_or_default();
851 if current_image != self.image.as_str() {
852 reasons.push(format!(
853 "image is {}, expected {}",
854 current_image, self.image
855 ));
856 }
857
858 if let Some(command) = &self.command {
859 let current = string_list(pointer(inspect, "/Config/Cmd"));
860 if current != command.args() {
861 reasons.push(format!(
862 "command is {:?}, expected {:?}",
863 current,
864 command.args()
865 ));
866 }
867 }
868
869 if let Some(entrypoint) = &self.entrypoint {
870 let current = string_list(pointer(inspect, "/Config/Entrypoint"));
871 let is_matching = if entrypoint.is_empty() {
872 current.iter().all(|part| part.is_empty())
873 } else {
874 current.len() == 1 && ¤t[0] == entrypoint
875 };
876 if !is_matching {
877 reasons.push(format!(
878 "entrypoint is {:?}, expected {:?}",
879 current, entrypoint
880 ));
881 }
882 }
883
884 if !env.is_empty() {
885 let current: BTreeMap<String, String> = string_list(pointer(inspect, "/Config/Env"))
886 .into_iter()
887 .filter_map(|entry| {
888 entry
889 .split_once('=')
890 .map(|(key, value)| (key.to_string(), value.to_string()))
891 })
892 .collect();
893 for (key, value) in env {
894 if current.get(key) != Some(value) {
895 reasons.push(format!("environment variable {} differs", key));
896 }
897 }
898 }
899
900 if let Some(volumes) = &self.volumes {
901 compare_sets(
903 &mut reasons,
904 "volumes",
905 volumes
906 .iter()
907 .filter(|volume| matches!(volume, ContainerVolume::Bind { .. }))
908 .map(|volume| volume.to_string())
909 .collect(),
910 string_list(pointer(inspect, "/HostConfig/Binds"))
911 .into_iter()
912 .collect(),
913 );
914 let current_anonymous = pointer(inspect, "/Config/Volumes");
915 for volume in volumes {
916 if let ContainerVolume::Anonymous { target } = volume
917 && current_anonymous.get(target).is_none()
918 {
919 reasons.push(format!("anonymous volume {} is missing", target));
920 }
921 }
922 }
923
924 if let Some(networking) = &self.networking {
925 let current_mode = pointer(inspect, "/HostConfig/NetworkMode")
926 .as_str()
927 .unwrap_or_default();
928 match networking {
929 DockerNetworking::Host | DockerNetworking::Isolated => {
930 let expected_mode = match networking {
931 DockerNetworking::Host => "host",
932 _ => "none",
933 };
934 if current_mode != expected_mode {
935 reasons.push(format!(
936 "network mode is {}, expected {}",
937 current_mode, expected_mode
938 ));
939 }
940 }
941 DockerNetworking::Attached(attached) => {
942 if current_mode == "host" || current_mode == "none" {
943 reasons.push(format!(
944 "network mode is {}, expected attached networks",
945 current_mode
946 ));
947 }
948 if let Some(networks) = &attached.networks {
949 compare_sets(
950 &mut reasons,
951 "networks",
952 networks
953 .networks()
954 .iter()
955 .map(|network| network.to_string())
956 .collect(),
957 pointer(inspect, "/NetworkSettings/Networks")
958 .as_object()
959 .map(|networks| networks.keys().cloned().collect())
960 .unwrap_or_default(),
961 );
962 }
963 if let Some(ports) = &attached.ports {
964 let expected: BTreeSet<PortBinding> = ports
965 .iter()
966 .flat_map(|port| port.bindings().iter().cloned())
967 .collect();
968 let current = current_port_bindings(inspect);
969 if expected != current {
970 reasons.push(format!(
971 "published ports are {:?}, expected {:?}",
972 current, expected
973 ));
974 }
975 }
976 }
977 }
978 }
979
980 for (option_name, expected, path) in [
981 ("hostname", &self.hostname, "/Config/Hostname"),
982 ("user", &self.user, "/Config/User"),
983 ("working directory", &self.working_dir, "/Config/WorkingDir"),
984 ("log driver", &self.log_driver, "/HostConfig/LogConfig/Type"),
985 ] {
986 if let Some(expected) = expected {
987 let current = pointer(inspect, path).as_str().unwrap_or_default();
988 if current != expected {
989 reasons.push(format!(
990 "{} is {:?}, expected {:?}",
991 option_name, current, expected
992 ));
993 }
994 }
995 }
996
997 for (option_name, expected, path) in [
998 ("labels", &self.labels, "/Config/Labels"),
999 (
1000 "log options",
1001 &self.log_options,
1002 "/HostConfig/LogConfig/Config",
1003 ),
1004 ] {
1005 if let Some(expected) = expected {
1006 let current = pointer(inspect, path);
1007 for (key, value) in expected {
1008 if current.get(key).and_then(Value::as_str) != Some(value.as_str()) {
1009 reasons.push(format!("{} entry {} differs", option_name, key));
1010 }
1011 }
1012 }
1013 }
1014
1015 for (option_name, expected, path) in [
1016 ("privileged", &self.privileged, "/HostConfig/Privileged"),
1017 ("read-only", &self.read_only, "/HostConfig/ReadonlyRootfs"),
1018 ("init", &self.init, "/HostConfig/Init"),
1019 ] {
1020 if let Some(expected) = expected {
1021 let current = pointer(inspect, path).as_bool().unwrap_or(false);
1023 if current != *expected {
1024 reasons.push(format!(
1025 "{} is {}, expected {}",
1026 option_name, current, expected
1027 ));
1028 }
1029 }
1030 }
1031
1032 for (option_name, expected, path) in [
1033 ("added capabilities", &self.cap_add, "/HostConfig/CapAdd"),
1034 (
1035 "dropped capabilities",
1036 &self.cap_drop,
1037 "/HostConfig/CapDrop",
1038 ),
1039 ] {
1040 if let Some(expected) = expected {
1041 compare_sets(
1042 &mut reasons,
1043 option_name,
1044 expected.iter().map(|cap| cap.to_string()).collect(),
1045 string_list(pointer(inspect, path))
1046 .iter()
1047 .map(|cap| normalize_capability(cap))
1048 .collect(),
1049 );
1050 }
1051 }
1052
1053 if let Some(extra_hosts) = &self.extra_hosts {
1054 compare_sets(
1055 &mut reasons,
1056 "extra hosts",
1057 extra_hosts.iter().map(|host| host.to_string()).collect(),
1058 string_list(pointer(inspect, "/HostConfig/ExtraHosts"))
1059 .into_iter()
1060 .collect(),
1061 );
1062 }
1063
1064 if let Some(dns) = &self.dns {
1065 compare_sets(
1066 &mut reasons,
1067 "DNS servers",
1068 dns.iter().map(|server| server.to_string()).collect(),
1069 string_list(pointer(inspect, "/HostConfig/Dns"))
1070 .into_iter()
1071 .collect(),
1072 );
1073 }
1074
1075 if let Some(memory) = &self.memory {
1076 let current = pointer(inspect, "/HostConfig/Memory")
1077 .as_i64()
1078 .unwrap_or_default();
1079 if current != memory.bytes() {
1080 reasons.push(format!(
1081 "memory limit is {} bytes, expected {} bytes",
1082 current,
1083 memory.bytes()
1084 ));
1085 }
1086 }
1087
1088 if let Some(cpus) = &self.cpus {
1089 let current = pointer(inspect, "/HostConfig/NanoCpus")
1090 .as_i64()
1091 .unwrap_or_default();
1092 if current != cpus.nano_cpus() {
1093 reasons.push(format!(
1094 "CPU limit is {}, expected {}",
1095 current as f64 / 1e9,
1096 cpus
1097 ));
1098 }
1099 }
1100
1101 if let Some(devices) = &self.devices {
1102 compare_sets(
1103 &mut reasons,
1104 "devices",
1105 devices.iter().map(|device| device.to_string()).collect(),
1106 pointer(inspect, "/HostConfig/Devices")
1107 .as_array()
1108 .into_iter()
1109 .flatten()
1110 .map(|device| {
1111 format!(
1112 "{}:{}:{}",
1113 pointer(device, "/PathOnHost").as_str().unwrap_or_default(),
1114 pointer(device, "/PathInContainer")
1115 .as_str()
1116 .unwrap_or_default(),
1117 pointer(device, "/CgroupPermissions")
1118 .as_str()
1119 .unwrap_or_default()
1120 )
1121 })
1122 .collect(),
1123 );
1124 }
1125
1126 if let Some(gpus) = &self.gpus {
1127 let current = current_gpus(inspect);
1128 let expected = gpus.ids();
1129 if current != expected {
1130 reasons.push(format!("GPUs are {:?}, expected {:?}", current, expected));
1131 }
1132 }
1133
1134 if let Some(ipc) = &self.ipc {
1135 let current = pointer(inspect, "/HostConfig/IpcMode")
1136 .as_str()
1137 .unwrap_or_default();
1138 if current != ipc.mode() {
1139 reasons.push(format!(
1140 "IPC mode is {:?}, expected {:?}",
1141 current,
1142 ipc.mode()
1143 ));
1144 }
1145 if let Some(shm_size) = ipc.shm_size() {
1146 let current = pointer(inspect, "/HostConfig/ShmSize")
1147 .as_i64()
1148 .unwrap_or_default();
1149 if current != shm_size.bytes() {
1150 reasons.push(format!(
1151 "/dev/shm size is {} bytes, expected {} bytes",
1152 current,
1153 shm_size.bytes()
1154 ));
1155 }
1156 }
1157 }
1158
1159 if let Some(health_check) = &self.health_check {
1160 let current = pointer(inspect, "/Config/Healthcheck");
1161 let expected_test = vec!["CMD-SHELL".to_string(), health_check.test.to_string()];
1162 let current_test = string_list(pointer(current, "/Test"));
1163 if current_test != expected_test {
1164 reasons.push(format!(
1165 "health check command is {:?}, expected {:?}",
1166 current_test, expected_test
1167 ));
1168 }
1169 for (option_name, expected, path) in [
1170 ("health check interval", &health_check.interval, "/Interval"),
1171 ("health check timeout", &health_check.timeout, "/Timeout"),
1172 (
1173 "health check start period",
1174 &health_check.start_period,
1175 "/StartPeriod",
1176 ),
1177 (
1178 "health check start interval",
1179 &health_check.start_interval,
1180 "/StartInterval",
1181 ),
1182 ] {
1183 if let Some(expected) = expected {
1184 let current = pointer(current, path).as_i64().unwrap_or_default();
1185 if current != expected.nanos() {
1186 reasons.push(format!(
1187 "{} is {} nanoseconds, expected {} nanoseconds",
1188 option_name,
1189 current,
1190 expected.nanos()
1191 ));
1192 }
1193 }
1194 }
1195 if let Some(retries) = &health_check.retries {
1196 let current = pointer(current, "/Retries").as_i64().unwrap_or_default();
1197 if current != *retries as i64 {
1198 reasons.push(format!(
1199 "health check retries is {}, expected {}",
1200 current, retries
1201 ));
1202 }
1203 }
1204 }
1205
1206 reasons
1207 }
1208
1209 fn is_restart_policy_compliant(&self, inspect: &Value) -> bool {
1210 match &self.restart_policy {
1211 Some(restart_policy) => restart_policy.matches(
1212 pointer(inspect, "/HostConfig/RestartPolicy/Name")
1213 .as_str()
1214 .unwrap_or_default(),
1215 pointer(inspect, "/HostConfig/RestartPolicy/MaximumRetryCount")
1216 .as_u64()
1217 .unwrap_or_default(),
1218 ),
1219 None => true,
1220 }
1221 }
1222}
1223
1224impl Timeout for DockerExpectedState {
1225 fn default_timeout(&self) -> Duration {
1226 Duration::from_secs(300)
1228 }
1229}
1230
1231impl Check for DockerExpectedState {
1232 fn check(&self) -> Result<(), RegentError> {
1233 Ok(())
1235 }
1236
1237 fn check_host_compatibility(
1238 &self,
1239 host_properties: &HostProperties,
1240 ) -> Result<(), RegentError> {
1241 check_docker_host_compatibility(host_properties)
1242 }
1243}
1244
1245impl<Handler: HostHandler> AssessCompliance<Handler> for DockerExpectedState {
1246 async fn assess_compliance(
1247 &self,
1248 host_handler: &mut Handler,
1249 host_properties: &Option<HostProperties>,
1250 privilege: &Privilege,
1251 optional_secret_provider: &Option<SecretProvidersPool>,
1252 ) -> Result<AttributeComplianceAssessment, RegentError> {
1253 if let Some(props) = host_properties {
1255 self.check_host_compatibility(props)?;
1256 }
1257
1258 let command_available = host_handler
1259 .is_this_command_available("docker", privilege)
1260 .await
1261 .unwrap_or(false);
1262 if !command_available {
1263 return Err(RegentError::FailedDryRunEvaluation(
1264 "Docker CLI (docker) is not available on this host".to_string(),
1265 ));
1266 }
1267
1268 let container = inspect_container(host_handler, &self.name, privilege).await?;
1269
1270 let mut remediations: Vec<Remediation> = Vec::new();
1271 let mut push = |api_call: DockerInternalApiCall| {
1272 remediations.push(Remediation::Docker(DockerApiCall::from(
1273 api_call,
1274 privilege.clone(),
1275 )));
1276 };
1277
1278 let (config, should_run) = match &self.state {
1279 DockerContainerState::Absent => {
1280 if container.is_some() {
1281 push(DockerInternalApiCall::Remove(self.name.clone()));
1282 }
1283 (None, false)
1284 }
1285 DockerContainerState::Running(config) => (Some(config), true),
1286 DockerContainerState::Stopped(config) => (Some(config), false),
1287 };
1288
1289 if let Some(config) = config {
1290 let image_id = inspect_image_id(host_handler, &config.image, privilege).await?;
1291
1292 if image_id.is_none() {
1293 match config.pull {
1294 PullPolicy::Missing => push(DockerInternalApiCall::Pull(config.image.clone())),
1295 PullPolicy::Never => {
1296 return Ok(AttributeComplianceAssessment::NonCompliantFatal(format!(
1297 "image {} is not present on the host and Pull is Never",
1298 config.image
1299 )));
1300 }
1301 }
1302 }
1303
1304 match container {
1305 None => {
1306 push(DockerInternalApiCall::Create {
1307 name: self.name.clone(),
1308 config: config.clone(),
1309 });
1310 if should_run {
1311 push(DockerInternalApiCall::Start(self.name.clone()));
1312 }
1313 }
1314 Some(inspect) => {
1315 let env = config.resolve_env(optional_secret_provider).await?;
1316 let mut reasons = config.recreation_reasons(&inspect, &env);
1317
1318 let current_image_id = pointer(&inspect, "/Image").as_str();
1320 match &image_id {
1321 Some(image_id)
1322 if reasons.is_empty()
1323 && current_image_id != Some(image_id.as_str()) =>
1324 {
1325 reasons.push(format!("image {} has been updated", config.image));
1326 }
1327 None => reasons.push(format!(
1328 "image {} is not present locally, it will be pulled",
1329 config.image
1330 )),
1331 _ => {}
1332 }
1333
1334 if !reasons.is_empty() {
1335 push(DockerInternalApiCall::Recreate {
1336 name: self.name.clone(),
1337 config: config.clone(),
1338 reasons,
1339 });
1340 if should_run {
1341 push(DockerInternalApiCall::Start(self.name.clone()));
1342 }
1343 } else {
1344 if let Some(restart_policy) = &config.restart_policy
1345 && !config.is_restart_policy_compliant(&inspect)
1346 {
1347 push(DockerInternalApiCall::UpdateRestartPolicy {
1348 name: self.name.clone(),
1349 restart_policy: restart_policy.clone(),
1350 });
1351 }
1352
1353 let is_running = pointer(&inspect, "/State/Running")
1354 .as_bool()
1355 .unwrap_or(false);
1356 let is_paused = pointer(&inspect, "/State/Paused")
1357 .as_bool()
1358 .unwrap_or(false);
1359 match (should_run, is_running, is_paused) {
1360 (true, true, true) => {
1361 push(DockerInternalApiCall::Unpause(self.name.clone()))
1362 }
1363 (true, false, _) => {
1364 push(DockerInternalApiCall::Start(self.name.clone()))
1365 }
1366 (false, true, _) => {
1367 push(DockerInternalApiCall::Stop(self.name.clone()))
1368 }
1369 _ => {
1370 }
1372 }
1373 }
1374 }
1375 }
1376 }
1377
1378 if remediations.is_empty() {
1379 Ok(AttributeComplianceAssessment::Compliant)
1380 } else {
1381 Ok(AttributeComplianceAssessment::NonCompliant(
1382 RemediationsList::from(remediations)?,
1383 ))
1384 }
1385 }
1386}
1387
1388#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1389#[serde(rename_all = "PascalCase", rename_all_fields = "PascalCase")]
1390pub enum DockerInternalApiCall {
1391 Pull(ContainerImage),
1392 Create {
1394 name: ContainerName,
1395 config: Box<DockerContainerConfig>,
1396 },
1397 Recreate {
1399 name: ContainerName,
1400 config: Box<DockerContainerConfig>,
1401 reasons: Vec<String>,
1402 },
1403 Remove(ContainerName),
1405 Start(ContainerName),
1406 Stop(ContainerName),
1407 Unpause(ContainerName),
1408 UpdateRestartPolicy {
1409 name: ContainerName,
1410 restart_policy: RestartPolicy,
1411 },
1412}
1413
1414#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1415pub struct DockerApiCall {
1416 pub api_call: DockerInternalApiCall,
1417 privilege: Privilege,
1418}
1419
1420impl DockerApiCall {
1421 pub fn display(&self) -> String {
1422 match &self.api_call {
1423 DockerInternalApiCall::Pull(image) => format!("Pull image {}", image),
1424 DockerInternalApiCall::Create { name, config } => {
1425 format!("Create container {} from {}", name, config.image)
1426 }
1427 DockerInternalApiCall::Recreate { name, reasons, .. } => {
1428 format!("Recreate container {} ({})", name, reasons.join("; "))
1429 }
1430 DockerInternalApiCall::Remove(name) => format!("Remove container {}", name),
1431 DockerInternalApiCall::Start(name) => format!("Start container {}", name),
1432 DockerInternalApiCall::Stop(name) => format!("Stop container {}", name),
1433 DockerInternalApiCall::Unpause(name) => format!("Unpause container {}", name),
1434 DockerInternalApiCall::UpdateRestartPolicy {
1435 name,
1436 restart_policy,
1437 } => format!(
1438 "Set restart policy of container {} to {}",
1439 name,
1440 restart_policy.as_cli_arg()
1441 ),
1442 }
1443 }
1444
1445 fn from(api_call: DockerInternalApiCall, privilege: Privilege) -> DockerApiCall {
1446 DockerApiCall {
1447 api_call,
1448 privilege,
1449 }
1450 }
1451}
1452
1453impl Timeout for DockerApiCall {
1454 fn default_timeout(&self) -> Duration {
1455 Duration::from_secs(300)
1456 }
1457}
1458
1459impl Check for DockerApiCall {
1460 fn check(&self) -> Result<(), RegentError> {
1461 Ok(())
1462 }
1463
1464 fn check_host_compatibility(
1465 &self,
1466 host_properties: &HostProperties,
1467 ) -> Result<(), RegentError> {
1468 check_docker_host_compatibility(host_properties)
1469 }
1470}
1471
1472impl<Handler: HostHandler> ReachCompliance<Handler> for DockerApiCall {
1473 async fn call(
1474 &self,
1475 host_handler: &mut Handler,
1476 host_properties: &Option<HostProperties>,
1477 optional_secret_provider: &Option<SecretProvidersPool>,
1478 ) -> Result<InternalApiCallOutcome, RegentError> {
1479 if let Some(props) = host_properties {
1481 self.check_host_compatibility(props)?;
1482 }
1483
1484 match &self.api_call {
1485 DockerInternalApiCall::Pull(image) => {
1486 run_docker(
1487 host_handler,
1488 &["docker", "pull", image.as_str()],
1489 &self.privilege,
1490 )
1491 .await
1492 }
1493 DockerInternalApiCall::Create { name, config } => {
1494 create_container(
1495 host_handler,
1496 name,
1497 config,
1498 optional_secret_provider,
1499 &self.privilege,
1500 )
1501 .await
1502 }
1503 DockerInternalApiCall::Recreate { name, config, .. } => {
1504 let outcome = run_docker(
1505 host_handler,
1506 &["docker", "rm", "--force", name.as_str()],
1507 &self.privilege,
1508 )
1509 .await?;
1510 if let InternalApiCallOutcome::Failure(_) = outcome {
1511 return Ok(outcome);
1512 }
1513 create_container(
1514 host_handler,
1515 name,
1516 config,
1517 optional_secret_provider,
1518 &self.privilege,
1519 )
1520 .await
1521 }
1522 DockerInternalApiCall::Remove(name) => {
1523 run_docker(
1524 host_handler,
1525 &["docker", "rm", "--force", name.as_str()],
1526 &self.privilege,
1527 )
1528 .await
1529 }
1530 DockerInternalApiCall::Start(name) => {
1531 run_docker(
1532 host_handler,
1533 &["docker", "start", name.as_str()],
1534 &self.privilege,
1535 )
1536 .await
1537 }
1538 DockerInternalApiCall::Stop(name) => {
1539 run_docker(
1540 host_handler,
1541 &["docker", "stop", name.as_str()],
1542 &self.privilege,
1543 )
1544 .await
1545 }
1546 DockerInternalApiCall::Unpause(name) => {
1547 run_docker(
1548 host_handler,
1549 &["docker", "unpause", name.as_str()],
1550 &self.privilege,
1551 )
1552 .await
1553 }
1554 DockerInternalApiCall::UpdateRestartPolicy {
1555 name,
1556 restart_policy,
1557 } => {
1558 run_docker(
1559 host_handler,
1560 &[
1561 "docker",
1562 "update",
1563 "--restart",
1564 &restart_policy.as_cli_arg(),
1565 name.as_str(),
1566 ],
1567 &self.privilege,
1568 )
1569 .await
1570 }
1571 }
1572 }
1573}
1574
1575fn check_docker_host_compatibility(host_properties: &HostProperties) -> Result<(), RegentError> {
1576 match host_properties.os_kind() {
1577 OsKind::Linux(_) | OsKind::MacOs(_) => Ok(()),
1578 incompatible_os_kind => Err(RegentError::IncompatibleHost(format!(
1579 "Host is {:?} but Docker container management is only supported on Linux and macOS",
1580 incompatible_os_kind
1581 ))),
1582 }
1583}
1584
1585async fn create_container<Handler: HostHandler>(
1587 host_handler: &mut Handler,
1588 name: &ContainerName,
1589 config: &DockerContainerConfig,
1590 optional_secret_provider: &Option<SecretProvidersPool>,
1591 privilege: &Privilege,
1592) -> Result<InternalApiCallOutcome, RegentError> {
1593 let env = config.resolve_env(optional_secret_provider).await?;
1594 let args = config.create_args(name, &env);
1595 let args: Vec<&str> = args.iter().map(String::as_str).collect();
1596
1597 let outcome = run_docker(host_handler, &args, privilege).await?;
1598 if let InternalApiCallOutcome::Failure(_) = outcome {
1599 return Ok(outcome);
1600 }
1601
1602 for network in config.additional_networks() {
1603 let outcome = run_docker(
1604 host_handler,
1605 &[
1606 "docker",
1607 "network",
1608 "connect",
1609 network.as_str(),
1610 name.as_str(),
1611 ],
1612 privilege,
1613 )
1614 .await?;
1615 if let InternalApiCallOutcome::Failure(_) = outcome {
1616 return Ok(outcome);
1617 }
1618 }
1619
1620 Ok(InternalApiCallOutcome::Success(None))
1621}
1622
1623async fn run_docker<Handler: HostHandler>(
1624 host_handler: &mut Handler,
1625 args: &[&str],
1626 privilege: &Privilege,
1627) -> Result<InternalApiCallOutcome, RegentError> {
1628 match host_handler
1629 .run_command("ed_command(args), privilege)
1630 .await
1631 {
1632 Ok(result) => {
1633 if result.return_code == 0 {
1634 Ok(InternalApiCallOutcome::Success(None))
1635 } else {
1636 Ok(InternalApiCallOutcome::Failure(format!(
1637 "RC: {}, STDOUT: {}, STDERR: {}",
1638 result.return_code, result.stdout, result.stderr
1639 )))
1640 }
1641 }
1642 Err(e) => Ok(InternalApiCallOutcome::Failure(format!(
1643 "Command execution failed: {:?}",
1644 e
1645 ))),
1646 }
1647}
1648
1649async fn inspect_container<Handler: HostHandler>(
1651 host_handler: &mut Handler,
1652 name: &ContainerName,
1653 privilege: &Privilege,
1654) -> Result<Option<Value>, RegentError> {
1655 let result = host_handler
1656 .run_command(
1657 "ed_command(&["docker", "container", "inspect", name.as_str()]),
1658 privilege,
1659 )
1660 .await
1661 .map_err(|e| {
1662 RegentError::FailedDryRunEvaluation(format!(
1663 "Unable to inspect container {}: {:?}",
1664 name, e
1665 ))
1666 })?;
1667
1668 if result.return_code != 0 {
1669 let output = format!("{}{}", result.stdout, result.stderr);
1670 if output.contains("No such container") || output.contains("No such object") {
1671 return Ok(None);
1672 }
1673 return Err(RegentError::FailedDryRunEvaluation(format!(
1674 "Unable to inspect container {}: {:?}",
1675 name, result
1676 )));
1677 }
1678
1679 let inspect = parse_inspect_output(&result.stdout).ok_or_else(|| {
1680 RegentError::FailureToParseContent(format!(
1681 "Unexpected output of docker container inspect: {}",
1682 result.stdout
1683 ))
1684 })?;
1685
1686 if pointer(&inspect, "/Name").as_str() == Some(&format!("/{}", name)) {
1688 Ok(Some(inspect))
1689 } else {
1690 Ok(None)
1691 }
1692}
1693
1694async fn inspect_image_id<Handler: HostHandler>(
1696 host_handler: &mut Handler,
1697 image: &ContainerImage,
1698 privilege: &Privilege,
1699) -> Result<Option<String>, RegentError> {
1700 let result = host_handler
1701 .run_command(
1702 "ed_command(&[
1703 "docker",
1704 "image",
1705 "inspect",
1706 "--format",
1707 "{{.Id}}",
1708 image.as_str(),
1709 ]),
1710 privilege,
1711 )
1712 .await
1713 .map_err(|e| {
1714 RegentError::FailedDryRunEvaluation(format!(
1715 "Unable to inspect image {}: {:?}",
1716 image, e
1717 ))
1718 })?;
1719
1720 if result.return_code != 0 {
1721 let output = format!("{}{}", result.stdout, result.stderr);
1722 if output.contains("No such image") || output.contains("No such object") {
1723 return Ok(None);
1724 }
1725 return Err(RegentError::FailedDryRunEvaluation(format!(
1726 "Unable to inspect image {}: {:?}",
1727 image, result
1728 )));
1729 }
1730
1731 match result
1733 .stdout
1734 .lines()
1735 .map(str::trim)
1736 .find(|line| line.starts_with("sha256:"))
1737 {
1738 Some(image_id) => Ok(Some(image_id.to_string())),
1739 None => Err(RegentError::FailureToParseContent(format!(
1740 "Unexpected output of docker image inspect: {}",
1741 result.stdout
1742 ))),
1743 }
1744}
1745
1746fn current_gpus(inspect: &Value) -> BTreeSet<String> {
1748 let mut gpus = BTreeSet::new();
1749 for request in pointer(inspect, "/HostConfig/DeviceRequests")
1750 .as_array()
1751 .into_iter()
1752 .flatten()
1753 {
1754 let is_gpu_request = pointer(request, "/Capabilities")
1755 .as_array()
1756 .into_iter()
1757 .flatten()
1758 .any(|capabilities| string_list(capabilities).iter().any(|cap| cap == "gpu"));
1759 if !is_gpu_request {
1760 continue;
1761 }
1762 if pointer(request, "/Count").as_i64() == Some(-1) {
1763 gpus.insert("all".to_string());
1764 }
1765 gpus.extend(string_list(pointer(request, "/DeviceIDs")));
1766 }
1767 gpus
1768}
1769
1770fn current_port_bindings(inspect: &Value) -> BTreeSet<PortBinding> {
1771 let mut current = BTreeSet::new();
1772 if let Some(bindings) = pointer(inspect, "/HostConfig/PortBindings").as_object() {
1773 for (container_port, host_bindings) in bindings {
1774 for host_binding in host_bindings.as_array().into_iter().flatten() {
1775 current.insert(PortBinding {
1776 container_port: container_port.clone(),
1777 host_ip: pointer(host_binding, "/HostIp")
1778 .as_str()
1779 .unwrap_or_default()
1780 .to_string(),
1781 host_port: pointer(host_binding, "/HostPort")
1782 .as_str()
1783 .unwrap_or_default()
1784 .to_string(),
1785 });
1786 }
1787 }
1788 }
1789 current
1790}
1791
1792#[cfg(test)]
1793mod tests {
1794 use super::*;
1795 use crate::secrets::SecretReference;
1796 use crate::state::attribute::container::container_spec::{GpuList, PrivateIpc};
1797
1798 fn full_yaml() -> &'static str {
1799 "---
1800Name: web
1801State: !Running
1802 Image: nginx:1.27
1803 Pull: Missing
1804 Entrypoint: /docker-entrypoint.sh
1805 Command: [nginx, -g, daemon off;]
1806 Env:
1807 TZ: Europe/Paris
1808 API_TOKEN:
1809 SecRef: web_api_token
1810 Provider: files
1811 Volumes:
1812 - /srv/www:/usr/share/nginx/html:ro
1813 - /var/cache/nginx
1814 Networking: !Attached
1815 Networks: [frontend, backend]
1816 Ports:
1817 - 8080:80
1818 - 127.0.0.1:8443:443/tcp
1819 RestartPolicy: UnlessStopped
1820 Hostname: web01
1821 User: '101:101'
1822 WorkingDir: /usr/share/nginx
1823 Labels:
1824 traefik.enable: 'true'
1825 Privileged: false
1826 ReadOnly: false
1827 Init: true
1828 CapAdd: [net_bind_service]
1829 CapDrop: [ALL]
1830 Memory: 512m
1831 Cpus: 1.5
1832 ExtraHosts: ['db.local:10.0.0.12']
1833 Dns: [1.1.1.1]
1834 Devices: ['/dev/snd']
1835 HealthCheck:
1836 Test: curl -f http://localhost || exit 1
1837 Interval: 30s
1838 Timeout: 5s
1839 StartPeriod: 10s
1840 StartInterval: 1s
1841 Retries: 3
1842 LogDriver: json-file
1843 LogOptions:
1844 max-size: 10m
1845"
1846 }
1847
1848 fn full_config() -> DockerContainerConfig {
1849 let state: DockerExpectedState = yaml_serde::from_str(full_yaml()).unwrap();
1850 match state.state {
1851 DockerContainerState::Running(config) => *config,
1852 other => panic!("unexpected state {:?}", other),
1853 }
1854 }
1855
1856 fn matching_inspect() -> Value {
1858 serde_json::json!({
1859 "Name": "/web",
1860 "Image": "sha256:abc",
1861 "State": { "Running": true, "Paused": false },
1862 "Config": {
1863 "Image": "nginx:1.27",
1864 "Cmd": ["nginx", "-g", "daemon off;"],
1865 "Entrypoint": ["/docker-entrypoint.sh"],
1866 "Env": ["TZ=Europe/Paris", "API_TOKEN=s3cr3t", "PATH=/usr/bin", "NGINX_VERSION=1.27"],
1867 "Hostname": "web01",
1868 "User": "101:101",
1869 "WorkingDir": "/usr/share/nginx",
1870 "Labels": { "traefik.enable": "true", "maintainer": "NGINX" },
1871 "Volumes": { "/var/cache/nginx": {} },
1872 "Healthcheck": {
1873 "Test": ["CMD-SHELL", "curl -f http://localhost || exit 1"],
1874 "Interval": 30000000000i64,
1875 "Timeout": 5000000000i64,
1876 "StartPeriod": 10000000000i64,
1877 "StartInterval": 1000000000i64,
1878 "Retries": 3
1879 }
1880 },
1881 "HostConfig": {
1882 "Binds": ["/srv/www:/usr/share/nginx/html:ro"],
1883 "NetworkMode": "frontend",
1884 "PortBindings": {
1885 "80/tcp": [{ "HostIp": "", "HostPort": "8080" }],
1886 "443/tcp": [{ "HostIp": "127.0.0.1", "HostPort": "8443" }]
1887 },
1888 "RestartPolicy": { "Name": "unless-stopped", "MaximumRetryCount": 0 },
1889 "Privileged": false,
1890 "ReadonlyRootfs": false,
1891 "Init": true,
1892 "CapAdd": ["CAP_NET_BIND_SERVICE"],
1893 "CapDrop": ["ALL"],
1894 "Memory": 536870912,
1895 "NanoCpus": 1500000000i64,
1896 "ExtraHosts": ["db.local:10.0.0.12"],
1897 "Dns": ["1.1.1.1"],
1898 "Devices": [{ "PathOnHost": "/dev/snd", "PathInContainer": "/dev/snd", "CgroupPermissions": "rwm" }],
1899 "LogConfig": { "Type": "json-file", "Config": { "max-size": "10m", "max-file": "1" } }
1900 },
1901 "NetworkSettings": { "Networks": { "frontend": {}, "backend": {} } }
1902 })
1903 }
1904
1905 fn resolved_env() -> Vec<(String, String)> {
1906 vec![
1907 ("API_TOKEN".to_string(), "s3cr3t".to_string()),
1908 ("TZ".to_string(), "Europe/Paris".to_string()),
1909 ]
1910 }
1911
1912 #[test]
1913 fn parsing_docker_module_block_from_yaml_str() {
1914 let config = full_config();
1915 assert_eq!(
1916 config
1917 .env
1918 .as_ref()
1919 .unwrap()
1920 .get(&"API_TOKEN".parse().unwrap()),
1921 Some(&Parameter::Secret(SecretReference::from(
1922 "web_api_token",
1923 Some("files".to_string())
1924 )))
1925 );
1926
1927 let raw = "---
1928- Name: legacy
1929 State: Absent
1930- Name: worker
1931 State: !Stopped
1932 Image: busybox
1933 Networking: Host
1934 RestartPolicy: !OnFailure 3
1935";
1936 let states: Vec<DockerExpectedState> = yaml_serde::from_str(raw).unwrap();
1937 assert_eq!(states[0].state, DockerContainerState::Absent);
1938 match &states[1].state {
1939 DockerContainerState::Stopped(config) => {
1940 assert_eq!(config.networking, Some(DockerNetworking::Host));
1941 assert_eq!(config.restart_policy, Some(RestartPolicy::OnFailure(3)));
1942 }
1943 other => panic!("unexpected state {:?}", other),
1944 }
1945 }
1946
1947 #[test]
1948 fn serialization_round_trip_preserves_state() {
1949 let state: DockerExpectedState = yaml_serde::from_str(full_yaml()).unwrap();
1951 let json = serde_json::to_string(&state).unwrap();
1952 assert_eq!(
1953 serde_json::from_str::<DockerExpectedState>(&json).unwrap(),
1954 state
1955 );
1956 }
1957
1958 #[test]
1959 fn invalid_states_are_rejected_while_parsing() {
1960 let rejected = [
1961 "Name: old\nState: !Absent\n User: root\n",
1963 "Name: web\nState: !Running\n User: root\n",
1965 "Name: web\nState: !Running\n Image: nginx\n Port: 80:80\n",
1967 "Name: web\nState: !Running\n Image: nginx\n Networking: !Host\n Ports: ['80:80']\n",
1969 "Name: web\nState: !Running\n Image: nginx\n Networking: !Attached\n Networks: [host, frontend]\n",
1971 "Name: web\nState: !Running\n Image: nginx\n Networking: !Attached\n Networks: []\n",
1972 "Name: /web\nState: Absent\n",
1973 "Name: web\nState: !Running\n Image: ''\n",
1974 "Name: web\nState: !Running\n Image: nginx\n Command: []\n",
1975 "Name: web\nState: !Running\n Image: nginx\n Memory: lots\n",
1976 "Name: web\nState: !Running\n Image: nginx\n Cpus: 0\n",
1977 "Name: web\nState: !Running\n Image: nginx\n Volumes: [relative/path]\n",
1978 "Name: web\nState: !Running\n Image: nginx\n Env: {'A=B': c}\n",
1979 "Name: web\nState: !Running\n Image: nginx\n ExtraHosts: ['db.local:not-an-ip']\n",
1980 "Name: web\nState: !Running\n Image: nginx\n Devices: ['/dev/snd:/dev/snd:x']\n",
1981 "Name: web\nState: !Running\n Image: nginx\n Dns: [dns.example.com]\n",
1982 "Name: web\nState: !Running\n Image: nginx\n Ipc: !Host\n ShmSize: 1g\n",
1984 "Name: web\nState: !Running\n Image: nginx\n Gpus: !Devices []\n",
1985 "Name: web\nState: !Running\n Image: nginx\n Gpus: !Devices [gpu0]\n",
1986 "Name: web\nState: !Running\n Image: nginx\n HealthCheck:\n Interval: 30s\n",
1988 "Name: web\nState: !Running\n Image: nginx\n HealthCheck:\n Test: ''\n",
1989 "Name: web\nState: !Running\n Image: nginx\n HealthCheck:\n Test: curl\n Interval: 30\n",
1991 "Name: web\nState: !Running\n Image: nginx\n HealthCheck:\n Test: curl\n Interval: lots\n",
1992 "Name: web\nState: !Running\n Image: nginx\n HealthCheck:\n Test: curl\n StartPeriod: 0s\n",
1993 "Name: web\nState: !Running\n Image: nginx\n HealthCheck:\n Test: curl\n Retries: -1\n",
1994 "Name: web\nState: !Running\n Image: nginx\n HealthCheck:\n Test: curl\n Unknown: x\n",
1995 ];
1996 for raw in rejected {
1997 assert!(
1998 yaml_serde::from_str::<DockerExpectedState>(raw).is_err(),
1999 "should be rejected: {}",
2000 raw
2001 );
2002 }
2003 }
2004
2005 #[test]
2006 fn matching_container_needs_no_recreation() {
2007 let config = full_config();
2008 let reasons = config.recreation_reasons(&matching_inspect(), &resolved_env());
2009 assert!(reasons.is_empty(), "{:?}", reasons);
2010 assert!(config.is_restart_policy_compliant(&matching_inspect()));
2011 }
2012
2013 #[test]
2014 fn drifted_container_needs_recreation() {
2015 let config = full_config();
2016 let mut inspect = matching_inspect();
2017 inspect["HostConfig"]["PortBindings"]["80/tcp"][0]["HostPort"] = "9090".into();
2018 inspect["Config"]["Env"][1] = "API_TOKEN=old_secret".into();
2019 inspect["NetworkSettings"]["Networks"] = serde_json::json!({ "bridge": {} });
2020
2021 let reasons = config.recreation_reasons(&inspect, &resolved_env());
2022 assert_eq!(reasons.len(), 3, "{:?}", reasons);
2023 assert!(reasons.iter().all(|reason| !reason.contains("secret")));
2025 }
2026
2027 #[test]
2028 fn network_mode_drift_is_detected() {
2029 let mut inspect = matching_inspect();
2030 inspect["HostConfig"]["NetworkMode"] = "host".into();
2031
2032 let attached = DockerContainerConfig::new("nginx:1.27".parse().unwrap()).with_networking(
2033 DockerNetworking::attached(None, Some(vec!["8080:80".parse().unwrap()])),
2034 );
2035 let reasons = attached.recreation_reasons(&inspect, &[]);
2036 assert!(
2037 reasons
2038 .iter()
2039 .any(|reason| reason.starts_with("network mode is host")),
2040 "{:?}",
2041 reasons
2042 );
2043
2044 let host = DockerContainerConfig::new("nginx:1.27".parse().unwrap())
2045 .with_networking(DockerNetworking::Host);
2046 assert!(host.recreation_reasons(&inspect, &[]).is_empty());
2047 }
2048
2049 #[test]
2050 fn restart_policy_drift_is_detected() {
2051 let config = DockerContainerConfig::new("nginx".parse().unwrap())
2052 .with_restart_policy(RestartPolicy::OnFailure(5));
2053 assert!(!config.is_restart_policy_compliant(&matching_inspect()));
2054
2055 let config = DockerContainerConfig::new("nginx".parse().unwrap())
2056 .with_restart_policy(RestartPolicy::No);
2057 let inspect = serde_json::json!({ "HostConfig": { "RestartPolicy": { "Name": "", "MaximumRetryCount": 0 } } });
2058 assert!(config.is_restart_policy_compliant(&inspect));
2059 }
2060
2061 #[test]
2062 fn gpu_inference_server() {
2063 let raw = "---
2064Name: vllm
2065State: !Running
2066 Image: vllm/vllm-openai:v0.10.1
2067 Command: [--model, Qwen/Qwen3-8B]
2068 Gpus: !Devices [0, GPU-4b2c7f1e-9a3d-4e6f-8b1a-2c3d4e5f6a7b]
2069 Ipc: !Private
2070 ShmSize: 16g
2071";
2072 let state: DockerExpectedState = yaml_serde::from_str(raw).unwrap();
2073 let json = serde_json::to_string(&state).unwrap();
2074 assert_eq!(
2075 serde_json::from_str::<DockerExpectedState>(&json).unwrap(),
2076 state
2077 );
2078 let DockerContainerState::Running(config) = state.state else {
2079 panic!("unexpected state");
2080 };
2081 let args = config.create_args(&"vllm".parse().unwrap(), &[]);
2082 assert_eq!(
2083 args[4..10],
2084 to_strings(&[
2085 "--gpus",
2086 "\"device=0,GPU-4b2c7f1e-9a3d-4e6f-8b1a-2c3d4e5f6a7b\"",
2087 "--ipc",
2088 "private",
2089 "--shm-size",
2090 "16g",
2091 ])
2092 );
2093
2094 let inspect = serde_json::json!({
2095 "Config": { "Image": "vllm/vllm-openai:v0.10.1", "Cmd": ["--model", "Qwen/Qwen3-8B"] },
2096 "HostConfig": {
2097 "IpcMode": "private",
2098 "ShmSize": 17179869184i64,
2099 "DeviceRequests": [{
2100 "Driver": "",
2101 "Count": 0,
2102 "DeviceIDs": ["0", "GPU-4b2c7f1e-9a3d-4e6f-8b1a-2c3d4e5f6a7b"],
2103 "Capabilities": [["gpu"]],
2104 }]
2105 }
2106 });
2107 assert!(config.recreation_reasons(&inspect, &[]).is_empty());
2108
2109 let all_gpus = DockerContainerConfig::new("vllm/vllm-openai:v0.10.1".parse().unwrap())
2110 .with_gpus(GpuSelection::All)
2111 .with_ipc(IpcNamespace::Host);
2112 let reasons = all_gpus.recreation_reasons(&inspect, &[]);
2113 assert_eq!(reasons.len(), 2, "{:?}", reasons);
2114
2115 let mut inspect = inspect;
2116 inspect["HostConfig"]["IpcMode"] = "host".into();
2117 inspect["HostConfig"]["DeviceRequests"][0]["Count"] = (-1).into();
2118 inspect["HostConfig"]["DeviceRequests"][0]["DeviceIDs"] = Value::Null;
2119 assert!(all_gpus.recreation_reasons(&inspect, &[]).is_empty());
2120 assert_eq!(
2121 all_gpus.create_args(&"vllm".parse().unwrap(), &[])[4..8],
2122 to_strings(&["--gpus", "all", "--ipc", "host"])
2123 );
2124 }
2125
2126 #[test]
2127 fn create_args_follow_docker_cli() {
2128 let config = DockerContainerConfig::new("nginx:1.27".parse().unwrap())
2129 .with_networking(DockerNetworking::attached(
2130 Some(
2131 NetworkList::try_from(vec![
2132 "frontend".parse().unwrap(),
2133 "backend".parse().unwrap(),
2134 ])
2135 .unwrap(),
2136 ),
2137 Some(vec!["8080:80".parse().unwrap()]),
2138 ))
2139 .with_env(
2140 "TZ".parse().unwrap(),
2141 Parameter::Clear("Europe/Paris".to_string()),
2142 )
2143 .with_init(true)
2144 .with_restart_policy(RestartPolicy::OnFailure(3))
2145 .with_health_check(
2146 HealthCheck::new("curl -f http://localhost || exit 1".parse().unwrap())
2147 .with_interval("30s".parse().unwrap())
2148 .with_retries(3),
2149 )
2150 .with_command(ContainerCommand::new(&["nginx", "-g", "daemon off;"]).unwrap());
2151 let env = vec![("TZ".to_string(), "Europe/Paris".to_string())];
2152 assert_eq!(
2153 config.create_args(&"web".parse().unwrap(), &env),
2154 to_strings(&[
2155 "docker",
2156 "create",
2157 "--name",
2158 "web",
2159 "--env",
2160 "TZ=Europe/Paris",
2161 "--network",
2162 "frontend",
2163 "--publish",
2164 "8080:80",
2165 "--restart",
2166 "on-failure:3",
2167 "--health-cmd",
2168 "curl -f http://localhost || exit 1",
2169 "--health-interval",
2170 "30s",
2171 "--health-retries",
2172 "3",
2173 "--init",
2174 "nginx:1.27",
2175 "nginx",
2176 "-g",
2177 "daemon off;",
2178 ])
2179 );
2180 assert_eq!(
2181 config
2182 .additional_networks()
2183 .iter()
2184 .map(NetworkName::as_str)
2185 .collect::<Vec<_>>(),
2186 vec!["backend"]
2187 );
2188 }
2189
2190 #[test]
2191 fn health_check_durations_follow_go_syntax() {
2192 let nanos = |spec: &str| {
2193 spec.parse::<HealthDuration>()
2194 .map(|duration| duration.nanos())
2195 };
2196 assert_eq!(nanos("30s").unwrap(), 30_000_000_000);
2197 assert_eq!(nanos("1m30s").unwrap(), 90_000_000_000);
2198 assert_eq!(nanos("500ms").unwrap(), 500_000_000);
2199 assert_eq!(nanos("1.5s").unwrap(), 1_500_000_000);
2200 assert_eq!(nanos("2h").unwrap(), 7_200_000_000_000);
2201 assert_eq!(nanos("100us").unwrap(), 100_000);
2202 assert_eq!("30s".parse::<HealthDuration>().unwrap().to_string(), "30s");
2203 for rejected in ["", "30", "lots", "0s", "-5s", "1x", "s", "1.2.3s"] {
2204 assert!(
2205 nanos(rejected).is_err(),
2206 "should be rejected: {:?}",
2207 rejected
2208 );
2209 }
2210
2211 assert!("".parse::<HealthCheckTest>().is_err());
2212 assert!(" ".parse::<HealthCheckTest>().is_err());
2213 assert_eq!(
2214 "curl -f http://localhost || exit 1"
2215 .parse::<HealthCheckTest>()
2216 .unwrap()
2217 .to_string(),
2218 "curl -f http://localhost || exit 1"
2219 );
2220 }
2221
2222 #[test]
2223 fn health_check_drift_is_detected() {
2224 let config = DockerContainerConfig::new("nginx".parse().unwrap()).with_health_check(
2225 HealthCheck::new("curl -f http://localhost || exit 1".parse().unwrap())
2226 .with_interval("30s".parse().unwrap())
2227 .with_timeout("5s".parse().unwrap())
2228 .with_start_period("10m".parse().unwrap())
2229 .with_retries(5),
2230 );
2231
2232 let inspect = serde_json::json!({ "Config": { "Image": "nginx" } });
2234 let reasons = config.recreation_reasons(&inspect, &[]);
2235 assert_eq!(reasons.len(), 5, "{:?}", reasons);
2236
2237 let inspect = serde_json::json!({
2239 "Config": {
2240 "Image": "nginx",
2241 "Healthcheck": {
2242 "Test": ["CMD-SHELL", "curl -f http://localhost || exit 1"],
2243 "Interval": 30000000000i64,
2244 "Timeout": 5000000000i64,
2245 "StartPeriod": 600000000000i64,
2246 "Retries": 5
2247 }
2248 }
2249 });
2250 assert!(config.recreation_reasons(&inspect, &[]).is_empty());
2251
2252 let mut inspect = inspect;
2254 inspect["Config"]["Healthcheck"]["Retries"] = 3.into();
2255 let reasons = config.recreation_reasons(&inspect, &[]);
2256 assert_eq!(reasons.len(), 1, "{:?}", reasons);
2257 assert!(reasons[0].contains("retries"), "{:?}", reasons);
2258
2259 let minimal = DockerContainerConfig::new("nginx".parse().unwrap()).with_health_check(
2261 HealthCheck::new("curl -f http://localhost || exit 1".parse().unwrap()),
2262 );
2263 assert!(minimal.recreation_reasons(&inspect, &[]).is_empty());
2264 }
2265
2266 #[test]
2267 fn choice_methods_match_the_explicit_enums() {
2268 let new = || DockerContainerConfig::new("nginx:1.27".parse().unwrap());
2269
2270 assert_eq!(
2271 new().with_pull_never(),
2272 new().with_pull_policy(PullPolicy::Never)
2273 );
2274 assert_eq!(
2275 new().with_pull_never().with_pull_if_missing(),
2276 new().with_pull_policy(PullPolicy::Missing)
2277 );
2278
2279 for (config, policy) in [
2280 (new().with_restart_never(), RestartPolicy::No),
2281 (new().with_restart_always(), RestartPolicy::Always),
2282 (
2283 new().with_restart_unless_stopped(),
2284 RestartPolicy::UnlessStopped,
2285 ),
2286 (
2287 new().with_restart_on_failure(3),
2288 RestartPolicy::OnFailure(3),
2289 ),
2290 ] {
2291 assert_eq!(config, new().with_restart_policy(policy));
2292 }
2293
2294 assert_eq!(
2295 new().with_host_networking(),
2296 new().with_networking(DockerNetworking::Host)
2297 );
2298 assert_eq!(
2299 new().with_isolated_networking(),
2300 new().with_networking(DockerNetworking::Isolated)
2301 );
2302 let attached = DockerNetworking::attached(
2303 Some(
2304 NetworkList::try_from(vec![
2305 "frontend".parse().unwrap(),
2306 "backend".parse().unwrap(),
2307 ])
2308 .unwrap(),
2309 ),
2310 Some(vec![
2311 "8080:80".parse().unwrap(),
2312 "127.0.0.1:8443:443".parse().unwrap(),
2313 ]),
2314 );
2315 assert_eq!(
2316 new()
2317 .with_network("frontend".parse().unwrap())
2318 .with_published_port("8080:80".parse().unwrap())
2319 .with_network("backend".parse().unwrap())
2320 .with_published_port("127.0.0.1:8443:443".parse().unwrap()),
2321 new().with_networking(attached.clone())
2322 );
2323 assert_eq!(
2325 new()
2326 .with_host_networking()
2327 .with_published_port("8080:80".parse().unwrap()),
2328 new().with_networking(DockerNetworking::attached(
2329 None,
2330 Some(vec!["8080:80".parse().unwrap()])
2331 ))
2332 );
2333 assert_eq!(
2334 new()
2335 .with_isolated_networking()
2336 .with_network("frontend".parse().unwrap()),
2337 new().with_networking(DockerNetworking::attached(
2338 Some(NetworkList::try_from(vec!["frontend".parse().unwrap()]).unwrap()),
2339 None
2340 ))
2341 );
2342 assert_eq!(
2344 new().with_networking(attached).with_host_networking(),
2345 new().with_networking(DockerNetworking::Host)
2346 );
2347
2348 assert_eq!(new().with_all_gpus(), new().with_gpus(GpuSelection::All));
2349 let devices = GpuSelection::Devices(
2350 GpuList::try_from(vec!["0".parse().unwrap(), "1".parse().unwrap()]).unwrap(),
2351 );
2352 assert_eq!(
2353 new()
2354 .with_gpu_device("0".parse().unwrap())
2355 .with_gpu_device("1".parse().unwrap()),
2356 new().with_gpus(devices.clone())
2357 );
2358 assert_eq!(
2359 new()
2360 .with_all_gpus()
2361 .with_gpu_device("0".parse().unwrap())
2362 .with_gpu_device("1".parse().unwrap()),
2363 new().with_gpus(devices)
2364 );
2365 assert_eq!(
2366 new().with_gpu_device("0".parse().unwrap()).with_all_gpus(),
2367 new().with_gpus(GpuSelection::All)
2368 );
2369
2370 let shm_size = || "16g".parse::<MemorySize>().unwrap();
2371 let own = || PrivateIpc::new().with_shm_size(shm_size());
2372 assert_eq!(new().with_host_ipc(), new().with_ipc(IpcNamespace::Host));
2373 assert_eq!(
2374 new().with_private_ipc(),
2375 new().with_ipc(IpcNamespace::Private(PrivateIpc::new()))
2376 );
2377 assert_eq!(
2378 new().with_shareable_ipc(),
2379 new().with_ipc(IpcNamespace::Shareable(PrivateIpc::new()))
2380 );
2381 assert_eq!(
2382 new().with_shm_size(shm_size()),
2383 new().with_ipc(IpcNamespace::Private(own()))
2384 );
2385 assert_eq!(
2387 new().with_host_ipc().with_shm_size(shm_size()),
2388 new().with_ipc(IpcNamespace::Private(own()))
2389 );
2390 assert_eq!(
2391 new().with_shareable_ipc().with_shm_size(shm_size()),
2392 new().with_ipc(IpcNamespace::Shareable(own()))
2393 );
2394 assert_eq!(
2395 new().with_shm_size(shm_size()).with_shareable_ipc(),
2396 new().with_ipc(IpcNamespace::Shareable(own()))
2397 );
2398 assert_eq!(
2399 new().with_shm_size(shm_size()).with_host_ipc(),
2400 new().with_ipc(IpcNamespace::Host)
2401 );
2402 }
2403}