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;
25
26type Result<T> = std::result::Result<T, Error>;
27
28#[derive(thiserror::Error, Debug)]
29#[non_exhaustive]
30pub enum Error {
31    #[error("serialization error: {0}")]
32    Serialization(#[from] serde_json::Error),
33    #[error("couldn't parse config version: {0}")]
34    InvalidVersion(#[from] semver::Error),
35    #[error("unsupported config version: {0}")]
36    UnknownVersion(Version),
37}
38
39#[derive(thiserror::Error, Debug)]
40#[non_exhaustive]
41pub enum Warning {
42    #[error("unused key: {0}")]
43    UnusedKey(String),
44}
45
46// can't implement Deserialize since that consumes the input stream and we
47// need to parse twice
48#[derive(Clone, Debug, PartialEq, Serialize)]
49#[serde(untagged)]
50#[non_exhaustive]
51pub enum Config {
52    V3_0(v3_0::Config),
53    V3_1(v3_1::Config),
54    V3_2(v3_2::Config),
55    V3_3(v3_3::Config),
56    V3_4(v3_4::Config),
57    V3_5(v3_5::Config),
58}
59
60impl Config {
61    pub fn parse_str(s: &str) -> Result<(Self, Vec<Warning>)> {
62        Self::parse_slice(s.as_bytes())
63    }
64
65    pub fn parse_slice(v: &[u8]) -> Result<(Self, Vec<Warning>)> {
66        let minimal: MinimalConfig = serde_json::from_slice(v)?;
67        let version = Version::parse(&minimal.ignition.version)?;
68        let mut warnings = Vec::new();
69        // can't use match because of some implementation details of Version
70        let parsed = if version == v3_0::VERSION {
71            Self::V3_0(parse_warn(v, &mut warnings)?)
72        } else if version == v3_1::VERSION {
73            Self::V3_1(parse_warn(v, &mut warnings)?)
74        } else if version == v3_2::VERSION {
75            Self::V3_2(parse_warn(v, &mut warnings)?)
76        } else if version == v3_3::VERSION {
77            Self::V3_3(parse_warn(v, &mut warnings)?)
78        } else if version == v3_4::VERSION {
79            Self::V3_4(parse_warn(v, &mut warnings)?)
80        } else if version == v3_5::VERSION {
81            Self::V3_5(parse_warn(v, &mut warnings)?)
82        } else {
83            return Err(Error::UnknownVersion(version));
84        };
85        Ok((parsed, warnings))
86    }
87}
88
89#[derive(Debug, Deserialize)]
90struct MinimalConfig {
91    ignition: MinimalIgnition,
92}
93
94#[derive(Debug, Deserialize)]
95struct MinimalIgnition {
96    version: String,
97}
98
99/// Deserialize and populate warnings.
100fn parse_warn<'de, T: Deserialize<'de>>(v: &'de [u8], warnings: &mut Vec<Warning>) -> Result<T> {
101    Ok(serde_ignored::deserialize(
102        &mut serde_json::Deserializer::from_slice(v),
103        |path| warnings.push(Warning::UnusedKey(path_string(&path))),
104    )?)
105}
106
107/// Convert Path to String using vcontext-style formatting.
108/// In particular, don't add a ? to Option<> wrappers, as Path.to_string()
109/// does.
110fn path_string(path: &Path) -> String {
111    use Path::*;
112    match path {
113        Root => "$".into(),
114        Seq { parent, index } => format!("{}.{}", path_string(parent), index),
115        Map { parent, key } => format!("{}.{}", path_string(parent), key),
116        Some { parent } => path_string(parent),
117        NewtypeStruct { parent } => path_string(parent),
118        NewtypeVariant { parent } => path_string(parent),
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn parse() {
128        assert!(matches!(
129            Config::parse_str("z").unwrap_err(),
130            Error::Serialization(_)
131        ));
132        assert!(matches!(
133            Config::parse_str("{}").unwrap_err(),
134            Error::Serialization(_)
135        ));
136        assert!(matches!(
137            Config::parse_str(r#"{"ignition": {"version": "z"}}"#).unwrap_err(),
138            Error::InvalidVersion(_)
139        ));
140        assert!(matches!(
141            Config::parse_str(r#"{"ignition": {"version": "2.0.0"}}"#).unwrap_err(),
142            Error::UnknownVersion(_)
143        ));
144        assert!(matches!(
145            Config::parse_str(r#"{"ignition": {"version": "3.0.0-experimental"}}"#).unwrap_err(),
146            Error::UnknownVersion(_)
147        ));
148
149        let mut expected = v3_0::Config::default();
150        expected
151            .storage
152            .get_or_insert_with(Default::default)
153            .files
154            .get_or_insert_with(Default::default)
155            .push(v3_0::File::new("/z".into()));
156        let (config, warnings) = Config::parse_str(
157            r#"{"ignition": {"version": "3.0.0"}, "storage": {"files": [{"path": "/z"}]}}"#,
158        )
159        .unwrap();
160        assert_eq!(config, Config::V3_0(expected));
161        assert!(warnings.is_empty());
162
163        let mut expected = v3_1::Config::default();
164        expected
165            .storage
166            .get_or_insert_with(Default::default)
167            .files
168            .get_or_insert_with(Default::default)
169            .push(v3_1::File::new("/z".into()));
170        let (config, warnings) = Config::parse_str(
171            r#"{"ignition": {"version": "3.1.0"}, "storage": {"files": [{"path": "/z"}]}}"#,
172        )
173        .unwrap();
174        assert_eq!(config, Config::V3_1(expected));
175        assert!(warnings.is_empty());
176
177        let mut expected = v3_2::Config::default();
178        expected
179            .storage
180            .get_or_insert_with(Default::default)
181            .files
182            .get_or_insert_with(Default::default)
183            .push(v3_2::File::new("/z".into()));
184        let (config, warnings) = Config::parse_str(
185            r#"{"ignition": {"version": "3.2.0"}, "storage": {"files": [{"path": "/z"}]}}"#,
186        )
187        .unwrap();
188        assert_eq!(config, Config::V3_2(expected));
189        assert!(warnings.is_empty());
190
191        let mut expected = v3_3::Config::default();
192        expected
193            .storage
194            .get_or_insert_with(Default::default)
195            .files
196            .get_or_insert_with(Default::default)
197            .push(v3_3::File::new("/z".into()));
198        let (config, warnings) = Config::parse_str(
199            r#"{"ignition": {"version": "3.3.0"}, "storage": {"files": [{"path": "/z"}]}}"#,
200        )
201        .unwrap();
202        assert_eq!(config, Config::V3_3(expected));
203        assert!(warnings.is_empty());
204
205        let mut expected = v3_4::Config::default();
206        expected
207            .storage
208            .get_or_insert_with(Default::default)
209            .files
210            .get_or_insert_with(Default::default)
211            .push(v3_4::File::new("/z".into()));
212        let (config, warnings) = Config::parse_str(
213            r#"{"ignition": {"version": "3.4.0"}, "storage": {"files": [{"path": "/z"}]}}"#,
214        )
215        .unwrap();
216        assert_eq!(config, Config::V3_4(expected));
217        assert!(warnings.is_empty());
218    }
219
220    #[test]
221    fn round_trip() {
222        let input = r#"{"ignition":{"version":"3.0.0"}}"#;
223        let (config, warnings) = Config::parse_str(input).unwrap();
224        assert_eq!(serde_json::to_string(&config).unwrap(), input);
225        assert!(warnings.is_empty());
226    }
227
228    #[test]
229    fn warnings() {
230        let (_, warnings) = Config::parse_str(
231            r#"{"ignition": {"version": "3.0.0"}, "a": {"y": "z"}, "b": 7, "c": null, "systemd": {"units": [{"name": "v", "d": "e"}]}}"#,
232        )
233        .unwrap();
234        assert_eq!(
235            warnings
236                .iter()
237                .map(|w| w.to_string())
238                .collect::<Vec<String>>(),
239            vec![
240                "unused key: $.a",
241                "unused key: $.b",
242                "unused key: $.c",
243                "unused key: $.systemd.units.0.d",
244            ]
245        );
246    }
247}