Skip to main content

isoloom_spec/syntax/step/
services.rs

1//! The steps that run things: `service` (a daemon enabled at boot, or a container's process),
2//! `command` (a command run once, behind its guard) and `firewall` (ports opened in the OS's own
3//! firewall).
4
5use std::borrow::Cow;
6use std::fmt;
7use std::num::NonZeroU16;
8
9use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
10use serde::de::{self, MapAccess, SeqAccess, Visitor};
11use serde::{Deserialize, Deserializer};
12
13use super::super::mistake::Mistake;
14use super::super::names::PortName;
15use super::super::problem::Code;
16use super::words::{AbsPath, UnitName, UserName};
17use crate::model::Protocol;
18
19/// A program and its arguments, run as they are (no shell): at least the program.
20#[derive(Debug, Clone, PartialEq, Eq, Hash)]
21pub struct Argv(Vec<String>);
22
23impl Argv {
24    /// The program, then its arguments.
25    #[must_use]
26    pub fn args(&self) -> &[String] {
27        &self.0
28    }
29
30    /// The program.
31    #[must_use]
32    pub fn program(&self) -> &str {
33        self.0.first().map_or("", String::as_str)
34    }
35}
36
37impl TryFrom<Vec<String>> for Argv {
38    type Error = String;
39
40    /// The arguments, when the first names a program.
41    ///
42    /// # Errors
43    ///
44    /// Why not: an empty list, or an empty program.
45    fn try_from(args: Vec<String>) -> Result<Argv, String> {
46        match args.first() {
47            Some(program) if !program.trim().is_empty() => Ok(Argv(args)),
48            _ => Err("a command starts with its program (`[/usr/sbin/myd, --foreground]`)".to_owned()),
49        }
50    }
51}
52
53impl<'de> Deserialize<'de> for Argv {
54    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Argv, D::Error> {
55        Argv::try_from(Vec::<String>::deserialize(d)?).map_err(de::Error::custom)
56    }
57}
58
59impl JsonSchema for Argv {
60    fn schema_name() -> Cow<'static, str> {
61        "Argv".into()
62    }
63
64    fn inline_schema() -> bool {
65        true
66    }
67
68    fn json_schema(_: &mut SchemaGenerator) -> Schema {
69        json_schema!({ "type": "array", "items": { "type": "string" }, "minItems": 1 })
70    }
71}
72
73/// A command a step runs: a text the shell runs (`sh -c`; PowerShell on Windows), or a program
74/// and its arguments run as they are.
75#[derive(Debug, Clone, PartialEq, Eq, Hash)]
76#[non_exhaustive]
77pub enum Invocation {
78    /// A text the shell runs, with its variables, pipes and `&&`.
79    Shell(String),
80    /// A program and its arguments, run as they are.
81    Exec(Argv),
82}
83
84impl<'de> Deserialize<'de> for Invocation {
85    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Invocation, D::Error> {
86        d.deserialize_any(InvocationVisitor)
87    }
88}
89
90/// A command as written: a text, or a list of arguments.
91struct InvocationVisitor;
92
93impl<'de> Visitor<'de> for InvocationVisitor {
94    type Value = Invocation;
95
96    fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
97        f.write_str("a text the shell runs, or a list of arguments")
98    }
99
100    fn visit_str<E: de::Error>(self, v: &str) -> Result<Invocation, E> {
101        if v.trim().is_empty() {
102            return Err(E::custom("a command says what it runs"));
103        }
104        Ok(Invocation::Shell(v.to_owned()))
105    }
106
107    fn visit_seq<A: SeqAccess<'de>>(self, seq: A) -> Result<Invocation, A::Error> {
108        Argv::deserialize(de::value::SeqAccessDeserializer::new(seq)).map(Invocation::Exec)
109    }
110}
111
112impl JsonSchema for Invocation {
113    fn schema_name() -> Cow<'static, str> {
114        "Invocation".into()
115    }
116
117    fn inline_schema() -> bool {
118        true
119    }
120
121    fn json_schema(_: &mut SchemaGenerator) -> Schema {
122        json_schema!({ "anyOf": [{ "type": "string", "minLength": 1 }, { "type": "array", "items": { "type": "string" }, "minItems": 1 }] })
123    }
124}
125
126/// How a step tells it already ran.
127#[derive(Debug, Clone, PartialEq, Eq, Hash)]
128#[non_exhaustive]
129pub enum Guard {
130    /// It already ran when this path exists.
131    Creates(AbsPath),
132    /// It already ran when this check exits 0.
133    Unless(Invocation),
134}
135
136/// A `command` step: a command run once, behind its guard.
137#[derive(Debug, Clone, PartialEq, Eq, Hash)]
138pub struct CommandStep {
139    /// What runs.
140    pub run: Invocation,
141    /// How it tells it already ran.
142    pub guard: Guard,
143}
144
145/// A `service` step: a daemon enabled at boot and running (a VM), or run by the container (ยง3 of
146/// the configuration model).
147#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize)]
148#[serde(try_from = "ServiceInput")]
149#[non_exhaustive]
150pub enum Service {
151    /// A unit a package installs (`nginx`), or a key of the daemon table.
152    Unit {
153        /// The unit.
154        name: UnitName,
155        /// Started at boot and now (`false`: disabled and stopped).
156        enabled: bool,
157    },
158    /// A service Isoloom defines: a systemd or OpenRC unit, or a Windows service, on VMs; a
159    /// process of the container's runner in containers.
160    Defined {
161        /// Its name.
162        name: UnitName,
163        /// What runs, in the foreground.
164        run: Argv,
165        /// The user it runs as (default root).
166        user: Option<UserName>,
167        /// The folder it runs in (absolute).
168        cwd: Option<AbsPath>,
169        /// Started at boot and now (`false`: defined, but disabled and stopped).
170        enabled: bool,
171    },
172}
173
174impl Service {
175    /// The unit's name.
176    #[must_use]
177    pub fn name(&self) -> &UnitName {
178        match self {
179            Service::Unit { name, .. } | Service::Defined { name, .. } => name,
180        }
181    }
182
183    /// Whether it starts at boot and now.
184    #[must_use]
185    pub fn enabled(&self) -> bool {
186        match self {
187            Service::Unit { enabled, .. } | Service::Defined { enabled, .. } => *enabled,
188        }
189    }
190}
191
192/// A service written in full.
193#[derive(Deserialize, JsonSchema)]
194#[serde(deny_unknown_fields)]
195struct ServiceFull {
196    /// The unit's name (`nginx`), or the name of the service `run` defines.
197    name: UnitName,
198    /// Defines the service: the program and its arguments, in the foreground (`[/srv/api/bin/api,
199    /// --port, "8000"]`). Without it, the unit comes from a package.
200    #[serde(default)]
201    run: Option<Argv>,
202    /// With `run`: the user it runs as (default root).
203    #[serde(default)]
204    user: Option<UserName>,
205    /// With `run`: the folder it runs in (absolute).
206    #[serde(default)]
207    cwd: Option<AbsPath>,
208    /// Started at boot and now (default `true`); `false` leaves it disabled and stopped.
209    #[serde(default)]
210    enabled: Option<bool>,
211}
212
213// What the schema shows and the reader returns.
214/// A service as written, for the schema.
215#[derive(JsonSchema)]
216#[serde(untagged)]
217enum ServiceInput {
218    /// A unit a package installs (`nginx`), enabled and started.
219    Unit(UnitName),
220    /// A unit with its settings, or a service Isoloom defines from `run`.
221    Full(ServiceFull),
222}
223
224schema_as!(Service, ServiceInput);
225
226impl<'de> Deserialize<'de> for ServiceInput {
227    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<ServiceInput, D::Error> {
228        struct V;
229        impl<'de> Visitor<'de> for V {
230            type Value = ServiceInput;
231            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
232                f.write_str("a unit's name, or `{ name, run, user, cwd, enabled }`")
233            }
234            fn visit_str<E: de::Error>(self, v: &str) -> Result<ServiceInput, E> {
235                v.parse().map(ServiceInput::Unit).map_err(E::custom)
236            }
237            fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<ServiceInput, A::Error> {
238                ServiceFull::deserialize(de::value::MapAccessDeserializer::new(map)).map(ServiceInput::Full)
239            }
240        }
241        d.deserialize_any(V)
242    }
243}
244
245impl TryFrom<ServiceInput> for Service {
246    type Error = Mistake;
247
248    fn try_from(written: ServiceInput) -> Result<Service, Mistake> {
249        let s = match written {
250            ServiceInput::Unit(name) => return Ok(Service::Unit { name, enabled: true }),
251            ServiceInput::Full(s) => s,
252        };
253        let enabled = s.enabled.unwrap_or(true);
254        match s.run {
255            Some(run) => Ok(Service::Defined {
256                name: s.name,
257                run,
258                user: s.user,
259                cwd: s.cwd,
260                enabled,
261            }),
262            None if s.user.is_some() || s.cwd.is_some() => {
263                let field = if s.user.is_some() { "user" } else { "cwd" };
264                Err(
265                    Mistake::new(Code::Conflict, format!("`{field}` goes with `run`: a package's unit runs as its package says"))
266                        .at(field)
267                        .fix("`run: [<program>, <args>...]` to define the service, or leave it out"),
268                )
269            }
270            None => Ok(Service::Unit { name: s.name, enabled }),
271        }
272    }
273}
274
275/// A `firewall` step: ports opened in the OS's own firewall, when one runs.
276#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, JsonSchema)]
277#[serde(deny_unknown_fields)]
278pub struct Firewall {
279    /// The ports to open: the machine's port names (`http`), or `<number>` and `<number>/udp`
280    /// (`8443`, `53/udp`). Allow rules only: deny rules are 1.x, and `reach` governs traffic
281    /// between machines.
282    pub allow: Vec<FirewallPort>,
283}
284
285/// A port a `firewall` step opens.
286#[derive(Debug, Clone, PartialEq, Eq, Hash)]
287#[non_exhaustive]
288pub enum FirewallPort {
289    /// One of the machine's ports, by name: its number and protocol.
290    Named(PortName),
291    /// A port by number.
292    Number {
293        /// The number.
294        port: NonZeroU16,
295        /// Its transport.
296        protocol: Protocol,
297    },
298}
299
300impl<'de> Deserialize<'de> for FirewallPort {
301    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<FirewallPort, D::Error> {
302        d.deserialize_any(PortVisitor)
303    }
304}
305
306/// A port as written: a name, a number, or `<number>/udp` (or `/tcp`).
307struct PortVisitor;
308
309impl Visitor<'_> for PortVisitor {
310    type Value = FirewallPort;
311
312    fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
313        f.write_str("a port's name, a number, or `<number>/udp`")
314    }
315
316    fn visit_u64<E: de::Error>(self, v: u64) -> Result<FirewallPort, E> {
317        number(&v.to_string(), Protocol::Tcp)
318    }
319
320    fn visit_i64<E: de::Error>(self, v: i64) -> Result<FirewallPort, E> {
321        number(&v.to_string(), Protocol::Tcp)
322    }
323
324    fn visit_str<E: de::Error>(self, v: &str) -> Result<FirewallPort, E> {
325        match v.split_once('/') {
326            Some((n, "udp")) => number(n, Protocol::Udp),
327            Some((n, "tcp")) => number(n, Protocol::Tcp),
328            Some(_) => Err(E::custom(format!("`{v}`: a port is `<number>`, `<number>/udp` or `<number>/tcp`"))),
329            None if v.starts_with(|c: char| c.is_ascii_digit()) => number(v, Protocol::Tcp),
330            None => v.parse().map(FirewallPort::Named).map_err(E::custom),
331        }
332    }
333}
334
335/// Port number `n`, 1 to 65535.
336fn number<E: de::Error>(n: &str, protocol: Protocol) -> Result<FirewallPort, E> {
337    n.parse::<NonZeroU16>()
338        .map(|port| FirewallPort::Number { port, protocol })
339        .map_err(|_| E::custom(format!("`{n}` isn't a port: 1 to 65535")))
340}
341
342impl JsonSchema for FirewallPort {
343    fn schema_name() -> Cow<'static, str> {
344        "FirewallPort".into()
345    }
346
347    fn inline_schema() -> bool {
348        true
349    }
350
351    fn json_schema(_: &mut SchemaGenerator) -> Schema {
352        json_schema!({ "anyOf": [
353            { "type": "integer", "minimum": 1, "maximum": 65535 },
354            { "type": "string", "pattern": "^([a-z][a-z0-9-]{0,30}|[0-9]{1,5}(/(udp|tcp))?)$" },
355        ] })
356    }
357}