Skip to main content

ociman/
image.rs

1//! OCI image reference and build utilities.
2//!
3//! This module provides types for working with OCI image references and building images.
4//!
5//! # Usage
6//!
7//! It's recommended to import this module as `image`:
8//!
9//! ```
10//! use ociman::image;
11//!
12//! let reference: image::Reference = "alpine:latest".parse().unwrap();
13//! ```
14
15pub use crate::reference::Reference;
16
17use crate::Backend;
18use sha2::{Digest, Sha256};
19use std::path::PathBuf;
20use std::str::FromStr;
21
22/// Build argument key
23#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
24pub struct BuildArgumentKey(String);
25
26impl BuildArgumentKey {
27    #[must_use]
28    pub fn as_str(&self) -> &str {
29        &self.0
30    }
31}
32
33impl FromStr for BuildArgumentKey {
34    type Err = BuildArgumentKeyError;
35
36    fn from_str(input: &str) -> Result<Self, Self::Err> {
37        if input.is_empty() {
38            return Err(BuildArgumentKeyError::Empty);
39        }
40        if input.contains('=') {
41            return Err(BuildArgumentKeyError::ContainsEquals);
42        }
43        Ok(BuildArgumentKey(input.to_string()))
44    }
45}
46
47#[derive(Debug, Clone, thiserror::Error)]
48pub enum BuildArgumentKeyError {
49    #[error("Build argument key cannot be empty")]
50    Empty,
51    #[error("Build argument key cannot contain '=' character")]
52    ContainsEquals,
53}
54
55/// Build argument value
56#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
57pub struct BuildArgumentValue(String);
58
59impl BuildArgumentValue {
60    #[must_use]
61    pub fn as_str(&self) -> &str {
62        &self.0
63    }
64}
65
66impl FromStr for BuildArgumentValue {
67    type Err = std::convert::Infallible;
68
69    fn from_str(input: &str) -> Result<Self, Self::Err> {
70        Ok(BuildArgumentValue(input.to_string()))
71    }
72}
73
74impl From<String> for BuildArgumentValue {
75    fn from(string: String) -> Self {
76        BuildArgumentValue(string)
77    }
78}
79
80impl From<&str> for BuildArgumentValue {
81    fn from(string: &str) -> Self {
82        BuildArgumentValue(string.to_string())
83    }
84}
85
86/// Source for building an image
87#[derive(Clone, Debug, Eq, PartialEq)]
88pub enum BuildSource {
89    /// Build from a directory containing a Dockerfile
90    Directory(PathBuf),
91    /// Build from Dockerfile instructions provided as a string
92    Instructions(String),
93}
94
95/// Target image specification for the build
96#[derive(Clone, Debug, Eq, PartialEq)]
97pub enum BuildTarget {
98    /// Use a fixed image reference
99    Fixed(Reference),
100    /// Generate tag from content hash, using the given name
101    ContentAddressed(crate::reference::Name),
102}
103
104/// Definition for building a container image
105#[derive(Clone, Debug, Eq, PartialEq)]
106pub struct BuildDefinition {
107    backend: Backend,
108    target: BuildTarget,
109    source: BuildSource,
110    build_arguments: std::collections::BTreeMap<BuildArgumentKey, BuildArgumentValue>,
111}
112
113impl BuildDefinition {
114    /// Create a new build definition from a directory containing a Dockerfile
115    pub fn from_directory(
116        backend: &Backend,
117        reference: Reference,
118        path: impl Into<PathBuf>,
119    ) -> Self {
120        Self {
121            backend: backend.clone(),
122            target: BuildTarget::Fixed(reference),
123            source: BuildSource::Directory(path.into()),
124            build_arguments: std::collections::BTreeMap::new(),
125        }
126    }
127
128    /// Create a new build definition from Dockerfile instructions as a string
129    pub fn from_instructions(
130        backend: &Backend,
131        reference: Reference,
132        instructions: impl Into<String>,
133    ) -> Self {
134        Self {
135            backend: backend.clone(),
136            target: BuildTarget::Fixed(reference),
137            source: BuildSource::Instructions(instructions.into()),
138            build_arguments: std::collections::BTreeMap::new(),
139        }
140    }
141
142    /// Create a build definition from a directory with content-based hash tag
143    pub fn from_directory_hash(
144        backend: &Backend,
145        name: crate::reference::Name,
146        path: impl Into<PathBuf>,
147    ) -> Self {
148        Self {
149            backend: backend.clone(),
150            target: BuildTarget::ContentAddressed(name),
151            source: BuildSource::Directory(path.into()),
152            build_arguments: std::collections::BTreeMap::new(),
153        }
154    }
155
156    /// Create a build definition from Dockerfile instructions with content-based hash tag
157    pub fn from_instructions_hash(
158        backend: &Backend,
159        name: crate::reference::Name,
160        instructions: impl Into<String>,
161    ) -> Self {
162        Self {
163            backend: backend.clone(),
164            target: BuildTarget::ContentAddressed(name),
165            source: BuildSource::Instructions(instructions.into()),
166            build_arguments: std::collections::BTreeMap::new(),
167        }
168    }
169
170    /// Add a build argument
171    pub fn build_argument(
172        mut self,
173        key: BuildArgumentKey,
174        value: impl Into<BuildArgumentValue>,
175    ) -> Self {
176        self.build_arguments.insert(key, value.into());
177        self
178    }
179
180    /// Add multiple build arguments
181    pub fn build_arguments<V: Into<BuildArgumentValue>>(
182        mut self,
183        arguments: impl IntoIterator<Item = (BuildArgumentKey, V)>,
184    ) -> Self {
185        self.build_arguments.extend(
186            arguments
187                .into_iter()
188                .map(|(key, value)| (key, value.into())),
189        );
190        self
191    }
192
193    /// Build the image using the specified backend and return the built image reference
194    pub async fn build(&self) -> Reference {
195        self.build_image(self.compute_final_reference()).await
196    }
197
198    /// Build the image only if it's not already present, and return the image reference
199    pub async fn build_if_absent(&self) -> Reference {
200        let target_reference = self.compute_final_reference();
201
202        if self.backend.is_image_present(&target_reference).await {
203            target_reference
204        } else {
205            self.build_image(target_reference).await
206        }
207    }
208
209    async fn build_image(&self, target_reference: Reference) -> Reference {
210        let mut arguments = vec!["build".into(), "--tag".into(), target_reference.to_string()];
211
212        for (key, value) in &self.build_arguments {
213            arguments.push("--build-arg".into());
214            arguments.push(format!("{}={}", key.as_str(), value.as_str()));
215        }
216
217        let command = match &self.source {
218            BuildSource::Directory(path) => {
219                arguments.push(path.to_string_lossy().into());
220                self.backend.command().arguments(arguments)
221            }
222            BuildSource::Instructions(content) => {
223                arguments.push("-".into());
224                self.backend
225                    .command()
226                    .arguments(arguments)
227                    .stdin_bytes(content.as_bytes().to_vec())
228            }
229        };
230
231        command.status().await.unwrap();
232
233        target_reference
234    }
235
236    /// Compute the final image reference with hash if this is a hash-based definition
237    fn compute_final_reference(&self) -> Reference {
238        match &self.target {
239            BuildTarget::Fixed(reference) => reference.clone(),
240            BuildTarget::ContentAddressed(name) => {
241                let hash = match &self.source {
242                    BuildSource::Directory(path) => {
243                        compute_directory_hash(path, &self.build_arguments)
244                    }
245                    BuildSource::Instructions(content) => {
246                        compute_content_hash(content, &self.build_arguments)
247                    }
248                };
249                Reference {
250                    name: name.clone(),
251                    tag: Some(hash.into()),
252                    digest: None,
253                }
254            }
255        }
256    }
257}
258
259fn compute_content_hash(
260    content: &str,
261    build_arguments: &std::collections::BTreeMap<BuildArgumentKey, BuildArgumentValue>,
262) -> sha2::digest::Output<Sha256> {
263    let mut hasher = Sha256::new();
264    hasher.update(content.as_bytes());
265
266    for (key, value) in build_arguments {
267        hasher.update(key.as_str().as_bytes());
268        hasher.update(b"=");
269        hasher.update(value.as_str().as_bytes());
270    }
271
272    hasher.finalize()
273}
274
275fn compute_directory_hash(
276    path: &PathBuf,
277    build_arguments: &std::collections::BTreeMap<BuildArgumentKey, BuildArgumentValue>,
278) -> sha2::digest::Output<Sha256> {
279    use walkdir::WalkDir;
280
281    let mut hasher = Sha256::new();
282
283    for entry in WalkDir::new(path)
284        .sort_by_file_name()
285        .into_iter()
286        .filter_map(|result| result.ok())
287    {
288        if entry.file_type().is_file() {
289            let relative_path = entry.path().strip_prefix(path).unwrap();
290            hasher.update(relative_path.to_string_lossy().as_bytes());
291
292            let content = std::fs::read(entry.path()).expect("Failed to read file");
293            hasher.update(&content);
294        }
295    }
296
297    for (key, value) in build_arguments {
298        hasher.update(key.as_str().as_bytes());
299        hasher.update(b"=");
300        hasher.update(value.as_str().as_bytes());
301    }
302
303    hasher.finalize()
304}