1use async_trait::async_trait;
16use boatramp_core::compute::{
17 compute_instance_id, Artifact, BackendError, Capabilities, ComputeBackend, ComputeSpec,
18 Endpoint, ExecOutput, Health, Instance, InstanceHandle, IsolationClass, LaunchRequest,
19 RestartPolicy, RootSource, Scheme, VolumeRef,
20};
21use bollard::container::{
22 Config, CreateContainerOptions, LogOutput, RemoveContainerOptions, StopContainerOptions,
23};
24use bollard::exec::{CreateExecOptions, StartExecResults};
25use bollard::image::CreateImageOptions;
26use bollard::models::{
27 HostConfig, Mount, MountTypeEnum, PortBinding, RestartPolicy as DockerRestartPolicy,
28 RestartPolicyNameEnum,
29};
30use bollard::Docker;
31use futures::StreamExt;
32use serde::{Deserialize, Serialize};
33use std::collections::HashMap;
34use std::path::{Path, PathBuf};
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
50#[serde(rename_all = "lowercase")]
51pub enum DockerEndpoint {
52 #[default]
54 Published,
55 Bridge,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
69#[serde(rename_all = "lowercase")]
70pub enum DockerVolumeMode {
71 #[default]
73 Named,
74 Bind,
76}
77
78fn docker_volume_name(name: &str) -> String {
81 format!("boatramp-{name}")
82}
83
84fn volume_dir(data_dir: &Path, name: &str) -> PathBuf {
87 data_dir.join("compute").join("volumes").join(name)
88}
89
90fn parse_uid_gid(user: &str) -> Option<(u32, u32)> {
95 match user.split_once(':') {
96 Some((u, g)) => Some((u.parse().ok()?, g.parse().ok()?)),
97 None => {
98 let uid = user.parse().ok()?;
99 Some((uid, uid))
100 }
101 }
102}
103
104#[cfg(unix)]
108fn chown_dir(path: &Path, uid: u32, gid: u32) -> std::io::Result<()> {
109 std::os::unix::fs::chown(path, Some(uid), Some(gid))
110}
111#[cfg(not(unix))]
112fn chown_dir(_path: &Path, _uid: u32, _gid: u32) -> std::io::Result<()> {
113 Ok(())
114}
115
116fn validate_volume(name: &str, mount: &str) -> Result<(), BackendError> {
122 use std::path::Component;
123 let name_ok = matches!(
124 Path::new(name).components().collect::<Vec<_>>().as_slice(),
125 [Component::Normal(_)]
126 );
127 if !name_ok {
128 return Err(BackendError::Launch(format!(
129 "invalid volume name {name:?}: must be a single path component"
130 )));
131 }
132 let m = Path::new(mount);
133 let mount_ok = m.is_absolute()
134 && m.components()
135 .all(|c| matches!(c, Component::RootDir | Component::Normal(_)));
136 if !mount_ok {
137 return Err(BackendError::Launch(format!(
138 "invalid volume mount {mount:?}: must be an absolute path with no `..`"
139 )));
140 }
141 Ok(())
142}
143
144fn volume_mount(vol: &VolumeRef, mode: DockerVolumeMode, data_dir: &Path) -> Mount {
148 let (typ, source) = match mode {
149 DockerVolumeMode::Named => (MountTypeEnum::VOLUME, docker_volume_name(&vol.name)),
150 DockerVolumeMode::Bind => (
151 MountTypeEnum::BIND,
152 volume_dir(data_dir, &vol.name).display().to_string(),
153 ),
154 };
155 Mount {
156 target: Some(vol.mount.clone()),
157 source: Some(source),
158 typ: Some(typ),
159 read_only: Some(false),
160 ..Default::default()
161 }
162}
163
164pub struct DockerBackend {
166 docker: Docker,
167 endpoint: DockerEndpoint,
168 volume_mode: DockerVolumeMode,
170 data_dir: PathBuf,
172 writable_root_allowed: bool,
176 cap_add_allowed: bool,
180}
181
182impl DockerBackend {
183 pub fn connect() -> Result<Self, BackendError> {
186 let docker = Docker::connect_with_defaults()
187 .map_err(|e| BackendError::Other(format!("connect to docker: {e}")))?;
188 Ok(Self {
189 docker,
190 endpoint: DockerEndpoint::default(),
191 volume_mode: DockerVolumeMode::default(),
192 data_dir: PathBuf::from("."),
193 writable_root_allowed: false,
194 cap_add_allowed: false,
195 })
196 }
197
198 pub fn with_client(docker: Docker) -> Self {
200 Self {
201 docker,
202 endpoint: DockerEndpoint::default(),
203 volume_mode: DockerVolumeMode::default(),
204 data_dir: PathBuf::from("."),
205 writable_root_allowed: false,
206 cap_add_allowed: false,
207 }
208 }
209
210 pub fn with_endpoint(mut self, endpoint: DockerEndpoint) -> Self {
212 self.endpoint = endpoint;
213 self
214 }
215
216 pub fn with_writable_root_allowed(mut self, allowed: bool) -> Self {
219 self.writable_root_allowed = allowed;
220 self
221 }
222
223 pub fn with_cap_add_allowed(mut self, allowed: bool) -> Self {
227 self.cap_add_allowed = allowed;
228 self
229 }
230
231 pub fn with_volume_mode(mut self, mode: DockerVolumeMode) -> Self {
233 self.volume_mode = mode;
234 self
235 }
236
237 pub fn with_data_dir(mut self, data_dir: impl Into<PathBuf>) -> Self {
239 self.data_dir = data_dir.into();
240 self
241 }
242
243 pub async fn reachable(&self) -> bool {
246 self.docker.ping().await.is_ok()
247 }
248
249 async fn stage_volumes(&self, spec: &ComputeSpec) -> Result<Vec<Mount>, BackendError> {
255 let chown = spec.user.as_deref().and_then(parse_uid_gid);
259 let mut mounts = Vec::with_capacity(spec.volumes.len());
260 for vol in &spec.volumes {
261 validate_volume(&vol.name, &vol.mount)?;
262 if self.volume_mode == DockerVolumeMode::Bind {
263 let dir = volume_dir(&self.data_dir, &vol.name);
264 tokio::fs::create_dir_all(&dir).await.map_err(|e| {
265 BackendError::Launch(format!("create volume {} dir: {e}", vol.name))
266 })?;
267 if let Some((uid, gid)) = chown {
268 chown_dir(&dir, uid, gid).map_err(|e| {
269 BackendError::Launch(format!(
270 "chown volume {} to {uid}:{gid}: {e}",
271 vol.name
272 ))
273 })?;
274 }
275 }
276 mounts.push(volume_mount(vol, self.volume_mode, &self.data_dir));
277 }
278 Ok(mounts)
279 }
280}
281
282fn container_name(project: &str, workload: &str, replica: u32) -> String {
287 format!(
288 "boatramp-{}",
289 compute_instance_id(project, workload, replica)
290 )
291}
292
293fn encode_ref(name: &str, ip: &str, port: u16) -> String {
296 format!("{name}@{ip}:{port}")
297}
298
299fn decode_ref(s: &str) -> Option<(String, String, u16)> {
301 let (name, rest) = s.split_once('@')?;
302 let (ip, port) = rest.rsplit_once(':')?;
303 Some((name.to_string(), ip.to_string(), port.parse().ok()?))
304}
305
306fn image_pull_target(reference: &str) -> (String, String) {
312 if reference.contains('@') {
313 return (reference.to_string(), String::new()); }
315 let last = reference.rsplit('/').next().unwrap_or(reference);
316 if last.contains(':') {
317 let (name, tag) = reference.rsplit_once(':').expect("last has ':'");
319 (name.to_string(), tag.to_string())
320 } else {
321 (reference.to_string(), "latest".to_string())
322 }
323}
324
325fn restart_policy(policy: RestartPolicy) -> DockerRestartPolicy {
327 let name = match policy {
328 RestartPolicy::Never => RestartPolicyNameEnum::NO,
329 RestartPolicy::OnFailure => RestartPolicyNameEnum::ON_FAILURE,
330 RestartPolicy::Always => RestartPolicyNameEnum::ALWAYS,
331 };
332 DockerRestartPolicy {
333 name: Some(name),
334 maximum_retry_count: None,
335 }
336}
337
338const MAX_PIDS: i64 = 512;
341
342fn hardened_host_config(
370 mem_mib: u32,
371 vcpus: u32,
372 restart: RestartPolicy,
373 writable_root: bool,
374 cap_add: &[String],
375) -> HostConfig {
376 let tmpfs = std::collections::HashMap::from([
377 (
378 "/tmp".to_string(),
379 "rw,noexec,nosuid,size=64m,mode=1777".to_string(),
380 ),
381 (
382 "/run".to_string(),
383 "rw,noexec,nosuid,size=16m,mode=1777".to_string(),
384 ),
385 ]);
386 HostConfig {
387 memory: Some(i64::from(mem_mib) * 1024 * 1024),
388 nano_cpus: Some(i64::from(vcpus.max(1)) * 1_000_000_000),
389 restart_policy: Some(restart_policy(restart)),
390 security_opt: Some(vec!["no-new-privileges:true".to_string()]),
392 cap_drop: Some(vec!["ALL".to_string()]),
393 cap_add: (!cap_add.is_empty()).then(|| cap_add.to_vec()),
395 readonly_rootfs: Some(!writable_root),
396 tmpfs: Some(tmpfs),
397 pids_limit: Some(MAX_PIDS),
398 ..Default::default()
399 }
400}
401
402#[async_trait]
403impl ComputeBackend for DockerBackend {
404 fn id(&self) -> &'static str {
405 "docker"
406 }
407
408 fn capabilities(&self) -> Capabilities {
409 Capabilities {
410 isolation: IsolationClass::Container,
411 scale_to_zero: false,
412 persistent_volumes: true,
413 max_vcpus: None,
414 max_mem_mib: None,
415 }
416 }
417
418 async fn materialize(&self, spec: &ComputeSpec) -> Result<Artifact, BackendError> {
419 let reference = match &spec.root {
422 RootSource::Image(reference) => reference.clone(),
423 RootSource::Tar(_) | RootSource::Rootfs(_) => {
424 return Err(BackendError::Materialize(
425 "docker backend requires an image reference (RootSource::Image)".into(),
426 ))
427 }
428 };
429 let (from_image, tag) = image_pull_target(&reference);
435 let options = CreateImageOptions {
436 from_image: from_image.clone(),
437 tag: tag.clone(),
438 ..Default::default()
439 };
440 let mut pull = self.docker.create_image(Some(options), None, None);
441 while let Some(step) = pull.next().await {
442 step.map_err(|e| BackendError::Materialize(format!("pull {reference}: {e}")))?;
443 }
444 let reference = if tag.is_empty() {
447 reference
448 } else {
449 format!("{from_image}:{tag}")
450 };
451 Ok(Artifact::Image { reference })
452 }
453
454 async fn launch(&self, req: &LaunchRequest) -> Result<Instance, BackendError> {
455 let reference = match &req.artifact {
456 Artifact::Image { reference } => reference.clone(),
457 _ => {
458 return Err(BackendError::Launch(
459 "docker backend requires an Image artifact".into(),
460 ))
461 }
462 };
463 let name = container_name(&req.project, &req.workload, req.replica);
464 let env: Vec<String> = req
465 .spec
466 .env
467 .iter()
468 .map(|(k, v)| format!("{k}={v}"))
469 .collect();
470 let port = req.spec.port;
471 let port_key = format!("{port}/tcp");
472 let writable_root = req.spec.writable_root && self.writable_root_allowed;
475 let cap_add: &[String] = if self.cap_add_allowed {
478 &req.spec.cap_add
479 } else {
480 &[]
481 };
482 let mut host_config = hardened_host_config(
483 req.spec.mem_mib,
484 req.spec.vcpus,
485 req.spec.restart,
486 writable_root,
487 cap_add,
488 );
489 let mounts = self.stage_volumes(&req.spec).await?;
491 if !mounts.is_empty() {
492 host_config.mounts = Some(mounts);
493 }
494 let mut config = Config {
495 image: Some(reference),
496 cmd: Some(req.spec.entrypoint.clone()),
497 env: Some(env),
498 user: req.spec.user.clone(),
502 ..Default::default()
503 };
504 if self.endpoint == DockerEndpoint::Published {
509 config.exposed_ports = Some(HashMap::from([(port_key.clone(), HashMap::new())]));
510 host_config.port_bindings = Some(HashMap::from([(
511 port_key.clone(),
512 Some(vec![PortBinding {
513 host_ip: Some("127.0.0.1".to_string()),
514 host_port: Some("0".to_string()),
515 }]),
516 )]));
517 }
518 config.host_config = Some(host_config);
519
520 let _ = self
522 .docker
523 .remove_container(
524 &name,
525 Some(RemoveContainerOptions {
526 force: true,
527 ..Default::default()
528 }),
529 )
530 .await;
531 let created = self
532 .docker
533 .create_container(
534 Some(CreateContainerOptions {
535 name: name.clone(),
536 platform: None,
537 }),
538 config,
539 )
540 .await
541 .map_err(|e| BackendError::Launch(format!("create {name}: {e}")))?;
542 self.docker
543 .start_container::<String>(&created.id, None)
544 .await
545 .map_err(|e| BackendError::Launch(format!("start {name}: {e}")))?;
546
547 let (host, endpoint_port) = match self.endpoint {
550 DockerEndpoint::Published => (
551 "127.0.0.1".to_string(),
552 self.published_host_port(&created.id, &port_key).await?,
553 ),
554 DockerEndpoint::Bridge => (self.container_ip(&created.id).await?, port),
555 };
556 Ok(Instance {
557 handle: InstanceHandle {
558 project: req.project.clone(),
559 workload: req.workload.clone(),
560 replica: req.replica,
561 backend_ref: encode_ref(&name, &host, endpoint_port),
562 },
563 endpoint: Endpoint {
564 scheme: Scheme::Http,
565 host,
566 port: endpoint_port,
567 },
568 })
569 }
570
571 async fn stop(&self, handle: &InstanceHandle) -> Result<(), BackendError> {
572 let name = decode_ref(&handle.backend_ref)
573 .map(|(n, _, _)| n)
574 .unwrap_or_else(|| container_name(&handle.project, &handle.workload, handle.replica));
575 let _ = self
577 .docker
578 .stop_container(&name, None::<StopContainerOptions>)
579 .await;
580 self.docker
581 .remove_container(
582 &name,
583 Some(RemoveContainerOptions {
584 force: true,
585 ..Default::default()
586 }),
587 )
588 .await
589 .map_err(|e| BackendError::Stop(format!("remove {name}: {e}")))?;
590 Ok(())
591 }
592
593 async fn health(&self, handle: &InstanceHandle) -> Result<Health, BackendError> {
594 let name = match decode_ref(&handle.backend_ref) {
595 Some((n, _, _)) => n,
596 None => container_name(&handle.project, &handle.workload, handle.replica),
597 };
598 let info = match self.docker.inspect_container(&name, None).await {
599 Ok(info) => info,
600 Err(_) => return Ok(Health::Unhealthy),
601 };
602 let running = info.state.and_then(|s| s.running).unwrap_or(false);
603 Ok(if running {
604 Health::Healthy
605 } else {
606 Health::Unhealthy
607 })
608 }
609
610 async fn exec(
620 &self,
621 handle: &InstanceHandle,
622 argv: &[String],
623 stdin: Option<&[u8]>,
624 ) -> Result<ExecOutput, BackendError> {
625 use tokio::io::AsyncWriteExt;
626
627 let name = match decode_ref(&handle.backend_ref) {
628 Some((n, _, _)) => n,
629 None => container_name(&handle.project, &handle.workload, handle.replica),
630 };
631 if argv.is_empty() {
632 return Err(BackendError::Other("exec: empty argv".into()));
633 }
634 let created = self
635 .docker
636 .create_exec(
637 &name,
638 CreateExecOptions {
639 cmd: Some(argv.to_vec()),
640 attach_stdout: Some(true),
641 attach_stderr: Some(true),
642 attach_stdin: Some(stdin.is_some()),
643 ..Default::default()
644 },
645 )
646 .await
647 .map_err(|e| BackendError::Other(format!("exec create on {name}: {e}")))?;
648 let started = self
649 .docker
650 .start_exec(&created.id, None)
651 .await
652 .map_err(|e| BackendError::Other(format!("exec start on {name}: {e}")))?;
653
654 let mut stdout = Vec::new();
655 let mut stderr = Vec::new();
656 match started {
657 StartExecResults::Attached { mut output, input } => {
658 if let Some(bytes) = stdin {
661 let mut input = input;
662 input.write_all(bytes).await.map_err(|e| {
663 BackendError::Other(format!("exec stdin write on {name}: {e}"))
664 })?;
665 input.flush().await.map_err(|e| {
666 BackendError::Other(format!("exec stdin flush on {name}: {e}"))
667 })?;
668 input.shutdown().await.map_err(|e| {
669 BackendError::Other(format!("exec stdin close on {name}: {e}"))
670 })?;
671 } else {
672 drop(input);
674 }
675 while let Some(chunk) = output.next().await {
676 match chunk
677 .map_err(|e| BackendError::Other(format!("exec output on {name}: {e}")))?
678 {
679 LogOutput::StdOut { message } => stdout.extend_from_slice(&message),
680 LogOutput::StdErr { message } | LogOutput::Console { message } => {
683 stderr.extend_from_slice(&message);
684 }
685 LogOutput::StdIn { .. } => {}
686 }
687 }
688 }
689 StartExecResults::Detached => {}
692 }
693
694 let inspected = self
698 .docker
699 .inspect_exec(&created.id)
700 .await
701 .map_err(|e| BackendError::Other(format!("exec inspect on {name}: {e}")))?;
702 let exit_code = inspected.exit_code.unwrap_or(0) as i32;
703
704 Ok(ExecOutput {
705 exit_code,
706 stdout,
707 stderr,
708 })
709 }
710}
711
712impl DockerBackend {
713 async fn container_ip(&self, id: &str) -> Result<String, BackendError> {
716 let info = self
717 .docker
718 .inspect_container(id, None)
719 .await
720 .map_err(|e| BackendError::Launch(format!("inspect {id}: {e}")))?;
721 let networks = info
722 .network_settings
723 .ok_or_else(|| BackendError::Launch("container has no network settings".into()))?;
724 if let Some(ip) = networks.ip_address.filter(|s| !s.is_empty()) {
726 return Ok(ip);
727 }
728 if let Some(nets) = networks.networks {
729 for net in nets.values() {
730 if let Some(ip) = net.ip_address.as_ref().filter(|s| !s.is_empty()) {
731 return Ok(ip.clone());
732 }
733 }
734 }
735 Err(BackendError::Launch("container has no IP address".into()))
736 }
737
738 async fn published_host_port(&self, id: &str, port_key: &str) -> Result<u16, BackendError> {
741 let info = self
742 .docker
743 .inspect_container(id, None)
744 .await
745 .map_err(|e| BackendError::Launch(format!("inspect {id}: {e}")))?;
746 info.network_settings
747 .and_then(|ns| ns.ports)
748 .and_then(|mut ports| ports.remove(port_key).flatten())
749 .and_then(|bindings| bindings.into_iter().next())
750 .and_then(|b| b.host_port)
751 .and_then(|hp| hp.parse::<u16>().ok())
752 .ok_or_else(|| {
753 BackendError::Launch(format!("no published host port for {port_key} on {id}"))
754 })
755 }
756}
757
758#[cfg(test)]
759mod tests {
760 use super::*;
761
762 #[test]
763 fn image_pull_target_defaults_untagged_to_latest() {
764 assert_eq!(
766 image_pull_target("alpine"),
767 ("alpine".into(), "latest".into())
768 );
769 assert_eq!(
770 image_pull_target("ghcr.io/owner/app"),
771 ("ghcr.io/owner/app".into(), "latest".into())
772 );
773 assert_eq!(
775 image_pull_target("nginx:1.27"),
776 ("nginx".into(), "1.27".into())
777 );
778 assert_eq!(
780 image_pull_target("localhost:5000/app"),
781 ("localhost:5000/app".into(), "latest".into())
782 );
783 assert_eq!(
784 image_pull_target("localhost:5000/app:v2"),
785 ("localhost:5000/app".into(), "v2".into())
786 );
787 let d = "alpine@sha256:abc123";
789 assert_eq!(image_pull_target(d), (d.into(), String::new()));
790 }
791
792 #[test]
793 fn docker_endpoint_defaults_to_published_and_parses_lowercase() {
794 assert_eq!(DockerEndpoint::default(), DockerEndpoint::Published);
796 assert_eq!(
798 serde_json::from_str::<DockerEndpoint>("\"published\"").unwrap(),
799 DockerEndpoint::Published
800 );
801 assert_eq!(
802 serde_json::from_str::<DockerEndpoint>("\"bridge\"").unwrap(),
803 DockerEndpoint::Bridge
804 );
805 }
806
807 #[test]
808 fn name_and_ref_round_trip() {
809 assert_eq!(container_name("default", "web", 0), "boatramp-web-0");
812 assert_eq!(container_name("acme", "web", 0), "boatramp-acme-web-0");
813 assert_ne!(
814 container_name("acme", "web", 0),
815 container_name("beta", "web", 0)
816 );
817 let r = encode_ref("boatramp-web-0", "172.17.0.3", 8080);
818 assert_eq!(r, "boatramp-web-0@172.17.0.3:8080");
819 assert_eq!(
820 decode_ref(&r),
821 Some(("boatramp-web-0".to_string(), "172.17.0.3".to_string(), 8080))
822 );
823 assert_eq!(decode_ref("garbage"), None);
824 }
825
826 #[test]
827 fn host_config_is_hardened_by_default() {
828 let hc = hardened_host_config(256, 2, RestartPolicy::Never, false, &[]);
829 assert_eq!(hc.memory, Some(256 * 1024 * 1024));
831 assert_eq!(hc.nano_cpus, Some(2_000_000_000));
832 assert_eq!(hc.pids_limit, Some(MAX_PIDS));
833 assert_eq!(
835 hc.security_opt.as_deref(),
836 Some(["no-new-privileges:true".to_string()].as_slice())
837 );
838 assert_eq!(hc.cap_drop.as_deref(), Some(["ALL".to_string()].as_slice()));
839 assert_eq!(hc.cap_add, None);
841 assert_eq!(hc.readonly_rootfs, Some(true));
842 let tmpfs = hc.tmpfs.expect("tmpfs mounts for a read-only rootfs");
844 assert!(tmpfs.get("/tmp").is_some_and(|o| o.contains("noexec")));
845 assert!(tmpfs.get("/run").is_some_and(|o| o.contains("mode=1777")));
849 assert!(tmpfs.get("/tmp").is_some_and(|o| o.contains("mode=1777")));
850 assert_eq!(
852 hardened_host_config(64, 0, RestartPolicy::Never, false, &[]).nano_cpus,
853 Some(1_000_000_000)
854 );
855 }
856
857 #[test]
858 fn writable_root_relaxes_only_the_read_only_root() {
859 let hc = hardened_host_config(256, 2, RestartPolicy::Never, true, &[]);
860 assert_eq!(hc.readonly_rootfs, Some(false));
862 assert_eq!(
864 hc.security_opt.as_deref(),
865 Some(["no-new-privileges:true".to_string()].as_slice())
866 );
867 assert_eq!(hc.cap_drop.as_deref(), Some(["ALL".to_string()].as_slice()));
868 assert_eq!(hc.cap_add, None);
869 assert_eq!(hc.pids_limit, Some(MAX_PIDS));
870 }
871
872 #[test]
873 fn cap_add_adds_back_only_the_allowlist() {
874 let caps = ["CHOWN".to_string(), "SETUID".to_string()];
875 let hc = hardened_host_config(256, 2, RestartPolicy::Never, false, &caps);
876 assert_eq!(hc.cap_drop.as_deref(), Some(["ALL".to_string()].as_slice()));
878 assert_eq!(hc.cap_add.as_deref(), Some(caps.as_slice()));
879 assert_eq!(
881 hc.security_opt.as_deref(),
882 Some(["no-new-privileges:true".to_string()].as_slice())
883 );
884 assert_eq!(hc.readonly_rootfs, Some(true));
885 }
886
887 #[test]
888 fn writable_root_is_off_by_default_on_the_backend() {
889 let docker = Docker::connect_with_defaults().unwrap();
891 let backend = DockerBackend::with_client(docker);
892 assert!(!backend.writable_root_allowed);
893 assert!(
894 backend
895 .with_writable_root_allowed(true)
896 .writable_root_allowed,
897 "the single-tenant posture opts in"
898 );
899 }
900
901 #[test]
902 fn parse_uid_gid_handles_uid_and_uid_gid() {
903 assert_eq!(parse_uid_gid("999"), Some((999, 999)));
904 assert_eq!(parse_uid_gid("1000:1001"), Some((1000, 1001)));
905 assert_eq!(parse_uid_gid("postgres"), None);
907 assert_eq!(parse_uid_gid("999:abc"), None);
908 }
909
910 #[test]
911 fn cap_add_is_off_by_default_on_the_backend() {
912 let docker = Docker::connect_with_defaults().unwrap();
914 let backend = DockerBackend::with_client(docker);
915 assert!(!backend.cap_add_allowed);
916 assert!(
917 backend.with_cap_add_allowed(true).cap_add_allowed,
918 "the single-tenant posture opts in"
919 );
920 }
921
922 #[test]
923 fn named_volume_mode_builds_a_prefixed_daemon_volume_mount() {
924 let vol = VolumeRef {
925 name: "db".into(),
926 mount: "/data".into(),
927 size_mib: 64,
928 };
929 let m = volume_mount(&vol, DockerVolumeMode::Named, Path::new("/srv/data"));
930 assert_eq!(m.typ, Some(MountTypeEnum::VOLUME));
931 assert_eq!(m.source.as_deref(), Some("boatramp-db"));
933 assert_eq!(m.target.as_deref(), Some("/data"));
934 assert_eq!(m.read_only, Some(false), "a persistent volume is writable");
935 }
936
937 #[test]
938 fn bind_volume_mode_builds_a_host_path_mount() {
939 let vol = VolumeRef {
940 name: "db".into(),
941 mount: "/data".into(),
942 size_mib: 64,
943 };
944 let m = volume_mount(&vol, DockerVolumeMode::Bind, Path::new("/srv/data"));
945 assert_eq!(m.typ, Some(MountTypeEnum::BIND));
946 assert_eq!(m.source.as_deref(), Some("/srv/data/compute/volumes/db"));
947 assert_eq!(m.target.as_deref(), Some("/data"));
948 assert_eq!(m.read_only, Some(false));
949 }
950
951 #[test]
952 fn validate_volume_rejects_traversal_in_name_and_mount() {
953 assert!(validate_volume("db", "/data").is_ok());
954 assert!(validate_volume("cache-1", "/var/lib/app").is_ok());
955 assert!(validate_volume("../etc", "/data").is_err());
957 assert!(validate_volume("a/b", "/data").is_err());
958 assert!(validate_volume("db", "relative").is_err());
960 assert!(validate_volume("db", "/data/../etc").is_err());
961 }
962
963 #[test]
964 fn volume_mode_defaults_to_named_and_parses_lowercase() {
965 assert_eq!(DockerVolumeMode::default(), DockerVolumeMode::Named);
966 assert_eq!(
967 serde_json::from_str::<DockerVolumeMode>("\"named\"").unwrap(),
968 DockerVolumeMode::Named
969 );
970 assert_eq!(
971 serde_json::from_str::<DockerVolumeMode>("\"bind\"").unwrap(),
972 DockerVolumeMode::Bind
973 );
974 }
975
976 #[test]
977 fn restart_policy_maps_to_docker() {
978 assert_eq!(
979 restart_policy(RestartPolicy::Always).name,
980 Some(RestartPolicyNameEnum::ALWAYS)
981 );
982 assert_eq!(
983 restart_policy(RestartPolicy::OnFailure).name,
984 Some(RestartPolicyNameEnum::ON_FAILURE)
985 );
986 assert_eq!(
987 restart_policy(RestartPolicy::Never).name,
988 Some(RestartPolicyNameEnum::NO)
989 );
990 }
991}