maelstrom_client_cli/
spec.rs

1use anyhow::{anyhow, Error, Result};
2use maelstrom_base::{
3    ArtifactType, EnumSet, GroupId, JobDevice, JobDeviceListDeserialize, JobMount, JobSpec, Layer,
4    NonEmpty, Sha256Digest, UserId, Utf8PathBuf,
5};
6use maelstrom_client::spec::{
7    incompatible, substitute, Image, ImageConfig, ImageOption, ImageUse, PossiblyImage,
8};
9use serde::{de, Deserialize, Deserializer};
10use std::{collections::BTreeMap, io::Read};
11
12struct JobSpecIterator<InnerT, LayerMapperT, EnvLookupT, ImageLookupT> {
13    inner: InnerT,
14    layer_mapper: LayerMapperT,
15    env_lookup: EnvLookupT,
16    image_lookup: ImageLookupT,
17}
18
19impl<InnerT, LayerMapperT, EnvLookupT, ImageLookupT> Iterator
20    for JobSpecIterator<InnerT, LayerMapperT, EnvLookupT, ImageLookupT>
21where
22    InnerT: Iterator<Item = serde_json::Result<Job>>,
23    LayerMapperT: Fn(Layer) -> Result<(Sha256Digest, ArtifactType)>,
24    EnvLookupT: Fn(&str) -> Result<Option<String>>,
25    ImageLookupT: FnMut(&str) -> Result<ImageConfig>,
26{
27    type Item = Result<JobSpec>;
28
29    fn next(&mut self) -> Option<Self::Item> {
30        match self.inner.next() {
31            None => None,
32            Some(Err(err)) => Some(Err(Error::new(err))),
33            Some(Ok(job)) => Some(job.into_job_spec(
34                &self.layer_mapper,
35                &self.env_lookup,
36                &mut self.image_lookup,
37            )),
38        }
39    }
40}
41
42pub fn job_spec_iter_from_reader(
43    reader: impl Read,
44    layer_mapper: impl Fn(Layer) -> Result<(Sha256Digest, ArtifactType)>,
45    env_lookup: impl Fn(&str) -> Result<Option<String>>,
46    image_lookup: impl FnMut(&str) -> Result<ImageConfig>,
47) -> impl Iterator<Item = Result<JobSpec>> {
48    let inner = serde_json::Deserializer::from_reader(reader).into_iter::<Job>();
49    JobSpecIterator {
50        inner,
51        layer_mapper,
52        env_lookup,
53        image_lookup,
54    }
55}
56
57#[derive(Debug, Eq, PartialEq)]
58struct Job {
59    program: Utf8PathBuf,
60    arguments: Option<Vec<String>>,
61    environment: Option<PossiblyImage<BTreeMap<String, String>>>,
62    added_environment: BTreeMap<String, String>,
63    layers: PossiblyImage<NonEmpty<Layer>>,
64    added_layers: Vec<Layer>,
65    devices: Option<EnumSet<JobDeviceListDeserialize>>,
66    mounts: Option<Vec<JobMount>>,
67    enable_loopback: Option<bool>,
68    enable_writable_file_system: Option<bool>,
69    working_directory: Option<PossiblyImage<Utf8PathBuf>>,
70    user: Option<UserId>,
71    group: Option<GroupId>,
72    image: Option<String>,
73}
74
75impl Job {
76    #[cfg(test)]
77    fn new(program: Utf8PathBuf, layers: NonEmpty<Layer>) -> Self {
78        Job {
79            program,
80            layers: PossiblyImage::Explicit(layers),
81            added_layers: Default::default(),
82            arguments: None,
83            environment: None,
84            added_environment: Default::default(),
85            devices: None,
86            mounts: None,
87            enable_loopback: None,
88            enable_writable_file_system: None,
89            working_directory: None,
90            user: None,
91            group: None,
92            image: None,
93        }
94    }
95
96    fn into_job_spec(
97        self,
98        layer_mapper: impl Fn(Layer) -> Result<(Sha256Digest, ArtifactType)>,
99        env_lookup: impl Fn(&str) -> Result<Option<String>>,
100        image_lookup: impl FnMut(&str) -> Result<ImageConfig>,
101    ) -> Result<JobSpec> {
102        let image = ImageOption::new(&self.image, image_lookup)?;
103        let mut environment = match self.environment {
104            None => BTreeMap::default(),
105            Some(PossiblyImage::Explicit(environment)) => environment
106                .into_iter()
107                .map(|(k, v)| -> Result<_> {
108                    Ok((
109                        k,
110                        substitute::substitute(&v, &env_lookup, |_| Option::<String>::None)?
111                            .into_owned(),
112                    ))
113                })
114                .collect::<Result<BTreeMap<_, _>>>()?,
115            Some(PossiblyImage::Image) => image.environment()?,
116        };
117        let added_environment = self
118            .added_environment
119            .into_iter()
120            .map(|(k, v)| -> Result<_> {
121                Ok((
122                    k,
123                    substitute::substitute(&v, &env_lookup, |var| {
124                        environment.get(var).map(|v| v.as_str())
125                    })?
126                    .into_owned(),
127                ))
128            })
129            .collect::<Result<BTreeMap<_, _>>>()?;
130        environment.extend(added_environment);
131        let environment = Vec::from_iter(environment.into_iter().map(|(k, v)| k + "=" + &v));
132        let mut layers = match self.layers {
133            PossiblyImage::Explicit(layers) => layers,
134            PossiblyImage::Image => NonEmpty::from_vec(image.layers()?.collect())
135                .ok_or_else(|| anyhow!("image {} has no layers to use", image.name()))?,
136        };
137        layers.extend(self.added_layers);
138        let layers = layers.try_map(layer_mapper)?;
139        let working_directory = match self.working_directory {
140            None => Utf8PathBuf::from("/"),
141            Some(PossiblyImage::Explicit(working_directory)) => working_directory,
142            Some(PossiblyImage::Image) => image.working_directory()?,
143        };
144        Ok(JobSpec {
145            program: self.program,
146            arguments: self.arguments.unwrap_or_default(),
147            environment,
148            layers,
149            devices: self
150                .devices
151                .unwrap_or(EnumSet::EMPTY)
152                .into_iter()
153                .map(JobDevice::from)
154                .collect(),
155            mounts: self.mounts.unwrap_or_default(),
156            enable_loopback: self.enable_loopback.unwrap_or_default(),
157            enable_writable_file_system: self.enable_writable_file_system.unwrap_or_default(),
158            working_directory,
159            user: self.user.unwrap_or(UserId::from(0)),
160            group: self.group.unwrap_or(GroupId::from(0)),
161        })
162    }
163}
164
165#[derive(Deserialize)]
166#[serde(field_identifier, rename_all = "snake_case")]
167enum JobField {
168    Program,
169    Arguments,
170    Environment,
171    AddedEnvironment,
172    Layers,
173    AddedLayers,
174    Devices,
175    Mounts,
176    EnableLoopback,
177    EnableWritableFileSystem,
178    WorkingDirectory,
179    User,
180    Group,
181    Image,
182}
183
184struct JobVisitor;
185
186fn must_be_image<T, E>(
187    var: &Option<PossiblyImage<T>>,
188    if_none: &str,
189    if_explicit: &str,
190) -> std::result::Result<(), E>
191where
192    E: de::Error,
193{
194    match var {
195        None => Err(E::custom(format_args!("{}", if_none))),
196        Some(PossiblyImage::Explicit(_)) => Err(E::custom(format_args!("{}", if_explicit))),
197        Some(PossiblyImage::Image) => Ok(()),
198    }
199}
200
201impl<'de> de::Visitor<'de> for JobVisitor {
202    type Value = Job;
203
204    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205        write!(formatter, "Job")
206    }
207
208    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
209    where
210        A: de::MapAccess<'de>,
211    {
212        let mut program = None;
213        let mut arguments = None;
214        let mut environment = None;
215        let mut added_environment = None;
216        let mut layers = None;
217        let mut added_layers = None;
218        let mut devices = None;
219        let mut mounts = None;
220        let mut enable_loopback = None;
221        let mut enable_writable_file_system = None;
222        let mut working_directory = None;
223        let mut user = None;
224        let mut group = None;
225        let mut image = None;
226        while let Some(key) = map.next_key()? {
227            match key {
228                JobField::Program => {
229                    program = Some(map.next_value()?);
230                }
231                JobField::Arguments => {
232                    arguments = Some(map.next_value()?);
233                }
234                JobField::Environment => {
235                    incompatible(
236                        &environment,
237                        concat!(
238                            "field `environment` cannot be set if `image` with a `use` of ",
239                            "`environment` is also set (try `added_environment` instead)"
240                        ),
241                    )?;
242                    environment = Some(PossiblyImage::Explicit(map.next_value()?));
243                }
244                JobField::AddedEnvironment => {
245                    must_be_image(
246                        &environment,
247                        "field `added_environment` set before `image` with a `use` of `environment`",
248                        "field `added_environment` cannot be set with `environment` field",
249                    )?;
250                    added_environment = Some(map.next_value()?);
251                }
252                JobField::Layers => {
253                    incompatible(
254                        &layers,
255                        concat!(
256                            "field `layers` cannot be set if `image` with a `use` of ",
257                            "`layers` is also set (try `added_layers` instead)"
258                        ),
259                    )?;
260                    layers = Some(PossiblyImage::Explicit(
261                        NonEmpty::from_vec(map.next_value()?).ok_or_else(|| {
262                            de::Error::custom(format_args!("field `layers` cannot be empty"))
263                        })?,
264                    ));
265                }
266                JobField::AddedLayers => {
267                    must_be_image(
268                        &layers,
269                        "field `added_layers` set before `image` with a `use` of `layers`",
270                        "field `added_layers` cannot be set with `layer` field",
271                    )?;
272                    added_layers = Some(map.next_value()?);
273                }
274                JobField::Devices => {
275                    devices = Some(map.next_value()?);
276                }
277                JobField::Mounts => {
278                    mounts = Some(map.next_value()?);
279                }
280                JobField::EnableLoopback => {
281                    enable_loopback = Some(map.next_value()?);
282                }
283                JobField::EnableWritableFileSystem => {
284                    enable_writable_file_system = Some(map.next_value()?);
285                }
286                JobField::WorkingDirectory => {
287                    incompatible(
288                        &working_directory,
289                        concat!(
290                            "field `working_directory` cannot be set if `image` with a `use` of ",
291                            "`working_directory` is also set"
292                        ),
293                    )?;
294                    working_directory = Some(PossiblyImage::Explicit(map.next_value()?));
295                }
296                JobField::User => {
297                    user = Some(map.next_value()?);
298                }
299                JobField::Group => {
300                    group = Some(map.next_value()?);
301                }
302                JobField::Image => {
303                    let i = map.next_value::<Image>()?;
304                    image = Some(i.name);
305                    for use_ in i.use_ {
306                        match use_ {
307                            ImageUse::WorkingDirectory => {
308                                incompatible(
309                                    &working_directory,
310                                    "field `image` cannot use `working_directory` if field `working_directory` is also set",
311                                )?;
312                                working_directory = Some(PossiblyImage::Image);
313                            }
314                            ImageUse::Layers => {
315                                incompatible(
316                                    &layers,
317                                    "field `image` cannot use `layers` if field `layers` is also set",
318                                )?;
319                                layers = Some(PossiblyImage::Image);
320                            }
321                            ImageUse::Environment => {
322                                incompatible(
323                                    &environment,
324                                    "field `image` cannot use `environment` if field `environment` is also set",
325                                )?;
326                                environment = Some(PossiblyImage::Image);
327                            }
328                        }
329                    }
330                }
331            }
332        }
333        Ok(Job {
334            program: program.ok_or_else(|| de::Error::missing_field("program"))?,
335            arguments,
336            environment,
337            added_environment: added_environment.unwrap_or_default(),
338            layers: layers.ok_or_else(|| de::Error::missing_field("layers"))?,
339            added_layers: added_layers.unwrap_or_default(),
340            devices,
341            mounts,
342            enable_loopback,
343            enable_writable_file_system,
344            working_directory,
345            user,
346            group,
347            image,
348        })
349    }
350}
351
352impl<'de> de::Deserialize<'de> for Job {
353    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
354    where
355        D: Deserializer<'de>,
356    {
357        deserializer.deserialize_any(JobVisitor)
358    }
359}
360
361#[cfg(test)]
362mod test {
363    use super::*;
364    use assert_matches::assert_matches;
365    use maelstrom_base::{enum_set, nonempty, JobMountFsType};
366    use maelstrom_test::{digest, path_buf_vec, string, string_vec, tar_layer, utf8_path_buf};
367
368    fn layer_mapper(layer: Layer) -> Result<(Sha256Digest, ArtifactType)> {
369        assert_matches!(layer, Layer::Tar { path } => {
370            Ok((
371                Sha256Digest::from(path.as_str().parse::<u64>()?),
372                ArtifactType::Tar,
373            ))
374        })
375    }
376
377    fn env(var: &str) -> Result<Option<String>> {
378        match var {
379            "FOO" => Ok(Some(string!("foo-env"))),
380            "err" => Err(anyhow!("error converting value to UTF-8")),
381            _ => Ok(None),
382        }
383    }
384
385    fn images(name: &str) -> Result<ImageConfig> {
386        match name {
387            "image1" => Ok(ImageConfig {
388                layers: path_buf_vec!["42", "43"],
389                working_directory: Some("/foo".into()),
390                environment: Some(string_vec!["FOO=image-foo", "BAZ=image-baz",]),
391            }),
392            "image-with-env-substitutions" => Ok(ImageConfig {
393                environment: Some(string_vec!["PATH=$env{PATH}"]),
394                ..Default::default()
395            }),
396            "empty" => Ok(Default::default()),
397            _ => Err(anyhow!("no container named {name} found")),
398        }
399    }
400
401    #[test]
402    fn minimum_into_job_spec() {
403        assert_eq!(
404            Job::new(utf8_path_buf!("program"), nonempty![tar_layer!("1")])
405                .into_job_spec(layer_mapper, env, images)
406                .unwrap(),
407            JobSpec::new("program", nonempty![(digest!(1), ArtifactType::Tar)]),
408        );
409    }
410
411    #[test]
412    fn most_into_job_spec() {
413        assert_eq!(
414            Job {
415                arguments: Some(string_vec!["arg1", "arg2"]),
416                environment: Some(PossiblyImage::Explicit(BTreeMap::from([
417                    (string!("FOO"), string!("foo")),
418                    (string!("BAR"), string!("bar")),
419                ]))),
420                devices: Some(enum_set! {JobDeviceListDeserialize::Null}),
421                mounts: Some(vec![JobMount {
422                    fs_type: JobMountFsType::Tmp,
423                    mount_point: utf8_path_buf!("/tmp"),
424                }]),
425                working_directory: Some(PossiblyImage::Explicit("/working-directory".into())),
426                user: Some(UserId::from(101)),
427                group: Some(GroupId::from(202)),
428                ..Job::new(utf8_path_buf!("program"), nonempty![tar_layer!("1")])
429            }
430            .into_job_spec(layer_mapper, env, images)
431            .unwrap(),
432            JobSpec::new("program", nonempty![(digest!(1), ArtifactType::Tar)])
433                .arguments(["arg1", "arg2"])
434                .environment(["BAR=bar", "FOO=foo"])
435                .devices(enum_set! {JobDevice::Null})
436                .mounts([JobMount {
437                    fs_type: JobMountFsType::Tmp,
438                    mount_point: utf8_path_buf!("/tmp"),
439                }])
440                .working_directory("/working-directory")
441                .user(101)
442                .group(202),
443        );
444    }
445
446    #[test]
447    fn enable_loopback_into_job_spec() {
448        assert_eq!(
449            Job {
450                enable_loopback: Some(true),
451                ..Job::new(utf8_path_buf!("program"), nonempty![tar_layer!("1")])
452            }
453            .into_job_spec(layer_mapper, env, images)
454            .unwrap(),
455            JobSpec::new("program", nonempty![(digest!(1), ArtifactType::Tar)])
456                .enable_loopback(true),
457        );
458    }
459
460    #[test]
461    fn enable_writable_file_system_into_job_spec() {
462        assert_eq!(
463            Job {
464                enable_writable_file_system: Some(true),
465                ..Job::new(utf8_path_buf!("program"), nonempty![tar_layer!("1")])
466            }
467            .into_job_spec(layer_mapper, env, images)
468            .unwrap(),
469            JobSpec::new("program", nonempty![(digest!(1), ArtifactType::Tar)])
470                .enable_writable_file_system(true),
471        );
472    }
473
474    fn parse_job(str_: &str) -> serde_json::Result<Job> {
475        serde_json::from_str(str_)
476    }
477
478    fn assert_error(err: serde_json::Error, expected: &str) {
479        let message = format!("{err}");
480        assert!(
481            message.starts_with(expected),
482            "message: {message:?}, expected: {expected:?}"
483        );
484    }
485
486    fn assert_anyhow_error(err: Error, expected: &str) {
487        let message = format!("{err}");
488        assert!(
489            message == expected,
490            "message: {message:?}, expected: {expected:?}"
491        );
492    }
493
494    #[test]
495    fn basic() {
496        assert_eq!(
497            parse_job(
498                r#"{
499                    "program": "/bin/sh",
500                    "layers": [ { "tar": "1" } ]
501                }"#
502            )
503            .unwrap()
504            .into_job_spec(layer_mapper, env, images)
505            .unwrap(),
506            JobSpec::new(
507                string!("/bin/sh"),
508                nonempty![(digest!(1), ArtifactType::Tar)]
509            ),
510        );
511    }
512
513    #[test]
514    fn missing_program() {
515        assert_error(
516            parse_job(
517                r#"{
518                    "layers": [ { "tar": "1" } ]
519                }"#,
520            )
521            .unwrap_err(),
522            "missing field `program`",
523        );
524    }
525
526    #[test]
527    fn missing_layers() {
528        assert_error(
529            parse_job(
530                r#"{
531                    "program": "/bin/sh"
532                }"#,
533            )
534            .unwrap_err(),
535            "missing field `layers`",
536        );
537    }
538
539    #[test]
540    fn empty_layers() {
541        assert_error(
542            parse_job(
543                r#"{
544                    "program": "/bin/sh",
545                    "layers": []
546                }"#,
547            )
548            .unwrap_err(),
549            "field `layers` cannot be empty",
550        );
551    }
552
553    #[test]
554    fn layers_from_image() {
555        assert_eq!(
556            parse_job(
557                r#"{
558                    "program": "/bin/sh",
559                    "image": {
560                        "name": "image1",
561                        "use": [ "layers" ]
562                    }
563                }"#
564            )
565            .unwrap()
566            .into_job_spec(layer_mapper, env, images)
567            .unwrap(),
568            JobSpec::new(
569                string!("/bin/sh"),
570                nonempty![
571                    (digest!(42), ArtifactType::Tar),
572                    (digest!(43), ArtifactType::Tar)
573                ]
574            ),
575        );
576    }
577
578    #[test]
579    fn empty_layers_from_image() {
580        assert_anyhow_error(
581            parse_job(
582                r#"{
583                    "program": "/bin/sh",
584                    "image": {
585                        "name": "empty",
586                        "use": [ "layers" ]
587                    }
588                }"#,
589            )
590            .unwrap()
591            .into_job_spec(layer_mapper, env, images)
592            .unwrap_err(),
593            "image empty has no layers to use",
594        );
595    }
596
597    #[test]
598    fn layers_after_layers_from_image() {
599        assert_error(
600            parse_job(
601                r#"{
602                    "program": "/bin/sh",
603                    "image": {
604                        "name": "image1",
605                        "use": [ "layers" ]
606                    },
607                    "layers": [ { "tar": "1" } ]
608                }"#,
609            )
610            .unwrap_err(),
611            "field `layers` cannot be set if `image` with a `use` of `layers` is also set",
612        );
613    }
614
615    #[test]
616    fn layers_from_image_after_layers() {
617        assert_error(
618            parse_job(
619                r#"{
620                    "program": "/bin/sh",
621                    "layers": [ { "tar": "1" } ],
622                    "image": {
623                        "name": "image1",
624                        "use": [ "layers" ]
625                    }
626                }"#,
627            )
628            .unwrap_err(),
629            "field `image` cannot use `layers` if field `layers` is also set",
630        );
631    }
632
633    #[test]
634    fn added_layers_after_layers_from_image() {
635        assert_eq!(
636            parse_job(
637                r#"{
638                    "program": "/bin/sh",
639                    "image": {
640                        "name": "image1",
641                        "use": [ "layers" ]
642                    },
643                    "added_layers": [ { "tar": "1" } ]
644                }"#
645            )
646            .unwrap()
647            .into_job_spec(layer_mapper, env, images)
648            .unwrap(),
649            JobSpec::new(
650                string!("/bin/sh"),
651                nonempty![
652                    (digest!(42), ArtifactType::Tar),
653                    (digest!(43), ArtifactType::Tar),
654                    (digest!(1), ArtifactType::Tar)
655                ]
656            ),
657        );
658    }
659
660    #[test]
661    fn added_layers_only() {
662        assert_error(
663            parse_job(
664                r#"{
665                    "program": "/bin/sh",
666                    "added_layers": [ { "tar": "1" } ]
667                }"#,
668            )
669            .unwrap_err(),
670            "field `added_layers` set before `image` with a `use` of `layers`",
671        );
672    }
673
674    #[test]
675    fn added_layers_before_layers() {
676        assert_error(
677            parse_job(
678                r#"{
679                    "program": "/bin/sh",
680                    "added_layers": [ { "tar": "3" } ],
681                    "layers": [ { "tar": "1" }, { "tar": "2" } ]
682                }"#,
683            )
684            .unwrap_err(),
685            "field `added_layers` set before `image` with a `use` of `layers`",
686        );
687    }
688
689    #[test]
690    fn added_layers_after_layers() {
691        assert_error(
692            parse_job(
693                r#"{
694                    "program": "/bin/sh",
695                    "layers": [ { "tar": "1" }, { "tar": "2" } ],
696                    "added_layers": [ { "tar": "3" } ]
697                }"#,
698            )
699            .unwrap_err(),
700            "field `added_layers` cannot be set with `layer` field",
701        );
702    }
703
704    #[test]
705    fn added_layers_before_image_with_layers() {
706        assert_error(
707            parse_job(
708                r#"{
709                    "program": "/bin/sh",
710                    "added_layers": [ "3" ],
711                    "image": { "name": "image1", "use": [ "layers" ] }
712                }"#,
713            )
714            .unwrap_err(),
715            "field `added_layers` set before `image` with a `use` of `layers`",
716        );
717    }
718
719    #[test]
720    fn arguments() {
721        assert_eq!(
722            parse_job(
723                r#"{
724                    "program": "/bin/sh",
725                    "layers": [ { "tar": "1" } ],
726                    "arguments": [ "-e", "echo foo" ]
727                }"#,
728            )
729            .unwrap()
730            .into_job_spec(layer_mapper, env, images)
731            .unwrap(),
732            JobSpec::new(
733                string!("/bin/sh"),
734                nonempty![(digest!(1), ArtifactType::Tar)]
735            )
736            .arguments(["-e", "echo foo"]),
737        )
738    }
739
740    #[test]
741    fn environment() {
742        assert_eq!(
743            parse_job(
744                r#"{
745                    "program": "/bin/sh",
746                    "layers": [ { "tar": "1" } ],
747                    "environment": { "FOO": "foo", "BAR": "bar" }
748                }"#,
749            )
750            .unwrap()
751            .into_job_spec(layer_mapper, env, images)
752            .unwrap(),
753            JobSpec::new(
754                string!("/bin/sh"),
755                nonempty![(digest!(1), ArtifactType::Tar)]
756            )
757            .environment(["BAR=bar", "FOO=foo"]),
758        )
759    }
760
761    #[test]
762    fn environment_with_env_substitution() {
763        assert_eq!(
764            parse_job(
765                r#"{
766                    "program": "/bin/sh",
767                    "layers": [ { "tar": "1" } ],
768                    "environment": { "FOO": "pre-$env{FOO}-post", "BAR": "bar" }
769                }"#,
770            )
771            .unwrap()
772            .into_job_spec(layer_mapper, env, images)
773            .unwrap(),
774            JobSpec::new(
775                string!("/bin/sh"),
776                nonempty![(digest!(1), ArtifactType::Tar)]
777            )
778            .environment(["BAR=bar", "FOO=pre-foo-env-post"]),
779        )
780    }
781
782    #[test]
783    fn environment_with_prev_substitution() {
784        assert_eq!(
785            parse_job(
786                r#"{
787                    "program": "/bin/sh",
788                    "layers": [ { "tar": "1" } ],
789                    "environment": { "FOO": "pre-$prev{FOO:-no-prev}-post", "BAR": "bar" }
790                }"#,
791            )
792            .unwrap()
793            .into_job_spec(layer_mapper, env, images)
794            .unwrap(),
795            JobSpec::new(
796                string!("/bin/sh"),
797                nonempty![(digest!(1), ArtifactType::Tar)]
798            )
799            .environment(["BAR=bar", "FOO=pre-no-prev-post"]),
800        )
801    }
802
803    #[test]
804    fn environment_from_image() {
805        assert_eq!(
806            parse_job(
807                r#"{
808                    "program": "/bin/sh",
809                    "layers": [ { "tar": "1" } ],
810                    "image": { "name": "image1", "use": [ "environment" ] }
811                }"#,
812            )
813            .unwrap()
814            .into_job_spec(layer_mapper, env, images)
815            .unwrap(),
816            JobSpec::new(
817                string!("/bin/sh"),
818                nonempty![(digest!(1), ArtifactType::Tar)]
819            )
820            .environment(["BAZ=image-baz", "FOO=image-foo"]),
821        )
822    }
823
824    #[test]
825    fn environment_from_image_ignores_env_substitutions() {
826        assert_eq!(
827            parse_job(
828                r#"{
829                    "program": "/bin/sh",
830                    "layers": [ { "tar": "1" } ],
831                    "image": { "name": "image-with-env-substitutions", "use": [ "environment" ] }
832                }"#,
833            )
834            .unwrap()
835            .into_job_spec(layer_mapper, env, images)
836            .unwrap(),
837            JobSpec::new(
838                string!("/bin/sh"),
839                nonempty![(digest!(1), ArtifactType::Tar)]
840            )
841            .environment(["PATH=$env{PATH}"]),
842        )
843    }
844
845    #[test]
846    fn environment_from_image_after_environment() {
847        assert_error(
848            parse_job(
849                r#"{
850                    "program": "/bin/sh",
851                    "layers": [ { "tar": "1" } ],
852                    "environment": { "FOO": "foo", "BAR": "bar" },
853                    "image": { "name": "image1", "use": [ "environment" ] }
854                }"#,
855            )
856            .unwrap_err(),
857            "field `image` cannot use `environment` if field `environment` is also set",
858        )
859    }
860
861    #[test]
862    fn environment_after_environment_from_image() {
863        assert_error(
864            parse_job(
865                r#"{
866                    "program": "/bin/sh",
867                    "layers": [ { "tar": "1" } ],
868                    "image": { "name": "image1", "use": [ "environment" ] },
869                    "environment": { "FOO": "foo", "BAR": "bar" }
870                }"#,
871            )
872            .unwrap_err(),
873            "field `environment` cannot be set if `image` with a `use` of `environment` is also set",
874        )
875    }
876
877    #[test]
878    fn added_environment_after_environment_from_image() {
879        assert_eq!(
880            parse_job(
881                r#"{
882                    "program": "/bin/sh",
883                    "layers": [ { "tar": "1" } ],
884                    "image": { "name": "image1", "use": [ "environment" ] },
885                    "added_environment": { "FOO": "foo", "BAR": "bar" }
886                }"#,
887            )
888            .unwrap()
889            .into_job_spec(layer_mapper, env, images)
890            .unwrap(),
891            JobSpec::new(
892                string!("/bin/sh"),
893                nonempty![(digest!(1), ArtifactType::Tar)]
894            )
895            .environment(["BAR=bar", "BAZ=image-baz", "FOO=foo"]),
896        )
897    }
898
899    #[test]
900    fn added_environment_after_environment_from_image_with_substitutions() {
901        assert_eq!(
902            parse_job(
903                r#"{
904                    "program": "/bin/sh",
905                    "layers": [ { "tar": "1" } ],
906                    "image": { "name": "image1", "use": [ "environment" ] },
907                    "added_environment": {
908                        "FOO": "$env{FOO:-no-env-foo}:$prev{FOO:-no-prev-foo}",
909                        "BAR": "$env{BAR:-no-env-bar}:$prev{BAR:-no-prev-bar}",
910                        "BAZ": "$env{BAZ:-no-env-baz}:$prev{BAZ:-no-prev-baz}"
911                    }
912                }"#,
913            )
914            .unwrap()
915            .into_job_spec(layer_mapper, env, images)
916            .unwrap(),
917            JobSpec::new(
918                string!("/bin/sh"),
919                nonempty![(digest!(1), ArtifactType::Tar)]
920            )
921            .environment([
922                "BAR=no-env-bar:no-prev-bar",
923                "BAZ=no-env-baz:image-baz",
924                "FOO=foo-env:image-foo"
925            ]),
926        )
927    }
928
929    #[test]
930    fn added_environment_without_environment_from_image() {
931        assert_error(
932            parse_job(
933                r#"{
934                    "program": "/bin/sh",
935                    "layers": [ { "tar": "1" } ],
936                    "added_environment": { "FOO": "foo", "BAR": "bar" }
937                }"#,
938            )
939            .unwrap_err(),
940            "field `added_environment` set before `image` with a `use` of `environment`",
941        )
942    }
943
944    #[test]
945    fn added_environment_before_environment_from_image() {
946        assert_error(
947            parse_job(
948                r#"{
949                    "program": "/bin/sh",
950                    "layers": [ { "tar": "1" } ],
951                    "added_environment": { "FOO": "foo", "BAR": "bar" },
952                    "image": { "name": "image1", "use": [ "environment" ] }
953                }"#,
954            )
955            .unwrap_err(),
956            "field `added_environment` set before `image` with a `use` of `environment`",
957        )
958    }
959
960    #[test]
961    fn added_environment_before_environment() {
962        assert_error(
963            parse_job(
964                r#"{
965                    "program": "/bin/sh",
966                    "layers": [ { "tar": "1" } ],
967                    "added_environment": { "FOO": "foo", "BAR": "bar" },
968                    "environment": { "FOO": "foo", "BAR": "bar" }
969                }"#,
970            )
971            .unwrap_err(),
972            "field `added_environment` set before `image` with a `use` of `environment`",
973        )
974    }
975
976    #[test]
977    fn added_environment_after_environment() {
978        assert_error(
979            parse_job(
980                r#"{
981                    "program": "/bin/sh",
982                    "layers": [ { "tar": "1" } ],
983                    "environment": { "FOO": "foo", "BAR": "bar" },
984                    "added_environment": { "FOO": "foo", "BAR": "bar" }
985                }"#,
986            )
987            .unwrap_err(),
988            "field `added_environment` cannot be set with `environment` field",
989        )
990    }
991
992    #[test]
993    fn devices() {
994        assert_eq!(
995            parse_job(
996                r#"{
997                    "program": "/bin/sh",
998                    "layers": [ { "tar": "1" } ],
999                    "devices": [ "null", "zero" ]
1000                }"#,
1001            )
1002            .unwrap()
1003            .into_job_spec(layer_mapper, env, images)
1004            .unwrap(),
1005            JobSpec::new(
1006                string!("/bin/sh"),
1007                nonempty![(digest!(1), ArtifactType::Tar)]
1008            )
1009            .devices(enum_set! {JobDevice::Null | JobDevice::Zero}),
1010        )
1011    }
1012
1013    #[test]
1014    fn mounts() {
1015        assert_eq!(
1016            parse_job(
1017                r#"{
1018                    "program": "/bin/sh",
1019                    "layers": [ { "tar": "1" } ],
1020                    "mounts": [
1021                        { "fs_type": "tmp", "mount_point": "/tmp" }
1022                    ]
1023                }"#,
1024            )
1025            .unwrap()
1026            .into_job_spec(layer_mapper, env, images)
1027            .unwrap(),
1028            JobSpec::new(
1029                string!("/bin/sh"),
1030                nonempty![(digest!(1), ArtifactType::Tar)]
1031            )
1032            .mounts([JobMount {
1033                fs_type: JobMountFsType::Tmp,
1034                mount_point: utf8_path_buf!("/tmp"),
1035            }])
1036        )
1037    }
1038
1039    #[test]
1040    fn enable_loopback() {
1041        assert_eq!(
1042            parse_job(
1043                r#"{
1044                    "program": "/bin/sh",
1045                    "layers": [ { "tar": "1" } ],
1046                    "enable_loopback": true
1047                }"#,
1048            )
1049            .unwrap()
1050            .into_job_spec(layer_mapper, env, images)
1051            .unwrap(),
1052            JobSpec::new(
1053                string!("/bin/sh"),
1054                nonempty![(digest!(1), ArtifactType::Tar)]
1055            )
1056            .enable_loopback(true),
1057        )
1058    }
1059
1060    #[test]
1061    fn enable_writable_file_system() {
1062        assert_eq!(
1063            parse_job(
1064                r#"{
1065                    "program": "/bin/sh",
1066                    "layers": [ { "tar": "1" } ],
1067                    "enable_writable_file_system": true
1068                }"#,
1069            )
1070            .unwrap()
1071            .into_job_spec(layer_mapper, env, images)
1072            .unwrap(),
1073            JobSpec::new(
1074                string!("/bin/sh"),
1075                nonempty![(digest!(1), ArtifactType::Tar)]
1076            )
1077            .enable_writable_file_system(true),
1078        )
1079    }
1080
1081    #[test]
1082    fn working_directory() {
1083        assert_eq!(
1084            parse_job(
1085                r#"{
1086                    "program": "/bin/sh",
1087                    "layers": [ { "tar": "1" } ],
1088                    "working_directory": "/foo/bar"
1089                }"#,
1090            )
1091            .unwrap()
1092            .into_job_spec(layer_mapper, env, images)
1093            .unwrap(),
1094            JobSpec::new(
1095                string!("/bin/sh"),
1096                nonempty![(digest!(1), ArtifactType::Tar)]
1097            )
1098            .working_directory("/foo/bar"),
1099        )
1100    }
1101
1102    #[test]
1103    fn working_directory_from_image() {
1104        assert_eq!(
1105            parse_job(
1106                r#"{
1107                    "program": "/bin/sh",
1108                    "layers": [ { "tar": "1" } ],
1109                    "image": {
1110                        "name": "image1",
1111                        "use": [ "working_directory" ]
1112                    }
1113                }"#,
1114            )
1115            .unwrap()
1116            .into_job_spec(layer_mapper, env, images)
1117            .unwrap(),
1118            JobSpec::new(
1119                string!("/bin/sh"),
1120                nonempty![(digest!(1), ArtifactType::Tar)]
1121            )
1122            .working_directory("/foo"),
1123        )
1124    }
1125
1126    #[test]
1127    fn working_directory_from_image_after_working_directory() {
1128        assert_error(
1129            parse_job(
1130                r#"{
1131                    "program": "/bin/sh",
1132                    "layers": [ { "tar": "1" } ],
1133                    "working_directory": "/foo/bar",
1134                    "image": {
1135                        "name": "image1",
1136                        "use": [ "working_directory" ]
1137                    }
1138                }"#,
1139            )
1140            .unwrap_err(),
1141            "field `image` cannot use `working_directory` if field `working_directory` is also set",
1142        )
1143    }
1144
1145    #[test]
1146    fn working_directory_after_working_directory_from_image() {
1147        assert_error(
1148            parse_job(
1149                r#"{
1150                    "program": "/bin/sh",
1151                    "layers": [ { "tar": "1" } ],
1152                    "image": {
1153                        "name": "image1",
1154                        "use": [ "working_directory" ]
1155                    },
1156                    "working_directory": "/foo/bar"
1157                }"#,
1158            )
1159            .unwrap_err(),
1160            "field `working_directory` cannot be set if `image` with a `use` of `working_directory` is also set",
1161        )
1162    }
1163
1164    #[test]
1165    fn user() {
1166        assert_eq!(
1167            parse_job(
1168                r#"{
1169                    "program": "/bin/sh",
1170                    "layers": [ { "tar": "1" } ],
1171                    "user": 1234
1172                }"#,
1173            )
1174            .unwrap()
1175            .into_job_spec(layer_mapper, env, images)
1176            .unwrap(),
1177            JobSpec::new(
1178                string!("/bin/sh"),
1179                nonempty![(digest!(1), ArtifactType::Tar)]
1180            )
1181            .user(1234),
1182        )
1183    }
1184
1185    #[test]
1186    fn group() {
1187        assert_eq!(
1188            parse_job(
1189                r#"{
1190                    "program": "/bin/sh",
1191                    "layers": [ { "tar": "1" } ],
1192                    "group": 4321
1193                }"#,
1194            )
1195            .unwrap()
1196            .into_job_spec(layer_mapper, env, images)
1197            .unwrap(),
1198            JobSpec::new(
1199                string!("/bin/sh"),
1200                nonempty![(digest!(1), ArtifactType::Tar)]
1201            )
1202            .group(4321),
1203        )
1204    }
1205}