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