bootc_lib/bootc_composefs/
gc.rs

1//! This module handles the case when deleting a deployment fails midway
2//!
3//! There could be the following cases (See ./delete.rs:delete_composefs_deployment):
4//! - We delete the bootloader entry but fail to delete image
5//! - We delete bootloader + image but fail to delete the state/unrefenced objects etc
6
7use anyhow::{Context, Result};
8use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt};
9use cfsctl::composefs;
10use cfsctl::composefs_boot;
11use composefs::repository::GcResult;
12use composefs_boot::bootloader::EFI_EXT;
13
14use crate::{
15    bootc_composefs::{
16        boot::{BOOTC_UKI_DIR, BootType, get_type1_dir_name, get_uki_addon_dir_name, get_uki_name},
17        delete::{delete_image, delete_staged, delete_state_dir},
18        status::{get_composefs_status, get_imginfo, list_bootloader_entries},
19    },
20    composefs_consts::{STATE_DIR_RELATIVE, TYPE1_BOOT_DIR_PREFIX, UKI_NAME_PREFIX},
21    store::{BootedComposefs, Storage},
22};
23
24#[fn_error_context::context("Listing EROFS images")]
25fn list_erofs_images(sysroot: &Dir) -> Result<Vec<String>> {
26    let images_dir = sysroot
27        .open_dir("composefs/images")
28        .context("Opening images dir")?;
29
30    let mut images = vec![];
31
32    for entry in images_dir.entries_utf8()? {
33        let entry = entry?;
34        let name = entry.file_name()?;
35        images.push(name);
36    }
37
38    Ok(images)
39}
40
41#[fn_error_context::context("Listing state directories")]
42fn list_state_dirs(sysroot: &Dir) -> Result<Vec<String>> {
43    let state = sysroot
44        .open_dir(STATE_DIR_RELATIVE)
45        .context("Opening state dir")?;
46
47    let mut dirs = vec![];
48
49    for dir in state.entries_utf8()? {
50        let dir = dir?;
51
52        if dir.file_type()?.is_file() {
53            continue;
54        }
55
56        dirs.push(dir.file_name()?);
57    }
58
59    Ok(dirs)
60}
61
62type BootBinary = (BootType, String);
63
64/// Collect all BLS Type1 boot binaries and UKI binaries by scanning filesystem
65///
66/// Returns a vector of binary type (UKI/Type1) + name of all boot binaries
67#[fn_error_context::context("Collecting boot binaries")]
68fn collect_boot_binaries(storage: &Storage) -> Result<Vec<BootBinary>> {
69    let mut boot_binaries = Vec::new();
70    let boot_dir = storage.bls_boot_binaries_dir()?;
71    let esp = storage.require_esp()?;
72
73    // Scan for UKI binaries in EFI/Linux/bootc
74    collect_uki_binaries(&esp.fd, &mut boot_binaries)?;
75
76    // Scan for Type1 boot binaries (kernels + initrds) in `boot_dir`
77    // depending upon whether systemd-boot is being used, or grub
78    collect_type1_boot_binaries(&boot_dir, &mut boot_binaries)?;
79
80    Ok(boot_binaries)
81}
82
83/// Scan for UKI binaries in EFI/Linux/bootc
84#[fn_error_context::context("Collecting UKI binaries")]
85fn collect_uki_binaries(boot_dir: &Dir, boot_binaries: &mut Vec<BootBinary>) -> Result<()> {
86    let Ok(Some(efi_dir)) = boot_dir.open_dir_optional(BOOTC_UKI_DIR) else {
87        return Ok(());
88    };
89
90    for entry in efi_dir.entries_utf8()? {
91        let entry = entry?;
92        let name = entry.file_name()?;
93
94        let Some(efi_name_no_prefix) = name.strip_prefix(UKI_NAME_PREFIX) else {
95            continue;
96        };
97
98        if let Some(verity) = efi_name_no_prefix.strip_suffix(EFI_EXT) {
99            boot_binaries.push((BootType::Uki, verity.into()));
100        }
101    }
102
103    Ok(())
104}
105
106/// Scan for Type1 boot binaries (kernels + initrds) by looking for directories with
107/// that start with bootc_composefs-
108///
109/// Strips the prefix and returns the rest of the string
110#[fn_error_context::context("Collecting Type1 boot binaries")]
111fn collect_type1_boot_binaries(boot_dir: &Dir, boot_binaries: &mut Vec<BootBinary>) -> Result<()> {
112    for entry in boot_dir.entries_utf8()? {
113        let entry = entry?;
114        let dir_name = entry.file_name()?;
115
116        if !entry.file_type()?.is_dir() {
117            continue;
118        }
119
120        let Some(verity) = dir_name.strip_prefix(TYPE1_BOOT_DIR_PREFIX) else {
121            continue;
122        };
123
124        // The directory name starts with our custom prefix
125        boot_binaries.push((BootType::Bls, verity.to_string()));
126    }
127
128    Ok(())
129}
130
131#[fn_error_context::context("Deleting kernel and initrd")]
132fn delete_kernel_initrd(storage: &Storage, dir_to_delete: &str, dry_run: bool) -> Result<()> {
133    tracing::debug!("Deleting Type1 entry {dir_to_delete}");
134
135    if dry_run {
136        return Ok(());
137    }
138
139    let boot_dir = storage.bls_boot_binaries_dir()?;
140
141    boot_dir
142        .remove_dir_all(dir_to_delete)
143        .with_context(|| anyhow::anyhow!("Deleting {dir_to_delete}"))
144}
145
146/// Deletes the UKI `uki_id` and any addons specific to it
147#[fn_error_context::context("Deleting UKI and UKI addons {uki_id}")]
148fn delete_uki(storage: &Storage, uki_id: &str, dry_run: bool) -> Result<()> {
149    let esp_mnt = storage.require_esp()?;
150
151    // NOTE: We don't delete global addons here
152    // Which is fine as global addons don't belong to any single deployment
153    let uki_dir = esp_mnt.fd.open_dir(BOOTC_UKI_DIR)?;
154
155    for entry in uki_dir.entries_utf8()? {
156        let entry = entry?;
157        let entry_name = entry.file_name()?;
158
159        // The actual UKI PE binary
160        if entry_name == get_uki_name(uki_id) {
161            tracing::debug!("Deleting UKI: {}", entry_name);
162
163            if dry_run {
164                continue;
165            }
166
167            entry.remove_file().context("Deleting UKI")?;
168        } else if entry_name == get_uki_addon_dir_name(uki_id) {
169            // Addons dir
170            tracing::debug!("Deleting UKI addons directory: {}", entry_name);
171
172            if dry_run {
173                continue;
174            }
175
176            uki_dir
177                .remove_dir_all(entry_name)
178                .context("Deleting UKI addons dir")?;
179        }
180    }
181
182    Ok(())
183}
184
185/// 1. List all bootloader entries
186/// 2. List all EROFS images
187/// 3. List all state directories
188/// 4. List staged depl if any
189///
190/// If bootloader entry B1 doesn't exist, but EROFS image B1 does exist, then delete the image and
191/// perform GC
192///
193/// Similarly if EROFS image B1 doesn't exist, but state dir does, then delete the state dir and
194/// perform GC
195//
196// Cases
197// - BLS Entries
198//      - On upgrade/switch, if only two are left, the staged and the current, then no GC
199//          - If there are three - rollback, booted and staged, GC the rollback, so the current
200//          becomes rollback
201#[fn_error_context::context("Running composefs garbage collection")]
202pub(crate) async fn composefs_gc(
203    storage: &Storage,
204    booted_cfs: &BootedComposefs,
205    dry_run: bool,
206) -> Result<GcResult> {
207    const COMPOSEFS_GC_JOURNAL_ID: &str = "3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e7";
208
209    tracing::info!(
210        message_id = COMPOSEFS_GC_JOURNAL_ID,
211        bootc.operation = "gc",
212        bootc.current_deployment = booted_cfs.cmdline.digest,
213        "Starting composefs garbage collection"
214    );
215
216    let host = get_composefs_status(storage, booted_cfs).await?;
217    let booted_cfs_status = host.require_composefs_booted()?;
218
219    let sysroot = &storage.physical_root;
220
221    let bootloader_entries = list_bootloader_entries(storage)?;
222    let boot_binaries = collect_boot_binaries(storage)?;
223
224    tracing::debug!("bootloader_entries: {bootloader_entries:?}");
225    tracing::debug!("boot_binaries: {boot_binaries:?}");
226
227    // Bootloader entry is deleted, but the binary (UKI/kernel+initrd) still exists
228    let unreferenced_boot_binaries = boot_binaries
229        .iter()
230        .filter(|bin_path| {
231            // We reuse kernel + initrd if they're the same for two deployments
232            // We don't want to delete the (being deleted) deployment's kernel + initrd
233            // if it's in use by any other deployment
234            //
235            // filter the ones that are not referenced by any bootloader entry
236            !bootloader_entries
237                .iter()
238                // We compare the name of directory containing the binary instead of comparing the
239                // fsverity digest. This is because a shared entry might differing directory
240                // name and fsverity digest in the cmdline. And since we want to GC the actual
241                // binaries, we compare with the directory name
242                .any(|boot_entry| boot_entry.boot_artifact_name == bin_path.1)
243        })
244        .collect::<Vec<_>>();
245
246    tracing::debug!("unreferenced_boot_binaries: {unreferenced_boot_binaries:?}");
247
248    if unreferenced_boot_binaries
249        .iter()
250        .find(|be| be.1 == booted_cfs_status.verity)
251        .is_some()
252    {
253        anyhow::bail!(
254            "Inconsistent state. Booted binaries '{}' found for cleanup",
255            booted_cfs_status.verity
256        )
257    }
258
259    for (ty, verity) in unreferenced_boot_binaries {
260        match ty {
261            BootType::Bls => delete_kernel_initrd(storage, &get_type1_dir_name(verity), dry_run)?,
262            BootType::Uki => delete_uki(storage, verity, dry_run)?,
263        }
264    }
265
266    let images = list_erofs_images(&sysroot)?;
267
268    // Collect the deployments that have an image but no bootloader entry
269    // and vice versa
270    //
271    // Images without corresponding bootloader entries
272    let orphaned_images: Vec<&String> = images
273        .iter()
274        .filter(|image| {
275            !bootloader_entries
276                .iter()
277                .any(|entry| &entry.fsverity == *image)
278        })
279        .collect();
280
281    // Bootloader entries without corresponding images
282    let orphaned_bootloader_entries: Vec<&String> = bootloader_entries
283        .iter()
284        .map(|entry| &entry.fsverity)
285        .filter(|verity| !images.contains(verity))
286        .collect();
287
288    let img_bootloader_diff: Vec<&String> = orphaned_images
289        .into_iter()
290        .chain(orphaned_bootloader_entries)
291        .collect();
292
293    tracing::debug!("img_bootloader_diff: {img_bootloader_diff:#?}");
294
295    let staged = &host.status.staged;
296
297    if img_bootloader_diff.contains(&&booted_cfs_status.verity) {
298        anyhow::bail!(
299            "Inconsistent state. Booted entry '{}' found for cleanup",
300            booted_cfs_status.verity
301        )
302    }
303
304    for verity in &img_bootloader_diff {
305        tracing::debug!("Cleaning up orphaned image: {verity}");
306
307        delete_staged(staged, &img_bootloader_diff, dry_run)?;
308        delete_image(&sysroot, verity, dry_run)?;
309        delete_state_dir(&sysroot, verity, dry_run)?;
310    }
311
312    let state_dirs = list_state_dirs(&sysroot)?;
313
314    // Collect all the deployments that have no image but have a state dir
315    // This for the case where the gc was interrupted after deleting the image
316    let state_img_diff = state_dirs
317        .iter()
318        .filter(|s| !images.contains(s))
319        .collect::<Vec<_>>();
320
321    for verity in &state_img_diff {
322        delete_staged(staged, &state_img_diff, dry_run)?;
323        delete_state_dir(&sysroot, verity, dry_run)?;
324    }
325
326    // Now we GC the unrefenced objects in composefs repo
327    let mut additional_roots = vec![];
328
329    for deployment in host.list_deployments() {
330        let verity = &deployment.require_composefs()?.verity;
331
332        // These need to be GC'd
333        if img_bootloader_diff.contains(&verity) || state_img_diff.contains(&verity) {
334            continue;
335        }
336
337        let image = get_imginfo(storage, verity, None).await?;
338        let stream = format!("oci-config-{}", image.manifest.config().digest());
339
340        additional_roots.push(verity.clone());
341        additional_roots.push(stream);
342    }
343
344    let additional_roots = additional_roots
345        .iter()
346        .map(|x| x.as_str())
347        .collect::<Vec<_>>();
348
349    // Run garbage collection on objects after deleting images
350    let gc_result = if dry_run {
351        booted_cfs.repo.gc_dry_run(&additional_roots)?
352    } else {
353        booted_cfs.repo.gc(&additional_roots)?
354    };
355
356    Ok(gc_result)
357}