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 published image name/tag this build produces, when `app.image` is given beside
54    /// `build` (`ghcr.io/cyberctf/ad-kerberoasting:1`): development builds and tags it, production
55    /// pulls it. `None` for a build with no published name (tagged locally). Set from `app.image`,
56    /// never read from inside `build:`.
57    #[serde(skip)]
58    #[schemars(skip)]
59    pub image: Option<String>,
60    /// The build context: a folder of the project.
61    pub context: String,
62    /// The Dockerfile, anywhere in the project (default `<context>/Dockerfile`).
63    #[serde(default)]
64    pub dockerfile: Option<String>,
65    /// The stage of a multi-stage Dockerfile to build, when not its last.
66    #[serde(default)]
67    pub target: Option<String>,
68    /// Build arguments (the Dockerfile's `ARG`s), fixed values.
69    #[serde(default)]
70    pub args: IndexMap<String, String>,
71}
72
73/// `app:` as written.
74#[derive(Deserialize, JsonSchema)]
75#[serde(deny_unknown_fields)]
76struct AppInput {
77    /// A published image, by tag or digest (`example/clamd:1.4`): the container form. With
78    /// `build`, the name that build is tagged with: development builds and tags it, production
79    /// pulls it.
80    #[serde(default)]
81    image: Option<String>,
82    /// The project's own Dockerfile: a folder holding a `Dockerfile`, or `{ context, dockerfile,
83    /// target, args }`. The container form; translating it to a VM is never attempted, so add
84    /// `os` and `steps` for a VM form. With `image`, the build is tagged as that published name.
85    #[serde(default, deserialize_with = "build")]
86    #[schemars(with = "Option<BuildInput>")]
87    build: Option<Build>,
88    /// Portable steps (they need `os`): a Dockerfile Isoloom generates from the OS's official
89    /// image on containers, set-up steps on VMs. With `image` or `build`, the VM form's recipe.
90    #[serde(default)]
91    steps: Vec<Step>,
92    /// A network appliance Isoloom wires (a router or switch OS in a container): with `image`, or
93    /// `firmware` for `cisco-dynamips`.
94    #[serde(default)]
95    appliance: Option<Appliance>,
96    /// An appliance's own configuration (a project file), added to the one Isoloom generates.
97    #[serde(default)]
98    config: Option<String>,
99    /// The firmware `cisco-dynamips` boots (the IOS `.bin`, a project file).
100    #[serde(default)]
101    firmware: Option<String>,
102}
103
104// What the schema shows and the reader returns.
105/// `build:` as written: a folder, or in full.
106#[derive(JsonSchema)]
107#[serde(untagged)]
108enum BuildInput {
109    /// A folder of the project holding a `Dockerfile`.
110    Folder(String),
111    /// The folder, the Dockerfile, the stage and the arguments.
112    Full(Build),
113}
114
115impl<'de> Deserialize<'de> for BuildInput {
116    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<BuildInput, D::Error> {
117        struct V;
118        impl<'de> Visitor<'de> for V {
119            type Value = BuildInput;
120            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
121                f.write_str("a project folder, or `{ context, dockerfile, target, args }`")
122            }
123            fn visit_str<E: de::Error>(self, v: &str) -> Result<BuildInput, E> {
124                Ok(BuildInput::Folder(v.to_owned()))
125            }
126            fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<BuildInput, A::Error> {
127                Build::deserialize(de::value::MapAccessDeserializer::new(map)).map(BuildInput::Full)
128            }
129        }
130        d.deserialize_any(V)
131    }
132}
133
134impl From<BuildInput> for Build {
135    fn from(written: BuildInput) -> Build {
136        match written {
137            BuildInput::Folder(context) => Build {
138                image: None,
139                context,
140                dockerfile: None,
141                target: None,
142                args: IndexMap::new(),
143            },
144            BuildInput::Full(build) => build,
145        }
146    }
147}
148
149fn build<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Build>, D::Error> {
150    BuildInput::deserialize(d).map(|written| Some(written.into()))
151}
152
153impl TryFrom<AppInput> for App {
154    type Error = Error;
155
156    fn try_from(a: AppInput) -> Result<App, Error> {
157        if a.config.is_some() && a.appliance.is_none() {
158            return Err(Error::ConfigWithoutAppliance);
159        }
160        let container = match (a.image, a.build, a.appliance) {
161            (_, Some(_), Some(_)) => return Err(Error::ApplianceBuild),
162            (image, None, Some(kind)) => Some(Recipe::Appliance(appliance(kind, image, a.config, a.firmware)?)),
163            (_, _, None) if a.firmware.is_some() => return Err(Error::FirmwareWithoutDynamips),
164            (Some(image), None, None) => Some(Recipe::Image(image)),
165            // `image` beside `build`: the build is tagged as that published name (built in dev,
166            // pulled in production), the way Docker Compose's `image` + `build` combine.
167            (Some(image), Some(b), None) => Some(Recipe::Build(Build { image: Some(image), ..b })),
168            (None, Some(b), None) => Some(Recipe::Build(b)),
169            (None, None, None) => None,
170        };
171        if container.is_none() && a.steps.is_empty() {
172            return Err(Error::NoRecipe);
173        }
174        Ok(App { container, steps: a.steps })
175    }
176}
177
178fn appliance(kind: Appliance, image: Option<String>, config: Option<String>, firmware: Option<String>) -> Result<ApplianceRecipe, Error> {
179    let dynamips = kind == Appliance::CiscoDynamips;
180    match (dynamips, image.is_some(), firmware.is_some()) {
181        (true, true, _) => Err(Error::DynamipsImage),
182        (true, false, false) => Err(Error::DynamipsFirmware),
183        (false, _, true) => Err(Error::ApplianceFirmware),
184        (false, false, false) => Err(Error::ApplianceImage),
185        (true, false, true) | (false, true, false) => Ok(ApplianceRecipe { kind, image, config, firmware }),
186    }
187}
188
189impl JsonSchema for App {
190    fn schema_name() -> std::borrow::Cow<'static, str> {
191        "App".into()
192    }
193
194    fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
195        AppInput::json_schema(g)
196    }
197}
198
199#[cfg(test)]
200mod tests;