bootc_lib/install/
config.rs

1//! # Configuration for `bootc install`
2//!
3//! This module handles the TOML configuration file for `bootc install`.
4
5use anyhow::{Context, Result};
6use clap::ValueEnum;
7use fn_error_context::context;
8use serde::{Deserialize, Serialize};
9
10#[cfg(feature = "install-to-disk")]
11use super::baseline::BlockSetup;
12
13/// Properties of the environment, such as the system architecture
14/// Left open for future properties such as `platform.id`
15pub(crate) struct EnvProperties {
16    pub(crate) sys_arch: String,
17}
18
19/// A well known filesystem type.
20#[derive(clap::ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "kebab-case")]
22pub(crate) enum Filesystem {
23    Xfs,
24    Ext4,
25    Btrfs,
26}
27
28impl std::fmt::Display for Filesystem {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        self.to_possible_value().unwrap().get_name().fmt(f)
31    }
32}
33
34/// The toplevel config entry for installation configs stored
35/// in bootc/install (e.g. /etc/bootc/install/05-custom.toml)
36#[derive(Debug, Clone, Serialize, Deserialize, Default)]
37#[serde(deny_unknown_fields)]
38pub(crate) struct InstallConfigurationToplevel {
39    pub(crate) install: Option<InstallConfiguration>,
40}
41
42/// Configuration for a filesystem
43#[derive(Debug, Clone, Serialize, Deserialize, Default)]
44#[serde(deny_unknown_fields)]
45pub(crate) struct RootFS {
46    #[serde(rename = "type")]
47    pub(crate) fstype: Option<Filesystem>,
48}
49
50/// This structure should only define "system" or "basic" filesystems; we are
51/// not trying to generalize this into e.g. supporting `/var` or other ones.
52#[derive(Debug, Clone, Serialize, Deserialize, Default)]
53#[serde(deny_unknown_fields)]
54pub(crate) struct BasicFilesystems {
55    pub(crate) root: Option<RootFS>,
56    // TODO allow configuration of these other filesystems too
57    // pub(crate) xbootldr: Option<FilesystemCustomization>,
58    // pub(crate) esp: Option<FilesystemCustomization>,
59}
60
61/// The serialized [install] section
62#[derive(Debug, Clone, Serialize, Deserialize, Default)]
63#[serde(rename = "install", rename_all = "kebab-case", deny_unknown_fields)]
64pub(crate) struct InstallConfiguration {
65    /// Root filesystem type
66    pub(crate) root_fs_type: Option<Filesystem>,
67    /// Enabled block storage configurations
68    #[cfg(feature = "install-to-disk")]
69    pub(crate) block: Option<Vec<BlockSetup>>,
70    pub(crate) filesystem: Option<BasicFilesystems>,
71    /// Kernel arguments, applied at installation time
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub(crate) kargs: Option<Vec<String>>,
74    /// Supported architectures for this configuration
75    pub(crate) match_architectures: Option<Vec<String>>,
76}
77
78fn merge_basic<T>(s: &mut Option<T>, o: Option<T>, _env: &EnvProperties) {
79    if let Some(o) = o {
80        *s = Some(o);
81    }
82}
83
84trait Mergeable {
85    fn merge(&mut self, other: Self, env: &EnvProperties)
86    where
87        Self: Sized;
88}
89
90impl<T> Mergeable for Option<T>
91where
92    T: Mergeable,
93{
94    fn merge(&mut self, other: Self, env: &EnvProperties)
95    where
96        Self: Sized,
97    {
98        if let Some(other) = other {
99            if let Some(s) = self.as_mut() {
100                s.merge(other, env)
101            } else {
102                *self = Some(other);
103            }
104        }
105    }
106}
107
108impl Mergeable for RootFS {
109    /// Apply any values in other, overriding any existing values in `self`.
110    fn merge(&mut self, other: Self, env: &EnvProperties) {
111        merge_basic(&mut self.fstype, other.fstype, env)
112    }
113}
114
115impl Mergeable for BasicFilesystems {
116    /// Apply any values in other, overriding any existing values in `self`.
117    fn merge(&mut self, other: Self, env: &EnvProperties) {
118        self.root.merge(other.root, env)
119    }
120}
121
122impl Mergeable for InstallConfiguration {
123    /// Apply any values in other, overriding any existing values in `self`.
124    fn merge(&mut self, other: Self, env: &EnvProperties) {
125        // if arch is specified, only merge config if it matches the current arch
126        // if arch is not specified, merge config unconditionally
127        if other
128            .match_architectures
129            .map(|a| a.contains(&env.sys_arch))
130            .unwrap_or(true)
131        {
132            merge_basic(&mut self.root_fs_type, other.root_fs_type, env);
133            #[cfg(feature = "install-to-disk")]
134            merge_basic(&mut self.block, other.block, env);
135            self.filesystem.merge(other.filesystem, env);
136            if let Some(other_kargs) = other.kargs {
137                self.kargs
138                    .get_or_insert_with(Default::default)
139                    .extend(other_kargs)
140            }
141        }
142    }
143}
144
145impl InstallConfiguration {
146    /// Set defaults (e.g. `block`), and also handle fields that can be specified multiple ways
147    /// by synchronizing the values of the fields to ensure they're the same.
148    ///
149    /// - install.root-fs-type is synchronized with install.filesystems.root.type; if
150    ///   both are set, then the latter takes precedence
151    pub(crate) fn canonicalize(&mut self) {
152        // New canonical form wins.
153        if let Some(rootfs_type) = self.filesystem_root().and_then(|f| f.fstype.as_ref()) {
154            self.root_fs_type = Some(*rootfs_type)
155        } else if let Some(rootfs) = self.root_fs_type.as_ref() {
156            let fs = self.filesystem.get_or_insert_with(Default::default);
157            let root = fs.root.get_or_insert_with(Default::default);
158            root.fstype = Some(*rootfs);
159        }
160
161        #[cfg(feature = "install-to-disk")]
162        if self.block.is_none() {
163            self.block = Some(vec![BlockSetup::Direct]);
164        }
165    }
166
167    /// Convenience helper to access the root filesystem
168    pub(crate) fn filesystem_root(&self) -> Option<&RootFS> {
169        self.filesystem.as_ref().and_then(|fs| fs.root.as_ref())
170    }
171
172    // Remove all configuration which is handled by `install to-filesystem`.
173    pub(crate) fn filter_to_external(&mut self) {
174        self.kargs.take();
175    }
176
177    #[cfg(feature = "install-to-disk")]
178    pub(crate) fn get_block_setup(&self, default: Option<BlockSetup>) -> Result<BlockSetup> {
179        let valid_block_setups = self.block.as_deref().unwrap_or_default();
180        let default_block = valid_block_setups.iter().next().ok_or_else(|| {
181            anyhow::anyhow!("Empty block storage configuration in install configuration")
182        })?;
183        let block_setup = default.as_ref().unwrap_or(default_block);
184        if !valid_block_setups.contains(block_setup) {
185            anyhow::bail!("Block setup {block_setup:?} is not enabled in installation config");
186        }
187        Ok(*block_setup)
188    }
189}
190
191#[context("Loading configuration")]
192/// Load the install configuration, merging all found configuration files.
193pub(crate) fn load_config() -> Result<Option<InstallConfiguration>> {
194    let env = EnvProperties {
195        sys_arch: std::env::consts::ARCH.to_string(),
196    };
197    const SYSTEMD_CONVENTIONAL_BASES: &[&str] = &["/usr/lib", "/usr/local/lib", "/etc", "/run"];
198    let fragments = liboverdrop::scan(SYSTEMD_CONVENTIONAL_BASES, "bootc/install", &["toml"], true);
199    let mut config: Option<InstallConfiguration> = None;
200    for (_name, path) in fragments {
201        let buf = std::fs::read_to_string(&path)?;
202        let mut unused = std::collections::HashSet::new();
203        let de = toml::Deserializer::parse(&buf).with_context(|| format!("Parsing {path:?}"))?;
204        let mut c: InstallConfigurationToplevel = serde_ignored::deserialize(de, |path| {
205            unused.insert(path.to_string());
206        })
207        .with_context(|| format!("Parsing {path:?}"))?;
208        for key in unused {
209            eprintln!("warning: {path:?}: Unknown key {key}");
210        }
211        if let Some(config) = config.as_mut() {
212            if let Some(install) = c.install {
213                tracing::debug!("Merging install config: {install:?}");
214                config.merge(install, &env);
215            }
216        } else {
217            // Only set the config if it matches the current arch
218            // If no arch is specified, set the config unconditionally
219            if let Some(ref mut install) = c.install {
220                if install
221                    .match_architectures
222                    .as_ref()
223                    .map(|a| a.contains(&env.sys_arch))
224                    .unwrap_or(true)
225                {
226                    config = c.install;
227                }
228            }
229        }
230    }
231    if let Some(config) = config.as_mut() {
232        config.canonicalize();
233    }
234    Ok(config)
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    /// Verify that we can parse our default config file
243    fn test_parse_config() {
244        let env = EnvProperties {
245            sys_arch: "x86_64".to_string(),
246        };
247        let c: InstallConfigurationToplevel = toml::from_str(
248            r##"[install]
249root-fs-type = "xfs"
250"##,
251        )
252        .unwrap();
253        let mut install = c.install.unwrap();
254        assert_eq!(install.root_fs_type.unwrap(), Filesystem::Xfs);
255        let other = InstallConfigurationToplevel {
256            install: Some(InstallConfiguration {
257                root_fs_type: Some(Filesystem::Ext4),
258                ..Default::default()
259            }),
260        };
261        install.merge(other.install.unwrap(), &env);
262        assert_eq!(
263            install.root_fs_type.as_ref().copied().unwrap(),
264            Filesystem::Ext4
265        );
266        // This one shouldn't have been set
267        assert!(install.filesystem_root().is_none());
268        install.canonicalize();
269        assert_eq!(install.root_fs_type.as_ref().unwrap(), &Filesystem::Ext4);
270        assert_eq!(
271            install.filesystem_root().unwrap().fstype.unwrap(),
272            Filesystem::Ext4
273        );
274
275        let c: InstallConfigurationToplevel = toml::from_str(
276            r##"[install]
277root-fs-type = "ext4"
278kargs = ["console=ttyS0", "foo=bar"]
279"##,
280        )
281        .unwrap();
282        let mut install = c.install.unwrap();
283        assert_eq!(install.root_fs_type.unwrap(), Filesystem::Ext4);
284        let other = InstallConfigurationToplevel {
285            install: Some(InstallConfiguration {
286                kargs: Some(
287                    ["console=tty0", "nosmt"]
288                        .into_iter()
289                        .map(ToOwned::to_owned)
290                        .collect(),
291                ),
292                ..Default::default()
293            }),
294        };
295        install.merge(other.install.unwrap(), &env);
296        assert_eq!(install.root_fs_type.unwrap(), Filesystem::Ext4);
297        assert_eq!(
298            install.kargs,
299            Some(
300                ["console=ttyS0", "foo=bar", "console=tty0", "nosmt"]
301                    .into_iter()
302                    .map(ToOwned::to_owned)
303                    .collect()
304            )
305        )
306    }
307
308    #[test]
309    fn test_parse_filesystems() {
310        let env = EnvProperties {
311            sys_arch: "x86_64".to_string(),
312        };
313        let c: InstallConfigurationToplevel = toml::from_str(
314            r##"[install.filesystem.root]
315type = "xfs"
316"##,
317        )
318        .unwrap();
319        let mut install = c.install.unwrap();
320        assert_eq!(
321            install.filesystem_root().unwrap().fstype.unwrap(),
322            Filesystem::Xfs
323        );
324        let other = InstallConfigurationToplevel {
325            install: Some(InstallConfiguration {
326                filesystem: Some(BasicFilesystems {
327                    root: Some(RootFS {
328                        fstype: Some(Filesystem::Ext4),
329                    }),
330                }),
331                ..Default::default()
332            }),
333        };
334        install.merge(other.install.unwrap(), &env);
335        assert_eq!(
336            install.filesystem_root().unwrap().fstype.unwrap(),
337            Filesystem::Ext4
338        );
339    }
340
341    #[test]
342    fn test_parse_block() {
343        let env = EnvProperties {
344            sys_arch: "x86_64".to_string(),
345        };
346        let c: InstallConfigurationToplevel = toml::from_str(
347            r##"[install.filesystem.root]
348type = "xfs"
349"##,
350        )
351        .unwrap();
352        let mut install = c.install.unwrap();
353        // Verify the default (but note canonicalization mutates)
354        {
355            let mut install = install.clone();
356            install.canonicalize();
357            assert_eq!(install.get_block_setup(None).unwrap(), BlockSetup::Direct);
358        }
359        let other = InstallConfigurationToplevel {
360            install: Some(InstallConfiguration {
361                block: Some(vec![]),
362                ..Default::default()
363            }),
364        };
365        install.merge(other.install.unwrap(), &env);
366        // Should be set, but zero length
367        assert_eq!(install.block.as_ref().unwrap().len(), 0);
368        assert!(install.get_block_setup(None).is_err());
369
370        let c: InstallConfigurationToplevel = toml::from_str(
371            r##"[install]
372block = ["tpm2-luks"]"##,
373        )
374        .unwrap();
375        let mut install = c.install.unwrap();
376        install.canonicalize();
377        assert_eq!(install.block.as_ref().unwrap().len(), 1);
378        assert_eq!(install.get_block_setup(None).unwrap(), BlockSetup::Tpm2Luks);
379
380        // And verify passing a disallowed config is an error
381        assert!(install.get_block_setup(Some(BlockSetup::Direct)).is_err());
382    }
383
384    #[test]
385    /// Verify that kargs are only applied to supported architectures
386    fn test_arch() {
387        // no arch specified, kargs ensure that kargs are applied unconditionally
388        let env = EnvProperties {
389            sys_arch: "x86_64".to_string(),
390        };
391        let c: InstallConfigurationToplevel = toml::from_str(
392            r##"[install]
393root-fs-type = "xfs"
394"##,
395        )
396        .unwrap();
397        let mut install = c.install.unwrap();
398        let other = InstallConfigurationToplevel {
399            install: Some(InstallConfiguration {
400                kargs: Some(
401                    ["console=tty0", "nosmt"]
402                        .into_iter()
403                        .map(ToOwned::to_owned)
404                        .collect(),
405                ),
406                ..Default::default()
407            }),
408        };
409        install.merge(other.install.unwrap(), &env);
410        assert_eq!(
411            install.kargs,
412            Some(
413                ["console=tty0", "nosmt"]
414                    .into_iter()
415                    .map(ToOwned::to_owned)
416                    .collect()
417            )
418        );
419        let env = EnvProperties {
420            sys_arch: "aarch64".to_string(),
421        };
422        let c: InstallConfigurationToplevel = toml::from_str(
423            r##"[install]
424root-fs-type = "xfs"
425"##,
426        )
427        .unwrap();
428        let mut install = c.install.unwrap();
429        let other = InstallConfigurationToplevel {
430            install: Some(InstallConfiguration {
431                kargs: Some(
432                    ["console=tty0", "nosmt"]
433                        .into_iter()
434                        .map(ToOwned::to_owned)
435                        .collect(),
436                ),
437                ..Default::default()
438            }),
439        };
440        install.merge(other.install.unwrap(), &env);
441        assert_eq!(
442            install.kargs,
443            Some(
444                ["console=tty0", "nosmt"]
445                    .into_iter()
446                    .map(ToOwned::to_owned)
447                    .collect()
448            )
449        );
450
451        // one arch matches and one doesn't, ensure that kargs are only applied for the matching arch
452        let env = EnvProperties {
453            sys_arch: "aarch64".to_string(),
454        };
455        let c: InstallConfigurationToplevel = toml::from_str(
456            r##"[install]
457root-fs-type = "xfs"
458"##,
459        )
460        .unwrap();
461        let mut install = c.install.unwrap();
462        let other = InstallConfigurationToplevel {
463            install: Some(InstallConfiguration {
464                kargs: Some(
465                    ["console=ttyS0", "foo=bar"]
466                        .into_iter()
467                        .map(ToOwned::to_owned)
468                        .collect(),
469                ),
470                match_architectures: Some(["x86_64"].into_iter().map(ToOwned::to_owned).collect()),
471                ..Default::default()
472            }),
473        };
474        install.merge(other.install.unwrap(), &env);
475        assert_eq!(install.kargs, None);
476        let other = InstallConfigurationToplevel {
477            install: Some(InstallConfiguration {
478                kargs: Some(
479                    ["console=tty0", "nosmt"]
480                        .into_iter()
481                        .map(ToOwned::to_owned)
482                        .collect(),
483                ),
484                match_architectures: Some(["aarch64"].into_iter().map(ToOwned::to_owned).collect()),
485                ..Default::default()
486            }),
487        };
488        install.merge(other.install.unwrap(), &env);
489        assert_eq!(
490            install.kargs,
491            Some(
492                ["console=tty0", "nosmt"]
493                    .into_iter()
494                    .map(ToOwned::to_owned)
495                    .collect()
496            )
497        );
498
499        // multiple arch specified, ensure that kargs are applied to both archs
500        let env = EnvProperties {
501            sys_arch: "x86_64".to_string(),
502        };
503        let c: InstallConfigurationToplevel = toml::from_str(
504            r##"[install]
505root-fs-type = "xfs"
506"##,
507        )
508        .unwrap();
509        let mut install = c.install.unwrap();
510        let other = InstallConfigurationToplevel {
511            install: Some(InstallConfiguration {
512                kargs: Some(
513                    ["console=tty0", "nosmt"]
514                        .into_iter()
515                        .map(ToOwned::to_owned)
516                        .collect(),
517                ),
518                match_architectures: Some(
519                    ["x86_64", "aarch64"]
520                        .into_iter()
521                        .map(ToOwned::to_owned)
522                        .collect(),
523                ),
524                ..Default::default()
525            }),
526        };
527        install.merge(other.install.unwrap(), &env);
528        assert_eq!(
529            install.kargs,
530            Some(
531                ["console=tty0", "nosmt"]
532                    .into_iter()
533                    .map(ToOwned::to_owned)
534                    .collect()
535            )
536        );
537        let env = EnvProperties {
538            sys_arch: "aarch64".to_string(),
539        };
540        let c: InstallConfigurationToplevel = toml::from_str(
541            r##"[install]
542root-fs-type = "xfs"
543"##,
544        )
545        .unwrap();
546        let mut install = c.install.unwrap();
547        let other = InstallConfigurationToplevel {
548            install: Some(InstallConfiguration {
549                kargs: Some(
550                    ["console=tty0", "nosmt"]
551                        .into_iter()
552                        .map(ToOwned::to_owned)
553                        .collect(),
554                ),
555                match_architectures: Some(
556                    ["x86_64", "aarch64"]
557                        .into_iter()
558                        .map(ToOwned::to_owned)
559                        .collect(),
560                ),
561                ..Default::default()
562            }),
563        };
564        install.merge(other.install.unwrap(), &env);
565        assert_eq!(
566            install.kargs,
567            Some(
568                ["console=tty0", "nosmt"]
569                    .into_iter()
570                    .map(ToOwned::to_owned)
571                    .collect()
572            )
573        );
574    }
575}