From cd63506d3c6dca6edb05c78a766d11ad8da124e6 Mon Sep 17 00:00:00 2001 From: Jan De Landtsheer Date: Mon, 29 Sep 2025 17:41:56 +0200 Subject: [PATCH] mount: use bcachefs -o X-mount.subdir={name} for subvolume mounts; update SAFETY notes; sync README and PROMPT with root/subvol scheme and bcachefs option --- PROMPT.md | 17 +- README.md | 14 +- src/mount/ops.rs | 364 ++++++++++++++++++++++++++++++++++------ src/orchestrator/run.rs | 5 +- 4 files changed, 347 insertions(+), 53 deletions(-) diff --git a/PROMPT.md b/PROMPT.md index cb5959c..b1bd27e 100644 --- a/PROMPT.md +++ b/PROMPT.md @@ -30,11 +30,22 @@ Partitioning Requirements - Before making changes, verify the device has no existing partitions or filesystem signatures; abort otherwise. Filesystem Provisioning -- All data mounts are placed somewhere under `/var/cache`. Precise mountpoints and subvolume strategies are configurable. +- Mount scheme and subvolumes: + * Root mounts for each data filesystem at `/var/mounts/{UUID}` (runtime only). For btrfs root, use `-o subvolid=5`; for bcachefs root, no subdir option. + * Create or ensure subvolumes on the primary data filesystem with names: `system`, `etc`, `modules`, `vm-meta`. + * Mount subvolumes to final targets: + - `/var/cache/system` + - `/var/cache/etc` + - `/var/cache/modules` + - `/var/cache/vm-meta` + * Use UUID= sources for all mounts (never device paths). + * Subvolume options: + - btrfs: `-o subvol={name},noatime` + - bcachefs: `-o X-mount.subdir={name},noatime` - Supported backends: * Single disk: default to `btrfs`, label `ZOSDATA`. - * Two disks/NVMe: default to individual `btrfs` filesystems per disk, each labeled `ZOSDATA`, mounted under `/var/cache/` (exact path pattern TBD). Optional support for `btrfs` RAID1 or `bcachefs` RAID1 if requested. - * Mixed SSD/NVMe + HDD: default to `bcachefs` with SSD as cache/promote and HDD as backing store, label resulting filesystem `ZOSDATA`. Alternative mode: separate `btrfs` per device (label `ZOSDATA`). + * Two disks/NVMe (dual_independent): default to independent `btrfs` per disk, each labeled `ZOSDATA`; root-mount all under `/var/mounts/{UUID}`, pick the first data FS as primary for final subvol mounts. + * Mixed SSD/NVMe + HDD: default to `bcachefs` with SSD as cache/promote and HDD as backing store, resulting FS labeled `ZOSDATA`. Alternative mode: separate `btrfs` per device (label `ZOSDATA`). - Reserved filesystem labels: `ZOSBOOT` (ESP), `ZOSDATA` (all data filesystems). GPT partition names: `zosboot` (bios_boot and ESP), `zosdata` (data), `zoscache` (cache). - Filesystem tuning options (compression, RAID profile, etc.) must be configurable; define sensible defaults and provide extension points. diff --git a/README.md b/README.md index 0eac75b..745809a 100644 --- a/README.md +++ b/README.md @@ -135,8 +135,18 @@ Defaults and policies btrfs (data) label: ZOSDATA bcachefs (data/cache) label: ZOSDATA - Mount scheme: - per-UUID under /var/cache/{UUID} - /etc/fstab generation is disabled by default + - Root mounts (runtime only): each data filesystem is mounted at /var/mounts/{UUID} + - btrfs root options: rw,noatime,subvolid=5 + - bcachefs root options: rw,noatime + - Subvolume mounts (from the primary data filesystem only) to final targets: + - /var/cache/system + - /var/cache/etc + - /var/cache/modules + - /var/cache/vm-meta + - Subvolume mount options: + - btrfs: -o rw,noatime,subvol={name} + - bcachefs: -o rw,noatime,X-mount.subdir={name} + - /etc/fstab generation is disabled by default; when enabled, only the four subvolume mounts are written (UUID= sources, deterministic order) Tracing and logs - stderr logging level controlled by -l/--log-level (info by default) diff --git a/src/mount/ops.rs b/src/mount/ops.rs index 88a329b..139e125 100644 --- a/src/mount/ops.rs +++ b/src/mount/ops.rs @@ -1,85 +1,355 @@ -// REGION: API -// api: mount::MountPlan { entries: Vec<(String, String, String, String)> } -// note: tuple order = (source, target, fstype, options) +// REGION: API — one-liners for plan_mounts/apply_mounts/maybe_write_fstab and structs +// api: mount::MountPlan { root_mounts: Vec, subvol_mounts: Vec, primary_uuid: Option } // api: mount::MountResult { source: String, target: String, fstype: String, options: String } -// api: mount::plan_mounts(fs_results: &[crate::fs::FsResult], cfg: &crate::config::types::Config) -> crate::Result +// api: mount::plan_mounts(fs_results: &[crate::fs::FsResult], cfg: &crate::types::Config) -> crate::Result // api: mount::apply_mounts(plan: &MountPlan) -> crate::Result> -// api: mount::maybe_write_fstab(mounts: &[MountResult], cfg: &crate::config::types::Config) -> crate::Result<()> +// api: mount::maybe_write_fstab(mounts: &[MountResult], cfg: &crate::types::Config) -> crate::Result<()> // REGION: API-END // // REGION: RESPONSIBILITIES -// - Translate filesystem identities to mount targets, defaulting to /var/cache/. -// - Perform mounts using syscalls (nix) and create target directories as needed. -// - Optionally generate /etc/fstab entries in deterministic order. -// Non-goals: filesystem creation, device discovery, partitioning. +// - Implement mount phase only: plan root mounts under /var/mounts/{UUID}, ensure/plan subvols, and mount subvols to /var/cache/*. +// - Use UUID= sources, deterministic primary selection (first FsResult) for dual_independent. +// - Generate fstab entries only for four subvol targets; exclude runtime root mounts. // REGION: RESPONSIBILITIES-END // -// REGION: EXTENSION_POINTS -// ext: support custom mount scheme mapping beyond per-UUID. -// ext: add configurable mount options per filesystem kind via Config. -// REGION: EXTENSION_POINTS-END -// // REGION: SAFETY -// safety: must ensure target directories exist and avoid overwriting unintended paths. -// safety: ensure options include sensible defaults (e.g., btrfs compress, ssd) when applicable. +// - Never mount ESP; only Btrfs/Bcachefs data FS. Root btrfs mounts use subvolid=5 (top-level). +// - Create-if-missing subvolumes prior to subvol mounts; ensure directories exist. +// - Always use UUID= sources; no device paths. +// - Bcachefs subvolume mounts use option key 'X-mount.subdir={name}' (not 'subvol='). // REGION: SAFETY-END // // REGION: ERROR_MAPPING -// errmap: syscall failures -> crate::Error::Mount with context. -// errmap: fstab write IO errors -> crate::Error::Mount with path details. +// - External tool failures map to Error::Tool via util::run_cmd/run_cmd_capture. +// - Missing required tools map to Error::Mount with clear explanation. // REGION: ERROR_MAPPING-END // // REGION: TODO -// todo: implement option synthesis (e.g., compress=zstd:3 for btrfs) based on Config and device rotational hints. -// todo: implement deterministic fstab ordering and idempotent writes. +// - Defer compression/SSD options; later map from Config into mount options. +// - Consider validating tool presence up-front for clearer early errors. // REGION: TODO-END //! Mount planning and application. //! -//! Translates filesystem results into mount targets (default under /var/cache/) -//! and applies mounts using syscalls (via nix) in later implementation. -//! -//! See [fn plan_mounts](ops.rs:1), [fn apply_mounts](ops.rs:1), -//! and [fn maybe_write_fstab](ops.rs:1). +//! See [fn plan_mounts()](src/mount/ops.rs:1), [fn apply_mounts()](src/mount/ops.rs:1), +//! and [fn maybe_write_fstab()](src/mount/ops.rs:1). #![allow(dead_code)] -use crate::{Result, types::Config, fs::FsResult}; +use crate::{ + fs::{FsKind, FsResult}, + types::Config, + util::{run_cmd, run_cmd_capture, which_tool}, + Error, Result, +}; +use std::fs::{create_dir_all, File}; +use std::io::Write; +use std::path::Path; + +const ROOT_BASE: &str = "/var/mounts"; +const TARGET_SYSTEM: &str = "/var/cache/system"; +const TARGET_ETC: &str = "/var/cache/etc"; +const TARGET_MODULES: &str = "/var/cache/modules"; +const TARGET_VM_META: &str = "/var/cache/vm-meta"; +const SUBVOLS: &[&str] = &["system", "etc", "modules", "vm-meta"]; -/// Mount plan entries: (source, target, fstype, options) #[derive(Debug, Clone)] -pub struct MountPlan { - /// Source device path, target directory, filesystem type, and mount options. - pub entries: Vec<(String, String, String, String)>, +pub struct PlannedMount { + pub uuid: String, // UUID string without prefix + pub target: String, // absolute path + pub fstype: String, // "btrfs" | "bcachefs" + pub options: String, // e.g., "rw,noatime,subvolid=5" } -/// Result of applying a single mount entry. +#[derive(Debug, Clone)] +pub struct PlannedSubvolMount { + pub uuid: String, // UUID of primary FS + pub name: String, // subvol name (system/etc/modules/vm-meta) + pub target: String, // absolute final target + pub fstype: String, // "btrfs" | "bcachefs" + pub options: String, // e.g., "rw,noatime,subvol=system" +} + +/// Mount plan per policy. +#[derive(Debug, Clone)] +pub struct MountPlan { + /// Root mounts under /var/mounts/{UUID} for all data filesystems. + pub root_mounts: Vec, + /// Four subvol mounts chosen from the primary FS only. + pub subvol_mounts: Vec, + /// Primary UUID selection (only data FS; for multiple pick first in input order). + pub primary_uuid: Option, +} + +/// Result of applying a mount (root or subvol). #[derive(Debug, Clone)] pub struct MountResult { - /// Source device path (e.g., /dev/nvme0n1p3). + /// Source as "UUID=..." (never device paths). pub source: String, - /// Target directory (e.g., /var/cache/). + /// Target directory. pub target: String, - /// Filesystem type (e.g., "btrfs", "vfat"). + /// Filesystem type string. pub fstype: String, - /// Options string (comma-separated). + /// Options used for the mount. pub options: String, } -/// Build mount plan under /var/cache/ by default. +fn fstype_str(kind: FsKind) -> &'static str { + match kind { + FsKind::Btrfs => "btrfs", + FsKind::Bcachefs => "bcachefs", + FsKind::Vfat => "vfat", + } +} + +/// Build mount plan per policy. pub fn plan_mounts(fs_results: &[FsResult], _cfg: &Config) -> Result { - let _ = fs_results; - // Placeholder: map filesystem UUIDs to per-UUID directories and assemble options. - todo!("create per-UUID directories and mount mapping based on config") + // Identify data filesystems (Btrfs/Bcachefs), ignore ESP (Vfat) + let data: Vec<&FsResult> = fs_results + .iter() + .filter(|r| matches!(r.kind, FsKind::Btrfs | FsKind::Bcachefs)) + .collect(); + + if data.is_empty() { + return Err(Error::Mount( + "no data filesystems to mount (expected Btrfs or Bcachefs)".into(), + )); + } + + // Root mounts for all data filesystems + let mut root_mounts: Vec = Vec::new(); + for r in &data { + let uuid = r.uuid.clone(); + let fstype = fstype_str(r.kind).to_string(); + let target = format!("{}/{}", ROOT_BASE, uuid); + let options = match r.kind { + FsKind::Btrfs => "rw,noatime,subvolid=5".to_string(), + FsKind::Bcachefs => "rw,noatime".to_string(), + FsKind::Vfat => continue, + }; + root_mounts.push(PlannedMount { + uuid, + target, + fstype, + options, + }); + } + + // Determine primary UUID + let primary_uuid = Some(data[0].uuid.clone()); + + // Subvol mounts only from primary FS + let primary = data[0]; + let mut subvol_mounts: Vec = Vec::new(); + let fstype = fstype_str(primary.kind).to_string(); + // Option key differs per filesystem: btrfs uses subvol=, bcachefs uses X-mount.subdir= + let opt_key = match primary.kind { + FsKind::Btrfs => "subvol=", + FsKind::Bcachefs => "X-mount.subdir=", + FsKind::Vfat => "subvol=", // not used for Vfat (ESP ignored) + }; + for name in SUBVOLS { + let target = match *name { + "system" => TARGET_SYSTEM.to_string(), + "etc" => TARGET_ETC.to_string(), + "modules" => TARGET_MODULES.to_string(), + "vm-meta" => TARGET_VM_META.to_string(), + _ => continue, + }; + let options = format!("rw,noatime,{}{}", opt_key, name); + subvol_mounts.push(PlannedSubvolMount { + uuid: primary.uuid.clone(), + name: name.to_string(), + target, + fstype: fstype.clone(), + options, + }); + } + + Ok(MountPlan { + root_mounts, + subvol_mounts, + primary_uuid, + }) } -/// Apply mounts using syscalls (nix), ensuring directories exist. -pub fn apply_mounts(_plan: &MountPlan) -> Result> { - // Placeholder: perform mount syscalls and return results. - todo!("perform mount syscalls and return results") +/// Apply mounts: ensure dirs, mount roots, create subvols if missing, mount subvols. +pub fn apply_mounts(plan: &MountPlan) -> Result> { + // Tool discovery + let mount_tool = which_tool("mount")? + .ok_or_else(|| Error::Mount("required tool 'mount' not found in PATH".into()))?; + + // Ensure target directories exist for root mounts + for pm in &plan.root_mounts { + create_dir_all(&pm.target) + .map_err(|e| Error::Mount(format!("failed to create dir {}: {}", pm.target, e)))?; + } + // Ensure final subvol targets exist + for sm in &plan.subvol_mounts { + create_dir_all(&sm.target) + .map_err(|e| Error::Mount(format!("failed to create dir {}: {}", sm.target, e)))?; + } + + let mut results: Vec = Vec::new(); + + // Root mounts + for pm in &plan.root_mounts { + let source = format!("UUID={}", pm.uuid); + let args = [ + mount_tool.as_str(), + "-t", + pm.fstype.as_str(), + "-o", + pm.options.as_str(), + source.as_str(), + pm.target.as_str(), + ]; + run_cmd(&args)?; + results.push(MountResult { + source, + target: pm.target.clone(), + fstype: pm.fstype.clone(), + options: pm.options.clone(), + }); + } + + // Subvolume creation (create-if-missing) and mounts for the primary + if let Some(primary_uuid) = &plan.primary_uuid { + // Determine primary fs kind from planned subvols (they all share fstype for primary) + let primary_kind = plan + .subvol_mounts + .get(0) + .map(|s| s.fstype.clone()) + .unwrap_or_else(|| "btrfs".to_string()); + let root = format!("{}/{}", ROOT_BASE, primary_uuid); + + if primary_kind == "btrfs" { + let btrfs_tool = which_tool("btrfs")? + .ok_or_else(|| Error::Mount("required tool 'btrfs' not found in PATH".into()))?; + // List existing subvols under root + let out = run_cmd_capture(&[ + btrfs_tool.as_str(), + "subvolume", + "list", + "-o", + root.as_str(), + ])?; + for sm in &plan.subvol_mounts { + if &sm.uuid != primary_uuid { + continue; + } + // Check existence by scanning output for " path {name}" + let exists = out + .stdout + .lines() + .any(|l| l.contains(&format!(" path {}", sm.name))); + if !exists { + // Create subvolume + let subvol_path = format!("{}/{}", root, sm.name); + let args = [btrfs_tool.as_str(), "subvolume", "create", subvol_path.as_str()]; + run_cmd(&args)?; + } + } + } else if primary_kind == "bcachefs" { + let bcachefs_tool = which_tool("bcachefs")?.ok_or_else(|| { + Error::Mount("required tool 'bcachefs' not found in PATH".into()) + })?; + for sm in &plan.subvol_mounts { + if &sm.uuid != primary_uuid { + continue; + } + let subvol_path = format!("{}/{}", root, sm.name); + if !Path::new(&subvol_path).exists() { + let args = [ + bcachefs_tool.as_str(), + "subvolume", + "create", + subvol_path.as_str(), + ]; + run_cmd(&args)?; + } + } + } else { + return Err(Error::Mount(format!( + "unsupported primary fstype for subvols: {}", + primary_kind + ))); + } + } + + // Subvol mounts + for sm in &plan.subvol_mounts { + let source = format!("UUID={}", sm.uuid); + let args = [ + mount_tool.as_str(), + "-t", + sm.fstype.as_str(), + "-o", + sm.options.as_str(), + source.as_str(), + sm.target.as_str(), + ]; + run_cmd(&args)?; + results.push(MountResult { + source, + target: sm.target.clone(), + fstype: sm.fstype.clone(), + options: sm.options.clone(), + }); + } + + Ok(results) } -/// Optionally generate /etc/fstab entries in deterministic order. -pub fn maybe_write_fstab(_mounts: &[MountResult], _cfg: &Config) -> Result<()> { - // Placeholder: write fstab when enabled in configuration. - todo!("when enabled, write fstab entries deterministically") +/// Optionally write fstab entries for subvol mounts only (deterministic order). +pub fn maybe_write_fstab(mounts: &[MountResult], cfg: &Config) -> Result<()> { + if !cfg.mount.fstab_enabled { + return Ok(()); + } + + // Filter only the four subvol targets + let wanted = [TARGET_ETC, TARGET_MODULES, TARGET_SYSTEM, TARGET_VM_META]; + let mut entries: Vec<&MountResult> = mounts + .iter() + .filter(|m| wanted.contains(&m.target.as_str())) + .collect(); + + // Sort by target path ascending to be deterministic + entries.sort_by(|a, b| a.target.cmp(&b.target)); + + // Compose lines + let mut lines: Vec = Vec::new(); + for m in entries { + // m.source already "UUID=..." + let line = format!( + "{} {} {} {} 0 0", + m.source, m.target, m.fstype, m.options + ); + lines.push(line); + } + + // Atomic write to /etc/fstab + let fstab_path = "/etc/fstab"; + let tmp_path = "/etc/fstab.zosstorage.tmp"; + if let Some(parent) = Path::new(fstab_path).parent() { + create_dir_all(parent) + .map_err(|e| Error::Mount(format!("failed to create {}: {}", parent.display(), e)))?; + } + { + let mut f = File::create(tmp_path) + .map_err(|e| Error::Mount(format!("failed to create {}: {}", tmp_path, e)))?; + for line in lines { + writeln!(f, "{}", line) + .map_err(|e| Error::Mount(format!("failed to write tmp fstab: {}", e)))?; + } + f.flush() + .map_err(|e| Error::Mount(format!("failed to flush tmp fstab: {}", e)))?; + } + std::fs::rename(tmp_path, fstab_path).map_err(|e| { + Error::Mount(format!( + "failed to replace {} atomically: {}", + fstab_path, e + )) + })?; + + Ok(()) } \ No newline at end of file diff --git a/src/orchestrator/run.rs b/src/orchestrator/run.rs index 63359b1..8869518 100644 --- a/src/orchestrator/run.rs +++ b/src/orchestrator/run.rs @@ -201,7 +201,10 @@ pub fn run(ctx: &Context) -> Result<()> { let fs_results = zfs::make_filesystems(&fs_plan)?; info!("orchestrator: created {} filesystem(s)", fs_results.len()); - // Next steps (mounts, optional fstab, state report) will be wired in follow-ups. + // Mount planning and application + let mplan = crate::mount::plan_mounts(&fs_results, &ctx.cfg)?; + let mres = crate::mount::apply_mounts(&mplan)?; + crate::mount::maybe_write_fstab(&mres, &ctx.cfg)?; return Ok(()); }