Skip to main content

ignition_config/
lib.rs

1// Copyright 2021 Red Hat, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use semver::Version;
16use serde::{Deserialize, Serialize};
17use serde_ignored::Path;
18
19pub mod v3_0;
20pub mod v3_1;
21pub mod v3_2;
22pub mod v3_3;
23pub mod v3_4;
24pub mod v3_5;
25pub mod v3_6;
26
27type Result<T> = std::result::Result<T, Error>;
28
29#[derive(thiserror::Error, Debug)]
30#[non_exhaustive]
31pub enum Error {
32    #[error("serialization error: {0}")]
33    Serialization(#[from] serde_json::Error),
34    #[error("couldn't parse config version: {0}")]
35    InvalidVersion(#[from] semver::Error),
36    #[error("unsupported config version: {0}")]
37    UnknownVersion(Version),
38}
39
40#[derive(thiserror::Error, Debug)]
41#[non_exhaustive]
42pub enum Warning {
43    #[error("unused key: {0}")]
44    UnusedKey(String),
45}
46
47// can't implement Deserialize since that consumes the input stream and we
48// need to parse twice
49#[derive(Clone, Debug, PartialEq, Serialize)]
50#[serde(untagged)]
51#[non_exhaustive]
52pub enum Config {
53    V3_0(v3_0::Config),
54    V3_1(v3_1::Config),
55    V3_2(v3_2::Config),
56    V3_3(v3_3::Config),
57    V3_4(v3_4::Config),
58    V3_5(v3_5::Config),
59    V3_6(v3_6::Config),
60}
61
62impl Config {
63    pub fn parse_str(s: &str) -> Result<(Self, Vec<Warning>)> {
64        Self::parse_slice(s.as_bytes())
65    }
66
67    pub fn parse_slice(v: &[u8]) -> Result<(Self, Vec<Warning>)> {
68        let minimal: MinimalConfig = serde_json::from_slice(v)?;
69        let version = Version::parse(&minimal.ignition.version)?;
70        let mut warnings = Vec::new();
71        // can't use match because of some implementation details of Version
72        let parsed = if version == v3_0::VERSION {
73            Self::V3_0(parse_warn(v, &mut warnings)?)
74        } else if version == v3_1::VERSION {
75            Self::V3_1(parse_warn(v, &mut warnings)?)
76        } else if version == v3_2::VERSION {
77            Self::V3_2(parse_warn(v, &mut warnings)?)
78        } else if version == v3_3::VERSION {
79            Self::V3_3(parse_warn(v, &mut warnings)?)
80        } else if version == v3_4::VERSION {
81            Self::V3_4(parse_warn(v, &mut warnings)?)
82        } else if version == v3_5::VERSION {
83            Self::V3_5(parse_warn(v, &mut warnings)?)
84        } else if version == v3_6::VERSION {
85            Self::V3_6(parse_warn(v, &mut warnings)?)
86        } else {
87            return Err(Error::UnknownVersion(version));
88        };
89        Ok((parsed, warnings))
90    }
91}
92
93#[derive(Debug, Deserialize)]
94struct MinimalConfig {
95    ignition: MinimalIgnition,
96}
97
98#[derive(Debug, Deserialize)]
99struct MinimalIgnition {
100    version: String,
101}
102
103/// Deserialize and populate warnings.
104fn parse_warn<'de, T: Deserialize<'de>>(v: &'de [u8], warnings: &mut Vec<Warning>) -> Result<T> {
105    Ok(serde_ignored::deserialize(
106        &mut serde_json::Deserializer::from_slice(v),
107        |path| warnings.push(Warning::UnusedKey(path_string(&path))),
108    )?)
109}
110
111/// Convert Path to String using vcontext-style formatting.
112/// In particular, don't add a ? to Option<> wrappers, as Path.to_string()
113/// does.
114fn path_string(path: &Path) -> String {
115    use Path::*;
116    match path {
117        Root => "$".into(),
118        Seq { parent, index } => format!("{}.{}", path_string(parent), index),
119        Map { parent, key } => format!("{}.{}", path_string(parent), key),
120        Some { parent } => path_string(parent),
121        NewtypeStruct { parent } => path_string(parent),
122        NewtypeVariant { parent } => path_string(parent),
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn parse() {
132        assert!(matches!(
133            Config::parse_str("z").unwrap_err(),
134            Error::Serialization(_)
135        ));
136        assert!(matches!(
137            Config::parse_str("{}").unwrap_err(),
138            Error::Serialization(_)
139        ));
140        assert!(matches!(
141            Config::parse_str(r#"{"ignition": {"version": "z"}}"#).unwrap_err(),
142            Error::InvalidVersion(_)
143        ));
144        assert!(matches!(
145            Config::parse_str(r#"{"ignition": {"version": "2.0.0"}}"#).unwrap_err(),
146            Error::UnknownVersion(_)
147        ));
148        assert!(matches!(
149            Config::parse_str(r#"{"ignition": {"version": "3.0.0-experimental"}}"#).unwrap_err(),
150            Error::UnknownVersion(_)
151        ));
152
153        let mut expected = v3_0::Config::default();
154        expected
155            .storage
156            .get_or_insert_with(Default::default)
157            .files
158            .get_or_insert_with(Default::default)
159            .push(v3_0::File::new("/z".into()));
160        let (config, warnings) = Config::parse_str(
161            r#"{"ignition": {"version": "3.0.0"}, "storage": {"files": [{"path": "/z"}]}}"#,
162        )
163        .unwrap();
164        assert_eq!(config, Config::V3_0(expected));
165        assert!(warnings.is_empty());
166
167        let mut expected = v3_1::Config::default();
168        expected
169            .storage
170            .get_or_insert_with(Default::default)
171            .files
172            .get_or_insert_with(Default::default)
173            .push(v3_1::File::new("/z".into()));
174        let (config, warnings) = Config::parse_str(
175            r#"{"ignition": {"version": "3.1.0"}, "storage": {"files": [{"path": "/z"}]}}"#,
176        )
177        .unwrap();
178        assert_eq!(config, Config::V3_1(expected));
179        assert!(warnings.is_empty());
180
181        let mut expected = v3_2::Config::default();
182        expected
183            .storage
184            .get_or_insert_with(Default::default)
185            .files
186            .get_or_insert_with(Default::default)
187            .push(v3_2::File::new("/z".into()));
188        let (config, warnings) = Config::parse_str(
189            r#"{"ignition": {"version": "3.2.0"}, "storage": {"files": [{"path": "/z"}]}}"#,
190        )
191        .unwrap();
192        assert_eq!(config, Config::V3_2(expected));
193        assert!(warnings.is_empty());
194
195        let mut expected = v3_3::Config::default();
196        expected
197            .storage
198            .get_or_insert_with(Default::default)
199            .files
200            .get_or_insert_with(Default::default)
201            .push(v3_3::File::new("/z".into()));
202        let (config, warnings) = Config::parse_str(
203            r#"{"ignition": {"version": "3.3.0"}, "storage": {"files": [{"path": "/z"}]}}"#,
204        )
205        .unwrap();
206        assert_eq!(config, Config::V3_3(expected));
207        assert!(warnings.is_empty());
208
209        let mut expected = v3_4::Config::default();
210        expected
211            .storage
212            .get_or_insert_with(Default::default)
213            .files
214            .get_or_insert_with(Default::default)
215            .push(v3_4::File::new("/z".into()));
216        let (config, warnings) = Config::parse_str(
217            r#"{"ignition": {"version": "3.4.0"}, "storage": {"files": [{"path": "/z"}]}}"#,
218        )
219        .unwrap();
220        assert_eq!(config, Config::V3_4(expected));
221        assert!(warnings.is_empty());
222
223        let mut expected = v3_5::Config::default();
224        expected
225            .storage
226            .get_or_insert_with(Default::default)
227            .files
228            .get_or_insert_with(Default::default)
229            .push(v3_5::File::new("/z".into()));
230        let (config, warnings) = Config::parse_str(
231            r#"{"ignition": {"version": "3.5.0"}, "storage": {"files": [{"path": "/z"}]}}"#,
232        )
233        .unwrap();
234        assert_eq!(config, Config::V3_5(expected));
235        assert!(warnings.is_empty());
236
237        let mut expected = v3_6::Config::default();
238        expected
239            .storage
240            .get_or_insert_with(Default::default)
241            .files
242            .get_or_insert_with(Default::default)
243            .push(v3_6::File::new("/z".into()));
244        let (config, warnings) = Config::parse_str(
245            r#"{"ignition": {"version": "3.6.0"}, "storage": {"files": [{"path": "/z"}]}}"#,
246        )
247        .unwrap();
248        assert_eq!(config, Config::V3_6(expected));
249        assert!(warnings.is_empty());
250    }
251
252    #[test]
253    fn round_trip() {
254        let input = r#"{"ignition":{"version":"3.0.0"}}"#;
255        let (config, warnings) = Config::parse_str(input).unwrap();
256        assert_eq!(serde_json::to_string(&config).unwrap(), input);
257        assert!(warnings.is_empty());
258    }
259
260    #[test]
261    fn warnings() {
262        let (_, warnings) = Config::parse_str(
263            r#"{"ignition": {"version": "3.0.0"}, "a": {"y": "z"}, "b": 7, "c": null, "systemd": {"units": [{"name": "v", "d": "e"}]}}"#,
264        )
265        .unwrap();
266        assert_eq!(
267            warnings
268                .iter()
269                .map(|w| w.to_string())
270                .collect::<Vec<String>>(),
271            vec![
272                "unused key: $.a",
273                "unused key: $.b",
274                "unused key: $.c",
275                "unused key: $.systemd.units.0.d",
276            ]
277        );
278    }
279}