Skip to main content

bootc_lib/bootc_composefs/
state.rs

1use std::io::Write;
2use std::os::unix::fs::symlink;
3use std::path::Path;
4use std::{fs::create_dir_all, process::Command};
5
6use anyhow::{Context, Result};
7use bootc_initramfs_setup::{mount_at_wrapper, overlay_transient};
8use bootc_kernel_cmdline::utf8::Cmdline;
9use bootc_mount::tempmount::TempMount;
10use bootc_utils::CommandRunExt;
11use camino::Utf8PathBuf;
12use canon_json::CanonJsonSerialize;
13use cap_std_ext::cap_std::ambient_authority;
14use cap_std_ext::cap_std::fs::{Dir, Permissions, PermissionsExt};
15use cap_std_ext::dirext::CapStdExtDirExt;
16use composefs::fsverity::{FsVerityHashValue, Sha512HashValue};
17use composefs_ctl::composefs;
18use fn_error_context::context;
19
20use ostree_ext::container::deploy::ORIGIN_CONTAINER;
21use rustix::{
22    fd::AsFd,
23    fs::{Mode, OFlags, StatVfsMountFlags, open},
24    mount::MountAttrFlags,
25    path::Arg,
26};
27
28use crate::bootc_composefs::boot::BootType;
29use crate::bootc_composefs::status::{
30    ComposefsCmdline, StagedDeployment, get_sorted_type1_boot_entries,
31};
32use crate::parsers::bls_config::BLSConfigType;
33use crate::store::{BootedComposefs, Storage};
34use crate::{
35    composefs_consts::{
36        COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, ORIGIN_KEY_BOOT,
37        ORIGIN_KEY_BOOT_DIGEST, ORIGIN_KEY_BOOT_TYPE, ORIGIN_KEY_IMAGE, ORIGIN_KEY_MANIFEST_DIGEST,
38        SHARED_VAR_PATH, STATE_DIR_RELATIVE,
39    },
40    parsers::bls_config::BLSConfig,
41    spec::ImageReference,
42    spec::{FilesystemOverlay, FilesystemOverlayAccessMode, FilesystemOverlayPersistence},
43    utils::path_relative_to,
44};
45
46/// Read and parse the `.origin` INI file for a deployment.
47///
48/// Returns `None` if the state directory or origin file doesn't exist
49/// (e.g. the deployment was partially deleted).
50#[context("Reading origin for deployment {deployment_id}")]
51pub(crate) fn read_origin(sysroot: &Dir, deployment_id: &str) -> Result<Option<tini::Ini>> {
52    let depl_state_path = std::path::PathBuf::from(STATE_DIR_RELATIVE).join(deployment_id);
53
54    let Some(state_dir) = sysroot.open_dir_optional(&depl_state_path)? else {
55        return Ok(None);
56    };
57
58    let origin_filename = format!("{deployment_id}.origin");
59    let Some(origin_contents) = state_dir.read_to_string_optional(&origin_filename)? else {
60        return Ok(None);
61    };
62
63    let ini = tini::Ini::from_string(&origin_contents).context("Failed to parse origin file")?;
64    Ok(Some(ini))
65}
66
67pub(crate) fn get_booted_bls(boot_dir: &Dir, booted_cfs: &BootedComposefs) -> Result<BLSConfig> {
68    let sorted_entries = get_sorted_type1_boot_entries(boot_dir, true)?;
69
70    for entry in sorted_entries {
71        match &entry.cfg_type {
72            BLSConfigType::EFI { efi } => {
73                if efi.as_str().contains(&*booted_cfs.cmdline.digest) {
74                    return Ok(entry);
75                }
76            }
77
78            BLSConfigType::NonEFI { options, .. } => {
79                let Some(opts) = options else {
80                    anyhow::bail!("options not found in bls config")
81                };
82
83                let cfs_cmdline = ComposefsCmdline::find_in_cmdline(&Cmdline::from(opts))
84                    .ok_or_else(|| anyhow::anyhow!("composefs param not found in cmdline"))?;
85
86                if cfs_cmdline.digest == booted_cfs.cmdline.digest {
87                    return Ok(entry);
88                }
89            }
90
91            BLSConfigType::Unknown => anyhow::bail!("Unknown BLS Config type"),
92        };
93    }
94
95    Err(anyhow::anyhow!("Booted BLS not found"))
96}
97
98/// Mounts an EROFS image and copies the pristine /etc and /var to the deployment's /etc and /var.
99/// Only copies /var for initial installation of deployments (non-staged deployments)
100#[context("Initializing /etc and /var for state")]
101pub(crate) fn initialize_state(
102    sysroot_path: &Utf8PathBuf,
103    erofs_id: &String,
104    state_path: &Utf8PathBuf,
105    initialize_var: bool,
106    allow_missing_fsverity: bool,
107) -> Result<()> {
108    let sysroot_fd = open(
109        sysroot_path.as_std_path(),
110        OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
111        Mode::empty(),
112    )
113    .context("Opening sysroot")?;
114
115    let composefs_fd = bootc_initramfs_setup::mount_composefs_image(
116        &sysroot_fd,
117        &erofs_id,
118        allow_missing_fsverity,
119    )?;
120
121    let tempdir = TempMount::mount_fd(composefs_fd)?;
122
123    // TODO: Replace this with a function to cap_std_ext
124    if initialize_var {
125        Command::new("cp")
126            .args([
127                "-a",
128                "--remove-destination",
129                &format!("{}/var/.", tempdir.dir.path().as_str()?),
130                &format!("{state_path}/var/."),
131            ])
132            .run_capture_stderr()?;
133    }
134
135    let cp_ret = Command::new("cp")
136        .args([
137            "-a",
138            "--remove-destination",
139            &format!("{}/etc/.", tempdir.dir.path().as_str()?),
140            &format!("{state_path}/etc/."),
141        ])
142        .run_capture_stderr();
143
144    cp_ret
145}
146
147/// Adds or updates the provided key/value pairs in the .origin file of the deployment pointed to
148/// by the `deployment_id`
149fn add_update_in_origin(
150    storage: &Storage,
151    deployment_id: &str,
152    section: &str,
153    kv_pairs: &[(&str, &str)],
154) -> Result<()> {
155    let path = Path::new(STATE_DIR_RELATIVE).join(deployment_id);
156
157    let state_dir = storage
158        .physical_root
159        .open_dir(path)
160        .context("Opening state dir")?;
161
162    let origin_filename = format!("{deployment_id}.origin");
163
164    let origin_file = state_dir
165        .read_to_string(&origin_filename)
166        .context("Reading origin file")?;
167
168    let mut ini =
169        tini::Ini::from_string(&origin_file).context("Failed to parse file origin file as ini")?;
170
171    for (key, value) in kv_pairs {
172        ini = ini.section(section).item(*key, *value);
173    }
174
175    state_dir
176        .atomic_replace_with(origin_filename, move |f| -> std::io::Result<_> {
177            f.write_all(ini.to_string().as_bytes())?;
178            f.flush()?;
179
180            let perms = Permissions::from_mode(0o644);
181            f.get_mut().as_file_mut().set_permissions(perms)?;
182
183            Ok(())
184        })
185        .context("Writing to origin file")?;
186
187    Ok(())
188}
189
190pub(crate) fn update_boot_digest_in_origin(
191    storage: &Storage,
192    digest: &str,
193    boot_digest: &str,
194) -> Result<()> {
195    add_update_in_origin(
196        storage,
197        digest,
198        ORIGIN_KEY_BOOT,
199        &[(ORIGIN_KEY_BOOT_DIGEST, boot_digest)],
200    )
201}
202
203/// Creates and populates the composefs state directory for a deployment.
204///
205/// This function sets up the state directory structure and configuration files
206/// needed for a composefs deployment. It creates the deployment state directory,
207/// copies configuration, sets up the shared `/var` directory, and writes metadata
208/// files including the origin configuration and image information.
209///
210/// # Arguments
211///
212/// * `root_path`         - The root filesystem path (typically `/sysroot`)
213/// * `deployment_id`     - Unique SHA512 hash identifier for this deployment
214/// * `imgref`            - Container image reference for the deployment
215/// * `staged`            - Whether this is a staged deployment (writes to transient state dir)
216/// * `boot_type`         - Boot loader type (`Bls` or `Uki`)
217/// * `boot_digest`       - Optional boot digest for verification
218/// * `manifest_digest`   - OCI manifest content digest, stored in the origin file so the
219///                         manifest+config can be retrieved from the composefs repo later
220///
221/// # State Directory Structure
222///
223/// Creates the following structure under `/sysroot/state/deploy/{deployment_id}/`:
224/// * `etc/`                    - Copy of system configuration files
225/// * `var`                     - Symlink to shared `/var` directory
226/// * `{deployment_id}.origin`  - Origin configuration with image ref, boot, and image metadata
227///
228/// For staged deployments, also writes to `/run/composefs/staged-deployment`.
229#[context("Writing composefs state")]
230pub(crate) async fn write_composefs_state(
231    root_path: &Utf8PathBuf,
232    deployment_id: &Sha512HashValue,
233    target_imgref: &ImageReference,
234    staged: Option<StagedDeployment>,
235    boot_type: BootType,
236    boot_digest: String,
237    manifest_digest: &str,
238    allow_missing_fsverity: bool,
239) -> Result<()> {
240    let state_path = root_path
241        .join(STATE_DIR_RELATIVE)
242        .join(deployment_id.to_hex());
243
244    create_dir_all(state_path.join("etc"))?;
245
246    let actual_var_path = root_path.join(SHARED_VAR_PATH);
247    create_dir_all(&actual_var_path)?;
248
249    symlink(
250        path_relative_to(state_path.as_std_path(), actual_var_path.as_std_path())
251            .context("Getting var symlink path")?,
252        state_path.join("var"),
253    )
254    .context("Failed to create symlink for /var")?;
255
256    initialize_state(
257        &root_path,
258        &deployment_id.to_hex(),
259        &state_path,
260        staged.is_none(),
261        allow_missing_fsverity,
262    )?;
263
264    let imgref = target_imgref.to_image_proxy_ref()?;
265
266    let mut config = tini::Ini::new().section("origin").item(
267        ORIGIN_CONTAINER,
268        // TODO (Johan-Liebert1): The image won't always be unverified
269        format!("ostree-unverified-image:{imgref}"),
270    );
271
272    config = config
273        .section(ORIGIN_KEY_BOOT)
274        .item(ORIGIN_KEY_BOOT_TYPE, boot_type);
275
276    config = config
277        .section(ORIGIN_KEY_BOOT)
278        .item(ORIGIN_KEY_BOOT_DIGEST, boot_digest);
279
280    // Store the OCI manifest digest so we can retrieve the manifest+config
281    // from the composefs repository later (composefs-rs stores them as splitstreams).
282    config = config
283        .section(ORIGIN_KEY_IMAGE)
284        .item(ORIGIN_KEY_MANIFEST_DIGEST, manifest_digest);
285
286    let state_dir =
287        Dir::open_ambient_dir(&state_path, ambient_authority()).context("Opening state dir")?;
288
289    state_dir
290        .atomic_write(
291            format!("{}.origin", deployment_id.to_hex()),
292            config.to_string().as_bytes(),
293        )
294        .context("Failed to write to .origin file")?;
295
296    if let Some(staged) = staged {
297        std::fs::create_dir_all(COMPOSEFS_TRANSIENT_STATE_DIR)
298            .with_context(|| format!("Creating {COMPOSEFS_TRANSIENT_STATE_DIR}"))?;
299
300        let staged_depl_dir =
301            Dir::open_ambient_dir(COMPOSEFS_TRANSIENT_STATE_DIR, ambient_authority())
302                .with_context(|| format!("Opening {COMPOSEFS_TRANSIENT_STATE_DIR}"))?;
303
304        staged_depl_dir
305            .atomic_write(
306                COMPOSEFS_STAGED_DEPLOYMENT_FNAME,
307                staged
308                    .to_canon_json_vec()
309                    .context("Failed to serialize staged deployment JSON")?,
310            )
311            .with_context(|| format!("Writing to {COMPOSEFS_STAGED_DEPLOYMENT_FNAME}"))?;
312    }
313
314    Ok(())
315}
316
317pub(crate) fn composefs_usr_overlay(access_mode: FilesystemOverlayAccessMode) -> Result<()> {
318    let status = get_composefs_usr_overlay_status()?;
319    if status.is_some() {
320        println!("An overlayfs is already mounted on /usr");
321        return Ok(());
322    }
323
324    let usr = Dir::open_ambient_dir("/usr", ambient_authority()).context("Opening /usr")?;
325
326    let mount_attr_flags = match access_mode {
327        FilesystemOverlayAccessMode::ReadOnly => Some(MountAttrFlags::MOUNT_ATTR_RDONLY),
328        FilesystemOverlayAccessMode::ReadWrite => None,
329    };
330
331    let overlay_fd = overlay_transient(usr.as_fd(), "transient", mount_attr_flags)?;
332    mount_at_wrapper(overlay_fd, &usr, ".").context("Attaching /usr overlay")?;
333
334    println!("A {} overlayfs is now mounted on /usr", access_mode);
335    println!("All changes there will be discarded on reboot.");
336
337    Ok(())
338}
339
340pub(crate) fn get_composefs_usr_overlay_status() -> Result<Option<FilesystemOverlay>> {
341    let usr = Dir::open_ambient_dir("/usr", ambient_authority()).context("Opening /usr")?;
342    let is_usr_mounted = usr
343        .is_mountpoint(".")
344        .context("Failed to get mount details for /usr")?
345        .ok_or_else(|| anyhow::anyhow!("Failed to get mountinfo"))?;
346
347    if is_usr_mounted {
348        let st =
349            rustix::fs::fstatvfs(usr.as_fd()).context("Failed to get filesystem info for /usr")?;
350        let permissions = if st.f_flag.contains(StatVfsMountFlags::RDONLY) {
351            FilesystemOverlayAccessMode::ReadOnly
352        } else {
353            FilesystemOverlayAccessMode::ReadWrite
354        };
355        // For the composefs backend, assume the /usr overlay is always transient.
356        Ok(Some(FilesystemOverlay {
357            access_mode: permissions,
358            persistence: FilesystemOverlayPersistence::Transient,
359        }))
360    } else {
361        Ok(None)
362    }
363}