use std::collections::{HashMap, HashSet};
use chrono::{DateTime, Utc};
use serde::{ser::SerializeMap, Deserialize, Deserializer, Serialize, Serializer};
pub type Architecture = oci_spec::image::Arch;
pub type Os = oci_spec::image::Os;
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
pub struct ConfigFile {
#[serde(skip_serializing_if = "Option::is_none")]
pub created: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
pub architecture: Architecture,
pub os: Os,
#[serde(skip_serializing_if = "Option::is_none")]
pub config: Option<Config>,
pub rootfs: Rootfs,
#[serde(skip_serializing_if = "is_option_vec_empty")]
pub history: Option<Vec<History>>,
}
fn is_option_vec_empty<T>(opt_vec: &Option<Vec<T>>) -> bool {
if let Some(vec) = opt_vec {
vec.is_empty()
} else {
true
}
}
#[derive(Deserialize, Serialize)]
struct Empty {}
fn optional_hashset_from_str<'de, D: Deserializer<'de>>(
d: D,
) -> Result<Option<HashSet<String>>, D::Error> {
let res = <Option<HashMap<String, Empty>>>::deserialize(d)?.map(|h| h.into_keys().collect());
Ok(res)
}
fn serialize_optional_hashset<T, S>(
value: &Option<HashSet<T>>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
T: Serialize,
S: Serializer,
{
match value {
Some(set) => {
let empty = Empty {};
let mut map = serializer.serialize_map(Some(set.len()))?;
for k in set {
map.serialize_entry(k, &empty)?;
}
map.end()
}
None => serializer.serialize_none(),
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
#[serde(rename_all = "PascalCase")]
pub struct Config {
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(
skip_serializing_if = "is_option_hashset_empty",
deserialize_with = "optional_hashset_from_str",
serialize_with = "serialize_optional_hashset",
default
)]
pub exposed_ports: Option<HashSet<String>>,
#[serde(skip_serializing_if = "is_option_vec_empty")]
pub env: Option<Vec<String>>,
#[serde(skip_serializing_if = "is_option_vec_empty")]
pub cmd: Option<Vec<String>>,
#[serde(skip_serializing_if = "is_option_vec_empty")]
pub entrypoint: Option<Vec<String>>,
#[serde(
skip_serializing_if = "is_option_hashset_empty",
deserialize_with = "optional_hashset_from_str",
serialize_with = "serialize_optional_hashset",
default
)]
pub volumes: Option<HashSet<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub working_dir: Option<String>,
#[serde(skip_serializing_if = "is_option_hashmap_empty")]
pub labels: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop_signal: Option<String>,
}
fn is_option_hashset_empty<T>(opt_hash: &Option<HashSet<T>>) -> bool {
if let Some(hash) = opt_hash {
hash.is_empty()
} else {
true
}
}
fn is_option_hashmap_empty<T, V>(opt_hash: &Option<HashMap<T, V>>) -> bool {
if let Some(hash) = opt_hash {
hash.is_empty()
} else {
true
}
}
pub const ROOTFS_TYPE: &str = "layers";
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct Rootfs {
pub r#type: String,
pub diff_ids: Vec<String>,
}
impl Default for Rootfs {
fn default() -> Self {
Self {
r#type: String::from(ROOTFS_TYPE),
diff_ids: Default::default(),
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
pub struct History {
#[serde(skip_serializing_if = "Option::is_none")]
pub created: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub empty_layer: Option<bool>,
}
#[cfg(test)]
mod tests {
use assert_json_diff::assert_json_eq;
use chrono::DateTime;
use oci_spec::image::Arch;
use rstest::*;
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use super::{Config, ConfigFile, History, Os, Rootfs};
const EXAMPLE_CONFIG: &str = r#"
{
"created": "2015-10-31T22:22:56.015925234Z",
"author": "Alyssa P. Hacker <alyspdev@example.com>",
"architecture": "amd64",
"os": "linux",
"config": {
"User": "alice",
"ExposedPorts": {
"8080/tcp": {}
},
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"FOO=oci_is_a",
"BAR=well_written_spec"
],
"Entrypoint": [
"/bin/my-app-binary"
],
"Cmd": [
"--foreground",
"--config",
"/etc/my-app.d/default.cfg"
],
"Volumes": {
"/var/job-result-data": {},
"/var/log/my-app-logs": {}
},
"WorkingDir": "/home/alice",
"Labels": {
"com.example.project.git.url": "https://example.com/project.git",
"com.example.project.git.commit": "45a939b2999782a3f005621a8d0f29aa387e1d6b"
}
},
"rootfs": {
"diff_ids": [
"sha256:c6f988f4874bb0add23a778f753c65efe992244e148a1d2ec2a8b664fb66bbd1",
"sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef"
],
"type": "layers"
},
"history": [
{
"created": "2015-10-31T22:22:54.690851953Z",
"created_by": "/bin/sh -c #(nop) ADD file:a3bc1e842b69636f9df5256c49c5374fb4eef1e281fe3f282c65fb853ee171c5 in /"
},
{
"created": "2015-10-31T22:22:55.613815829Z",
"created_by": "/bin/sh -c #(nop) CMD [\"sh\"]",
"empty_layer": true
}
]
}"#;
fn example_config() -> ConfigFile {
let config = Config {
user: Some("alice".into()),
exposed_ports: Some(HashSet::from_iter(vec!["8080/tcp".into()])),
env: Some(vec![
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into(),
"FOO=oci_is_a".into(),
"BAR=well_written_spec".into(),
]),
cmd: Some(vec![
"--foreground".into(),
"--config".into(),
"/etc/my-app.d/default.cfg".into(),
]),
entrypoint: Some(vec!["/bin/my-app-binary".into()]),
volumes: Some(HashSet::from_iter(vec![
"/var/job-result-data".into(),
"/var/log/my-app-logs".into(),
])),
working_dir: Some("/home/alice".into()),
labels: Some(HashMap::from_iter(vec![
(
"com.example.project.git.url".into(),
"https://example.com/project.git".into(),
),
(
"com.example.project.git.commit".into(),
"45a939b2999782a3f005621a8d0f29aa387e1d6b".into(),
),
])),
stop_signal: None,
};
let rootfs = Rootfs {
r#type: "layers".into(),
diff_ids: vec![
"sha256:c6f988f4874bb0add23a778f753c65efe992244e148a1d2ec2a8b664fb66bbd1".into(),
"sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef".into(),
],
};
let history = Some(vec![History {
created: Some(DateTime::parse_from_rfc3339("2015-10-31T22:22:54.690851953Z").expect("parse time failed").into()),
author: None,
created_by: Some("/bin/sh -c #(nop) ADD file:a3bc1e842b69636f9df5256c49c5374fb4eef1e281fe3f282c65fb853ee171c5 in /".into()),
comment: None,
empty_layer: None,
},
History {
created: Some(DateTime::parse_from_rfc3339("2015-10-31T22:22:55.613815829Z").expect("parse time failed").into()),
author: None,
created_by: Some("/bin/sh -c #(nop) CMD [\"sh\"]".into()),
comment: None,
empty_layer: Some(true),
}]);
ConfigFile {
created: Some(
DateTime::parse_from_rfc3339("2015-10-31T22:22:56.015925234Z")
.expect("parse time failed")
.into(),
),
author: Some("Alyssa P. Hacker <alyspdev@example.com>".into()),
architecture: Arch::Amd64,
os: Os::Linux,
config: Some(config),
rootfs,
history,
}
}
const MINIMAL_CONFIG: &str = r#"
{
"architecture": "amd64",
"os": "linux",
"rootfs": {
"diff_ids": [
"sha256:c6f988f4874bb0add23a778f753c65efe992244e148a1d2ec2a8b664fb66bbd1",
"sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef"
],
"type": "layers"
}
}"#;
fn minimal_config() -> ConfigFile {
let rootfs = Rootfs {
r#type: "layers".into(),
diff_ids: vec![
"sha256:c6f988f4874bb0add23a778f753c65efe992244e148a1d2ec2a8b664fb66bbd1".into(),
"sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef".into(),
],
};
ConfigFile {
architecture: Arch::Amd64,
os: Os::Linux,
config: None,
rootfs,
history: None,
created: None,
author: None,
}
}
const MINIMAL_CONFIG2: &str = r#"
{
"architecture":"arm64",
"config":{
"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
"WorkingDir":"/"
},
"created":"2023-04-21T11:53:28.176613804Z",
"history":[{
"created":"2023-04-21T11:53:28.176613804Z",
"created_by":"COPY ./src/main.rs / # buildkit",
"comment":"buildkit.dockerfile.v0"
}],
"os":"linux",
"rootfs":{
"type":"layers",
"diff_ids":[
"sha256:267fbf1f5a9377e40a2dc65b355000111e000a35ac77f7b19a59f587d4dd778e"
]
}
}"#;
fn minimal_config2() -> ConfigFile {
let config = Some(Config {
env: Some(vec![
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into(),
]),
working_dir: Some("/".into()),
..Config::default()
});
let history = Some(vec![History {
created: Some(
DateTime::parse_from_rfc3339("2023-04-21T11:53:28.176613804Z")
.expect("parse time failed")
.into(),
),
author: None,
created_by: Some("COPY ./src/main.rs / # buildkit".into()),
comment: Some("buildkit.dockerfile.v0".into()),
empty_layer: None,
}]);
let rootfs = Rootfs {
r#type: "layers".into(),
diff_ids: vec![
"sha256:267fbf1f5a9377e40a2dc65b355000111e000a35ac77f7b19a59f587d4dd778e".into(),
],
};
ConfigFile {
architecture: Arch::ARM64,
os: Os::Linux,
config,
rootfs,
history,
created: Some(
DateTime::parse_from_rfc3339("2023-04-21T11:53:28.176613804Z")
.expect("parse time failed")
.into(),
),
author: None,
}
}
#[rstest]
#[case(example_config(), EXAMPLE_CONFIG)]
#[case(minimal_config(), MINIMAL_CONFIG)]
#[case(minimal_config2(), MINIMAL_CONFIG2)]
fn deserialize_test(#[case] config: ConfigFile, #[case] expected: &str) {
let parsed: ConfigFile = serde_json::from_str(expected).expect("parsed failed");
assert_eq!(config, parsed);
}
#[rstest]
#[case(example_config(), EXAMPLE_CONFIG)]
#[case(minimal_config(), MINIMAL_CONFIG)]
#[case(minimal_config2(), MINIMAL_CONFIG2)]
fn serialize_test(#[case] config: ConfigFile, #[case] expected: &str) {
let serialized = serde_json::to_value(&config).expect("serialize failed");
let parsed: Value = serde_json::from_str(expected).expect("parsed failed");
assert_json_eq!(serialized, parsed);
}
}