Skip to main content

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