bootc_lib/
generator.rs

1use std::io::BufRead;
2
3use anyhow::{Context, Result};
4use camino::Utf8PathBuf;
5use cap_std::fs::Dir;
6use cap_std_ext::{cap_std, dirext::CapStdExtDirExt};
7use fn_error_context::context;
8use ostree_ext::container_utils::{OSTREE_BOOTED, is_ostree_booted_in};
9use rustix::{fd::AsFd, fs::StatVfsMountFlags};
10
11use crate::install::DESTRUCTIVE_CLEANUP;
12
13const STATUS_ONBOOT_UNIT: &str = "bootc-status-updated-onboot.target";
14const STATUS_PATH_UNIT: &str = "bootc-status-updated.path";
15const CLEANUP_UNIT: &str = "bootc-destructive-cleanup.service";
16const MULTI_USER_TARGET: &str = "multi-user.target";
17const EDIT_UNIT: &str = "bootc-fstab-edit.service";
18const FSTAB_ANACONDA_STAMP: &str = "Created by anaconda";
19pub(crate) const BOOTC_EDITED_STAMP: &str = "Updated by bootc-fstab-edit.service";
20
21/// Called when the root is read-only composefs to reconcile /etc/fstab
22#[context("bootc generator")]
23pub(crate) fn fstab_generator_impl(root: &Dir, unit_dir: &Dir) -> Result<bool> {
24    // Do nothing if not ostree-booted
25    if !is_ostree_booted_in(root)? {
26        return Ok(false);
27    }
28
29    if let Some(fd) = root
30        .open_optional("etc/fstab")
31        .context("Opening /etc/fstab")?
32        .map(std::io::BufReader::new)
33    {
34        let mut from_anaconda = false;
35        for line in fd.lines() {
36            let line = line.context("Reading /etc/fstab")?;
37            if line.contains(BOOTC_EDITED_STAMP) {
38                // We're done
39                return Ok(false);
40            }
41            if line.contains(FSTAB_ANACONDA_STAMP) {
42                from_anaconda = true;
43            }
44        }
45        if !from_anaconda {
46            return Ok(false);
47        }
48        tracing::debug!("/etc/fstab from anaconda: {from_anaconda}");
49        if from_anaconda {
50            generate_fstab_editor(unit_dir)?;
51            return Ok(true);
52        }
53    }
54    Ok(false)
55}
56
57pub(crate) fn enable_unit(unitdir: &Dir, name: &str, target: &str) -> Result<()> {
58    let wants = Utf8PathBuf::from(format!("{target}.wants"));
59    unitdir
60        .create_dir_all(&wants)
61        .with_context(|| format!("Creating {wants}"))?;
62    let source = format!("/usr/lib/systemd/system/{name}");
63    let target = wants.join(name);
64    unitdir.remove_file_optional(&target)?;
65    unitdir
66        .symlink_contents(&source, &target)
67        .with_context(|| format!("Writing {name}"))?;
68    Ok(())
69}
70
71/// Enable our units
72pub(crate) fn unit_enablement_impl(sysroot: &Dir, unit_dir: &Dir) -> Result<()> {
73    for unit in [STATUS_ONBOOT_UNIT, STATUS_PATH_UNIT] {
74        enable_unit(unit_dir, unit, MULTI_USER_TARGET)?;
75    }
76
77    if sysroot.try_exists(DESTRUCTIVE_CLEANUP)? {
78        tracing::debug!("Found {DESTRUCTIVE_CLEANUP}");
79        enable_unit(unit_dir, CLEANUP_UNIT, MULTI_USER_TARGET)?;
80    } else {
81        tracing::debug!("Didn't find {DESTRUCTIVE_CLEANUP}");
82    }
83
84    Ok(())
85}
86
87/// Main entrypoint for the generator
88pub(crate) fn generator(root: &Dir, unit_dir: &Dir) -> Result<()> {
89    // Only run on ostree systems
90    if !root.try_exists(OSTREE_BOOTED)? {
91        return Ok(());
92    }
93
94    let Some(ref sysroot) = root.open_dir_optional("sysroot")? else {
95        return Ok(());
96    };
97
98    unit_enablement_impl(sysroot, unit_dir)?;
99
100    // Also only run if the root is a read-only overlayfs (a composefs really)
101    let st = rustix::fs::fstatfs(root.as_fd())?;
102    if st.f_type != libc::OVERLAYFS_SUPER_MAGIC {
103        tracing::trace!("Root is not overlayfs");
104        return Ok(());
105    }
106    let st = rustix::fs::fstatvfs(root.as_fd())?;
107    if !st.f_flag.contains(StatVfsMountFlags::RDONLY) {
108        tracing::trace!("Root is writable");
109        return Ok(());
110    }
111    let updated = fstab_generator_impl(root, unit_dir)?;
112    tracing::trace!("Generated fstab: {updated}");
113
114    Ok(())
115}
116
117/// Parse /etc/fstab and check if the root mount is out of sync with the composefs
118/// state, and if so, fix it.
119fn generate_fstab_editor(unit_dir: &Dir) -> Result<()> {
120    unit_dir.atomic_write(
121        EDIT_UNIT,
122        "[Unit]\n\
123DefaultDependencies=no\n\
124After=systemd-fsck-root.service\n\
125Before=local-fs-pre.target local-fs.target shutdown.target systemd-remount-fs.service\n\
126\n\
127[Service]\n\
128Type=oneshot\n\
129RemainAfterExit=yes\n\
130ExecStart=bootc internals fixup-etc-fstab\n\
131",
132    )?;
133    let target = "local-fs-pre.target.wants";
134    unit_dir.create_dir_all(target)?;
135    unit_dir.symlink(&format!("../{EDIT_UNIT}"), &format!("{target}/{EDIT_UNIT}"))?;
136    Ok(())
137}
138
139#[cfg(test)]
140mod tests {
141    use camino::Utf8Path;
142
143    use super::*;
144
145    fn fixture() -> Result<cap_std_ext::cap_tempfile::TempDir> {
146        let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
147        tempdir.create_dir("etc")?;
148        tempdir.create_dir("run")?;
149        tempdir.create_dir("sysroot")?;
150        tempdir.create_dir_all("run/systemd/system")?;
151        Ok(tempdir)
152    }
153
154    #[test]
155    fn test_generator_no_fstab() -> Result<()> {
156        let tempdir = fixture()?;
157        let unit_dir = &tempdir.open_dir("run/systemd/system")?;
158        fstab_generator_impl(&tempdir, &unit_dir).unwrap();
159
160        assert_eq!(unit_dir.entries()?.count(), 0);
161        Ok(())
162    }
163
164    #[test]
165    fn test_units() -> Result<()> {
166        let tempdir = &fixture()?;
167        let sysroot = &tempdir.open_dir("sysroot").unwrap();
168        let unit_dir = &tempdir.open_dir("run/systemd/system")?;
169
170        let verify = |wantsdir: &Dir, n: u32| -> Result<()> {
171            assert_eq!(unit_dir.entries()?.count(), 1);
172            let r = wantsdir.read_link_contents(STATUS_ONBOOT_UNIT)?;
173            let r: Utf8PathBuf = r.try_into().unwrap();
174            assert_eq!(r, format!("/usr/lib/systemd/system/{STATUS_ONBOOT_UNIT}"));
175            assert_eq!(wantsdir.entries()?.count(), n as usize);
176            anyhow::Ok(())
177        };
178
179        // Explicitly run this twice to test idempotency
180
181        unit_enablement_impl(sysroot, &unit_dir).unwrap();
182        unit_enablement_impl(sysroot, &unit_dir).unwrap();
183        let wantsdir = &unit_dir.open_dir("multi-user.target.wants")?;
184        verify(wantsdir, 2)?;
185        assert!(
186            wantsdir
187                .symlink_metadata_optional(CLEANUP_UNIT)
188                .unwrap()
189                .is_none()
190        );
191
192        // Now create sysroot and rerun the generator
193        unit_enablement_impl(sysroot, &unit_dir).unwrap();
194        verify(wantsdir, 2)?;
195
196        // Create the destructive stamp
197        sysroot
198            .create_dir_all(Utf8Path::new(DESTRUCTIVE_CLEANUP).parent().unwrap())
199            .unwrap();
200        sysroot.atomic_write(DESTRUCTIVE_CLEANUP, b"").unwrap();
201        unit_enablement_impl(sysroot, unit_dir).unwrap();
202        verify(wantsdir, 3)?;
203
204        // And now the unit should be enabled
205        assert!(
206            wantsdir
207                .symlink_metadata(CLEANUP_UNIT)
208                .unwrap()
209                .is_symlink()
210        );
211
212        Ok(())
213    }
214
215    #[cfg(test)]
216    mod test {
217        use super::*;
218
219        use ostree_ext::container_utils::OSTREE_BOOTED;
220
221        #[test]
222        fn test_generator_fstab() -> Result<()> {
223            let tempdir = fixture()?;
224            let unit_dir = &tempdir.open_dir("run/systemd/system")?;
225            // Should still be a no-op
226            tempdir.atomic_write("etc/fstab", "# Some dummy fstab")?;
227            fstab_generator_impl(&tempdir, &unit_dir).unwrap();
228            assert_eq!(unit_dir.entries()?.count(), 0);
229
230            // Also a no-op, not booted via ostree
231            tempdir.atomic_write("etc/fstab", &format!("# {FSTAB_ANACONDA_STAMP}"))?;
232            fstab_generator_impl(&tempdir, &unit_dir).unwrap();
233            assert_eq!(unit_dir.entries()?.count(), 0);
234
235            // Now it should generate
236            tempdir.atomic_write(OSTREE_BOOTED, "ostree booted")?;
237            fstab_generator_impl(&tempdir, &unit_dir).unwrap();
238            assert_eq!(unit_dir.entries()?.count(), 2);
239
240            Ok(())
241        }
242
243        #[test]
244        fn test_generator_fstab_idempotent() -> Result<()> {
245            let anaconda_fstab = indoc::indoc! { "
246#
247# /etc/fstab
248# Created by anaconda on Tue Mar 19 12:24:29 2024
249#
250# Accessible filesystems, by reference, are maintained under '/dev/disk/'.
251# See man pages fstab(5), findfs(8), mount(8) and/or blkid(8) for more info.
252#
253# After editing this file, run 'systemctl daemon-reload' to update systemd
254# units generated from this file.
255#
256# Updated by bootc-fstab-edit.service
257UUID=715be2b7-c458-49f2-acec-b2fdb53d9089 /                       xfs     ro              0 0
258UUID=341c4712-54e8-4839-8020-d94073b1dc8b /boot                   xfs     defaults        0 0
259" };
260            let tempdir = fixture()?;
261            let unit_dir = &tempdir.open_dir("run/systemd/system")?;
262
263            tempdir.atomic_write("etc/fstab", anaconda_fstab)?;
264            tempdir.atomic_write(OSTREE_BOOTED, "ostree booted")?;
265            let updated = fstab_generator_impl(&tempdir, &unit_dir).unwrap();
266            assert!(!updated);
267            assert_eq!(unit_dir.entries()?.count(), 0);
268
269            Ok(())
270        }
271    }
272}