Skip to content

Commit ee4ddc2

Browse files
committed
store: Support read-only /sysroot on a live ISO
We want to support at least read-only operation when mounted from a physically read-only medium (like a squashfs/EROFS for /sysroot). Check the backing block device status, and propagate its read-only state up. Generated-by: AI Signed-off-by: Colin Walters <walters@verbum.org>
1 parent 5476871 commit ee4ddc2

6 files changed

Lines changed: 120 additions & 13 deletions

File tree

crates/blockdev/src/blockdev.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,9 @@ pub struct Device {
113113
pub path: Option<String>,
114114
/// Partition table type (e.g., "gpt", "dos"). Only present on whole disk devices.
115115
pub pttype: Option<String>,
116+
/// Whether the device is read-only (e.g. a loopback over an immutable
117+
/// rootfs image on a live ISO). `None` if not reported by `lsblk`.
118+
pub ro: Option<bool>,
116119
}
117120

118121
impl Device {
@@ -504,6 +507,30 @@ pub fn list_dev_by_dir(dir: &Dir) -> Result<Device> {
504507
list_dev(&Utf8PathBuf::from(source))
505508
}
506509

510+
/// Determine whether the block device backing the filesystem mounted at the
511+
/// given directory is physically read-only.
512+
///
513+
/// This is the case for e.g. a live ISO, where `/sysroot` is a loopback device
514+
/// over an immutable rootfs image and the kernel will reject any attempt to
515+
/// remount it read-write.
516+
///
517+
/// Returns `Ok(None)` when the backing device cannot be determined (for example
518+
/// when the filesystem source is not a real block device, such as an overlay),
519+
/// leaving the decision to the caller.
520+
pub fn is_dir_backing_device_ro(dir: &Dir) -> Result<Option<bool>> {
521+
let fsinfo = bootc_mount::inspect_filesystem_of_dir(dir)?;
522+
let source = &fsinfo.source;
523+
// Only real block device nodes can be queried via lsblk; sources like
524+
// "overlay" or a ZFS dataset are not physical devices we can interrogate
525+
// for a read-only flag here.
526+
if !source.starts_with("/dev/") {
527+
tracing::debug!("Filesystem source {source} is not a block device node");
528+
return Ok(None);
529+
}
530+
let dev = list_dev(&Utf8PathBuf::from(source))?;
531+
Ok(dev.ro)
532+
}
533+
507534
pub struct LoopbackDevice {
508535
pub dev: Option<Utf8PathBuf>,
509536
// Handle to the cleanup helper process

crates/lib/src/cli.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -999,6 +999,7 @@ pub(crate) async fn get_storage() -> Result<crate::store::BootedStorage> {
999999
let r = BootedStorage::new(env)
10001000
.await?
10011001
.ok_or_else(|| anyhow!("System not booted via bootc"))?;
1002+
r.require_writable()?;
10021003
Ok(r)
10031004
}
10041005

crates/lib/src/spec.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,12 @@ pub struct HostStatus {
450450

451451
/// The state of the overlay mounted on /usr
452452
pub usr_overlay: Option<FilesystemOverlay>,
453+
454+
/// Set to true if the physical root (`/sysroot`) is on a read-only medium
455+
/// (e.g. a live ISO) and so cannot be mutated; commands that would change
456+
/// the system (upgrade, switch, etc.) are not available.
457+
#[serde(default)]
458+
pub read_only: bool,
453459
}
454460

455461
pub(crate) struct DeploymentEntry<'a> {

crates/lib/src/status.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,8 @@ pub(crate) fn get_status(
437437
rollback_queued,
438438
ty,
439439
usr_overlay,
440+
// Set by callers that have storage context (e.g. get_host).
441+
read_only: false,
440442
};
441443
Ok((deployments, host))
442444
}
@@ -452,7 +454,7 @@ pub(crate) async fn get_host() -> Result<Host> {
452454
return Ok(Host::default());
453455
};
454456

455-
let host = match storage.kind() {
457+
let mut host = match storage.kind() {
456458
Ok(kind) => match kind {
457459
BootedStorageKind::Ostree(booted_ostree) => {
458460
let (_deployments, host) = get_status(&booted_ostree)?;
@@ -469,6 +471,10 @@ pub(crate) async fn get_host() -> Result<Host> {
469471
}
470472
};
471473

474+
// Surface whether the physical root is on a read-only medium (e.g. a live
475+
// ISO), so consumers know mutating operations are unavailable.
476+
host.status.read_only = storage.is_ro;
477+
472478
Ok(host)
473479
}
474480

crates/lib/src/store/mod.rs

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ use ostree_ext::container_utils::ostree_booted;
108108
use ostree_ext::prelude::FileExt;
109109
use ostree_ext::sysroot::SysrootLock;
110110
use ostree_ext::{gio, ostree};
111-
use rustix::fs::Mode;
111+
use rustix::{fd::AsFd, fs::Mode, fs::StatVfsMountFlags};
112112

113113
use composefs::fsverity::Sha512HashValue;
114114
use composefs::repository::RepositoryConfig;
@@ -302,15 +302,51 @@ pub(crate) enum BootedStorageKind<'a> {
302302
}
303303

304304
/// Open the physical root (/sysroot) and /run directories for a booted system.
305-
fn get_physical_root_and_run() -> Result<(Dir, Dir)> {
306-
let physical_root = {
307-
let d = Dir::open_ambient_dir("/sysroot", cap_std::ambient_authority())
308-
.context("Opening /sysroot")?;
309-
open_dir_remount_rw(&d, ".".into())?
310-
};
305+
///
306+
/// The returned boolean is `true` if `/sysroot` is on a physically read-only
307+
/// medium (e.g. a live ISO) that cannot be remounted writable.
308+
///
309+
/// Note that ostree mounts `/sysroot` read-only by default even on writable
310+
/// systems and remounts it writable on demand, so being read-only is not by
311+
/// itself meaningful: we only conclude the system is read-only if a remount
312+
/// attempt *fails* and the backing block device confirms it.
313+
fn get_physical_root_and_run() -> Result<(Dir, Dir, bool)> {
314+
let d = Dir::open_ambient_dir("/sysroot", cap_std::ambient_authority())
315+
.context("Opening /sysroot")?;
316+
let is_ro = sysroot_is_read_only(&d)?;
317+
let physical_root = d.open_dir(".").context("Opening /sysroot")?;
311318
let run =
312319
Dir::open_ambient_dir("/run", cap_std::ambient_authority()).context("Opening /run")?;
313-
Ok((physical_root, run))
320+
Ok((physical_root, run, is_ro))
321+
}
322+
323+
/// Determine whether `/sysroot` (given as `d`) is on a physically read-only
324+
/// block device, remounting it read-write as a side effect when necessary.
325+
///
326+
/// ostree mounts `/sysroot` read-only by default even on writable systems and
327+
/// remounts it writable on demand, so the read-only mount flag alone is not
328+
/// meaningful. The authoritative signal is the backing block device's read-only
329+
/// flag, which is set for a live ISO (a read-only loop device over an immutable
330+
/// rootfs image) and propagates from a whole disk to its partitions. We check
331+
/// that first and avoid even attempting a remount that is bound to fail.
332+
fn sysroot_is_read_only(d: &Dir) -> Result<bool> {
333+
let st = rustix::fs::fstatvfs(d.as_fd())?;
334+
if !st.f_flag.contains(StatVfsMountFlags::RDONLY) {
335+
// Already writable: the common case.
336+
return Ok(false);
337+
}
338+
339+
// The backing block device is physically read-only (e.g. a live ISO); there
340+
// is no point attempting a remount, and write operations are not possible.
341+
if matches!(bootc_blockdev::is_dir_backing_device_ro(d), Ok(Some(true))) {
342+
return Ok(true);
343+
}
344+
345+
// The mount is read-only but the device is writable: this is the normal
346+
// ostree case where /sysroot is mounted read-only by default. Remount it
347+
// writable on demand; a failure here is an unexpected error.
348+
open_dir_remount_rw(d, ".".into())?;
349+
Ok(false)
314350
}
315351

316352
impl BootedStorage {
@@ -321,7 +357,7 @@ impl BootedStorage {
321357
pub(crate) async fn new(env: Environment) -> Result<Option<Self>> {
322358
let r = match &env {
323359
Environment::ComposefsBooted(cmdline) => {
324-
let (physical_root, run) = get_physical_root_and_run()?;
360+
let (physical_root, run, is_ro) = get_physical_root_and_run()?;
325361
let mut composefs = ComposefsRepository::open_path(&physical_root, COMPOSEFS)?;
326362
if cmdline.allow_missing_fsverity {
327363
composefs.set_insecure();
@@ -346,6 +382,7 @@ impl BootedStorage {
346382
let storage = Storage {
347383
physical_root,
348384
physical_root_path: Utf8PathBuf::from("/sysroot"),
385+
is_ro,
349386
run,
350387
boot_dir: Some(boot_dir),
351388
esp: Some(esp_mount),
@@ -372,16 +409,25 @@ impl BootedStorage {
372409
// remount /sysroot as writable, and we call set_mount_namespace_in_use()
373410
// to indicate we're in a mount namespace. Without actually being in a
374411
// mount namespace, this would leave the global /sysroot writable.
375-
let (physical_root, run) = get_physical_root_and_run()?;
412+
let (physical_root, run, is_ro) = get_physical_root_and_run()?;
376413

377414
let sysroot = ostree::Sysroot::new_default();
378-
sysroot.set_mount_namespace_in_use();
379-
let sysroot = ostree_ext::sysroot::SysrootLock::new_from_sysroot(&sysroot).await?;
415+
// On a read-only sysroot (e.g. a live ISO) we must NOT mark the mount
416+
// namespace as in-use, otherwise ostree will attempt to remount /sysroot
417+
// read-write on write operations and fail. We also avoid taking the write
418+
// lock (which would write a lockfile to the read-only filesystem).
419+
let sysroot = if is_ro {
420+
ostree_ext::sysroot::SysrootLock::from_assumed_locked(&sysroot)
421+
} else {
422+
sysroot.set_mount_namespace_in_use();
423+
ostree_ext::sysroot::SysrootLock::new_from_sysroot(&sysroot).await?
424+
};
380425
sysroot.load(gio::Cancellable::NONE)?;
381426

382427
let storage = Storage {
383428
physical_root,
384429
physical_root_path: Utf8PathBuf::from("/sysroot"),
430+
is_ro,
385431
run,
386432
boot_dir: None,
387433
esp: None,
@@ -432,6 +478,10 @@ pub(crate) struct Storage {
432478
/// This is `/sysroot` on a running system, or the target mount point during install.
433479
pub physical_root_path: Utf8PathBuf,
434480

481+
/// True if the physical root (`/sysroot`) is on a physically read-only
482+
/// block device (e.g. a live ISO) and cannot be made writable.
483+
pub(crate) is_ro: bool,
484+
435485
/// The 'boot' directory, useful and `Some` only for composefs systems
436486
/// For grub booted systems, this points to `/sysroot/boot`
437487
/// For systemd booted systems, this points to the ESP
@@ -498,6 +548,7 @@ impl Storage {
498548
Ok(Self {
499549
physical_root,
500550
physical_root_path,
551+
is_ro: false,
501552
run,
502553
boot_dir: None,
503554
esp: None,
@@ -507,6 +558,16 @@ impl Storage {
507558
})
508559
}
509560

561+
/// Ensure the storage is writable, erroring with a clear message if the
562+
/// underlying `/sysroot` is on a read-only block device (e.g. a live ISO).
563+
pub(crate) fn require_writable(&self) -> Result<()> {
564+
anyhow::ensure!(
565+
!self.is_ro,
566+
"Cannot perform this operation: /sysroot is on a read-only block device (e.g. a live ISO)"
567+
);
568+
Ok(())
569+
}
570+
510571
/// Returns `boot_dir` if it exists
511572
pub(crate) fn require_boot_dir(&self) -> Result<&Dir> {
512573
self.boot_dir

docs/src/host-v1.schema.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"$ref": "#/$defs/HostStatus",
2828
"default": {
2929
"booted": null,
30+
"readOnly": false,
3031
"rollback": null,
3132
"rollbackQueued": false,
3233
"staged": null,
@@ -323,6 +324,11 @@
323324
"$ref": "#/$defs/BootEntry"
324325
}
325326
},
327+
"readOnly": {
328+
"description": "Set to true if the physical root (`/sysroot`) is on a read-only medium\n(e.g. a live ISO) and so cannot be mutated; commands that would change\nthe system (upgrade, switch, etc.) are not available.",
329+
"type": "boolean",
330+
"default": false
331+
},
326332
"rollback": {
327333
"description": "The previously booted image",
328334
"anyOf": [

0 commit comments

Comments
 (0)