1use std::borrow::Cow;
5use std::fmt;
6use std::num::NonZeroU16;
7
8use schemars::{JsonSchema, Schema, SchemaGenerator};
9use serde::de::{self, MapAccess, SeqAccess, Visitor};
10use serde::{Deserialize, Deserializer};
11
12use super::names::{InputName, PortName};
13use crate::Error;
14use crate::model::CommandLine;
15
16#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
19#[serde(from = "CommandInput")]
20pub struct Command {
21 pub run: CommandLine,
23 pub cwd: Option<String>,
26}
27
28#[derive(Deserialize, JsonSchema)]
30#[serde(deny_unknown_fields)]
31struct CommandFull {
32 run: CommandLine,
34 #[serde(default)]
36 cwd: Option<String>,
37}
38
39#[derive(JsonSchema)]
42#[serde(untagged)]
43enum CommandInput {
44 Shell(String),
46 Exec(Vec<String>),
48 Full(CommandFull),
50}
51
52impl<'de> Deserialize<'de> for CommandInput {
53 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<CommandInput, D::Error> {
54 struct V;
55 impl<'de> Visitor<'de> for V {
56 type Value = CommandInput;
57 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
58 f.write_str("a text, a list of arguments, or `{ run, cwd }`")
59 }
60 fn visit_str<E: de::Error>(self, v: &str) -> Result<CommandInput, E> {
61 Ok(CommandInput::Shell(v.to_owned()))
62 }
63 fn visit_seq<A: SeqAccess<'de>>(self, seq: A) -> Result<CommandInput, A::Error> {
64 Vec::<String>::deserialize(de::value::SeqAccessDeserializer::new(seq)).map(CommandInput::Exec)
65 }
66 fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<CommandInput, A::Error> {
67 CommandFull::deserialize(de::value::MapAccessDeserializer::new(map)).map(CommandInput::Full)
68 }
69 }
70 d.deserialize_any(V)
71 }
72}
73
74impl From<CommandInput> for Command {
75 fn from(written: CommandInput) -> Command {
76 match written {
77 CommandInput::Shell(text) => Command {
78 run: CommandLine::Shell(text),
79 cwd: None,
80 },
81 CommandInput::Exec(args) => Command {
82 run: CommandLine::Exec(args),
83 cwd: None,
84 },
85 CommandInput::Full(c) => Command { run: c.run, cwd: c.cwd },
86 }
87 }
88}
89
90impl JsonSchema for Command {
91 fn schema_name() -> Cow<'static, str> {
92 "Command".into()
93 }
94
95 fn json_schema(g: &mut SchemaGenerator) -> Schema {
96 CommandInput::json_schema(g)
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)]
102#[serde(untagged)]
103pub enum PortRef {
104 Number(NonZeroU16),
106 Name(PortName),
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)]
112#[serde(deny_unknown_fields)]
113pub struct HealthPage {
114 pub port: PortRef,
116 #[serde(default = "root")]
118 pub path: String,
119}
120
121fn root() -> String {
122 "/".into()
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
127pub enum HealthProbe {
128 Test(CommandLine),
130 Http(HealthPage),
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
137#[serde(try_from = "HealthcheckInput")]
138pub struct Healthcheck {
139 pub probe: HealthProbe,
141 pub interval: Option<String>,
143 pub timeout: Option<String>,
145 pub retries: Option<u32>,
147 pub start_period: Option<String>,
149}
150
151#[derive(Deserialize, JsonSchema)]
155#[serde(deny_unknown_fields)]
156struct HealthcheckInput {
157 #[serde(default)]
160 test: Option<CommandLine>,
161 #[serde(default)]
163 http: Option<HealthPage>,
164 #[serde(default)]
166 interval: Option<String>,
167 #[serde(default)]
169 timeout: Option<String>,
170 #[serde(default)]
172 retries: Option<u32>,
173 #[serde(default)]
175 start_period: Option<String>,
176}
177
178impl TryFrom<HealthcheckInput> for Healthcheck {
179 type Error = Error;
180
181 fn try_from(h: HealthcheckInput) -> Result<Healthcheck, Error> {
182 let probe = match (h.test, h.http) {
183 (Some(test), None) => HealthProbe::Test(test),
184 (None, Some(page)) => HealthProbe::Http(page),
185 (Some(_), Some(_)) => return Err(Error::TestAndHttp),
186 (None, None) => return Err(Error::NoHealthProbe),
187 };
188 Ok(Healthcheck {
189 probe,
190 interval: h.interval,
191 timeout: h.timeout,
192 retries: h.retries,
193 start_period: h.start_period,
194 })
195 }
196}
197
198impl JsonSchema for Healthcheck {
199 fn schema_name() -> Cow<'static, str> {
200 "Healthcheck".into()
201 }
202
203 fn json_schema(g: &mut SchemaGenerator) -> Schema {
204 HealthcheckInput::json_schema(g)
205 }
206}
207
208#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
211#[serde(try_from = "EnvInput")]
212pub enum EnvValue {
213 Text(String),
215 Input(InputName),
217 Secret(String),
219}
220
221#[derive(Deserialize, JsonSchema)]
223#[serde(deny_unknown_fields)]
224struct EnvReference {
225 #[serde(default)]
227 input: Option<InputName>,
228 #[serde(default)]
230 secret: Option<String>,
231}
232
233#[derive(JsonSchema)]
236#[serde(untagged)]
237enum EnvInput {
238 Text(String),
240 Number(f64),
242 Bool(bool),
244 Reference(EnvReference),
246}
247
248impl<'de> Deserialize<'de> for EnvInput {
249 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<EnvInput, D::Error> {
250 struct V;
251 impl<'de> Visitor<'de> for V {
252 type Value = EnvInput;
253 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
254 f.write_str("a text, a number, a boolean, or `{ input: NAME }`")
255 }
256 fn visit_str<E: de::Error>(self, v: &str) -> Result<EnvInput, E> {
257 Ok(EnvInput::Text(v.to_owned()))
258 }
259 fn visit_bool<E: de::Error>(self, v: bool) -> Result<EnvInput, E> {
260 Ok(EnvInput::Bool(v))
261 }
262 fn visit_u64<E: de::Error>(self, v: u64) -> Result<EnvInput, E> {
264 Ok(EnvInput::Text(v.to_string()))
265 }
266 fn visit_i64<E: de::Error>(self, v: i64) -> Result<EnvInput, E> {
267 Ok(EnvInput::Text(v.to_string()))
268 }
269 fn visit_f64<E: de::Error>(self, v: f64) -> Result<EnvInput, E> {
270 Ok(EnvInput::Number(v))
271 }
272 fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<EnvInput, A::Error> {
273 EnvReference::deserialize(de::value::MapAccessDeserializer::new(map)).map(EnvInput::Reference)
274 }
275 }
276 d.deserialize_any(V)
277 }
278}
279
280impl TryFrom<EnvInput> for EnvValue {
281 type Error = Error;
282
283 fn try_from(written: EnvInput) -> Result<EnvValue, Error> {
284 match written {
285 EnvInput::Text(text) => Ok(EnvValue::Text(text)),
286 EnvInput::Number(n) => Ok(EnvValue::Text(n.to_string())),
287 EnvInput::Bool(b) => Ok(EnvValue::Text(b.to_string())),
288 EnvInput::Reference(EnvReference { input: Some(i), secret: None }) => Ok(EnvValue::Input(i)),
289 EnvInput::Reference(EnvReference { input: None, secret: Some(s) }) => Ok(EnvValue::Secret(s)),
290 EnvInput::Reference(_) => Err(Error::EnvReference),
291 }
292 }
293}
294
295impl JsonSchema for EnvValue {
296 fn schema_name() -> Cow<'static, str> {
297 "EnvValue".into()
298 }
299
300 fn json_schema(g: &mut SchemaGenerator) -> Schema {
301 EnvInput::json_schema(g)
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn a_command_or_an_environment_value_of_the_wrong_shape_says_what_is_expected() {
311 let command = serde_yaml_ng::from_str::<Command>("5").unwrap_err().to_string();
312 assert!(command.contains("a text, a list of arguments, or `{ run, cwd }`"), "{command}");
313 let value = serde_yaml_ng::from_str::<EnvValue>("[1]").unwrap_err().to_string();
314 assert!(value.contains("a text, a number, a boolean, or `{ input: NAME }`"), "{value}");
315 let both = serde_yaml_ng::from_str::<EnvValue>("{ input: A, secret: b }").unwrap_err().to_string();
316 assert!(both.contains("write one of `{ input: NAME }`"), "{both}");
317 assert_eq!(serde_yaml_ng::from_str::<EnvValue>("1.5").ok(), Some(EnvValue::Text("1.5".into())));
318 assert_eq!(serde_yaml_ng::from_str::<EnvValue>("-3").ok(), Some(EnvValue::Text("-3".into())));
319 }
320
321 #[test]
322 fn a_command_is_a_text_a_list_or_with_its_directory() {
323 let text: Command = serde_yaml_ng::from_str("python3 app.py").unwrap();
324 assert_eq!(text.run, CommandLine::Shell("python3 app.py".into()));
325 let list: Command = serde_yaml_ng::from_str("[nginx, -g, daemon off;]").unwrap();
326 assert!(matches!(list.run, CommandLine::Exec(ref a) if a.len() == 3));
327 let full: Command = serde_yaml_ng::from_str("{ run: [python3, app.py], cwd: /srv }").unwrap();
328 assert_eq!(full.cwd.as_deref(), Some("/srv"));
329 }
330
331 #[test]
332 fn a_healthcheck_names_its_port() {
333 let by_name: Healthcheck = serde_yaml_ng::from_str("{ http: { port: site, path: /health } }").unwrap();
334 assert!(matches!(by_name.probe, HealthProbe::Http(HealthPage { port: PortRef::Name(ref n), .. }) if n.as_str() == "site"));
335 let by_number: Healthcheck = serde_yaml_ng::from_str("{ http: { port: 8000 } }").unwrap();
336 assert!(matches!(by_number.probe, HealthProbe::Http(HealthPage { port: PortRef::Number(_), .. })));
337 }
338
339 #[test]
340 fn a_healthcheck_tries_one_thing() {
341 let test: Healthcheck = serde_yaml_ng::from_str("{ test: [pg_isready], interval: 2s }").unwrap();
342 assert_eq!(
343 (test.probe, test.interval.as_deref()),
344 (HealthProbe::Test(CommandLine::Exec(vec!["pg_isready".into()])), Some("2s"))
345 );
346 let both = serde_yaml_ng::from_str::<Healthcheck>("{ test: x, http: { port: 80 } }")
347 .unwrap_err()
348 .to_string();
349 assert!(both.contains("`test` and `http` are both given"), "{both}");
350 let neither = serde_yaml_ng::from_str::<Healthcheck>("{ interval: 5s }").unwrap_err().to_string();
351 assert!(neither.contains("a healthcheck says what to try"), "{neither}");
352 }
353
354 #[test]
355 fn environment_values_are_scalars_or_inputs() {
356 let v: indexmap::IndexMap<String, EnvValue> = serde_yaml_ng::from_str("{ A: x, B: 1024, C: true, D: { input: API_URL }, E: { secret: db } }").unwrap();
357 let texts: Vec<_> = v.values().collect();
358 assert_eq!(texts[1], &EnvValue::Text("1024".into()));
359 assert!(matches!(texts[3], EnvValue::Input(_)) && matches!(texts[4], EnvValue::Secret(_)));
360 assert!(serde_yaml_ng::from_str::<EnvValue>("{ file: x }").is_err());
361 }
362}