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