1use std::collections::BTreeMap;
4
5use serde::{Deserialize, Deserializer, Serialize};
6
7use crate::{Digest, Error, Result};
8
9fn null_as_default<'de, D, T>(deserializer: D) -> std::result::Result<T, D::Error>
13where
14 D: Deserializer<'de>,
15 T: Default + Deserialize<'de>,
16{
17 Option::<T>::deserialize(deserializer).map(|v| v.unwrap_or_default())
18}
19
20#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
25pub struct ImageConfig {
26 pub architecture: String,
28
29 pub os: String,
31
32 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub os_version: Option<String>,
35
36 #[serde(default, rename = "os.features", skip_serializing_if = "Vec::is_empty", deserialize_with = "null_as_default")]
38 pub os_features: Vec<String>,
39
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub variant: Option<String>,
43
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub config: Option<ContainerConfig>,
47
48 pub rootfs: RootFs,
50
51 #[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "null_as_default")]
53 pub history: Vec<History>,
54
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub created: Option<String>,
58
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub author: Option<String>,
62}
63
64#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
66pub struct EmptyObject {}
67
68#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "PascalCase")]
71pub struct Healthcheck {
72 #[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "null_as_default")]
74 pub test: Vec<String>,
75
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub interval: Option<i64>,
79
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub timeout: Option<i64>,
83
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub retries: Option<i32>,
87
88 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub start_period: Option<i64>,
91
92 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub start_interval: Option<i64>,
95}
96
97#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "PascalCase")]
100pub struct ContainerConfig {
101 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub hostname: Option<String>,
104
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub domainname: Option<String>,
108
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub user: Option<String>,
112
113 #[serde(default, skip_serializing_if = "BTreeMap::is_empty", deserialize_with = "null_as_default")]
115 pub exposed_ports: BTreeMap<String, EmptyObject>,
116
117 #[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "null_as_default")]
119 pub env: Vec<String>,
120
121 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub entrypoint: Option<Vec<String>>,
124
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub cmd: Option<Vec<String>>,
128
129 #[serde(default, skip_serializing_if = "BTreeMap::is_empty", deserialize_with = "null_as_default")]
131 pub volumes: BTreeMap<String, EmptyObject>,
132
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub working_dir: Option<String>,
136
137 #[serde(default, skip_serializing_if = "BTreeMap::is_empty", deserialize_with = "null_as_default")]
139 pub labels: BTreeMap<String, String>,
140
141 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub stop_signal: Option<String>,
144
145 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub healthcheck: Option<Healthcheck>,
148
149 #[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "null_as_default")]
151 pub on_build: Vec<String>,
152
153 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub shell: Option<Vec<String>>,
156
157 #[serde(default, skip_serializing_if = "is_false")]
159 pub args_escaped: bool,
160}
161#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
163pub struct RootFs {
164 #[serde(rename = "type")]
166 pub fs_type: String,
167
168 pub diff_ids: Vec<Digest>,
170}
171
172#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
174pub struct History {
175 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub created: Option<String>,
178
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub created_by: Option<String>,
182
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub author: Option<String>,
186
187 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub comment: Option<String>,
190
191 #[serde(default, skip_serializing_if = "is_false")]
193 pub empty_layer: bool,
194}
195
196fn is_false(b: &bool) -> bool {
197 !*b
198}
199
200impl ImageConfig {
201 pub fn new(architecture: impl Into<String>, os: impl Into<String>) -> Self {
203 Self {
204 architecture: architecture.into(),
205 os: os.into(),
206 os_version: None,
207 os_features: Vec::new(),
208 variant: None,
209 config: None,
210 rootfs: RootFs {
211 fs_type: "layers".to_string(),
212 diff_ids: Vec::new(),
213 },
214 history: Vec::new(),
215 created: None,
216 author: None,
217 }
218 }
219
220 pub fn from_bytes(data: &[u8]) -> Result<Self> {
222 serde_json::from_slice(data).map_err(Error::from)
223 }
224
225 pub fn to_bytes(&self) -> Result<Vec<u8>> {
227 serde_json::to_vec(self).map_err(Error::from)
228 }
229
230 pub fn digest(&self) -> Result<Digest> {
232 let bytes = self.to_bytes()?;
233 Ok(Digest::sha256(&bytes))
234 }
235
236 pub fn validate(&self) -> Result<()> {
241 if self.rootfs.fs_type != "layers" {
242 return Err(Error::InvalidConfig(format!(
243 "invalid rootfs.type: expected \"layers\", got \"{}\"",
244 self.rootfs.fs_type
245 )));
246 }
247 Ok(())
248 }
249
250 pub fn size(&self) -> Result<u64> {
252 let bytes = self.to_bytes()?;
253 Ok(bytes.len() as u64)
254 }
255
256 pub fn with_layer(mut self, diff_id: Digest) -> Self {
258 self.rootfs.diff_ids.push(diff_id);
259 self
260 }
261
262 pub fn with_config(mut self, config: ContainerConfig) -> Self {
264 self.config = Some(config);
265 self
266 }
267
268 pub fn with_history(mut self, history: History) -> Self {
270 self.history.push(history);
271 self
272 }
273
274 pub fn labels(&self) -> Option<&BTreeMap<String, String>> {
276 self.config.as_ref().map(|c| &c.labels)
277 }
278
279 pub fn entrypoint(&self) -> Option<&[String]> {
281 self.config.as_ref().and_then(|c| c.entrypoint.as_deref())
282 }
283
284 pub fn cmd(&self) -> Option<&[String]> {
286 self.config.as_ref().and_then(|c| c.cmd.as_deref())
287 }
288
289 pub fn env(&self) -> Option<&[String]> {
291 self.config.as_ref().map(|c| c.env.as_slice())
292 }
293}
294
295impl ContainerConfig {
296 pub fn new() -> Self {
298 Self::default()
299 }
300
301 pub fn with_entrypoint(mut self, entrypoint: Vec<String>) -> Self {
303 self.entrypoint = Some(entrypoint);
304 self
305 }
306
307 pub fn with_cmd(mut self, cmd: Vec<String>) -> Self {
309 self.cmd = Some(cmd);
310 self
311 }
312
313 pub fn with_env(mut self, var: impl Into<String>) -> Self {
315 self.env.push(var.into());
316 self
317 }
318
319 pub fn with_label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
321 self.labels.insert(key.into(), value.into());
322 self
323 }
324
325 pub fn with_working_dir(mut self, dir: impl Into<String>) -> Self {
327 self.working_dir = Some(dir.into());
328 self
329 }
330
331 pub fn with_user(mut self, user: impl Into<String>) -> Self {
333 self.user = Some(user.into());
334 self
335 }
336}
337
338impl History {
339 pub fn new() -> Self {
341 Self::default()
342 }
343
344 pub fn with_created_by(mut self, created_by: impl Into<String>) -> Self {
346 self.created_by = Some(created_by.into());
347 self
348 }
349
350 pub fn as_empty_layer(mut self) -> Self {
352 self.empty_layer = true;
353 self
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 #[test]
362 fn test_image_config_create() {
363 let config = ImageConfig::new("amd64", "linux");
364
365 assert_eq!(config.architecture, "amd64");
366 assert_eq!(config.os, "linux");
367 assert_eq!(config.rootfs.fs_type, "layers");
368 }
369
370 #[test]
371 fn test_image_config_roundtrip() {
372 let config = ImageConfig::new("amd64", "linux")
373 .with_layer(
374 "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
375 .parse()
376 .unwrap(),
377 )
378 .with_config(
379 ContainerConfig::new()
380 .with_entrypoint(vec!["/bin/sh".to_string()])
381 .with_env("PATH=/usr/bin:/bin".to_string())
382 .with_label("version", "1.0"),
383 );
384
385 let bytes = config.to_bytes().unwrap();
386 let parsed = ImageConfig::from_bytes(&bytes).unwrap();
387
388 assert_eq!(config, parsed);
389 }
390
391 #[test]
392 fn test_image_config_digest_stability() {
393 let config = ImageConfig::new("amd64", "linux");
394
395 let digest1 = config.digest().unwrap();
396 let digest2 = config.digest().unwrap();
397
398 assert_eq!(digest1, digest2);
399 }
400
401 #[test]
402 fn test_container_config_builder() {
403 let config = ContainerConfig::new()
404 .with_entrypoint(vec!["/bin/sh".to_string()])
405 .with_cmd(vec!["-c".to_string(), "echo hello".to_string()])
406 .with_env("FOO=bar")
407 .with_working_dir("/app")
408 .with_user("nobody")
409 .with_label("maintainer", "test@example.com");
410
411 assert_eq!(config.entrypoint, Some(vec!["/bin/sh".to_string()]));
412 assert_eq!(
413 config.cmd,
414 Some(vec!["-c".to_string(), "echo hello".to_string()])
415 );
416 assert_eq!(config.env, vec!["FOO=bar"]);
417 assert_eq!(config.working_dir, Some("/app".to_string()));
418 assert_eq!(config.user, Some("nobody".to_string()));
419 assert_eq!(
420 config.labels.get("maintainer"),
421 Some(&"test@example.com".to_string())
422 );
423 }
424
425 #[test]
426 fn test_history_builder() {
427 let history = History::new()
428 .with_created_by("ADD file:abc123 /")
429 .as_empty_layer();
430
431 assert_eq!(history.created_by, Some("ADD file:abc123 /".to_string()));
432 assert!(history.empty_layer);
433 }
434
435 #[test]
436 fn test_image_config_accessors() {
437 let config = ImageConfig::new("amd64", "linux").with_config(
438 ContainerConfig::new()
439 .with_entrypoint(vec!["/app".to_string()])
440 .with_cmd(vec!["--help".to_string()])
441 .with_env("DEBUG=1")
442 .with_label("version", "1.0"),
443 );
444
445 assert_eq!(config.entrypoint(), Some(&["/app".to_string()][..]));
446 assert_eq!(config.cmd(), Some(&["--help".to_string()][..]));
447 assert_eq!(config.env(), Some(&["DEBUG=1".to_string()][..]));
448 assert_eq!(
449 config.labels().and_then(|l| l.get("version")),
450 Some(&"1.0".to_string())
451 );
452 }
453}