bootc_lib/bootc_composefs/
update.rs

1use anyhow::{Context, Result};
2use camino::Utf8PathBuf;
3use cap_std_ext::cap_std::fs::Dir;
4use composefs::{
5    fsverity::{FsVerityHashValue, Sha512HashValue},
6    util::{parse_sha256, Sha256Digest},
7};
8use composefs_boot::BootOps;
9use composefs_oci::image::create_filesystem;
10use fn_error_context::context;
11use ostree_ext::container::ManifestDiff;
12
13use crate::{
14    bootc_composefs::{
15        boot::{setup_composefs_bls_boot, setup_composefs_uki_boot, BootSetupType, BootType},
16        repo::{get_imgref, pull_composefs_repo},
17        service::start_finalize_stated_svc,
18        soft_reboot::prepare_soft_reboot_composefs,
19        state::write_composefs_state,
20        status::{
21            get_bootloader, get_composefs_status, get_container_manifest_and_config, get_imginfo,
22            ImgConfigManifest,
23        },
24    },
25    cli::{SoftRebootMode, UpgradeOpts},
26    composefs_consts::{STATE_DIR_RELATIVE, TYPE1_ENT_PATH_STAGED, USER_CFG_STAGED},
27    spec::{Bootloader, Host, ImageReference},
28    store::{BootedComposefs, ComposefsRepository, Storage},
29};
30
31#[context("Getting SHA256 Digest for {id}")]
32pub fn str_to_sha256digest(id: &str) -> Result<Sha256Digest> {
33    let id = id.strip_prefix("sha256:").unwrap_or(id);
34    Ok(parse_sha256(&id)?)
35}
36
37/// Checks if a container image has been pulled to the local composefs repository.
38///
39/// This function verifies whether the specified container image exists in the local
40/// composefs repository by checking if the image's configuration digest stream is
41/// available. It retrieves the image manifest and configuration from the container
42/// registry and uses the configuration digest to perform the local availability check.
43///
44/// # Arguments
45///
46/// * `repo` - The composefs repository
47/// * `imgref` - Reference to the container image to check
48///
49/// # Returns
50///
51/// Returns a tuple containing:
52/// * `Some<Sha512HashValue>` if the image is pulled/available locally, `None` otherwise
53/// * The container image manifest
54/// * The container image configuration
55#[context("Checking if image {} is pulled", imgref.image)]
56pub(crate) async fn is_image_pulled(
57    repo: &ComposefsRepository,
58    imgref: &ImageReference,
59) -> Result<(Option<Sha512HashValue>, ImgConfigManifest)> {
60    let imgref_repr = get_imgref(&imgref.transport, &imgref.image);
61    let img_config_manifest = get_container_manifest_and_config(&imgref_repr).await?;
62
63    let img_digest = img_config_manifest.manifest.config().digest().digest();
64    let img_sha256 = str_to_sha256digest(&img_digest)?;
65
66    // check_stream is expensive to run, but probably a good idea
67    let container_pulled = repo.check_stream(&img_sha256).context("Checking stream")?;
68
69    Ok((container_pulled, img_config_manifest))
70}
71
72fn rm_staged_type1_ent(boot_dir: &Dir) -> Result<()> {
73    if boot_dir.exists(TYPE1_ENT_PATH_STAGED) {
74        boot_dir
75            .remove_dir_all(TYPE1_ENT_PATH_STAGED)
76            .context("Removing staged bootloader entry")?;
77    }
78
79    Ok(())
80}
81
82#[derive(Debug)]
83pub(crate) enum UpdateAction {
84    /// Skip the update. We probably have the update in our deployments
85    Skip,
86    /// Proceed with the update
87    Proceed,
88    /// Only update the target imgref in the .origin file
89    /// Will only be returned if the Operation is update and not switch
90    UpdateOrigin,
91}
92
93/// Determines what action should be taken for the update
94///
95/// Cases:
96///
97/// - The verity is the same as that of the currently booted deployment
98///
99///    Nothing to do here as we're currently booted
100///
101/// - The verity is the same as that of the staged deployment
102///
103///    Nothing to do, as we only get a "staged" deployment if we have
104///    /run/composefs/staged-deployment which is the last thing we create while upgrading
105///
106/// - The verity is the same as that of the rollback deployment
107///
108///    Nothing to do since this is a rollback deployment which means this was unstaged at some
109///    point
110///
111/// - The verity is not found
112///
113///    The update/switch might've been canceled before /run/composefs/staged-deployment
114///    was created, or at any other point in time, or it's a new one.
115///    Any which way, we can overwrite everything
116///
117/// # Arguments
118///
119/// * `storage`       - The global storage object
120/// * `booted_cfs`    - Reference to the booted composefs deployment
121/// * `host`          - Object returned by `get_composefs_status`
122/// * `img_digest`    - The SHA256 sum of the target image
123/// * `config_verity` - The verity of the Image config splitstream
124/// * `is_switch`     - Whether this is an update operation or a switch operation
125///
126/// # Returns
127/// * UpdateAction::Skip         - Skip the update/switch as we have it as a deployment
128/// * UpdateAction::UpdateOrigin - Just update the target imgref in the origin file
129/// * UpdateAction::Proceed      - Proceed with the update
130pub(crate) fn validate_update(
131    storage: &Storage,
132    booted_cfs: &BootedComposefs,
133    host: &Host,
134    img_digest: &str,
135    config_verity: &Sha512HashValue,
136    is_switch: bool,
137) -> Result<UpdateAction> {
138    let repo = &*booted_cfs.repo;
139
140    let mut fs = create_filesystem(repo, img_digest, Some(config_verity))?;
141    fs.transform_for_boot(&repo)?;
142
143    let image_id = fs.compute_image_id();
144
145    // Case1
146    //
147    // "update" image has the same verity as the one currently booted
148    // This could be someone trying to `bootc switch <remote_image>` where
149    // remote_image is the exact same image as the one currently booted, but
150    // they are wanting to change the target
151    // We just update the image origin file here
152    //
153    // If it's not a switch op, then we skip the update
154    if image_id.to_hex() == *booted_cfs.cmdline.digest {
155        let ret = if is_switch {
156            UpdateAction::UpdateOrigin
157        } else {
158            UpdateAction::Skip
159        };
160
161        return Ok(ret);
162    }
163
164    let all_deployments = host.all_composefs_deployments()?;
165
166    let found_depl = all_deployments
167        .iter()
168        .find(|d| d.deployment.verity == image_id.to_hex());
169
170    // We have this in our deployments somewhere, i.e. Case 2 or 3
171    if found_depl.is_some() {
172        return Ok(UpdateAction::Skip);
173    }
174
175    let booted = host.require_composefs_booted()?;
176    let boot_dir = storage.require_boot_dir()?;
177
178    // Remove staged bootloader entries, if any
179    // GC should take care of the UKI PEs and other binaries
180    match get_bootloader()? {
181        Bootloader::Grub => match booted.boot_type {
182            BootType::Bls => rm_staged_type1_ent(boot_dir)?,
183
184            BootType::Uki => {
185                let grub = boot_dir.open_dir("grub2").context("Opening grub dir")?;
186
187                if grub.exists(USER_CFG_STAGED) {
188                    grub.remove_file(USER_CFG_STAGED)
189                        .context("Removing staged grub user config")?;
190                }
191            }
192        },
193
194        Bootloader::Systemd => rm_staged_type1_ent(boot_dir)?,
195    }
196
197    // Remove state directory
198    let state_dir = storage
199        .physical_root
200        .open_dir(STATE_DIR_RELATIVE)
201        .context("Opening state dir")?;
202
203    if state_dir.exists(image_id.to_hex()) {
204        state_dir
205            .remove_dir_all(image_id.to_hex())
206            .context("Removing state")?;
207    }
208
209    Ok(UpdateAction::Proceed)
210}
211
212/// This is just an intersection of SwitchOpts and UpgradeOpts
213pub(crate) struct DoUpgradeOpts {
214    pub(crate) apply: bool,
215    pub(crate) soft_reboot: Option<SoftRebootMode>,
216}
217
218/// Performs the Update or Switch operation
219#[context("Performing Upgrade Operation")]
220pub(crate) async fn do_upgrade(
221    storage: &Storage,
222    booted_cfs: &BootedComposefs,
223    host: &Host,
224    imgref: &ImageReference,
225    img_manifest_config: &ImgConfigManifest,
226    opts: &DoUpgradeOpts,
227) -> Result<()> {
228    start_finalize_stated_svc()?;
229
230    let (repo, entries, id, fs) = pull_composefs_repo(&imgref.transport, &imgref.image).await?;
231
232    let Some(entry) = entries.iter().next() else {
233        anyhow::bail!("No boot entries!");
234    };
235
236    let mounted_fs = Dir::reopen_dir(
237        &repo
238            .mount(&id.to_hex())
239            .context("Failed to mount composefs image")?,
240    )?;
241
242    let boot_type = BootType::from(entry);
243
244    let boot_digest = match boot_type {
245        BootType::Bls => setup_composefs_bls_boot(
246            BootSetupType::Upgrade((storage, &fs, &host)),
247            repo,
248            &id,
249            entry,
250            &mounted_fs,
251        )?,
252
253        BootType::Uki => setup_composefs_uki_boot(
254            BootSetupType::Upgrade((storage, &fs, &host)),
255            repo,
256            &id,
257            entries,
258        )?,
259    };
260
261    write_composefs_state(
262        &Utf8PathBuf::from("/sysroot"),
263        &id,
264        imgref,
265        true,
266        boot_type,
267        boot_digest,
268        img_manifest_config,
269    )
270    .await?;
271
272    if opts.apply {
273        return crate::reboot::reboot();
274    }
275
276    if opts.soft_reboot.is_some() {
277        prepare_soft_reboot_composefs(storage, booted_cfs, &id.to_hex(), true).await?;
278    }
279
280    Ok(())
281}
282
283#[context("Upgrading composefs")]
284pub(crate) async fn upgrade_composefs(
285    opts: UpgradeOpts,
286    storage: &Storage,
287    composefs: &BootedComposefs,
288) -> Result<()> {
289    // Download-only mode is not yet supported for composefs backend
290    if opts.download_only {
291        anyhow::bail!("--download-only is not yet supported for composefs backend");
292    }
293    if opts.from_downloaded {
294        anyhow::bail!("--from-downloaded is not yet supported for composefs backend");
295    }
296
297    let host = get_composefs_status(storage, composefs)
298        .await
299        .context("Getting composefs deployment status")?;
300
301    let mut booted_imgref = host
302        .spec
303        .image
304        .as_ref()
305        .ok_or_else(|| anyhow::anyhow!("No image source specified"))?;
306
307    let repo = &*composefs.repo;
308
309    let (img_pulled, mut img_config) = is_image_pulled(&repo, booted_imgref).await?;
310    let booted_img_digest = img_config.manifest.config().digest().digest().to_owned();
311
312    // Check if we already have this update staged
313    // Or if we have another staged deployment with a different image
314    let staged_image = host.status.staged.as_ref().and_then(|i| i.image.as_ref());
315
316    let do_upgrade_opts = DoUpgradeOpts {
317        soft_reboot: opts.soft_reboot,
318        apply: opts.apply,
319    };
320
321    if let Some(staged_image) = staged_image {
322        // We have a staged image and it has the same digest as the currently booted image's latest
323        // digest
324        if staged_image.image_digest == booted_img_digest {
325            if opts.apply {
326                return crate::reboot::reboot();
327            }
328
329            println!("Update already staged. To apply update run `bootc update --apply`");
330
331            return Ok(());
332        }
333
334        // We have a staged image but it's not the update image.
335        // Maybe it's something we got by `bootc switch`
336        // Switch takes precedence over update, so we change the imgref
337        booted_imgref = &staged_image.image;
338
339        let (img_pulled, staged_img_config) = is_image_pulled(&repo, booted_imgref).await?;
340        img_config = staged_img_config;
341
342        if let Some(cfg_verity) = img_pulled {
343            let action = validate_update(
344                storage,
345                composefs,
346                &host,
347                img_config.manifest.config().digest().digest(),
348                &cfg_verity,
349                false,
350            )?;
351
352            match action {
353                UpdateAction::Skip => {
354                    println!("No changes in staged image: {booted_imgref:#}");
355                    return Ok(());
356                }
357
358                UpdateAction::Proceed => {
359                    return do_upgrade(
360                        storage,
361                        composefs,
362                        &host,
363                        booted_imgref,
364                        &img_config,
365                        &do_upgrade_opts,
366                    )
367                    .await;
368                }
369
370                UpdateAction::UpdateOrigin => {
371                    anyhow::bail!("Updating origin not supported for update operation")
372                }
373            }
374        }
375    }
376
377    // We already have this container config
378    if let Some(cfg_verity) = img_pulled {
379        let action = validate_update(
380            storage,
381            composefs,
382            &host,
383            &booted_img_digest,
384            &cfg_verity,
385            false,
386        )?;
387
388        match action {
389            UpdateAction::Skip => {
390                println!("No changes in: {booted_imgref:#}");
391                return Ok(());
392            }
393
394            UpdateAction::Proceed => {
395                return do_upgrade(
396                    storage,
397                    composefs,
398                    &host,
399                    booted_imgref,
400                    &img_config,
401                    &do_upgrade_opts,
402                )
403                .await;
404            }
405
406            UpdateAction::UpdateOrigin => {
407                anyhow::bail!("Updating origin not supported for update operation")
408            }
409        }
410    }
411
412    if opts.check {
413        let current_manifest =
414            get_imginfo(storage, &*composefs.cmdline.digest, Some(booted_imgref)).await?;
415        let diff = ManifestDiff::new(&current_manifest.manifest, &img_config.manifest);
416        diff.print();
417        return Ok(());
418    }
419
420    do_upgrade(
421        storage,
422        composefs,
423        &host,
424        booted_imgref,
425        &img_config,
426        &do_upgrade_opts,
427    )
428    .await?;
429
430    Ok(())
431}