Skip to main content

isoloom_spec/syntax/
app.rs

1//! `app:`, a custom machine's one recipe: a container-only one (`image`, `build`, `appliance`),
2//! portable `steps`, or both (each form its own recipe).
3
4use std::fmt;
5
6use indexmap::IndexMap;
7use schemars::JsonSchema;
8use serde::de::{self, MapAccess, Visitor};
9use serde::{Deserialize, Deserializer};
10
11use super::step::Step;
12use crate::Error;
13use crate::model::Appliance;
14
15/// A custom machine's recipe.
16#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
17#[serde(try_from = "AppInput")]
18pub struct App {
19    /// The container form's own recipe, when it has one beside (or instead of) the steps.
20    pub container: Option<Recipe>,
21    /// The portable recipe: a generated Dockerfile on containers, set-up steps on VMs.
22    pub steps: Vec<Step>,
23}
24
25/// A recipe only a container has.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum Recipe {
28    /// A published image, by tag or digest.
29    Image(String),
30    /// The project's own Dockerfile.
31    Build(Build),
32    /// A network appliance's image (or a Dynamips router's firmware).
33    Appliance(ApplianceRecipe),
34}
35
36/// A network appliance (`app.appliance`).
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct ApplianceRecipe {
39    /// Which appliance it is.
40    pub kind: Appliance,
41    /// Its image (every appliance but `cisco-dynamips`, which Isoloom builds itself).
42    pub image: Option<String>,
43    /// Its own configuration, added to the one Isoloom generates.
44    pub config: Option<String>,
45    /// The firmware `cisco-dynamips` boots.
46    pub firmware: Option<String>,
47}
48
49/// The project's own Dockerfile: its folder, or the folder with the file, stage and arguments.
50#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)]
51#[serde(deny_unknown_fields)]
52pub struct Build {
53    /// The build context: a folder of the project.
54    pub context: String,
55    /// The Dockerfile, anywhere in the project (default `<context>/Dockerfile`).
56    #[serde(default)]
57    pub dockerfile: Option<String>,
58    /// The stage of a multi-stage Dockerfile to build, when not its last.
59    #[serde(default)]
60    pub target: Option<String>,
61    /// Build arguments (the Dockerfile's `ARG`s), fixed values.
62    #[serde(default)]
63    pub args: IndexMap<String, String>,
64}
65
66/// `app:` as written.
67#[derive(Deserialize, JsonSchema)]
68#[serde(deny_unknown_fields)]
69struct AppInput {
70    /// A published image, by tag or digest (`example/clamd:1.4`): the container form. Not with
71    /// `build`.
72    #[serde(default)]
73    image: Option<String>,
74    /// The project's own Dockerfile: a folder holding a `Dockerfile`, or `{ context, dockerfile,
75    /// target, args }`. The container form; translating it to a VM is never attempted, so add
76    /// `os` and `steps` for a VM form.
77    #[serde(default, deserialize_with = "build")]
78    #[schemars(with = "Option<BuildInput>")]
79    build: Option<Build>,
80    /// Portable steps (they need `os`): a Dockerfile Isoloom generates from the OS's official
81    /// image on containers, set-up steps on VMs. With `image` or `build`, the VM form's recipe.
82    #[serde(default)]
83    steps: Vec<Step>,
84    /// A network appliance Isoloom wires (a router or switch OS in a container): with `image`, or
85    /// `firmware` for `cisco-dynamips`.
86    #[serde(default)]
87    appliance: Option<Appliance>,
88    /// An appliance's own configuration (a project file), added to the one Isoloom generates.
89    #[serde(default)]
90    config: Option<String>,
91    /// The firmware `cisco-dynamips` boots (the IOS `.bin`, a project file).
92    #[serde(default)]
93    firmware: Option<String>,
94}
95
96// What the schema shows and the reader returns.
97/// `build:` as written: a folder, or in full.
98#[derive(JsonSchema)]
99#[serde(untagged)]
100enum BuildInput {
101    /// A folder of the project holding a `Dockerfile`.
102    Folder(String),
103    /// The folder, the Dockerfile, the stage and the arguments.
104    Full(Build),
105}
106
107impl<'de> Deserialize<'de> for BuildInput {
108    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<BuildInput, D::Error> {
109        struct V;
110        impl<'de> Visitor<'de> for V {
111            type Value = BuildInput;
112            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
113                f.write_str("a project folder, or `{ context, dockerfile, target, args }`")
114            }
115            fn visit_str<E: de::Error>(self, v: &str) -> Result<BuildInput, E> {
116                Ok(BuildInput::Folder(v.to_owned()))
117            }
118            fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<BuildInput, A::Error> {
119                Build::deserialize(de::value::MapAccessDeserializer::new(map)).map(BuildInput::Full)
120            }
121        }
122        d.deserialize_any(V)
123    }
124}
125
126impl From<BuildInput> for Build {
127    fn from(written: BuildInput) -> Build {
128        match written {
129            BuildInput::Folder(context) => Build {
130                context,
131                dockerfile: None,
132                target: None,
133                args: IndexMap::new(),
134            },
135            BuildInput::Full(build) => build,
136        }
137    }
138}
139
140fn build<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Build>, D::Error> {
141    BuildInput::deserialize(d).map(|written| Some(written.into()))
142}
143
144impl TryFrom<AppInput> for App {
145    type Error = Error;
146
147    fn try_from(a: AppInput) -> Result<App, Error> {
148        if a.config.is_some() && a.appliance.is_none() {
149            return Err(Error::ConfigWithoutAppliance);
150        }
151        let container = match (a.image, a.build, a.appliance) {
152            (Some(_), Some(_), _) => return Err(Error::ImageAndBuild),
153            (_, Some(_), Some(_)) => return Err(Error::ApplianceBuild),
154            (image, None, Some(kind)) => Some(Recipe::Appliance(appliance(kind, image, a.config, a.firmware)?)),
155            (_, _, None) if a.firmware.is_some() => return Err(Error::FirmwareWithoutDynamips),
156            (Some(image), None, None) => Some(Recipe::Image(image)),
157            (None, Some(b), None) => Some(Recipe::Build(b)),
158            (None, None, None) => None,
159        };
160        if container.is_none() && a.steps.is_empty() {
161            return Err(Error::NoRecipe);
162        }
163        Ok(App { container, steps: a.steps })
164    }
165}
166
167fn appliance(kind: Appliance, image: Option<String>, config: Option<String>, firmware: Option<String>) -> Result<ApplianceRecipe, Error> {
168    let dynamips = kind == Appliance::CiscoDynamips;
169    match (dynamips, image.is_some(), firmware.is_some()) {
170        (true, true, _) => Err(Error::DynamipsImage),
171        (true, false, false) => Err(Error::DynamipsFirmware),
172        (false, _, true) => Err(Error::ApplianceFirmware),
173        (false, false, false) => Err(Error::ApplianceImage),
174        (true, false, true) | (false, true, false) => Ok(ApplianceRecipe { kind, image, config, firmware }),
175    }
176}
177
178impl JsonSchema for App {
179    fn schema_name() -> std::borrow::Cow<'static, str> {
180        "App".into()
181    }
182
183    fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
184        AppInput::json_schema(g)
185    }
186}
187
188#[cfg(test)]
189mod tests;