1use async_trait::async_trait;
16use boatramp_core::compute::{
17 Artifact, BackendError, Capabilities, ComputeBackend, ComputeSpec, Endpoint, Health, Instance,
18 InstanceHandle, IsolationClass, LaunchRequest, RestartPolicy, RootSource, Scheme, VolumeRef,
19};
20use bollard::container::{
21 Config, CreateContainerOptions, RemoveContainerOptions, StopContainerOptions,
22};
23use bollard::image::CreateImageOptions;
24use bollard::models::{
25 HostConfig, Mount, MountTypeEnum, PortBinding, RestartPolicy as DockerRestartPolicy,
26 RestartPolicyNameEnum,
27};
28use bollard::Docker;
29use futures::StreamExt;
30use serde::{Deserialize, Serialize};
31use std::collections::HashMap;
32use std::path::{Path, PathBuf};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
48#[serde(rename_all = "lowercase")]
49pub enum DockerEndpoint {
50 #[default]
52 Published,
53 Bridge,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
67#[serde(rename_all = "lowercase")]
68pub enum DockerVolumeMode {
69 #[default]
71 Named,
72 Bind,
74}
75
76fn docker_volume_name(name: &str) -> String {
79 format!("boatramp-{name}")
80}
81
82fn volume_dir(data_dir: &Path, name: &str) -> PathBuf {
85 data_dir.join("compute").join("volumes").join(name)
86}
87
88fn parse_uid_gid(user: &str) -> Option<(u32, u32)> {
93 match user.split_once(':') {
94 Some((u, g)) => Some((u.parse().ok()?, g.parse().ok()?)),
95 None => {
96 let uid = user.parse().ok()?;
97 Some((uid, uid))
98 }
99 }
100}
101
102#[cfg(unix)]
106fn chown_dir(path: &Path, uid: u32, gid: u32) -> std::io::Result<()> {
107 std::os::unix::fs::chown(path, Some(uid), Some(gid))
108}
109#[cfg(not(unix))]
110fn chown_dir(_path: &Path, _uid: u32, _gid: u32) -> std::io::Result<()> {
111 Ok(())
112}
113
114fn validate_volume(name: &str, mount: &str) -> Result<(), BackendError> {
120 use std::path::Component;
121 let name_ok = matches!(
122 Path::new(name).components().collect::<Vec<_>>().as_slice(),
123 [Component::Normal(_)]
124 );
125 if !name_ok {
126 return Err(BackendError::Launch(format!(
127 "invalid volume name {name:?}: must be a single path component"
128 )));
129 }
130 let m = Path::new(mount);
131 let mount_ok = m.is_absolute()
132 && m.components()
133 .all(|c| matches!(c, Component::RootDir | Component::Normal(_)));
134 if !mount_ok {
135 return Err(BackendError::Launch(format!(
136 "invalid volume mount {mount:?}: must be an absolute path with no `..`"
137 )));
138 }
139 Ok(())
140}
141
142fn volume_mount(vol: &VolumeRef, mode: DockerVolumeMode, data_dir: &Path) -> Mount {
146 let (typ, source) = match mode {
147 DockerVolumeMode::Named => (MountTypeEnum::VOLUME, docker_volume_name(&vol.name)),
148 DockerVolumeMode::Bind => (
149 MountTypeEnum::BIND,
150 volume_dir(data_dir, &vol.name).display().to_string(),
151 ),
152 };
153 Mount {
154 target: Some(vol.mount.clone()),
155 source: Some(source),
156 typ: Some(typ),
157 read_only: Some(false),
158 ..Default::default()
159 }
160}
161
162pub struct DockerBackend {
164 docker: Docker,
165 endpoint: DockerEndpoint,
166 volume_mode: DockerVolumeMode,
168 data_dir: PathBuf,
170 writable_root_allowed: bool,
174 cap_add_allowed: bool,
178}
179
180impl DockerBackend {
181 pub fn connect() -> Result<Self, BackendError> {
184 let docker = Docker::connect_with_defaults()
185 .map_err(|e| BackendError::Other(format!("connect to docker: {e}")))?;
186 Ok(Self {
187 docker,
188 endpoint: DockerEndpoint::default(),
189 volume_mode: DockerVolumeMode::default(),
190 data_dir: PathBuf::from("."),
191 writable_root_allowed: false,
192 cap_add_allowed: false,
193 })
194 }
195
196 pub fn with_client(docker: Docker) -> Self {
198 Self {
199 docker,
200 endpoint: DockerEndpoint::default(),
201 volume_mode: DockerVolumeMode::default(),
202 data_dir: PathBuf::from("."),
203 writable_root_allowed: false,
204 cap_add_allowed: false,
205 }
206 }
207
208 pub fn with_endpoint(mut self, endpoint: DockerEndpoint) -> Self {
210 self.endpoint = endpoint;
211 self
212 }
213
214 pub fn with_writable_root_allowed(mut self, allowed: bool) -> Self {
217 self.writable_root_allowed = allowed;
218 self
219 }
220
221 pub fn with_cap_add_allowed(mut self, allowed: bool) -> Self {
225 self.cap_add_allowed = allowed;
226 self
227 }
228
229 pub fn with_volume_mode(mut self, mode: DockerVolumeMode) -> Self {
231 self.volume_mode = mode;
232 self
233 }
234
235 pub fn with_data_dir(mut self, data_dir: impl Into<PathBuf>) -> Self {
237 self.data_dir = data_dir.into();
238 self
239 }
240
241 pub async fn reachable(&self) -> bool {
244 self.docker.ping().await.is_ok()
245 }
246
247 async fn stage_volumes(&self, spec: &ComputeSpec) -> Result<Vec<Mount>, BackendError> {
253 let chown = spec.user.as_deref().and_then(parse_uid_gid);
257 let mut mounts = Vec::with_capacity(spec.volumes.len());
258 for vol in &spec.volumes {
259 validate_volume(&vol.name, &vol.mount)?;
260 if self.volume_mode == DockerVolumeMode::Bind {
261 let dir = volume_dir(&self.data_dir, &vol.name);
262 tokio::fs::create_dir_all(&dir).await.map_err(|e| {
263 BackendError::Launch(format!("create volume {} dir: {e}", vol.name))
264 })?;
265 if let Some((uid, gid)) = chown {
266 chown_dir(&dir, uid, gid).map_err(|e| {
267 BackendError::Launch(format!(
268 "chown volume {} to {uid}:{gid}: {e}",
269 vol.name
270 ))
271 })?;
272 }
273 }
274 mounts.push(volume_mount(vol, self.volume_mode, &self.data_dir));
275 }
276 Ok(mounts)
277 }
278}
279
280fn container_name(workload: &str, replica: u32) -> String {
282 format!("boatramp-{workload}-{replica}")
283}
284
285fn encode_ref(name: &str, ip: &str, port: u16) -> String {
288 format!("{name}@{ip}:{port}")
289}
290
291fn decode_ref(s: &str) -> Option<(String, String, u16)> {
293 let (name, rest) = s.split_once('@')?;
294 let (ip, port) = rest.rsplit_once(':')?;
295 Some((name.to_string(), ip.to_string(), port.parse().ok()?))
296}
297
298fn image_pull_target(reference: &str) -> (String, String) {
304 if reference.contains('@') {
305 return (reference.to_string(), String::new()); }
307 let last = reference.rsplit('/').next().unwrap_or(reference);
308 if last.contains(':') {
309 let (name, tag) = reference.rsplit_once(':').expect("last has ':'");
311 (name.to_string(), tag.to_string())
312 } else {
313 (reference.to_string(), "latest".to_string())
314 }
315}
316
317fn restart_policy(policy: RestartPolicy) -> DockerRestartPolicy {
319 let name = match policy {
320 RestartPolicy::Never => RestartPolicyNameEnum::NO,
321 RestartPolicy::OnFailure => RestartPolicyNameEnum::ON_FAILURE,
322 RestartPolicy::Always => RestartPolicyNameEnum::ALWAYS,
323 };
324 DockerRestartPolicy {
325 name: Some(name),
326 maximum_retry_count: None,
327 }
328}
329
330const MAX_PIDS: i64 = 512;
333
334fn hardened_host_config(
362 mem_mib: u32,
363 vcpus: u32,
364 restart: RestartPolicy,
365 writable_root: bool,
366 cap_add: &[String],
367) -> HostConfig {
368 let tmpfs = std::collections::HashMap::from([
369 (
370 "/tmp".to_string(),
371 "rw,noexec,nosuid,size=64m,mode=1777".to_string(),
372 ),
373 (
374 "/run".to_string(),
375 "rw,noexec,nosuid,size=16m,mode=1777".to_string(),
376 ),
377 ]);
378 HostConfig {
379 memory: Some(i64::from(mem_mib) * 1024 * 1024),
380 nano_cpus: Some(i64::from(vcpus.max(1)) * 1_000_000_000),
381 restart_policy: Some(restart_policy(restart)),
382 security_opt: Some(vec!["no-new-privileges:true".to_string()]),
384 cap_drop: Some(vec!["ALL".to_string()]),
385 cap_add: (!cap_add.is_empty()).then(|| cap_add.to_vec()),
387 readonly_rootfs: Some(!writable_root),
388 tmpfs: Some(tmpfs),
389 pids_limit: Some(MAX_PIDS),
390 ..Default::default()
391 }
392}
393
394#[async_trait]
395impl ComputeBackend for DockerBackend {
396 fn id(&self) -> &'static str {
397 "docker"
398 }
399
400 fn capabilities(&self) -> Capabilities {
401 Capabilities {
402 isolation: IsolationClass::Container,
403 scale_to_zero: false,
404 persistent_volumes: true,
405 max_vcpus: None,
406 max_mem_mib: None,
407 }
408 }
409
410 async fn materialize(&self, spec: &ComputeSpec) -> Result<Artifact, BackendError> {
411 let reference = match &spec.root {
414 RootSource::Image(reference) => reference.clone(),
415 RootSource::Tar(_) | RootSource::Rootfs(_) => {
416 return Err(BackendError::Materialize(
417 "docker backend requires an image reference (RootSource::Image)".into(),
418 ))
419 }
420 };
421 let (from_image, tag) = image_pull_target(&reference);
427 let options = CreateImageOptions {
428 from_image: from_image.clone(),
429 tag: tag.clone(),
430 ..Default::default()
431 };
432 let mut pull = self.docker.create_image(Some(options), None, None);
433 while let Some(step) = pull.next().await {
434 step.map_err(|e| BackendError::Materialize(format!("pull {reference}: {e}")))?;
435 }
436 let reference = if tag.is_empty() {
439 reference
440 } else {
441 format!("{from_image}:{tag}")
442 };
443 Ok(Artifact::Image { reference })
444 }
445
446 async fn launch(&self, req: &LaunchRequest) -> Result<Instance, BackendError> {
447 let reference = match &req.artifact {
448 Artifact::Image { reference } => reference.clone(),
449 _ => {
450 return Err(BackendError::Launch(
451 "docker backend requires an Image artifact".into(),
452 ))
453 }
454 };
455 let name = container_name(&req.workload, req.replica);
456 let env: Vec<String> = req
457 .spec
458 .env
459 .iter()
460 .map(|(k, v)| format!("{k}={v}"))
461 .collect();
462 let port = req.spec.port;
463 let port_key = format!("{port}/tcp");
464 let writable_root = req.spec.writable_root && self.writable_root_allowed;
467 let cap_add: &[String] = if self.cap_add_allowed {
470 &req.spec.cap_add
471 } else {
472 &[]
473 };
474 let mut host_config = hardened_host_config(
475 req.spec.mem_mib,
476 req.spec.vcpus,
477 req.spec.restart,
478 writable_root,
479 cap_add,
480 );
481 let mounts = self.stage_volumes(&req.spec).await?;
483 if !mounts.is_empty() {
484 host_config.mounts = Some(mounts);
485 }
486 let mut config = Config {
487 image: Some(reference),
488 cmd: Some(req.spec.entrypoint.clone()),
489 env: Some(env),
490 user: req.spec.user.clone(),
494 ..Default::default()
495 };
496 if self.endpoint == DockerEndpoint::Published {
501 config.exposed_ports = Some(HashMap::from([(port_key.clone(), HashMap::new())]));
502 host_config.port_bindings = Some(HashMap::from([(
503 port_key.clone(),
504 Some(vec![PortBinding {
505 host_ip: Some("127.0.0.1".to_string()),
506 host_port: Some("0".to_string()),
507 }]),
508 )]));
509 }
510 config.host_config = Some(host_config);
511
512 let _ = self
514 .docker
515 .remove_container(
516 &name,
517 Some(RemoveContainerOptions {
518 force: true,
519 ..Default::default()
520 }),
521 )
522 .await;
523 let created = self
524 .docker
525 .create_container(
526 Some(CreateContainerOptions {
527 name: name.clone(),
528 platform: None,
529 }),
530 config,
531 )
532 .await
533 .map_err(|e| BackendError::Launch(format!("create {name}: {e}")))?;
534 self.docker
535 .start_container::<String>(&created.id, None)
536 .await
537 .map_err(|e| BackendError::Launch(format!("start {name}: {e}")))?;
538
539 let (host, endpoint_port) = match self.endpoint {
542 DockerEndpoint::Published => (
543 "127.0.0.1".to_string(),
544 self.published_host_port(&created.id, &port_key).await?,
545 ),
546 DockerEndpoint::Bridge => (self.container_ip(&created.id).await?, port),
547 };
548 Ok(Instance {
549 handle: InstanceHandle {
550 workload: req.workload.clone(),
551 replica: req.replica,
552 backend_ref: encode_ref(&name, &host, endpoint_port),
553 },
554 endpoint: Endpoint {
555 scheme: Scheme::Http,
556 host,
557 port: endpoint_port,
558 },
559 })
560 }
561
562 async fn stop(&self, handle: &InstanceHandle) -> Result<(), BackendError> {
563 let name = decode_ref(&handle.backend_ref)
564 .map(|(n, _, _)| n)
565 .unwrap_or_else(|| container_name(&handle.workload, handle.replica));
566 let _ = self
568 .docker
569 .stop_container(&name, None::<StopContainerOptions>)
570 .await;
571 self.docker
572 .remove_container(
573 &name,
574 Some(RemoveContainerOptions {
575 force: true,
576 ..Default::default()
577 }),
578 )
579 .await
580 .map_err(|e| BackendError::Stop(format!("remove {name}: {e}")))?;
581 Ok(())
582 }
583
584 async fn health(&self, handle: &InstanceHandle) -> Result<Health, BackendError> {
585 let name = match decode_ref(&handle.backend_ref) {
586 Some((n, _, _)) => n,
587 None => container_name(&handle.workload, handle.replica),
588 };
589 let info = match self.docker.inspect_container(&name, None).await {
590 Ok(info) => info,
591 Err(_) => return Ok(Health::Unhealthy),
592 };
593 let running = info.state.and_then(|s| s.running).unwrap_or(false);
594 Ok(if running {
595 Health::Healthy
596 } else {
597 Health::Unhealthy
598 })
599 }
600}
601
602impl DockerBackend {
603 async fn container_ip(&self, id: &str) -> Result<String, BackendError> {
606 let info = self
607 .docker
608 .inspect_container(id, None)
609 .await
610 .map_err(|e| BackendError::Launch(format!("inspect {id}: {e}")))?;
611 let networks = info
612 .network_settings
613 .ok_or_else(|| BackendError::Launch("container has no network settings".into()))?;
614 if let Some(ip) = networks.ip_address.filter(|s| !s.is_empty()) {
616 return Ok(ip);
617 }
618 if let Some(nets) = networks.networks {
619 for net in nets.values() {
620 if let Some(ip) = net.ip_address.as_ref().filter(|s| !s.is_empty()) {
621 return Ok(ip.clone());
622 }
623 }
624 }
625 Err(BackendError::Launch("container has no IP address".into()))
626 }
627
628 async fn published_host_port(&self, id: &str, port_key: &str) -> Result<u16, BackendError> {
631 let info = self
632 .docker
633 .inspect_container(id, None)
634 .await
635 .map_err(|e| BackendError::Launch(format!("inspect {id}: {e}")))?;
636 info.network_settings
637 .and_then(|ns| ns.ports)
638 .and_then(|mut ports| ports.remove(port_key).flatten())
639 .and_then(|bindings| bindings.into_iter().next())
640 .and_then(|b| b.host_port)
641 .and_then(|hp| hp.parse::<u16>().ok())
642 .ok_or_else(|| {
643 BackendError::Launch(format!("no published host port for {port_key} on {id}"))
644 })
645 }
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651
652 #[test]
653 fn image_pull_target_defaults_untagged_to_latest() {
654 assert_eq!(
656 image_pull_target("alpine"),
657 ("alpine".into(), "latest".into())
658 );
659 assert_eq!(
660 image_pull_target("ghcr.io/owner/app"),
661 ("ghcr.io/owner/app".into(), "latest".into())
662 );
663 assert_eq!(
665 image_pull_target("nginx:1.27"),
666 ("nginx".into(), "1.27".into())
667 );
668 assert_eq!(
670 image_pull_target("localhost:5000/app"),
671 ("localhost:5000/app".into(), "latest".into())
672 );
673 assert_eq!(
674 image_pull_target("localhost:5000/app:v2"),
675 ("localhost:5000/app".into(), "v2".into())
676 );
677 let d = "alpine@sha256:abc123";
679 assert_eq!(image_pull_target(d), (d.into(), String::new()));
680 }
681
682 #[test]
683 fn docker_endpoint_defaults_to_published_and_parses_lowercase() {
684 assert_eq!(DockerEndpoint::default(), DockerEndpoint::Published);
686 assert_eq!(
688 serde_json::from_str::<DockerEndpoint>("\"published\"").unwrap(),
689 DockerEndpoint::Published
690 );
691 assert_eq!(
692 serde_json::from_str::<DockerEndpoint>("\"bridge\"").unwrap(),
693 DockerEndpoint::Bridge
694 );
695 }
696
697 #[test]
698 fn name_and_ref_round_trip() {
699 assert_eq!(container_name("web", 0), "boatramp-web-0");
700 let r = encode_ref("boatramp-web-0", "172.17.0.3", 8080);
701 assert_eq!(r, "boatramp-web-0@172.17.0.3:8080");
702 assert_eq!(
703 decode_ref(&r),
704 Some(("boatramp-web-0".to_string(), "172.17.0.3".to_string(), 8080))
705 );
706 assert_eq!(decode_ref("garbage"), None);
707 }
708
709 #[test]
710 fn host_config_is_hardened_by_default() {
711 let hc = hardened_host_config(256, 2, RestartPolicy::Never, false, &[]);
712 assert_eq!(hc.memory, Some(256 * 1024 * 1024));
714 assert_eq!(hc.nano_cpus, Some(2_000_000_000));
715 assert_eq!(hc.pids_limit, Some(MAX_PIDS));
716 assert_eq!(
718 hc.security_opt.as_deref(),
719 Some(["no-new-privileges:true".to_string()].as_slice())
720 );
721 assert_eq!(hc.cap_drop.as_deref(), Some(["ALL".to_string()].as_slice()));
722 assert_eq!(hc.cap_add, None);
724 assert_eq!(hc.readonly_rootfs, Some(true));
725 let tmpfs = hc.tmpfs.expect("tmpfs mounts for a read-only rootfs");
727 assert!(tmpfs.get("/tmp").is_some_and(|o| o.contains("noexec")));
728 assert!(tmpfs.get("/run").is_some_and(|o| o.contains("mode=1777")));
732 assert!(tmpfs.get("/tmp").is_some_and(|o| o.contains("mode=1777")));
733 assert_eq!(
735 hardened_host_config(64, 0, RestartPolicy::Never, false, &[]).nano_cpus,
736 Some(1_000_000_000)
737 );
738 }
739
740 #[test]
741 fn writable_root_relaxes_only_the_read_only_root() {
742 let hc = hardened_host_config(256, 2, RestartPolicy::Never, true, &[]);
743 assert_eq!(hc.readonly_rootfs, Some(false));
745 assert_eq!(
747 hc.security_opt.as_deref(),
748 Some(["no-new-privileges:true".to_string()].as_slice())
749 );
750 assert_eq!(hc.cap_drop.as_deref(), Some(["ALL".to_string()].as_slice()));
751 assert_eq!(hc.cap_add, None);
752 assert_eq!(hc.pids_limit, Some(MAX_PIDS));
753 }
754
755 #[test]
756 fn cap_add_adds_back_only_the_allowlist() {
757 let caps = ["CHOWN".to_string(), "SETUID".to_string()];
758 let hc = hardened_host_config(256, 2, RestartPolicy::Never, false, &caps);
759 assert_eq!(hc.cap_drop.as_deref(), Some(["ALL".to_string()].as_slice()));
761 assert_eq!(hc.cap_add.as_deref(), Some(caps.as_slice()));
762 assert_eq!(
764 hc.security_opt.as_deref(),
765 Some(["no-new-privileges:true".to_string()].as_slice())
766 );
767 assert_eq!(hc.readonly_rootfs, Some(true));
768 }
769
770 #[test]
771 fn writable_root_is_off_by_default_on_the_backend() {
772 let docker = Docker::connect_with_defaults().unwrap();
774 let backend = DockerBackend::with_client(docker);
775 assert!(!backend.writable_root_allowed);
776 assert!(
777 backend
778 .with_writable_root_allowed(true)
779 .writable_root_allowed,
780 "the single-tenant posture opts in"
781 );
782 }
783
784 #[test]
785 fn parse_uid_gid_handles_uid_and_uid_gid() {
786 assert_eq!(parse_uid_gid("999"), Some((999, 999)));
787 assert_eq!(parse_uid_gid("1000:1001"), Some((1000, 1001)));
788 assert_eq!(parse_uid_gid("postgres"), None);
790 assert_eq!(parse_uid_gid("999:abc"), None);
791 }
792
793 #[test]
794 fn cap_add_is_off_by_default_on_the_backend() {
795 let docker = Docker::connect_with_defaults().unwrap();
797 let backend = DockerBackend::with_client(docker);
798 assert!(!backend.cap_add_allowed);
799 assert!(
800 backend.with_cap_add_allowed(true).cap_add_allowed,
801 "the single-tenant posture opts in"
802 );
803 }
804
805 #[test]
806 fn named_volume_mode_builds_a_prefixed_daemon_volume_mount() {
807 let vol = VolumeRef {
808 name: "db".into(),
809 mount: "/data".into(),
810 size_mib: 64,
811 };
812 let m = volume_mount(&vol, DockerVolumeMode::Named, Path::new("/srv/data"));
813 assert_eq!(m.typ, Some(MountTypeEnum::VOLUME));
814 assert_eq!(m.source.as_deref(), Some("boatramp-db"));
816 assert_eq!(m.target.as_deref(), Some("/data"));
817 assert_eq!(m.read_only, Some(false), "a persistent volume is writable");
818 }
819
820 #[test]
821 fn bind_volume_mode_builds_a_host_path_mount() {
822 let vol = VolumeRef {
823 name: "db".into(),
824 mount: "/data".into(),
825 size_mib: 64,
826 };
827 let m = volume_mount(&vol, DockerVolumeMode::Bind, Path::new("/srv/data"));
828 assert_eq!(m.typ, Some(MountTypeEnum::BIND));
829 assert_eq!(m.source.as_deref(), Some("/srv/data/compute/volumes/db"));
830 assert_eq!(m.target.as_deref(), Some("/data"));
831 assert_eq!(m.read_only, Some(false));
832 }
833
834 #[test]
835 fn validate_volume_rejects_traversal_in_name_and_mount() {
836 assert!(validate_volume("db", "/data").is_ok());
837 assert!(validate_volume("cache-1", "/var/lib/app").is_ok());
838 assert!(validate_volume("../etc", "/data").is_err());
840 assert!(validate_volume("a/b", "/data").is_err());
841 assert!(validate_volume("db", "relative").is_err());
843 assert!(validate_volume("db", "/data/../etc").is_err());
844 }
845
846 #[test]
847 fn volume_mode_defaults_to_named_and_parses_lowercase() {
848 assert_eq!(DockerVolumeMode::default(), DockerVolumeMode::Named);
849 assert_eq!(
850 serde_json::from_str::<DockerVolumeMode>("\"named\"").unwrap(),
851 DockerVolumeMode::Named
852 );
853 assert_eq!(
854 serde_json::from_str::<DockerVolumeMode>("\"bind\"").unwrap(),
855 DockerVolumeMode::Bind
856 );
857 }
858
859 #[test]
860 fn restart_policy_maps_to_docker() {
861 assert_eq!(
862 restart_policy(RestartPolicy::Always).name,
863 Some(RestartPolicyNameEnum::ALWAYS)
864 );
865 assert_eq!(
866 restart_policy(RestartPolicy::OnFailure).name,
867 Some(RestartPolicyNameEnum::ON_FAILURE)
868 );
869 assert_eq!(
870 restart_policy(RestartPolicy::Never).name,
871 Some(RestartPolicyNameEnum::NO)
872 );
873 }
874}