Skip to main content

bootc_lib/bootc_composefs/
rollback.rs

1use std::{io::Write, process::Command};
2
3use anyhow::{Context, Result, anyhow};
4use cap_std_ext::cap_std::fs::Dir;
5use cap_std_ext::dirext::CapStdExtDirExt;
6use fn_error_context::context;
7use ocidir::cap_std::ambient_authority;
8use rustix::fs::{AtFlags, RenameFlags, fsync, renameat_with};
9
10use crate::bootc_composefs::aboot;
11use crate::bootc_composefs::boot::{
12    BootType, FILENAME_PRIORITY_PRIMARY, FILENAME_PRIORITY_SECONDARY, STAGED_GLOBAL_UKI_ADDONS_DIR,
13    os_id_from_sort_key, primary_sort_key, secondary_sort_key, type1_entry_conf_file_name,
14};
15use crate::bootc_composefs::status::{get_composefs_status, get_sorted_type1_boot_entries};
16use crate::composefs_consts::{
17    COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, TYPE1_ENT_PATH_STAGED,
18};
19use crate::deploy::ROLLBACK_JOURNAL_ID;
20use crate::spec::{Bootloader, BootloaderKind, Host};
21use crate::store::{BootedComposefs, Storage};
22use crate::{
23    bootc_composefs::{boot::get_efi_uuid_source, status::get_sorted_grub_uki_boot_entries},
24    composefs_consts::{
25        BOOT_LOADER_ENTRIES, STAGED_BOOT_LOADER_ENTRIES, USER_CFG, USER_CFG_STAGED,
26    },
27    spec::BootOrder,
28};
29
30/// Atomically rename exchange grub user.cfg with the staged version
31/// Performed as the last step in rollback/update/switch operation
32#[context("Atomically exchanging user.cfg")]
33pub(crate) fn rename_exchange_user_cfg(grub2_dir: &Dir) -> Result<()> {
34    tracing::debug!("Atomically exchanging {USER_CFG_STAGED} and {USER_CFG}");
35    renameat_with(
36        &grub2_dir,
37        USER_CFG_STAGED,
38        &grub2_dir,
39        USER_CFG,
40        RenameFlags::EXCHANGE,
41    )
42    .context("renameat")?;
43
44    tracing::debug!("Removing {USER_CFG_STAGED}");
45    rustix::fs::unlinkat(&grub2_dir, USER_CFG_STAGED, AtFlags::empty()).context("unlinkat")?;
46
47    tracing::debug!("Syncing to disk");
48    let entries_dir = grub2_dir
49        .reopen_as_ownedfd()
50        .context("Reopening entries dir as owned fd")?;
51
52    fsync(entries_dir).context("fsync entries dir")?;
53
54    Ok(())
55}
56
57/// Atomically rename exchange "entries" <-> "entries.staged"
58/// Performed as the last step in rollback/update/switch operation
59///
60/// `entries_dir` is the directory that contains the BLS entries directories
61/// Ex: entries_dir = ESP/loader or boot/loader
62#[context("Atomically exchanging BLS entries")]
63pub(crate) fn rename_exchange_bls_entries(entries_dir: &Dir) -> Result<()> {
64    tracing::debug!("Atomically exchanging {STAGED_BOOT_LOADER_ENTRIES} and {BOOT_LOADER_ENTRIES}");
65    renameat_with(
66        &entries_dir,
67        STAGED_BOOT_LOADER_ENTRIES,
68        &entries_dir,
69        BOOT_LOADER_ENTRIES,
70        RenameFlags::EXCHANGE,
71    )
72    .context("renameat")?;
73
74    tracing::debug!("Removing {STAGED_BOOT_LOADER_ENTRIES}");
75    entries_dir
76        .remove_dir_all(STAGED_BOOT_LOADER_ENTRIES)
77        .context("Removing staged dir")?;
78
79    tracing::debug!("Syncing to disk");
80    let entries_dir = entries_dir
81        .reopen_as_ownedfd()
82        .context("Reopening as owned fd")?;
83
84    fsync(entries_dir).context("fsync")?;
85
86    Ok(())
87}
88
89#[context("Rolling back Grub UKI")]
90fn rollback_grub_uki_entries(boot_dir: &Dir) -> Result<()> {
91    let mut str = String::new();
92    let mut menuentries = get_sorted_grub_uki_boot_entries(&boot_dir, &mut str)
93        .context("Getting UKI boot entries")?;
94
95    // TODO(Johan-Liebert): Currently assuming there are only two deployments
96    assert!(menuentries.len() == 2);
97
98    let (first, second) = menuentries.split_at_mut(1);
99    std::mem::swap(&mut first[0], &mut second[0]);
100
101    let entries_dir = boot_dir.open_dir("grub2").context("Opening grub dir")?;
102
103    entries_dir
104        .atomic_replace_with(USER_CFG_STAGED, |f| -> std::io::Result<_> {
105            f.write_all(get_efi_uuid_source().as_bytes())?;
106
107            for entry in menuentries {
108                f.write_all(entry.to_string().as_bytes())?;
109            }
110
111            Ok(())
112        })
113        .with_context(|| format!("Writing to {USER_CFG_STAGED}"))?;
114
115    rename_exchange_user_cfg(&entries_dir)
116}
117
118/// Performs rollback for
119/// - Grub Type1 boot entries
120/// - Systemd Typ1 boot entries
121/// - Systemd UKI (Type2) boot entries [since we use BLS entries for systemd boot]
122///
123/// Cases
124/// 1. We're actually booted into the deployment that has it's sort_key as 0
125///    a. Just swap the primary and secondary bootloader entries
126///    b. If they're already swapped (rollback was queued), re-swap them (unqueue rollback)
127///
128/// 2. We're booted into the depl with sort_key 1 (choose the rollback deployment on boot screen)
129///    a. Here we assume that rollback is queued as there's no way to differentiate between this
130///    case and Case 1-b. This is what ostree does as well
131#[context("Rolling back {bootloader} entries")]
132fn rollback_composefs_entries(host: &Host, boot_dir: &Dir, bootloader: Bootloader) -> Result<()> {
133    // Get all boot entries sorted in descending order by sort-key
134    let mut all_configs = get_sorted_type1_boot_entries(&boot_dir, false)?;
135
136    // TODO(Johan-Liebert): Currently assuming there are only two deployments
137    assert!(all_configs.len() == 2);
138
139    // Keep the os_id the entries were written with, so rolling back only swaps them.
140    // Use "bootc" as default os_id for entries with a sort-key we didn't write.
141    let os_id = all_configs
142        .iter()
143        .find_map(|cfg| os_id_from_sort_key(cfg.sort_key.as_deref()?))
144        .unwrap_or("bootc")
145        .to_owned();
146    let os_id = os_id.as_str();
147
148    // For rollback: previous gets primary sort-key, booted gets secondary sort-key
149
150    // This is the currently booted deployment - it should become secondary
151    // OR if rollback was queued, it would become primary
152    all_configs[0].sort_key = Some(primary_sort_key(os_id));
153    // This is the previous deployment - it should become primary (rollback target)
154    // OR if rollback was queued, it would become secondary
155    all_configs[1].sort_key = Some(secondary_sort_key(os_id));
156
157    // Ostree will drop any staged deployment on rollback
158    // We follow the same approach for now
159    //
160    // Cleanup any previous staged entries
161    boot_dir
162        .remove_all_optional(TYPE1_ENT_PATH_STAGED)
163        .context("Removing staged entries")?;
164
165    if let Some(staged) = &host.status.staged {
166        tracing::info!(
167            message_id = ROLLBACK_JOURNAL_ID,
168            "Removing currently staged composefs deployment {}",
169            // SAFETY: This is a staged composefs entry, so composefs property
170            // will always exist
171            staged.composefs.as_ref().unwrap().verity
172        );
173
174        let transient_dir =
175            Dir::open_ambient_dir(COMPOSEFS_TRANSIENT_STATE_DIR, ambient_authority())
176                .context("Opening transient dir")?;
177
178        transient_dir
179            .remove_file(COMPOSEFS_STAGED_DEPLOYMENT_FNAME)
180            .context("Removing staged deployment file")?;
181    }
182
183    // Write these
184    boot_dir
185        .create_dir_all(TYPE1_ENT_PATH_STAGED)
186        .context("Creating staged dir")?;
187
188    let rollback_entries_dir = boot_dir
189        .open_dir(TYPE1_ENT_PATH_STAGED)
190        .context("Opening staged entries dir")?;
191
192    // Write the BLS configs in there
193    for cfg in all_configs {
194        // After rollback: previous deployment becomes primary, booted becomes secondary
195        let priority = if cfg.sort_key == Some(secondary_sort_key(os_id)) {
196            FILENAME_PRIORITY_SECONDARY
197        } else {
198            FILENAME_PRIORITY_PRIMARY
199        };
200
201        let file_name = type1_entry_conf_file_name(os_id, &cfg.version(), priority);
202
203        rollback_entries_dir
204            .atomic_write(&file_name, cfg.to_string())
205            .with_context(|| format!("Writing to {file_name}"))?;
206    }
207
208    let rollback_entries_dir = rollback_entries_dir
209        .reopen_as_ownedfd()
210        .context("Reopening as owned fd")?;
211
212    // Should we sync after every write?
213    fsync(rollback_entries_dir).context("fsync")?;
214
215    // Atomically exchange "entries" <-> "entries.rollback"
216    let dir = boot_dir.open_dir("loader").context("Opening loader dir")?;
217
218    rename_exchange_bls_entries(&dir)
219}
220
221#[context("Rolling back composefs")]
222pub(crate) async fn composefs_rollback(
223    storage: &Storage,
224    booted_cfs: &BootedComposefs,
225) -> Result<()> {
226    const COMPOSEFS_ROLLBACK_JOURNAL_ID: &str = "6f5e4d3c2b1a0f9e8d7c6b5a4e3d2c1b0";
227
228    tracing::info!(
229        message_id = COMPOSEFS_ROLLBACK_JOURNAL_ID,
230        bootc.operation = "rollback",
231        "Starting composefs rollback operation"
232    );
233
234    let host = get_composefs_status(storage, booted_cfs).await?;
235
236    let new_spec = {
237        let mut new_spec = host.spec.clone();
238        new_spec.boot_order = new_spec.boot_order.swap();
239        new_spec
240    };
241
242    // Just to be sure
243    host.spec.verify_transition(&new_spec)?;
244
245    let reverting = new_spec.boot_order == BootOrder::Default;
246    if reverting {
247        println!("notice: Reverting queued rollback state");
248    }
249
250    let rollback_status = host
251        .status
252        .rollback
253        .as_ref()
254        .ok_or_else(|| anyhow!("No rollback available"))?;
255
256    let Some(rollback_entry) = &rollback_status.composefs else {
257        anyhow::bail!("Rollback deployment not a composefs deployment")
258    };
259
260    if host.require_composefs_booted()?.boot_type == BootType::Aboot {
261        anyhow::ensure!(
262            rollback_entry.boot_type == BootType::Aboot,
263            "Rollback deployment is not aboot"
264        );
265        let mut state = aboot::AbootState::open(&storage.physical_root)?;
266        state.discard_pending()?;
267
268        let status = Command::new("aboot-deploy")
269            .arg("--rollback")
270            .status()
271            .context("Running aboot-deploy --rollback")?;
272        anyhow::ensure!(status.success(), "aboot-deploy exited with status {status}");
273
274        if reverting {
275            state.clear_rollback()?;
276        } else {
277            state.queue_rollback(&rollback_entry.verity)?;
278        }
279    } else {
280        let boot_dir = storage.require_boot_dir()?;
281
282        match &rollback_entry.bootloader.kind()? {
283            BootloaderKind::GRUBClassic => match rollback_entry.boot_type {
284                BootType::Bls => {
285                    rollback_composefs_entries(&host, boot_dir, rollback_entry.bootloader.clone())?;
286                }
287                BootType::Uki => {
288                    rollback_grub_uki_entries(boot_dir)?;
289                }
290                BootType::Aboot => anyhow::bail!("Unexpected aboot rollback deployment"),
291            },
292
293            BootloaderKind::BLSCompatible => {
294                // Clean up any staged global addons which is consistent with what we do
295                // for staged BLS Config files
296                //
297                // The fsync for ESP is done when we rollback entries
298                let boot_mount = storage.require_boot_mount()?;
299
300                boot_mount
301                    .fd
302                    .remove_all_optional(STAGED_GLOBAL_UKI_ADDONS_DIR)
303                    .context("Removing staged Global UKI Addons")?;
304
305                // We use BLS entries for systemd UKI as well
306                rollback_composefs_entries(&host, boot_dir, rollback_entry.bootloader.clone())?;
307            }
308        }
309    }
310
311    if reverting {
312        println!("Next boot: current deployment");
313    } else {
314        println!("Next boot: rollback deployment");
315    }
316
317    Ok(())
318}