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