Skip to main content

bootc_lib/bootc_composefs/
rollback.rs

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