1use std::collections::BTreeMap;
2use std::convert::TryFrom;
3use std::path::PathBuf;
4
5use chrono::prelude::*;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct ImageSpecification {
13 #[serde(skip_serializing_if = "Option::is_none")]
15 pub created: Option<DateTime<Utc>>,
16
17 #[serde(skip_serializing_if = "Option::is_none")]
19 pub author: Option<String>,
20
21 pub architecture: Architecture,
23
24 pub os: OperatingSystem,
26
27 #[serde(skip_serializing_if = "Option::is_none")]
30 pub config: Option<ImageConfig>,
31
32 #[serde(skip_serializing_if = "Option::is_none")]
34 pub rootfs: Option<ImageRootfs>,
35
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub history: Option<Vec<LayerHistoryItem>>,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
42#[serde(rename_all = "lowercase")]
43pub enum Architecture {
44 Amd64,
46
47 I386,
49
50 ARM,
52
53 ARM64,
55
56 PPC64le,
58
59 PPC64,
61
62 Mips64le,
64
65 Mips64,
67
68 Mipsle,
70
71 Mips,
73
74 S390x,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
79#[serde(rename_all = "lowercase")]
80pub enum OperatingSystem {
81 Darwin,
82 Dragonfly,
83 Freebsd,
84 Linux,
85 Netbsd,
86 Openbsd,
87 Plan9,
88 Solaris,
89 Windows,
90}
91
92#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
93#[serde(from = "RawImageConfig")]
94#[serde(into = "RawImageConfig")]
95pub struct ImageConfig {
96 pub user: Option<String>,
98
99 pub exposed_ports: Option<Vec<ExposedPort>>,
101
102 pub env: Option<BTreeMap<String, String>>,
104
105 pub entrypoint: Option<Vec<String>>,
107
108 pub cmd: Option<Vec<String>>,
110
111 pub volumes: Option<Vec<PathBuf>>,
113
114 pub working_dir: Option<PathBuf>,
116
117 pub labels: Option<BTreeMap<String, String>>,
119
120 pub stop_signal: Option<Signal>,
122}
123
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125#[serde(rename_all = "PascalCase")]
126struct RawImageConfig {
127 #[serde(skip_serializing_if = "Option::is_none")]
128 user: Option<String>,
129
130 #[serde(skip_serializing_if = "Option::is_none")]
131 exposed_ports: Option<BTreeMap<ExposedPort, Value>>,
132
133 #[serde(skip_serializing_if = "Option::is_none")]
134 env: Option<Vec<String>>,
135
136 #[serde(skip_serializing_if = "Option::is_none")]
137 entrypoint: Option<Vec<String>>,
138
139 #[serde(skip_serializing_if = "Option::is_none")]
140 cmd: Option<Vec<String>>,
141
142 #[serde(skip_serializing_if = "Option::is_none")]
143 volumes: Option<BTreeMap<PathBuf, Value>>,
144
145 #[serde(skip_serializing_if = "Option::is_none")]
146 working_dir: Option<PathBuf>,
147
148 #[serde(skip_serializing_if = "Option::is_none")]
149 labels: Option<BTreeMap<String, String>>,
150
151 #[serde(skip_serializing_if = "Option::is_none")]
152 stop_signal: Option<Signal>,
153}
154
155#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156pub struct ImageRootfs {
157 #[serde(rename = "type")]
159 pub diff_type: RootfsType,
160
161 pub diff_ids: Vec<String>,
163}
164
165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
166pub struct LayerHistoryItem {
167 #[serde(skip_serializing_if = "Option::is_none")]
169 pub created: Option<DateTime<Utc>>,
170
171 #[serde(skip_serializing_if = "Option::is_none")]
173 pub author: Option<String>,
174
175 #[serde(skip_serializing_if = "Option::is_none")]
177 pub created_by: Option<String>,
178
179 #[serde(skip_serializing_if = "Option::is_none")]
181 pub comment: Option<String>,
182
183 #[serde(skip_serializing_if = "Option::is_none")]
187 pub empty_layer: Option<bool>,
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Eq, Ord, Serialize, Deserialize)]
191#[serde(try_from = "String")]
192#[serde(into = "String")]
193pub enum ExposedPort {
194 Tcp(u16),
195 Udp(u16),
196}
197
198impl TryFrom<String> for ExposedPort {
199 type Error = std::num::ParseIntError;
200
201 fn try_from(value: String) -> Result<Self, Self::Error> {
202 let postfix_len = value.len() - 4;
203
204 match &value[postfix_len..] {
205 "/tcp" => Ok(ExposedPort::Tcp(value[..postfix_len].parse()?)),
206 "/udp" => Ok(ExposedPort::Udp(value[..postfix_len].parse()?)),
207
208 _ => Ok(ExposedPort::Tcp(value.parse()?)),
209 }
210 }
211}
212
213impl Into<String> for ExposedPort {
214 fn into(self) -> String {
215 match self {
216 ExposedPort::Tcp(port) => format!("{}/tcp", port),
217 ExposedPort::Udp(port) => format!("{}/udp", port),
218 }
219 }
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
223#[serde(rename_all = "lowercase")]
224pub enum RootfsType {
225 Layers,
226}
227
228#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
229pub enum Signal {
230 SIGHUP,
231 SIGINT,
232 SIGQUIT,
233 SIGILL,
234 SIGTRAP,
235 SIGABRT,
236 SIGBUS,
237 SIGFPE,
238 SIGKILL,
239 SIGUSR1,
240 SIGSEGV,
241 SIGUSR2,
242 SIGPIPE,
243 SIGALRM,
244 SIGTERM,
245 SIGSTKFLT,
246 SIGCHLD,
247 SIGCONT,
248 SIGSTOP,
249 SIGTSTP,
250 SIGTTIN,
251 SIGTTOU,
252 SIGURG,
253 SIGXCPU,
254 SIGXFSZ,
255 SIGVTALRM,
256 SIGPROF,
257 SIGWINCH,
258 SIGIO,
259 SIGPWR,
260 SIGSYS,
261 SIGEMT,
262 SIGINFO,
263}
264
265impl From<RawImageConfig> for ImageConfig {
266 fn from(raw: RawImageConfig) -> Self {
267 Self {
268 user: raw.user,
269 entrypoint: raw.entrypoint,
270 cmd: raw.cmd,
271 working_dir: raw.working_dir,
272 labels: raw.labels,
273 stop_signal: raw.stop_signal,
274
275 env: raw.env.map(|inner| {
276 inner
277 .into_iter()
278 .map(|mut pair| match pair.find('=') {
279 Some(pos) => {
280 let value = pair.split_off(pos + 1);
281 let mut name = pair;
282 name.pop();
283
284 (name, value)
285 }
286
287 None => (pair, String::with_capacity(0)),
288 })
289 .collect()
290 }),
291
292 exposed_ports: raw
293 .exposed_ports
294 .map(|inner| inner.into_iter().map(|(port, _)| port).collect()),
295
296 volumes: raw
297 .volumes
298 .map(|inner| inner.into_iter().map(|(volume, _)| volume).collect()),
299 }
300 }
301}
302
303impl Into<RawImageConfig> for ImageConfig {
304 fn into(self) -> RawImageConfig {
305 RawImageConfig {
306 user: self.user,
307 entrypoint: self.entrypoint,
308 cmd: self.cmd,
309 working_dir: self.working_dir,
310 labels: self.labels,
311 stop_signal: self.stop_signal,
312
313 env: self.env.map(|inner| {
314 inner
315 .into_iter()
316 .map(|(key, value)| format!("{}={}", key, value))
317 .collect()
318 }),
319
320 exposed_ports: self.exposed_ports.map(|inner| {
321 inner
322 .into_iter()
323 .map(|port| (port, Value::Object(Default::default())))
324 .collect()
325 }),
326
327 volumes: self.volumes.map(|inner| {
328 inner
329 .into_iter()
330 .map(|volume| (volume, Value::Object(Default::default())))
331 .collect()
332 }),
333 }
334 }
335}
336
337#[test]
338fn serialization() {
339 use pretty_assertions::assert_eq;
340
341 let ref_json = include_str!("../tests/oci-image-spec.json");
342 let ref_spec = ImageSpecification {
343 created: Some("2015-10-31T22:22:56.015925234Z".parse().unwrap()),
344 author: Some("Alyssa P. Hacker <alyspdev@example.com>".into()),
345 architecture: Architecture::Amd64,
346 os: OperatingSystem::Linux,
347 rootfs: Some(ImageRootfs {
348 diff_type: RootfsType::Layers,
349 diff_ids: vec![
350 "sha256:c6f988f4874bb0add23a778f753c65efe992244e148a1d2ec2a8b664fb66bbd1".into(),
351 "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef".into(),
352 ],
353 }),
354 history: Some(vec![
355 LayerHistoryItem {
356 created: Some("2015-10-31T22:22:54.690851953Z".parse().unwrap()),
357 created_by: Some("/bin/sh -c #(nop) ADD file in /".into()),
358 author: None,
359 comment: None,
360 empty_layer: None,
361 },
362 LayerHistoryItem {
363 created: Some("2015-10-31T22:22:55.613815829Z".parse().unwrap()),
364 created_by: Some("/bin/sh -c #(nop) CMD [\"sh\"]".into()),
365 author: None,
366 comment: None,
367 empty_layer: Some(true),
368 },
369 ]),
370
371 config: Some(ImageConfig {
372 user: Some("alice".into()),
373 exposed_ports: Some(vec![ExposedPort::Tcp(8080), ExposedPort::Udp(8081)]),
374 env: Some(
375 vec![(
376 String::from("PATH"),
377 String::from("/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"),
378 )]
379 .into_iter()
380 .collect(),
381 ),
382 entrypoint: Some(vec!["/bin/my-app-binary".into()]),
383 cmd: Some(vec![
384 "--foreground".into(),
385 "--config".into(),
386 "/etc/my-app.d/default.cfg".into(),
387 ]),
388 volumes: Some(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(
394 vec![(
395 String::from("com.example.project.git.url"),
396 String::from("https://example.com/project.git"),
397 )]
398 .into_iter()
399 .collect(),
400 ),
401 stop_signal: Some(Signal::SIGKILL),
402 }),
403 };
404
405 assert_eq!(serde_json::to_string_pretty(&ref_spec).unwrap(), ref_json);
406 assert_eq!(
407 serde_json::from_str::<ImageSpecification>(ref_json).unwrap(),
408 ref_spec
409 );
410}
411
412#[test]
413fn min_serialization() {
414 use pretty_assertions::assert_eq;
415
416 let ref_json = include_str!("../tests/oci-image-spec-min.json");
417 let ref_spec = ImageSpecification {
418 created: None,
419 author: None,
420
421 architecture: Architecture::Amd64,
422 os: OperatingSystem::Linux,
423 rootfs: Some(ImageRootfs {
424 diff_type: RootfsType::Layers,
425 diff_ids: vec![
426 "sha256:c6f988f4874bb0add23a778f753c65efe992244e148a1d2ec2a8b664fb66bbd1".into(),
427 "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef".into(),
428 ],
429 }),
430
431 history: None,
432 config: None,
433 };
434
435 assert_eq!(serde_json::to_string_pretty(&ref_spec).unwrap(), ref_json);
436 assert_eq!(
437 serde_json::from_str::<ImageSpecification>(ref_json).unwrap(),
438 ref_spec
439 );
440}