Skip to main content

isoloom_spec/syntax/
shared.rs

1//! `common:` and `groups:`: machine fields several machines share. They take any machine field
2//! but what makes a machine itself (`networks`, `ports`, `access`, `count`, `aliases`); a
3//! machine's own value wins over its groups' (the most specific first), and theirs over
4//! `common:`. The fields fold into the machines before the machines are read ([`crate::groups`]),
5//! so a value is checked where it lands.
6
7use std::borrow::Cow;
8use std::fmt;
9
10use indexmap::IndexMap;
11use schemars::{JsonSchema, Schema, SchemaGenerator};
12use serde::de::{self, MapAccess, Visitor};
13use serde::{Deserialize, Deserializer};
14
15use super::machine::MachineInput;
16
17/// The machine fields `common:` and groups can't share: each is one machine's own.
18pub(crate) const OWN_FIELDS: &[&str] = &["networks", "ports", "access", "count", "aliases", "seal"];
19
20/// The machine fields `common:` and groups share.
21pub(crate) const SHARED_FIELDS: &[&str] = &[
22    "os",
23    "service",
24    "with",
25    "app",
26    "command",
27    "setup",
28    "healthcheck",
29    "depends_on",
30    "environment",
31    "files",
32    "volumes",
33    "disks",
34    "tmpfs",
35    "resources",
36    "hardware",
37    "dns",
38    "privileged",
39    "read_only",
40    "shm_size",
41    "sysctls",
42    "ulimits",
43    "rate_limit",
44];
45
46/// Shared machine fields, as written (checked where they land, in each machine).
47#[derive(Debug, Clone, PartialEq, Default)]
48pub struct Shared(pub IndexMap<String, serde_json::Value>);
49
50/// A group: the machines in it, and the fields they share.
51#[derive(Debug, Clone, PartialEq, Default)]
52pub struct Group {
53    /// Machine names, globs (`ws*`), or other groups.
54    pub members: Vec<String>,
55    /// The fields its members share.
56    pub shared: Shared,
57}
58
59/// Reads shared fields from a map; with `members`, a group's.
60struct Fields {
61    group: bool,
62}
63
64impl<'de> Visitor<'de> for Fields {
65    type Value = Group;
66
67    fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
68        f.write_str("a map of machine fields")
69    }
70
71    fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Group, A::Error> {
72        let mut out = Group::default();
73        while let Some(key) = map.next_key::<String>()? {
74            if self.group && key == "members" {
75                out.members = map.next_value()?;
76            } else if OWN_FIELDS.contains(&key.as_str()) {
77                return Err(de::Error::custom(format!("`{key}` is each machine's own: write it on the machines")));
78            } else if SHARED_FIELDS.contains(&key.as_str()) {
79                out.shared.0.insert(key, map.next_value()?);
80            } else {
81                return Err(de::Error::unknown_field(&key, SHARED_FIELDS));
82            }
83        }
84        if self.group && out.members.is_empty() {
85            return Err(de::Error::custom("a group names its `members`"));
86        }
87        Ok(out)
88    }
89
90    fn visit_unit<E: de::Error>(self) -> Result<Group, E> {
91        Ok(Group::default())
92    }
93}
94
95impl<'de> Deserialize<'de> for Shared {
96    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Shared, D::Error> {
97        d.deserialize_any(Fields { group: false }).map(|g| g.shared)
98    }
99}
100
101impl<'de> Deserialize<'de> for Group {
102    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Group, D::Error> {
103        d.deserialize_map(Fields { group: true })
104    }
105}
106
107/// A machine's schema without its own fields, and nothing required.
108fn shared_schema(g: &mut SchemaGenerator) -> Schema {
109    let mut s = MachineInput::json_schema(g);
110    if let Some(props) = s.get_mut("properties").and_then(serde_json::Value::as_object_mut) {
111        props.retain(|k, _| !OWN_FIELDS.contains(&k.as_str()));
112    }
113    s.remove("required");
114    s
115}
116
117impl JsonSchema for Shared {
118    fn schema_name() -> Cow<'static, str> {
119        "Shared".into()
120    }
121
122    fn json_schema(g: &mut SchemaGenerator) -> Schema {
123        let mut s = shared_schema(g);
124        s.insert("description".into(), "Machine fields every machine shares (a machine's own value wins).".into());
125        s
126    }
127}
128
129impl JsonSchema for Group {
130    fn schema_name() -> Cow<'static, str> {
131        "Group".into()
132    }
133
134    fn json_schema(g: &mut SchemaGenerator) -> Schema {
135        let mut s = shared_schema(g);
136        let members = g.subschema_for::<Vec<String>>();
137        if let Some(props) = s.get_mut("properties").and_then(serde_json::Value::as_object_mut) {
138            let mut m = members.to_value();
139            if let Some(o) = m.as_object_mut() {
140                o.insert("description".into(), "Machine names, globs (`ws*`), or other groups.".into());
141            }
142            props.insert("members".into(), m);
143        }
144        s.insert("required".into(), serde_json::json!(["members"]));
145        s
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn shared_fields_left_empty_share_nothing() {
155        assert_eq!(serde_yaml_ng::from_str::<Shared>("~").unwrap(), Shared::default());
156        let wrong = serde_yaml_ng::from_str::<Group>("[a]").unwrap_err().to_string();
157        assert!(wrong.contains("a map of machine fields"), "{wrong}");
158    }
159
160    #[test]
161    fn the_shared_fields_are_a_machines_but_its_own() {
162        let schema = MachineInput::json_schema(&mut SchemaGenerator::default());
163        let mut fields: Vec<&str> = schema.get("properties").unwrap().as_object().unwrap().keys().map(String::as_str).collect();
164        fields.retain(|f| !OWN_FIELDS.contains(f));
165        let mut shared = SHARED_FIELDS.to_vec();
166        fields.sort_unstable();
167        shared.sort_unstable();
168        assert_eq!(fields, shared);
169    }
170
171    #[test]
172    fn groups_share_fields_but_not_a_machines_own() {
173        let g: Group = serde_yaml_ng::from_str("{ members: [dc01, ws*], resources: { cpus: 2 }, os: windows-server@2019 }").unwrap();
174        assert_eq!((g.members.len(), g.shared.0.len()), (2, 2));
175        let e = serde_yaml_ng::from_str::<Group>("{ members: [a], networks: { lab: 10 } }")
176            .unwrap_err()
177            .to_string();
178        assert!(e.contains("each machine's own"), "{e}");
179        assert!(
180            serde_yaml_ng::from_str::<Shared>("{ docker: { image: x } }")
181                .unwrap_err()
182                .to_string()
183                .contains("unknown field `docker`")
184        );
185        assert!(serde_yaml_ng::from_str::<Group>("{ resources: { cpus: 2 } }").is_err());
186    }
187}