Skip to main content

bootc_lib/bootc_composefs/
finalize.rs

1use std::{path::Path, process::Command};
2
3use crate::bootc_composefs::boot::{GLOBAL_UKI_ADDONS_DIR, STAGED_GLOBAL_UKI_ADDONS_DIR};
4use crate::bootc_composefs::gc::{GCOpts, composefs_gc};
5use crate::bootc_composefs::rollback::{rename_exchange_bls_entries, rename_exchange_user_cfg};
6use crate::bootc_composefs::status::get_composefs_status;
7use crate::bootc_composefs::{aboot, boot::BootType};
8use crate::composefs_consts::STATE_DIR_ABS;
9use crate::install::BOOT;
10use crate::spec::BootloaderKind;
11use crate::store::{BootedComposefs, Storage};
12use anyhow::{Context, Result};
13use bootc_initramfs_setup::mount_composefs_image;
14use bootc_mount::tempmount::TempMount;
15use cap_std_ext::cap_std::{ambient_authority, fs::Dir};
16use cap_std_ext::dirext::CapStdExtDirExt;
17use composefs::generic_tree::{FileSystem, Stat};
18use composefs_ctl::composefs;
19use etc_merge::{Diff, compute_diff, merge, traverse_etc};
20use rustix::fs::{Mode, OFlags, RenameFlags, fsync, renameat, renameat_with};
21
22use fn_error_context::context;
23
24pub(crate) async fn get_etc_diff(
25    storage: &Storage,
26    booted_cfs: &BootedComposefs,
27    new_etc: Option<&Dir>,
28) -> Result<Diff> {
29    let host = get_composefs_status(storage, booted_cfs).await?;
30    let booted_composefs = host.require_composefs_booted()?;
31
32    // Mount the booted EROFS image to get pristine etc
33    let sysroot_fd = storage.physical_root.reopen_as_ownedfd()?;
34    let composefs_fd = mount_composefs_image(
35        &sysroot_fd,
36        &booted_composefs.verity,
37        booted_cfs.cmdline.allow_missing_fsverity,
38    )?;
39
40    let erofs_tmp_mnt = TempMount::mount_fd(&composefs_fd)?;
41
42    let pristine_etc =
43        Dir::open_ambient_dir(erofs_tmp_mnt.dir.path().join("etc"), ambient_authority())?;
44    let current_etc = Dir::open_ambient_dir("/etc", ambient_authority())?;
45
46    let (pristine_files, current_files, new_etc_files) =
47        traverse_etc(&pristine_etc, &current_etc, new_etc)?;
48
49    let diff = compute_diff(
50        &pristine_files,
51        &current_files,
52        &new_etc_files
53            .as_ref()
54            .unwrap_or(&FileSystem::new(Stat::uninitialized())),
55    )?;
56
57    Ok(diff)
58}
59
60pub(crate) async fn composefs_backend_finalize(
61    storage: &Storage,
62    booted_cfs: &BootedComposefs,
63) -> Result<()> {
64    const COMPOSEFS_FINALIZE_JOURNAL_ID: &str = "0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4";
65
66    tracing::info!(
67        message_id = COMPOSEFS_FINALIZE_JOURNAL_ID,
68        bootc.operation = "finalize",
69        bootc.current_deployment = booted_cfs.cmdline.digest,
70        "Starting composefs staged deployment finalization"
71    );
72
73    let host = get_composefs_status(storage, booted_cfs).await?;
74
75    let booted_composefs = host.require_composefs_booted()?;
76
77    let Some(staged_depl) = host.status.staged.as_ref() else {
78        tracing::info!(
79            message_id = COMPOSEFS_FINALIZE_JOURNAL_ID,
80            bootc.operation = "finalize",
81            "No staged deployment found"
82        );
83        return Ok(());
84    };
85
86    if staged_depl.download_only {
87        tracing::info!(
88            message_id = COMPOSEFS_FINALIZE_JOURNAL_ID,
89            bootc.operation = "finalize",
90            bootc.download_only = "true",
91            "Staged deployment is marked download only. Won't finalize"
92        );
93        return Ok(());
94    }
95
96    let staged_composefs = staged_depl.composefs.as_ref().ok_or(anyhow::anyhow!(
97        "Staged deployment is not a composefs deployment"
98    ))?;
99
100    let mut aboot_state = (staged_composefs.boot_type == BootType::Aboot)
101        .then(|| aboot::AbootState::open(&storage.physical_root))
102        .transpose()?;
103    if let Some(state) = aboot_state.as_ref()
104        && state.read_attempted()?.is_some()
105    {
106        tracing::warn!("Aboot deployment was already attempted; not flashing it again");
107        return Ok(());
108    }
109
110    // Mount the booted EROFS image to get pristine etc
111    let sysroot_fd = storage.physical_root.reopen_as_ownedfd()?;
112    let composefs_fd = mount_composefs_image(
113        &sysroot_fd,
114        &booted_composefs.verity,
115        booted_cfs.cmdline.allow_missing_fsverity,
116    )?;
117
118    let erofs_tmp_mnt = TempMount::mount_fd(&composefs_fd)?;
119
120    // Perform the /etc merge
121    let pristine_etc =
122        Dir::open_ambient_dir(erofs_tmp_mnt.dir.path().join("etc"), ambient_authority())?;
123    let current_etc = Dir::open_ambient_dir("/etc", ambient_authority())?;
124
125    let new_etc_path = Path::new(STATE_DIR_ABS)
126        .join(&staged_composefs.verity)
127        .join("etc");
128
129    let new_etc = Dir::open_ambient_dir(new_etc_path, ambient_authority())?;
130
131    let (pristine_files, current_files, new_files) =
132        traverse_etc(&pristine_etc, &current_etc, Some(&new_etc))?;
133
134    let new_files =
135        new_files.ok_or_else(|| anyhow::anyhow!("Failed to get dirtree for new etc"))?;
136
137    let diff = compute_diff(&pristine_files, &current_files, &new_files)?;
138    merge(&current_etc, &current_files, &new_etc, &new_files, &diff)?;
139
140    // Remove /etc/.updated from the new deployment so that ConditionNeedsUpdate=|/etc
141    // services (systemd-sysusers, systemd-tmpfiles) run on the first boot, mirroring
142    // what ostree does in sysroot_finalize_deployment.
143    new_etc
144        .remove_file_optional(".updated")
145        .context("Removing /etc/.updated from staged deployment")?;
146
147    // Unmount EROFS
148    drop(erofs_tmp_mnt);
149
150    if staged_composefs.boot_type == BootType::Aboot {
151        return finalize_staged_aboot(
152            storage,
153            aboot_state.as_mut().unwrap(),
154            &staged_composefs.verity,
155        );
156    }
157
158    let boot_mount = storage.require_boot_mount()?;
159
160    // Un-stage Global UKI Addons (if present) as the penultimate step
161    if boot_mount.fd.exists(STAGED_GLOBAL_UKI_ADDONS_DIR) {
162        if boot_mount.fd.exists(GLOBAL_UKI_ADDONS_DIR) {
163            tracing::debug!(
164                "Atomically exchanging {STAGED_GLOBAL_UKI_ADDONS_DIR} and {GLOBAL_UKI_ADDONS_DIR}"
165            );
166
167            renameat_with(
168                &boot_mount.fd,
169                STAGED_GLOBAL_UKI_ADDONS_DIR,
170                &boot_mount.fd,
171                GLOBAL_UKI_ADDONS_DIR,
172                RenameFlags::EXCHANGE,
173            )
174            .context("Atomically exchanging staged Global UKI Addons")?;
175
176            boot_mount
177                .fd
178                .remove_dir_all(STAGED_GLOBAL_UKI_ADDONS_DIR)
179                .context("Removing staged Global UKI Addons dir")?;
180        } else {
181            tracing::debug!(
182                "No global addons in the current deployment. Finalizing global UKI Addons"
183            );
184
185            renameat(
186                &boot_mount.fd,
187                STAGED_GLOBAL_UKI_ADDONS_DIR,
188                &boot_mount.fd,
189                GLOBAL_UKI_ADDONS_DIR,
190            )
191            .context("Finalizing global UKI Addons")?;
192        }
193    }
194
195    let boot_dir = storage.require_boot_dir()?;
196
197    match booted_composefs.bootloader.kind()? {
198        BootloaderKind::GRUBClassic => match staged_composefs.boot_type {
199            BootType::Bls => {
200                let entries_dir = boot_dir.open_dir("loader")?;
201                rename_exchange_bls_entries(&entries_dir)?;
202            }
203            BootType::Uki => finalize_staged_grub_uki(boot_dir)?,
204            BootType::Aboot => unreachable!(),
205        },
206
207        BootloaderKind::BLSCompatible => {
208            let entries_dir = boot_dir.open_dir("loader")?;
209            rename_exchange_bls_entries(&entries_dir)?;
210        }
211    };
212
213    // Now that we have successfully updated bootloader entries, we can GC the unreferenced ones
214    // We do not prune the composefs repository here though
215    composefs_gc(
216        storage,
217        booted_cfs,
218        GCOpts {
219            dry_run: false,
220            prune_repo: false,
221        },
222    )
223    .await?;
224
225    Ok(())
226}
227
228/// Keep /boot open until we're killed, which systemd does with SIGTERM
229/// when `bootc-finalize-staged-hold.service` is stopped after
230/// `bootc-finalize-staged.service`.
231///
232/// When /boot is an automount (e.g. the ESP set up by
233/// systemd-gpt-auto-generator), an idle expire breaks finalization in two
234/// ways. If it races with shutdown, it deadlocks: the finalization (which
235/// looks up /boot) blocks on the expire, while the unmount is ordered after
236/// the finalization. If it completes before shutdown, systemd won't remount
237/// /boot once shutdown has begun, so finalization fails to open it (EHOSTDOWN)
238/// and the old deployment boots. An open file descriptor makes autofs treat
239/// the mount as busy, so it never expires. Note this only works from the
240/// root mount namespace.
241pub(crate) fn hold_boot() -> Result<()> {
242    let path = format!("/{BOOT}");
243    let _fd = rustix::fs::open(
244        path.as_str(),
245        OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
246        Mode::empty(),
247    )
248    .with_context(|| format!("Opening {path}"))?;
249    tracing::debug!("Holding {path} open until terminated");
250    loop {
251        std::thread::park();
252    }
253}
254
255#[context("Finalizing staged aboot deployment {deployment}")]
256fn finalize_staged_aboot(
257    storage: &Storage,
258    state: &mut aboot::AbootState<'_>,
259    deployment: &str,
260) -> Result<()> {
261    let pending = state
262        .read_pending()?
263        .ok_or_else(|| anyhow::anyhow!("No pending aboot deployment"))?;
264    anyhow::ensure!(
265        pending.depl_id == deployment,
266        "Pending aboot deployment changed"
267    );
268    anyhow::ensure!(
269        !pending.finalization_locked,
270        "Pending aboot deployment is download-only"
271    );
272    let artifacts = state.verify_artifacts(&storage.physical_root_path, &pending)?;
273    let kernel_cmdline =
274        std::fs::read_to_string("/proc/cmdline").context("Reading kernel command line")?;
275    let booted_slot = aboot::Slot::from_cmdline(&linux_kernel_cmdline::utf8::Cmdline::from(
276        kernel_cmdline.as_str(),
277    ));
278
279    state.record_attempt(deployment)?;
280    if let Some(slot) = booted_slot {
281        state.invalidate_other(slot)?;
282    }
283
284    let mut command = Command::new("aboot-deploy");
285    command.args(["--local", "--boot-image"]);
286    command.arg(artifacts.boot);
287    if let Some(vbmeta) = artifacts.vbmeta {
288        command.arg("--vbmeta-image").arg(vbmeta);
289    }
290    let status = command.status().context("Running aboot-deploy")?;
291    anyhow::ensure!(status.success(), "aboot-deploy exited with status {status}");
292    Ok(())
293}
294
295#[context("Grub: Finalizing staged UKI")]
296fn finalize_staged_grub_uki(boot_fd: &Dir) -> Result<()> {
297    let entries_dir = boot_fd.open_dir("grub2")?;
298    rename_exchange_user_cfg(&entries_dir)?;
299
300    let entries_dir = entries_dir.reopen_as_ownedfd()?;
301    fsync(entries_dir).context("fsync")?;
302
303    Ok(())
304}