Skip to main content

ociman/
lib.rs

1#![doc = include_str!("../README.md")]
2
3pub mod backend;
4pub mod image;
5pub mod platform;
6pub mod reference;
7pub mod testing;
8
9pub use backend::{Backend, BridgeSubnetError, ContainerHostnameResolver, ResolveHostnameError};
10use cmd_proc::Command;
11use cmd_proc::CommandError;
12pub use image::{
13    BuildArgumentKey, BuildArgumentKeyError, BuildArgumentValue, BuildDefinition, BuildSource,
14    BuildTarget, Reference,
15};
16
17trait Apply {
18    fn apply(&self, command: Command) -> Command;
19}
20
21impl<T: Apply> Apply for Vec<T> {
22    fn apply(&self, command: Command) -> Command {
23        self.iter()
24            .fold(command, |command, item| item.apply(command))
25    }
26}
27
28impl<T: Apply> Apply for Option<T> {
29    fn apply(&self, command: Command) -> Command {
30        match self {
31            Some(item) => item.apply(command),
32            None => command,
33        }
34    }
35}
36
37/// Macro to generate standard implementations for string wrapper newtypes
38macro_rules! string_newtype {
39    ($name:ident) => {
40        impl From<String> for $name {
41            fn from(value: String) -> Self {
42                Self(value)
43            }
44        }
45
46        impl From<&str> for $name {
47            fn from(value: &str) -> Self {
48                Self(value.to_string())
49            }
50        }
51
52        impl AsRef<std::ffi::OsStr> for $name {
53            fn as_ref(&self) -> &std::ffi::OsStr {
54                self.0.as_ref()
55            }
56        }
57
58        impl $name {
59            pub fn as_str(&self) -> &str {
60                &self.0
61            }
62        }
63    };
64}
65
66/// Macro to generate implementations for string wrapper newtypes with static flag + dynamic argument in Apply
67macro_rules! apply_argument {
68    ($name:ident, $flag:expr) => {
69        string_newtype!($name);
70
71        impl Apply for $name {
72            fn apply(&self, command: Command) -> Command {
73                command.argument($flag).argument(self)
74            }
75        }
76    };
77}
78
79#[derive(Clone, Debug, Eq, PartialEq)]
80pub struct ContainerArgument(String);
81
82string_newtype!(ContainerArgument);
83
84impl Apply for ContainerArgument {
85    fn apply(&self, command: Command) -> Command {
86        command.argument(self)
87    }
88}
89
90impl Apply for image::Reference {
91    fn apply(&self, command: Command) -> Command {
92        command.argument(self.to_string())
93    }
94}
95
96#[derive(Clone, Debug, Eq, PartialEq)]
97pub enum Detach {
98    Detach,
99    NoDetach,
100}
101
102impl Apply for Detach {
103    fn apply(&self, command: Command) -> Command {
104        match self {
105            Self::Detach => command.argument("--detach"),
106            Self::NoDetach => command,
107        }
108    }
109}
110
111#[derive(Clone, Debug, Eq, PartialEq)]
112pub enum Remove {
113    Remove,
114    NoRemove,
115}
116
117impl Apply for Remove {
118    fn apply(&self, command: Command) -> Command {
119        match self {
120            Self::Remove => command.argument("--rm"),
121            Self::NoRemove => command,
122        }
123    }
124}
125
126#[derive(Clone, Debug, Eq, PartialEq)]
127pub struct Mount(String);
128
129apply_argument!(Mount, "--mount");
130
131const UNSPECIFIED_IP: std::net::IpAddr = std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED);
132
133#[derive(Clone, Copy, Debug, Eq, PartialEq)]
134pub enum Protocol {
135    Tcp,
136    Udp,
137}
138
139impl Protocol {
140    fn as_str(&self) -> &'static str {
141        match self {
142            Self::Tcp => "tcp",
143            Self::Udp => "udp",
144        }
145    }
146}
147
148#[derive(Clone, Copy, Debug, Eq, PartialEq)]
149struct HostBinding {
150    ip: std::net::IpAddr,
151    port: Option<u16>,
152}
153
154impl std::fmt::Display for HostBinding {
155    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        write!(formatter, "{}:", self.ip)?;
157
158        if let Some(port) = self.port {
159            write!(formatter, "{port}")
160        } else {
161            Ok(())
162        }
163    }
164}
165
166/// Port publishing configuration for container networking.
167///
168/// Specifies how container ports are exposed to the host system.
169/// The format follows Docker's `--publish` flag: `[[ip:][hostPort]:]containerPort[/protocol]`
170#[derive(Clone, Debug, Eq, PartialEq)]
171pub struct Publish {
172    host_binding: Option<HostBinding>,
173    container_port: u16,
174    protocol: Protocol,
175}
176
177impl Publish {
178    /// Creates a TCP port publish configuration.
179    ///
180    /// # Example
181    ///
182    /// ```
183    /// let publish = ociman::Publish::tcp(8080);
184    /// assert_eq!(publish.to_string(), "8080/tcp");
185    /// ```
186    #[must_use]
187    pub fn tcp(container_port: u16) -> Self {
188        Self {
189            host_binding: None,
190            container_port,
191            protocol: Protocol::Tcp,
192        }
193    }
194
195    /// Creates a UDP port publish configuration.
196    ///
197    /// # Example
198    ///
199    /// ```
200    /// let publish = ociman::Publish::udp(53);
201    /// assert_eq!(publish.to_string(), "53/udp");
202    /// ```
203    #[must_use]
204    pub fn udp(container_port: u16) -> Self {
205        Self {
206            host_binding: None,
207            container_port,
208            protocol: Protocol::Udp,
209        }
210    }
211
212    /// Sets the host IP address to bind to.
213    ///
214    /// # Examples
215    ///
216    /// ```
217    /// let publish = ociman::Publish::tcp(8080)
218    ///     .host_ip(std::net::Ipv4Addr::LOCALHOST.into());
219    /// assert_eq!(publish.to_string(), "127.0.0.1::8080/tcp");
220    /// ```
221    ///
222    /// With unspecified address:
223    ///
224    /// ```
225    /// let publish = ociman::Publish::tcp(5432)
226    ///     .host_ip(std::net::Ipv4Addr::UNSPECIFIED.into());
227    /// assert_eq!(publish.to_string(), "0.0.0.0::5432/tcp");
228    /// ```
229    ///
230    /// With IPv6:
231    ///
232    /// ```
233    /// let publish = ociman::Publish::tcp(8080)
234    ///     .host_ip(std::net::Ipv6Addr::LOCALHOST.into());
235    /// assert_eq!(publish.to_string(), "::1::8080/tcp");
236    /// ```
237    ///
238    /// Preserves previously set host port:
239    ///
240    /// ```
241    /// let publish = ociman::Publish::tcp(80)
242    ///     .host_port(8080)
243    ///     .host_ip(std::net::Ipv4Addr::LOCALHOST.into());
244    /// assert_eq!(publish.to_string(), "127.0.0.1:8080:80/tcp");
245    /// ```
246    #[must_use]
247    pub fn host_ip(self, ip: std::net::IpAddr) -> Self {
248        Self {
249            host_binding: Some(HostBinding {
250                ip,
251                port: self.host_binding.and_then(|binding| binding.port),
252            }),
253            ..self
254        }
255    }
256
257    /// Sets the host port to map to the container port.
258    ///
259    /// If no host IP has been set, defaults to `0.0.0.0`.
260    ///
261    /// # Examples
262    ///
263    /// ```
264    /// let publish = ociman::Publish::tcp(80).host_port(8080);
265    /// assert_eq!(publish.to_string(), "0.0.0.0:8080:80/tcp");
266    /// ```
267    ///
268    /// Preserves previously set host IP:
269    ///
270    /// ```
271    /// let publish = ociman::Publish::tcp(80)
272    ///     .host_ip(std::net::Ipv4Addr::LOCALHOST.into())
273    ///     .host_port(8080);
274    /// assert_eq!(publish.to_string(), "127.0.0.1:8080:80/tcp");
275    /// ```
276    #[must_use]
277    pub fn host_port(self, port: u16) -> Self {
278        Self {
279            host_binding: Some(HostBinding {
280                ip: self
281                    .host_binding
282                    .map(|binding| binding.ip)
283                    .unwrap_or(UNSPECIFIED_IP),
284                port: Some(port),
285            }),
286            ..self
287        }
288    }
289
290    /// Sets both host IP and port in a single call.
291    ///
292    /// # Example
293    ///
294    /// ```
295    /// let publish = ociman::Publish::tcp(80)
296    ///     .host_ip_port(std::net::Ipv4Addr::LOCALHOST.into(), 8080);
297    /// assert_eq!(publish.to_string(), "127.0.0.1:8080:80/tcp");
298    /// ```
299    #[must_use]
300    pub fn host_ip_port(self, ip: std::net::IpAddr, port: u16) -> Self {
301        Self {
302            host_binding: Some(HostBinding {
303                ip,
304                port: Some(port),
305            }),
306            ..self
307        }
308    }
309}
310
311impl std::fmt::Display for Publish {
312    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
313        if let Some(host_binding) = self.host_binding {
314            write!(formatter, "{host_binding}:")?;
315        }
316
317        write!(
318            formatter,
319            "{}/{}",
320            self.container_port,
321            self.protocol.as_str()
322        )
323    }
324}
325
326impl Apply for Publish {
327    fn apply(&self, command: Command) -> Command {
328        command.argument("--publish").argument(self.to_string())
329    }
330}
331
332#[derive(Clone, Debug, Eq, PartialEq)]
333pub struct Entrypoint(String);
334
335apply_argument!(Entrypoint, "--entrypoint");
336
337#[derive(Clone, Debug, Eq, PartialEq)]
338pub struct Workdir(String);
339
340apply_argument!(Workdir, "--workdir");
341
342#[derive(Clone, Debug, Eq, PartialEq)]
343pub struct EnvironmentVariables(
344    std::collections::BTreeMap<cmd_proc::EnvVariableName<'static>, String>,
345);
346
347impl EnvironmentVariables {
348    fn new() -> Self {
349        Self(std::collections::BTreeMap::new())
350    }
351
352    fn insert(&mut self, key: cmd_proc::EnvVariableName<'static>, value: String) {
353        self.0.insert(key, value);
354    }
355}
356
357impl Apply for EnvironmentVariables {
358    fn apply(&self, command: Command) -> Command {
359        self.0.iter().fold(command, |command, (key, value)| {
360            command.argument("--env").argument(format!("{key}={value}"))
361        })
362    }
363}
364
365#[derive(Clone, Debug, Eq, PartialEq)]
366pub struct Definition {
367    backend: Backend,
368    container_arguments: Vec<ContainerArgument>,
369    detach: Detach,
370    entrypoint: Option<Entrypoint>,
371    environment_variables: EnvironmentVariables,
372    reference: image::Reference,
373    remove: Remove,
374    mounts: Vec<Mount>,
375    publish: Vec<Publish>,
376    workdir: Option<Workdir>,
377}
378
379impl Definition {
380    #[must_use]
381    pub fn new(backend: Backend, reference: image::Reference) -> Definition {
382        Definition {
383            backend,
384            container_arguments: vec![],
385            detach: Detach::NoDetach,
386            entrypoint: None,
387            environment_variables: EnvironmentVariables::new(),
388            reference,
389            mounts: vec![],
390            publish: vec![],
391            remove: Remove::NoRemove,
392            workdir: None,
393        }
394    }
395
396    /// Runs a detached container and passes it to the provided async closure.
397    ///
398    /// The container is automatically stopped after the closure returns.
399    pub async fn with_container<R>(&self, mut action: impl AsyncFnMut(&mut Container) -> R) -> R {
400        let mut container = self.clone().run_detached().await;
401        let result = action(&mut container).await;
402        container.stop().await;
403        result
404    }
405
406    #[must_use]
407    pub fn backend(self, backend: Backend) -> Self {
408        Self { backend, ..self }
409    }
410
411    pub fn entrypoint(self, command: impl Into<Entrypoint>) -> Self {
412        Self {
413            entrypoint: Some(command.into()),
414            ..self
415        }
416    }
417
418    pub fn workdir(self, path: impl Into<Workdir>) -> Self {
419        Self {
420            workdir: Some(path.into()),
421            ..self
422        }
423    }
424
425    pub fn arguments(
426        self,
427        arguments: impl IntoIterator<Item = impl Into<ContainerArgument>>,
428    ) -> Self {
429        Self {
430            container_arguments: arguments.into_iter().map(Into::into).collect(),
431            ..self
432        }
433    }
434
435    pub fn argument(self, argument: impl Into<ContainerArgument>) -> Self {
436        let mut container_arguments = self.container_arguments;
437        container_arguments.push(argument.into());
438        Self {
439            container_arguments,
440            ..self
441        }
442    }
443
444    /// Uses validated [`cmd_proc::EnvVariableName`] keys to prevent invalid env names.
445    #[must_use]
446    pub fn environment_variable(
447        self,
448        key: cmd_proc::EnvVariableName<'static>,
449        value: &str,
450    ) -> Self {
451        let mut environment_variables = self.environment_variables;
452
453        environment_variables.insert(key, value.to_string());
454
455        Self {
456            environment_variables,
457            ..self
458        }
459    }
460
461    /// Uses validated [`cmd_proc::EnvVariableName`] keys to prevent invalid env names.
462    pub fn environment_variables<V: Into<String>>(
463        self,
464        values: impl IntoIterator<Item = (cmd_proc::EnvVariableName<'static>, V)>,
465    ) -> Self {
466        let mut environment_variables = self.environment_variables;
467
468        for (key, value) in values {
469            environment_variables.insert(key, value.into());
470        }
471
472        Self {
473            environment_variables,
474            ..self
475        }
476    }
477
478    #[must_use]
479    pub fn remove(self) -> Self {
480        Self {
481            remove: Remove::Remove,
482            ..self
483        }
484    }
485
486    #[must_use]
487    pub fn no_remove(self) -> Self {
488        Self {
489            remove: Remove::NoRemove,
490            ..self
491        }
492    }
493
494    #[must_use]
495    pub fn detach(self) -> Self {
496        Self {
497            detach: Detach::Detach,
498            ..self
499        }
500    }
501
502    #[must_use]
503    pub fn no_detach(self) -> Self {
504        Self {
505            detach: Detach::NoDetach,
506            ..self
507        }
508    }
509
510    pub fn publish(self, value: impl Into<Publish>) -> Self {
511        let mut publish = self.publish;
512
513        publish.push(value.into());
514
515        Self { publish, ..self }
516    }
517
518    pub fn publishes(self, values: impl IntoIterator<Item = impl Into<Publish>>) -> Self {
519        let mut publish = self.publish;
520        publish.extend(values.into_iter().map(Into::into));
521        Self { publish, ..self }
522    }
523
524    pub fn mount(self, value: impl Into<Mount>) -> Self {
525        let mut mounts = self.mounts;
526
527        mounts.push(value.into());
528
529        Self { mounts, ..self }
530    }
531
532    pub fn mounts(self, values: impl IntoIterator<Item = impl Into<Mount>>) -> Self {
533        let mut mounts = self.mounts;
534        mounts.extend(values.into_iter().map(Into::into));
535        Self { mounts, ..self }
536    }
537
538    pub async fn run_detached(&self) -> Container {
539        let stdout = self.clone().detach().run_output().await;
540
541        Container {
542            backend: self.backend.clone(),
543            id: ContainerId::try_from(strip_nl_end(&stdout)).unwrap(),
544        }
545    }
546
547    pub async fn run_capture_only_stdout(&self) -> Vec<u8> {
548        self.clone().no_detach().run_output().await
549    }
550
551    /// Runs the container and returns success or an error.
552    pub async fn run(&self) -> Result<(), CommandError> {
553        self.build_run_command().status().await
554    }
555
556    fn build_run_command(&self) -> Command {
557        let command = self.backend.command().argument("run");
558
559        let command = self.detach.apply(command);
560        let command = self.remove.apply(command);
561        let command = self.environment_variables.apply(command);
562        let command = self.publish.apply(command);
563        let command = self.mounts.apply(command);
564        let command = self.workdir.apply(command);
565        let command = self.entrypoint.apply(command);
566        let command = self.reference.apply(command);
567
568        self.container_arguments.apply(command)
569    }
570
571    async fn run_output(&self) -> Vec<u8> {
572        self.build_run_command()
573            .stdout_capture()
574            .bytes()
575            .await
576            .unwrap()
577    }
578}
579
580fn strip_nl_end(value: &[u8]) -> &[u8] {
581    match value.split_last() {
582        Some((last, prefix)) => {
583            if *last == b'\n' {
584                prefix
585            } else {
586                panic!("last byte not a newline")
587            }
588        }
589        None => panic!("empty slice"),
590    }
591}
592
593#[derive(Clone, Debug, Eq, PartialEq)]
594pub struct ContainerId(String);
595
596impl std::convert::TryFrom<&[u8]> for ContainerId {
597    type Error = std::str::Utf8Error;
598
599    fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
600        std::str::from_utf8(value).map(|str| ContainerId(str.to_string()))
601    }
602}
603
604impl AsRef<std::ffi::OsStr> for ContainerId {
605    fn as_ref(&self) -> &std::ffi::OsStr {
606        self.0.as_ref()
607    }
608}
609
610impl ContainerId {
611    #[must_use]
612    pub fn as_str(&self) -> &str {
613        &self.0
614    }
615}
616
617#[derive(Debug)]
618pub struct Container {
619    backend: Backend,
620    id: ContainerId,
621}
622
623/// Builder for executing commands inside a container.
624pub struct ExecCommand<'a> {
625    container: &'a Container,
626    executable: String,
627    arguments: Vec<String>,
628    environment: Vec<(cmd_proc::EnvVariableName<'static>, String)>,
629    interactive: bool,
630    stdin_data: Option<Vec<u8>>,
631}
632
633impl<'a> ExecCommand<'a> {
634    fn new(container: &'a Container, executable: impl Into<String>) -> Self {
635        Self {
636            container,
637            executable: executable.into(),
638            arguments: Vec::new(),
639            environment: Vec::new(),
640            interactive: false,
641            stdin_data: None,
642        }
643    }
644
645    /// Add a single argument.
646    pub fn argument(mut self, value: impl Into<String>) -> Self {
647        self.arguments.push(value.into());
648        self
649    }
650
651    /// Add multiple arguments.
652    pub fn arguments(mut self, values: impl IntoIterator<Item = impl Into<String>>) -> Self {
653        self.arguments.extend(values.into_iter().map(Into::into));
654        self
655    }
656
657    /// Add an environment variable.
658    ///
659    /// Uses validated [`cmd_proc::EnvVariableName`] keys to prevent invalid env names.
660    pub fn environment_variable(
661        mut self,
662        key: cmd_proc::EnvVariableName<'static>,
663        value: impl Into<String>,
664    ) -> Self {
665        self.environment.push((key, value.into()));
666        self
667    }
668
669    /// Add multiple environment variables.
670    ///
671    /// Uses validated [`cmd_proc::EnvVariableName`] keys to prevent invalid env names.
672    pub fn environment_variables(
673        mut self,
674        variables: impl IntoIterator<Item = (cmd_proc::EnvVariableName<'static>, impl Into<String>)>,
675    ) -> Self {
676        self.environment.extend(
677            variables
678                .into_iter()
679                .map(|(key, value)| (key, value.into())),
680        );
681        self
682    }
683
684    /// Enable interactive mode (--tty --interactive).
685    #[must_use]
686    pub fn interactive(mut self) -> Self {
687        self.interactive = true;
688        self
689    }
690
691    /// Set stdin data to send to the command.
692    pub fn stdin(mut self, data: impl Into<Vec<u8>>) -> Self {
693        self.stdin_data = Some(data.into());
694        self
695    }
696
697    /// Build the command without executing it.
698    ///
699    /// Use this to access stream configuration methods on [`cmd_proc::Command`].
700    #[must_use]
701    pub fn build(self) -> Command {
702        let mut command = self.container.backend_command().argument("exec");
703
704        if self.interactive {
705            command = command.argument("--tty").argument("--interactive");
706        } else if self.stdin_data.is_some() {
707            command = command.argument("--interactive");
708        }
709
710        for (key, value) in self.environment {
711            command = command.argument("--env").argument(format!("{key}={value}"));
712        }
713
714        command = command
715            .argument(&self.container.id)
716            .argument(self.executable)
717            .arguments(self.arguments);
718
719        if let Some(data) = self.stdin_data {
720            command = command.stdin_bytes(data);
721        }
722
723        command
724    }
725
726    /// Execute the command and return success or an error.
727    pub async fn status(self) -> Result<(), CommandError> {
728        self.build().status().await
729    }
730}
731
732impl Container {
733    /// Create an exec command builder for running commands inside this container.
734    pub fn exec(&self, executable: impl Into<String>) -> ExecCommand<'_> {
735        ExecCommand::new(self, executable)
736    }
737
738    pub async fn stop(&mut self) {
739        self.backend_command()
740            .arguments(["container", "stop"])
741            .argument(&self.id)
742            .stdout_capture()
743            .bytes()
744            .await
745            .unwrap();
746    }
747
748    pub async fn remove(&mut self) {
749        self.backend_command()
750            .arguments(["container", "rm"])
751            .argument(&self.id)
752            .stdout_capture()
753            .bytes()
754            .await
755            .unwrap();
756    }
757
758    pub async fn inspect(&self) -> serde_json::Value {
759        let stdout = self
760            .backend_command()
761            .argument("inspect")
762            .argument(&self.id)
763            .stdout_capture()
764            .bytes()
765            .await
766            .unwrap();
767
768        serde_json::from_slice(&stdout).expect("invalid json")
769    }
770
771    pub async fn inspect_format(&self, format: &str) -> String {
772        let bytes = self
773            .backend_command()
774            .argument("inspect")
775            .argument("--format")
776            .argument(format)
777            .argument(&self.id)
778            .stdout_capture()
779            .bytes()
780            .await
781            .unwrap();
782
783        std::str::from_utf8(strip_nl_end(&bytes))
784            .expect("invalid utf8")
785            .to_string()
786    }
787
788    pub async fn read_host_tcp_port(&self, container_port: u16) -> Option<u16> {
789        let json = self.inspect().await;
790
791        json.get(0)?
792            .get("NetworkSettings")?
793            .get("Ports")?
794            .get(format!("{container_port}/tcp"))?
795            .get(0)?
796            .get("HostPort")?
797            .as_str()?
798            .parse()
799            .ok()
800    }
801
802    pub async fn commit(
803        &self,
804        reference: &image::Reference,
805        pause: bool,
806    ) -> Result<(), CommandError> {
807        let pause_argument = match (&self.backend, pause) {
808            (Backend::Docker { .. }, true) => None,
809            (Backend::Docker { version }, false) => {
810                // Docker 29.0 replaced --pause with --no-pause
811                // https://docs.docker.com/engine/release-notes/29/
812                if version.major >= 29 {
813                    Some("--no-pause")
814                } else {
815                    Some("--pause=false")
816                }
817            }
818            (Backend::Podman { .. }, true) => Some("--pause"),
819            (Backend::Podman { .. }, false) => None,
820        };
821
822        self.backend_command()
823            .argument("commit")
824            .optional_argument(pause_argument)
825            .argument(&self.id)
826            .argument(reference.to_string())
827            .status()
828            .await
829    }
830
831    fn backend_command(&self) -> Command {
832        self.backend.command()
833    }
834}