oci_client/
config.rs

1//! OCI Image Configuration
2//!
3//! Definition following <https://github.com/opencontainers/image-spec/blob/v1.0/config.md>
4
5use std::collections::{HashMap, HashSet};
6
7use chrono::{DateTime, Utc};
8use serde::{ser::SerializeMap, Deserialize, Deserializer, Serialize, Serializer};
9
10/// The CPU architecture which the binaries in this image are
11/// built to run on.
12/// Validated values are listed in [Go Language document for GOARCH](https://golang.org/doc/install/source#environment)
13#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
14#[serde(rename_all = "lowercase")]
15pub enum Architecture {
16    /// Arm
17    Arm,
18    /// Arm 64bit
19    Arm64,
20    /// Amd64/x86-64
21    #[default]
22    Amd64,
23    /// Intel i386
24    #[serde(rename = "386")]
25    I386,
26    /// Wasm
27    Wasm,
28    /// Loong64
29    Loong64,
30    /// MIPS
31    Mips,
32    /// MIPSle
33    Mipsle,
34    /// MIPS64
35    Mips64,
36    /// MIPS64le
37    Mips64le,
38    /// Power PC64
39    PPC64,
40    /// Power PC64le
41    PPC64le,
42    /// RiscV 64
43    Riscv64,
44    /// IBM s390x
45    S390x,
46    /// With this field empty
47    #[serde(rename = "")]
48    None,
49}
50
51/// The name of the operating system which the image is
52/// built to run on.
53/// Validated values are listed in [Go Language document for GOARCH](https://golang.org/doc/install/source#environment)
54#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
55#[serde(rename_all = "lowercase")]
56pub enum Os {
57    /// IBM AIX
58    Aix,
59    /// Android
60    Android,
61    /// Apple Darwin
62    Darwin,
63    /// FreeBSD Dragonfly
64    Dragonfly,
65    /// FreeBSD
66    Freebsd,
67    /// Illumos
68    Illumos,
69    /// iOS
70    Ios,
71    /// Js
72    Js,
73    /// Linux
74    #[default]
75    Linux,
76    /// NetBSD
77    Netbsd,
78    /// OpenBSD
79    Openbsd,
80    /// Plan9 from Bell Labs
81    Plan9,
82    /// Solaris
83    Solaris,
84    /// WASI Preview 1
85    Wasip1,
86    /// Microsoft Windows
87    Windows,
88    /// With this field empty
89    #[serde(rename = "")]
90    None,
91}
92
93/// An OCI Image is an ordered collection of root filesystem changes
94/// and the corresponding execution parameters for use within a
95/// container runtime.
96///
97/// Format defined [here](https://github.com/opencontainers/image-spec/blob/v1.0/config.md)
98#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
99pub struct ConfigFile {
100    /// An combined date and time at which the image was created,
101    /// formatted as defined by
102    /// [RFC 3339, section 5.6](https://tools.ietf.org/html/rfc3339#section-5.6)
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub created: Option<DateTime<Utc>>,
105
106    /// Gives the name and/or email address of the person or entity
107    /// which created and is responsible for maintaining the image.
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub author: Option<String>,
110
111    /// The CPU architecture which the binaries in this image are
112    /// built to run on.
113    pub architecture: Architecture,
114
115    /// The name of the operating system which the image is built to run on.
116    /// Validated values are listed in [Go Language document for GOOS](https://golang.org/doc/install/source#environment)
117    pub os: Os,
118
119    /// The execution parameters which SHOULD be used as a base when running a container using the image.
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub config: Option<Config>,
122
123    /// The rootfs key references the layer content addresses used by the image.
124    pub rootfs: Rootfs,
125
126    /// Describes the history of each layer.
127    #[serde(skip_serializing_if = "is_option_vec_empty")]
128    pub history: Option<Vec<History>>,
129}
130
131fn is_option_vec_empty<T>(opt_vec: &Option<Vec<T>>) -> bool {
132    if let Some(vec) = opt_vec {
133        vec.is_empty()
134    } else {
135        true
136    }
137}
138
139/// Helper struct to be serialized into and deserialized from `{}`
140#[derive(Deserialize, Serialize)]
141struct Empty {}
142
143/// Helper to deserialize a `map[string]struct{}` of golang
144fn optional_hashset_from_str<'de, D: Deserializer<'de>>(
145    d: D,
146) -> Result<Option<HashSet<String>>, D::Error> {
147    let res = <Option<HashMap<String, Empty>>>::deserialize(d)?.map(|h| h.into_keys().collect());
148    Ok(res)
149}
150
151/// Helper to serialize an optional hashset
152fn serialize_optional_hashset<T, S>(
153    value: &Option<HashSet<T>>,
154    serializer: S,
155) -> Result<S::Ok, S::Error>
156where
157    T: Serialize,
158    S: Serializer,
159{
160    match value {
161        Some(set) => {
162            let empty = Empty {};
163            let mut map = serializer.serialize_map(Some(set.len()))?;
164            for k in set {
165                map.serialize_entry(k, &empty)?;
166            }
167
168            map.end()
169        }
170        None => serializer.serialize_none(),
171    }
172}
173
174/// The execution parameters which SHOULD be used as a base when running a container using the image.
175#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
176#[serde(rename_all = "PascalCase")]
177pub struct Config {
178    /// The username or UID which is a platform-specific structure
179    /// that allows specific control over which user the process run as. This acts as a default value to use when the value is
180    /// not specified when creating a container. For Linux based
181    /// systems, all of the following are valid: `user`, `uid`,
182    /// `user:group`, `uid:gid`, `uid:group`, `user:gid`. If `group`/`gid` is
183    /// not specified, the default group and supplementary groups
184    /// of the given `user`/`uid` in `/etc/passwd` from the container are
185    /// applied.
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub user: Option<String>,
188
189    /// A set of ports to expose from a container running this
190    /// image. Its keys can be in the format of: `port/tcp`, `port/udp`,
191    /// `port` with the default protocol being `tcp` if not specified.
192    /// These values act as defaults and are merged with any
193    /// specified when creating a container.
194    #[serde(
195        skip_serializing_if = "is_option_hashset_empty",
196        deserialize_with = "optional_hashset_from_str",
197        serialize_with = "serialize_optional_hashset",
198        default
199    )]
200    pub exposed_ports: Option<HashSet<String>>,
201
202    /// Entries are in the format of `VARNAME=VARVALUE`.
203    #[serde(skip_serializing_if = "is_option_vec_empty")]
204    pub env: Option<Vec<String>>,
205
206    /// Default arguments to the entrypoint of the container.
207    #[serde(skip_serializing_if = "is_option_vec_empty")]
208    pub cmd: Option<Vec<String>>,
209
210    /// A list of arguments to use as the command to execute when
211    /// the container starts..
212    #[serde(skip_serializing_if = "is_option_vec_empty")]
213    pub entrypoint: Option<Vec<String>>,
214
215    /// A set of directories describing where the process is likely write data specific to a container instance.
216    #[serde(
217        skip_serializing_if = "is_option_hashset_empty",
218        deserialize_with = "optional_hashset_from_str",
219        serialize_with = "serialize_optional_hashset",
220        default
221    )]
222    pub volumes: Option<HashSet<String>>,
223
224    /// Sets the current working directory of the entrypoint
225    /// process in the container.
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub working_dir: Option<String>,
228
229    /// The field contains arbitrary metadata for the container.
230    /// This property MUST use the [annotation rules](https://github.com/opencontainers/image-spec/blob/v1.0/annotations.md#rules).
231    #[serde(skip_serializing_if = "is_option_hashmap_empty")]
232    pub labels: Option<HashMap<String, String>>,
233
234    /// The field contains the system call signal that will be sent
235    /// to the container to exit. The signal can be a signal name
236    /// in the format `SIGNAME`, for instance `SIGKILL` or `SIGRTMIN+3`.
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub stop_signal: Option<String>,
239}
240
241fn is_option_hashset_empty<T>(opt_hash: &Option<HashSet<T>>) -> bool {
242    if let Some(hash) = opt_hash {
243        hash.is_empty()
244    } else {
245        true
246    }
247}
248
249fn is_option_hashmap_empty<T, V>(opt_hash: &Option<HashMap<T, V>>) -> bool {
250    if let Some(hash) = opt_hash {
251        hash.is_empty()
252    } else {
253        true
254    }
255}
256
257/// Default value of the type of a [`Rootfs`]
258pub const ROOTFS_TYPE: &str = "layers";
259
260/// The rootfs key references the layer content addresses used by the image.
261/// This makes the image config hash depend on the filesystem hash.
262#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
263pub struct Rootfs {
264    /// MUST be set to `layers`.
265    pub r#type: String,
266
267    /// An array of layer content hashes (`DiffIDs`), in order from first to last.
268    pub diff_ids: Vec<String>,
269}
270
271impl Default for Rootfs {
272    fn default() -> Self {
273        Self {
274            r#type: String::from(ROOTFS_TYPE),
275            diff_ids: Default::default(),
276        }
277    }
278}
279
280/// Describes the history of each layer. The array is ordered from first to last.
281#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
282pub struct History {
283    /// A combined date and time at which the layer was created,
284    /// formatted as defined by [RFC 3339, section 5.6](https://tools.ietf.org/html/rfc3339#section-5.6).
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub created: Option<DateTime<Utc>>,
287
288    /// The author of the build point.
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub author: Option<String>,
291
292    /// The command which created the layer.
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub created_by: Option<String>,
295
296    /// A custom message set when creating the layer.
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub comment: Option<String>,
299
300    /// This field is used to mark if the history item created a
301    /// filesystem diff. It is set to true if this history item
302    /// doesn't correspond to an actual layer in the rootfs section
303    /// (for example, Dockerfile's `ENV` command results in no
304    /// change to the filesystem).
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub empty_layer: Option<bool>,
307}
308
309#[cfg(test)]
310mod tests {
311    use assert_json_diff::assert_json_eq;
312    use chrono::DateTime;
313    use rstest::*;
314    use serde_json::Value;
315    use std::collections::{HashMap, HashSet};
316
317    use super::{Architecture, Config, ConfigFile, History, Os, Rootfs};
318
319    const EXAMPLE_CONFIG: &str = r#"
320    {
321        "created": "2015-10-31T22:22:56.015925234Z",
322        "author": "Alyssa P. Hacker <alyspdev@example.com>",
323        "architecture": "amd64",
324        "os": "linux",
325        "config": {
326            "User": "alice",
327            "ExposedPorts": {
328                "8080/tcp": {}
329            },
330            "Env": [
331                "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
332                "FOO=oci_is_a",
333                "BAR=well_written_spec"
334            ],
335            "Entrypoint": [
336                "/bin/my-app-binary"
337            ],
338            "Cmd": [
339                "--foreground",
340                "--config",
341                "/etc/my-app.d/default.cfg"
342            ],
343            "Volumes": {
344                "/var/job-result-data": {},
345                "/var/log/my-app-logs": {}
346            },
347            "WorkingDir": "/home/alice",
348            "Labels": {
349                "com.example.project.git.url": "https://example.com/project.git",
350                "com.example.project.git.commit": "45a939b2999782a3f005621a8d0f29aa387e1d6b"
351            }
352        },
353        "rootfs": {
354          "diff_ids": [
355            "sha256:c6f988f4874bb0add23a778f753c65efe992244e148a1d2ec2a8b664fb66bbd1",
356            "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef"
357          ],
358          "type": "layers"
359        },
360        "history": [
361          {
362            "created": "2015-10-31T22:22:54.690851953Z",
363            "created_by": "/bin/sh -c #(nop) ADD file:a3bc1e842b69636f9df5256c49c5374fb4eef1e281fe3f282c65fb853ee171c5 in /"
364          },
365          {
366            "created": "2015-10-31T22:22:55.613815829Z",
367            "created_by": "/bin/sh -c #(nop) CMD [\"sh\"]",
368            "empty_layer": true
369          }
370        ]
371    }"#;
372
373    fn example_config() -> ConfigFile {
374        let config = Config {
375            user: Some("alice".into()),
376            exposed_ports: Some(HashSet::from_iter(vec!["8080/tcp".into()])),
377            env: Some(vec![
378                "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into(),
379                "FOO=oci_is_a".into(),
380                "BAR=well_written_spec".into(),
381            ]),
382            cmd: Some(vec![
383                "--foreground".into(),
384                "--config".into(),
385                "/etc/my-app.d/default.cfg".into(),
386            ]),
387            entrypoint: Some(vec!["/bin/my-app-binary".into()]),
388            volumes: Some(HashSet::from_iter(vec![
389                "/var/job-result-data".into(),
390                "/var/log/my-app-logs".into(),
391            ])),
392            working_dir: Some("/home/alice".into()),
393            labels: Some(HashMap::from_iter(vec![
394                (
395                    "com.example.project.git.url".into(),
396                    "https://example.com/project.git".into(),
397                ),
398                (
399                    "com.example.project.git.commit".into(),
400                    "45a939b2999782a3f005621a8d0f29aa387e1d6b".into(),
401                ),
402            ])),
403            stop_signal: None,
404        };
405        let rootfs = Rootfs {
406            r#type: "layers".into(),
407            diff_ids: vec![
408                "sha256:c6f988f4874bb0add23a778f753c65efe992244e148a1d2ec2a8b664fb66bbd1".into(),
409                "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef".into(),
410            ],
411        };
412
413        let history = Some(vec![History {
414            created: Some(DateTime::parse_from_rfc3339("2015-10-31T22:22:54.690851953Z").expect("parse time failed").into()),
415            author: None,
416            created_by: Some("/bin/sh -c #(nop) ADD file:a3bc1e842b69636f9df5256c49c5374fb4eef1e281fe3f282c65fb853ee171c5 in /".into()),
417            comment: None,
418            empty_layer: None,
419        },
420        History {
421            created: Some(DateTime::parse_from_rfc3339("2015-10-31T22:22:55.613815829Z").expect("parse time failed").into()),
422            author: None,
423            created_by: Some("/bin/sh -c #(nop) CMD [\"sh\"]".into()),
424            comment: None,
425            empty_layer: Some(true),
426        }]);
427        ConfigFile {
428            created: Some(
429                DateTime::parse_from_rfc3339("2015-10-31T22:22:56.015925234Z")
430                    .expect("parse time failed")
431                    .into(),
432            ),
433            author: Some("Alyssa P. Hacker <alyspdev@example.com>".into()),
434            architecture: Architecture::Amd64,
435            os: Os::Linux,
436            config: Some(config),
437            rootfs,
438            history,
439        }
440    }
441
442    const MINIMAL_CONFIG: &str = r#"
443    {
444        "architecture": "amd64",
445        "os": "linux",
446        "rootfs": {
447          "diff_ids": [
448            "sha256:c6f988f4874bb0add23a778f753c65efe992244e148a1d2ec2a8b664fb66bbd1",
449            "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef"
450          ],
451          "type": "layers"
452        }
453    }"#;
454
455    fn minimal_config() -> ConfigFile {
456        let rootfs = Rootfs {
457            r#type: "layers".into(),
458            diff_ids: vec![
459                "sha256:c6f988f4874bb0add23a778f753c65efe992244e148a1d2ec2a8b664fb66bbd1".into(),
460                "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef".into(),
461            ],
462        };
463
464        ConfigFile {
465            architecture: Architecture::Amd64,
466            os: Os::Linux,
467            config: None,
468            rootfs,
469            history: None,
470            created: None,
471            author: None,
472        }
473    }
474
475    const MINIMAL_CONFIG2: &str = r#"
476    {
477        "architecture":"arm64",
478        "config":{
479            "Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
480            "WorkingDir":"/"
481        },
482        "created":"2023-04-21T11:53:28.176613804Z",
483        "history":[{
484            "created":"2023-04-21T11:53:28.176613804Z",
485            "created_by":"COPY ./src/main.rs / # buildkit",
486            "comment":"buildkit.dockerfile.v0"
487        }],
488        "os":"linux",
489        "rootfs":{
490            "type":"layers",
491            "diff_ids":[
492                "sha256:267fbf1f5a9377e40a2dc65b355000111e000a35ac77f7b19a59f587d4dd778e"
493            ]
494        }
495    }"#;
496
497    fn minimal_config2() -> ConfigFile {
498        let config = Some(Config {
499            env: Some(vec![
500                "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into(),
501            ]),
502            working_dir: Some("/".into()),
503            ..Config::default()
504        });
505        let history = Some(vec![History {
506            created: Some(
507                DateTime::parse_from_rfc3339("2023-04-21T11:53:28.176613804Z")
508                    .expect("parse time failed")
509                    .into(),
510            ),
511            author: None,
512            created_by: Some("COPY ./src/main.rs / # buildkit".into()),
513            comment: Some("buildkit.dockerfile.v0".into()),
514            empty_layer: None,
515        }]);
516
517        let rootfs = Rootfs {
518            r#type: "layers".into(),
519            diff_ids: vec![
520                "sha256:267fbf1f5a9377e40a2dc65b355000111e000a35ac77f7b19a59f587d4dd778e".into(),
521            ],
522        };
523
524        ConfigFile {
525            architecture: Architecture::Arm64,
526            os: Os::Linux,
527            config,
528            rootfs,
529            history,
530            created: Some(
531                DateTime::parse_from_rfc3339("2023-04-21T11:53:28.176613804Z")
532                    .expect("parse time failed")
533                    .into(),
534            ),
535            author: None,
536        }
537    }
538
539    #[rstest]
540    #[case(example_config(), EXAMPLE_CONFIG)]
541    #[case(minimal_config(), MINIMAL_CONFIG)]
542    #[case(minimal_config2(), MINIMAL_CONFIG2)]
543    fn deserialize_test(#[case] config: ConfigFile, #[case] expected: &str) {
544        let parsed: ConfigFile = serde_json::from_str(expected).expect("parsed failed");
545        assert_eq!(config, parsed);
546    }
547
548    #[rstest]
549    #[case(example_config(), EXAMPLE_CONFIG)]
550    #[case(minimal_config(), MINIMAL_CONFIG)]
551    #[case(minimal_config2(), MINIMAL_CONFIG2)]
552    fn serialize_test(#[case] config: ConfigFile, #[case] expected: &str) {
553        let serialized = serde_json::to_value(&config).expect("serialize failed");
554        let parsed: Value = serde_json::from_str(expected).expect("parsed failed");
555        assert_json_eq!(serialized, parsed);
556    }
557}