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