Skip to main content

bootc_lib/
generator.rs

1use std::io::BufRead;
2
3use anyhow::{Context, Result};
4use camino::{Utf8Path, 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 ostree_ext::{gio, ostree};
10use rustix::{fd::AsFd, fs::StatVfsMountFlags};
11
12use crate::install::DESTRUCTIVE_CLEANUP;
13
14const STATUS_ONBOOT_UNIT: &str = "bootc-status-updated-onboot.target";
15const STATUS_PATH_UNIT: &str = "bootc-status-updated.path";
16const CLEANUP_UNIT: &str = "bootc-destructive-cleanup.service";
17const MULTI_USER_TARGET: &str = "multi-user.target";
18const EDIT_UNIT: &str = "bootc-fstab-edit.service";
19const FSTAB_ANACONDA_STAMP: &str = "Created by anaconda";
20pub(crate) const BOOTC_EDITED_STAMP: &str = "Updated by bootc-fstab-edit.service";
21const TRANSIENT_RELABEL_UNIT: &str = "bootc-early-overlay-relabel.service";
22const SYSINIT_TARGET: &str = "sysinit.target";
23
24/// Called when the root is read-only composefs to reconcile /etc/fstab
25#[context("bootc generator")]
26pub(crate) fn fstab_generator_impl(root: &Dir, unit_dir: &Dir) -> Result<bool> {
27    // Do nothing if not ostree-booted
28    if !is_ostree_booted_in(root)? {
29        return Ok(false);
30    }
31
32    if let Some(fd) = root
33        .open_optional("etc/fstab")
34        .context("Opening /etc/fstab")?
35        .map(std::io::BufReader::new)
36    {
37        let mut from_anaconda = false;
38        for line in fd.lines() {
39            let line = line.context("Reading /etc/fstab")?;
40            if line.contains(BOOTC_EDITED_STAMP) {
41                // We're done
42                return Ok(false);
43            }
44            if line.contains(FSTAB_ANACONDA_STAMP) {
45                from_anaconda = true;
46            }
47        }
48        if !from_anaconda {
49            return Ok(false);
50        }
51        tracing::debug!("/etc/fstab from anaconda: {from_anaconda}");
52        if from_anaconda {
53            generate_fstab_editor(unit_dir)?;
54            return Ok(true);
55        }
56    }
57    Ok(false)
58}
59
60pub(crate) fn enable_unit(unitdir: &Dir, name: &str, target: &str) -> Result<()> {
61    let wants = Utf8PathBuf::from(format!("{target}.wants"));
62    unitdir
63        .create_dir_all(&wants)
64        .with_context(|| format!("Creating {wants}"))?;
65    let source = format!("/usr/lib/systemd/system/{name}");
66    let target = wants.join(name);
67    unitdir.remove_file_optional(&target)?;
68    unitdir
69        .symlink_contents(&source, &target)
70        .with_context(|| format!("Writing {name}"))?;
71    Ok(())
72}
73
74/// Enable our units
75pub(crate) fn unit_enablement_impl(sysroot: &Dir, unit_dir: &Dir) -> Result<()> {
76    for unit in [STATUS_ONBOOT_UNIT, STATUS_PATH_UNIT] {
77        enable_unit(unit_dir, unit, MULTI_USER_TARGET)?;
78    }
79
80    if sysroot.try_exists(DESTRUCTIVE_CLEANUP)? {
81        tracing::debug!("Found {DESTRUCTIVE_CLEANUP}");
82        enable_unit(unit_dir, CLEANUP_UNIT, MULTI_USER_TARGET)?;
83    } else {
84        tracing::debug!("Didn't find {DESTRUCTIVE_CLEANUP}");
85    }
86
87    Ok(())
88}
89
90/// Main entrypoint for the generator
91pub(crate) fn generator(root: &Dir, unit_dir: &Dir) -> Result<()> {
92    // === Relabel unit: runs for ALL composefs boots (native or ostree) ===
93    // Must be before the ostree-booted guard because native composefs boots do
94    // not write /run/ostree-booted, but still need the relabel unit when any
95    // transient overlay is active.
96    //
97    // Gate on the root being overlayfs (composefs always mounts an overlay, so
98    // this excludes non-composefs systems without needing the ostree-booted marker).
99    //
100    // Two triggering conditions, detected independently:
101    //
102    // 1. Transient root: the initramfs sets the overlay source to
103    //    "transient:composefs=<digest>" in /proc/self/mountinfo.  Detect via
104    //    inspect_filesystem() rather than fstatvfs() because the `ro` kernel
105    //    cmdline flag can make an otherwise-writable overlay appear read-only
106    //    at generator time.
107    //
108    // 2. Transient /etc: this is mounted by bootc-root-setup.service
109    //    which runs *after* the generator, so fstatvfs would see the read-only
110    //    composefs at generator time.  Read setup-root-conf.toml directly from
111    //    the booted image instead.
112    {
113        let st = rustix::fs::fstatfs(root.as_fd())?;
114        if st.f_type == libc::OVERLAYFS_SUPER_MAGIC {
115            let root_is_transient =
116                match bootc_mount::inspect_filesystem(camino::Utf8Path::new("/")) {
117                    Ok(fs) => fs.source.starts_with("transient:composefs="),
118                    Err(e) => {
119                        tracing::debug!("Could not inspect root filesystem: {e:#}");
120                        false
121                    }
122                };
123            let submounts_are_transient = bootc_initramfs_setup::config_has_transient_submounts(
124                std::path::Path::new(bootc_initramfs_setup::SETUP_ROOT_CONF_PATH),
125            );
126            if root_is_transient || submounts_are_transient {
127                tracing::debug!(
128                    root_is_transient,
129                    submounts_are_transient,
130                    "Transient overlay detected; generating relabel unit"
131                );
132                generate_transient_overlay_relabel(unit_dir)?;
133            }
134        }
135    }
136
137    // === Ostree-specific generator logic ===
138    // Only run on ostree systems (native composefs boots skip below).
139    if !root.try_exists(OSTREE_BOOTED)? {
140        return Ok(());
141    }
142
143    let Some(ref sysroot) = root.open_dir_optional("sysroot")? else {
144        return Ok(());
145    };
146
147    unit_enablement_impl(sysroot, unit_dir)?;
148
149    // Only run for overlayfs roots (composefs mounts an overlay, regular or transient).
150    let st = rustix::fs::fstatfs(root.as_fd())?;
151    if st.f_type != libc::OVERLAYFS_SUPER_MAGIC {
152        tracing::trace!("Root is not overlayfs");
153        return Ok(());
154    }
155
156    // The fstab editor only applies to read-only composefs roots (not transient).
157    let st = rustix::fs::fstatvfs(root.as_fd())?;
158    if !st.f_flag.contains(StatVfsMountFlags::RDONLY) {
159        tracing::trace!("Root is writable, skipping fstab generator");
160        return Ok(());
161    }
162
163    let updated = fstab_generator_impl(root, unit_dir)?;
164    tracing::trace!("Generated fstab: {updated}");
165
166    Ok(())
167}
168
169/// Parse /etc/fstab and check if the root mount is out of sync with the composefs
170/// state, and if so, fix it.
171fn generate_fstab_editor(unit_dir: &Dir) -> Result<()> {
172    unit_dir.atomic_write(
173        EDIT_UNIT,
174        "[Unit]\n\
175DefaultDependencies=no\n\
176After=systemd-fsck-root.service\n\
177Before=local-fs-pre.target local-fs.target shutdown.target systemd-remount-fs.service\n\
178\n\
179[Service]\n\
180Type=oneshot\n\
181RemainAfterExit=yes\n\
182ExecStart=bootc internals fixup-etc-fstab\n\
183",
184    )?;
185    let target = "local-fs-pre.target.wants";
186    unit_dir.create_dir_all(target)?;
187    unit_dir.symlink(&format!("../{EDIT_UNIT}"), &format!("{target}/{EDIT_UNIT}"))?;
188    Ok(())
189}
190
191/// Generate a oneshot service that relabels the transient overlay inode
192/// after SELinux policy loads, fixing the tmpfs_t label SELinux assigns to
193/// overlay upper-dir inodes at policy-load time.
194fn generate_transient_overlay_relabel(unit_dir: &Dir) -> Result<()> {
195    unit_dir.atomic_write(
196        TRANSIENT_RELABEL_UNIT,
197        include_str!("units/bootc-early-overlay-relabel.service"),
198    )?;
199    let wants = format!("{SYSINIT_TARGET}.wants");
200    unit_dir.create_dir_all(&wants)?;
201    unit_dir.symlink(
202        &format!("../{TRANSIENT_RELABEL_UNIT}"),
203        &format!("{wants}/{TRANSIENT_RELABEL_UNIT}"),
204    )?;
205    Ok(())
206}
207
208/// Relabel transient overlay mount point inodes using the running SELinux policy.
209/// Called by the generated bootc-early-overlay-relabel.service oneshot to fix
210/// the tmpfs_t label that fs_use_trans assigns to overlay upper-dir inodes at
211/// policy-load time.  Each of /, /etc, /var is relabelled iff it is a writable
212/// overlayfs (i.e. a transient overlay, not the read-only composefs).
213pub(crate) fn relabel_overlay_mountpoints() -> Result<()> {
214    let policy = ostree::SePolicy::new(&gio::File::for_path("/"), gio::Cancellable::NONE)
215        .context("Loading SELinux policy")?;
216    for path in ["/", "/etc", "/var"] {
217        let dir = Dir::open_ambient_dir(path, cap_std::ambient_authority())
218            .with_context(|| format!("Opening {path}"))?;
219        let st = rustix::fs::fstatfs(dir.as_fd())?;
220        if st.f_type != libc::OVERLAYFS_SUPER_MAGIC {
221            tracing::trace!("{path} is not an overlayfs mount, skipping relabel");
222            continue;
223        }
224        let stv = rustix::fs::fstatvfs(dir.as_fd())?;
225        if stv.f_flag.contains(StatVfsMountFlags::RDONLY) {
226            tracing::trace!("{path} is a read-only overlayfs (composefs), skipping relabel");
227            continue;
228        }
229        let metadata = dir.metadata(".").with_context(|| format!("stat {path}"))?;
230        crate::lsm::relabel(
231            &dir,
232            &metadata,
233            Utf8Path::new("."),
234            Some(Utf8Path::new(path)),
235            &policy,
236        )
237        .with_context(|| format!("Relabelling {path}"))?;
238        tracing::debug!("Relabelled {path}");
239    }
240    Ok(())
241}
242
243#[cfg(test)]
244mod tests {
245    use camino::Utf8Path;
246
247    use super::*;
248
249    fn fixture() -> Result<cap_std_ext::cap_tempfile::TempDir> {
250        let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
251        tempdir.create_dir("etc")?;
252        tempdir.create_dir("run")?;
253        tempdir.create_dir("sysroot")?;
254        tempdir.create_dir_all("run/systemd/system")?;
255        Ok(tempdir)
256    }
257
258    #[test]
259    fn test_generator_no_fstab() -> Result<()> {
260        let tempdir = fixture()?;
261        let unit_dir = &tempdir.open_dir("run/systemd/system")?;
262        fstab_generator_impl(&tempdir, &unit_dir).unwrap();
263
264        assert_eq!(unit_dir.entries()?.count(), 0);
265        Ok(())
266    }
267
268    #[test]
269    fn test_units() -> Result<()> {
270        let tempdir = &fixture()?;
271        let sysroot = &tempdir.open_dir("sysroot").unwrap();
272        let unit_dir = &tempdir.open_dir("run/systemd/system")?;
273
274        let verify = |wantsdir: &Dir, n: u32| -> Result<()> {
275            assert_eq!(unit_dir.entries()?.count(), 1);
276            let r = wantsdir.read_link_contents(STATUS_ONBOOT_UNIT)?;
277            let r: Utf8PathBuf = r.try_into().unwrap();
278            assert_eq!(r, format!("/usr/lib/systemd/system/{STATUS_ONBOOT_UNIT}"));
279            assert_eq!(wantsdir.entries()?.count(), n as usize);
280            anyhow::Ok(())
281        };
282
283        // Explicitly run this twice to test idempotency
284
285        unit_enablement_impl(sysroot, &unit_dir).unwrap();
286        unit_enablement_impl(sysroot, &unit_dir).unwrap();
287        let wantsdir = &unit_dir.open_dir("multi-user.target.wants")?;
288        verify(wantsdir, 2)?;
289        assert!(
290            wantsdir
291                .symlink_metadata_optional(CLEANUP_UNIT)
292                .unwrap()
293                .is_none()
294        );
295
296        // Now create sysroot and rerun the generator
297        unit_enablement_impl(sysroot, &unit_dir).unwrap();
298        verify(wantsdir, 2)?;
299
300        // Create the destructive stamp
301        sysroot
302            .create_dir_all(Utf8Path::new(DESTRUCTIVE_CLEANUP).parent().unwrap())
303            .unwrap();
304        sysroot.atomic_write(DESTRUCTIVE_CLEANUP, b"").unwrap();
305        unit_enablement_impl(sysroot, unit_dir).unwrap();
306        verify(wantsdir, 3)?;
307
308        // And now the unit should be enabled
309        assert!(
310            wantsdir
311                .symlink_metadata(CLEANUP_UNIT)
312                .unwrap()
313                .is_symlink()
314        );
315
316        Ok(())
317    }
318
319    #[cfg(test)]
320    mod test {
321        use super::*;
322
323        use ostree_ext::container_utils::OSTREE_BOOTED;
324
325        #[test]
326        fn test_generator_fstab() -> Result<()> {
327            let tempdir = fixture()?;
328            let unit_dir = &tempdir.open_dir("run/systemd/system")?;
329            // Should still be a no-op
330            tempdir.atomic_write("etc/fstab", "# Some dummy fstab")?;
331            fstab_generator_impl(&tempdir, &unit_dir).unwrap();
332            assert_eq!(unit_dir.entries()?.count(), 0);
333
334            // Also a no-op, not booted via ostree
335            tempdir.atomic_write("etc/fstab", &format!("# {FSTAB_ANACONDA_STAMP}"))?;
336            fstab_generator_impl(&tempdir, &unit_dir).unwrap();
337            assert_eq!(unit_dir.entries()?.count(), 0);
338
339            // Now it should generate
340            tempdir.atomic_write(OSTREE_BOOTED, "ostree booted")?;
341            fstab_generator_impl(&tempdir, &unit_dir).unwrap();
342            assert_eq!(unit_dir.entries()?.count(), 2);
343
344            Ok(())
345        }
346
347        #[test]
348        fn test_transient_overlay_relabel_generated() -> Result<()> {
349            let tempdir = fixture()?;
350            let unit_dir = &tempdir.open_dir("run/systemd/system")?;
351
352            // We can't fake fstatfs or findmnt, so call generate_transient_overlay_relabel directly.
353            generate_transient_overlay_relabel(unit_dir)?;
354
355            // The unit file must exist
356            assert!(unit_dir.try_exists(TRANSIENT_RELABEL_UNIT)?);
357            // The symlink in sysinit.target.wants must point at the generated unit
358            let wants = format!("{SYSINIT_TARGET}.wants");
359            let link = unit_dir.read_link_contents(format!("{wants}/{TRANSIENT_RELABEL_UNIT}"))?;
360            let link: camino::Utf8PathBuf = link.try_into().unwrap();
361            assert_eq!(link, format!("../{TRANSIENT_RELABEL_UNIT}"));
362            // The unit must invoke bootc internals relabel-overlay-mountpoints
363            let content = unit_dir.read_to_string(TRANSIENT_RELABEL_UNIT)?;
364            assert!(
365                content.contains("ExecStart=bootc internals relabel-overlay-mountpoints"),
366                "unexpected unit content: {content}"
367            );
368
369            Ok(())
370        }
371
372        #[test]
373        fn test_transient_overlay_relabel_idempotent() -> Result<()> {
374            let tempdir = fixture()?;
375            let unit_dir = &tempdir.open_dir("run/systemd/system")?;
376
377            // Calling generate_transient_overlay_relabel twice must succeed
378            generate_transient_overlay_relabel(unit_dir)?;
379            // Second call: atomic_write overwrites the unit file; symlink already exists
380            // (symlink won't be re-created because the dir already contains it).
381            // The test just checks the call doesn't error.
382            // We need to remove the old symlink first (same as how enable_unit does it).
383            let wants = format!("{SYSINIT_TARGET}.wants");
384            unit_dir.remove_file_optional(format!("{wants}/{TRANSIENT_RELABEL_UNIT}"))?;
385            generate_transient_overlay_relabel(unit_dir)?;
386
387            assert!(unit_dir.try_exists(TRANSIENT_RELABEL_UNIT)?);
388
389            Ok(())
390        }
391
392        #[test]
393        fn test_generator_fstab_idempotent() -> Result<()> {
394            let anaconda_fstab = indoc::indoc! { "
395#
396# /etc/fstab
397# Created by anaconda on Tue Mar 19 12:24:29 2024
398#
399# Accessible filesystems, by reference, are maintained under '/dev/disk/'.
400# See man pages fstab(5), findfs(8), mount(8) and/or blkid(8) for more info.
401#
402# After editing this file, run 'systemctl daemon-reload' to update systemd
403# units generated from this file.
404#
405# Updated by bootc-fstab-edit.service
406UUID=715be2b7-c458-49f2-acec-b2fdb53d9089 /                       xfs     ro              0 0
407UUID=341c4712-54e8-4839-8020-d94073b1dc8b /boot                   xfs     defaults        0 0
408" };
409            let tempdir = fixture()?;
410            let unit_dir = &tempdir.open_dir("run/systemd/system")?;
411
412            tempdir.atomic_write("etc/fstab", anaconda_fstab)?;
413            tempdir.atomic_write(OSTREE_BOOTED, "ostree booted")?;
414            let updated = fstab_generator_impl(&tempdir, &unit_dir).unwrap();
415            assert!(!updated);
416            assert_eq!(unit_dir.entries()?.count(), 0);
417
418            Ok(())
419        }
420    }
421}