Compare commits

..

6 Commits

Author SHA1 Message Date
4cd8c54c44 Expand --report-current to show partitions and all mounts (except pseudo-filesystems) 2025-10-20 14:55:38 +02:00
224adf06d8 Standardize 2-copy bcachefs topology naming to 'bcachefs-2copy' across code and docs; align parser and Display; update docs and ADR 2025-10-20 14:21:47 +02:00
69370a2f53 config: reuse reserved label and GPT name constants 2025-10-13 10:35:31 +02:00
3d14f77516 mount: prefer boot disk ESP and run cargo fmt
* choose ESP matching the primary data disk when multiple ESPs exist,
  falling back gracefully for single-disk layouts
* keep new helper to normalize device names and reuse the idempotent
  mount logic
* apply cargo fmt across the tree
2025-10-10 14:49:39 +02:00
5746e285b2 Mount repeat fixes, update docs 2025-10-10 14:11:52 +02:00
cc126d77b4 mount: mount ESP at /boot alongside data runtimes
Add /boot root mount planning for VFAT ESP outputs, reuse the idempotent
mount logic to skip duplicates, and ensure /etc/fstab includes both
/var/mounts/{UUID} and the ESP when enabled.
2025-10-10 10:11:58 +02:00
30 changed files with 595 additions and 509 deletions

View File

@@ -20,7 +20,7 @@ Key modules
- [src/partition/plan.rs](src/partition/plan.rs) - [src/partition/plan.rs](src/partition/plan.rs)
- Filesystem planning/creation and mkfs integration: - Filesystem planning/creation and mkfs integration:
- [src/fs/plan.rs](src/fs/plan.rs) - [src/fs/plan.rs](src/fs/plan.rs)
- Mount planning and application (skeleton): - Mount planning and application:
- [src/mount/ops.rs](src/mount/ops.rs) - [src/mount/ops.rs](src/mount/ops.rs)
Features at a glance Features at a glance

View File

@@ -1,185 +0,0 @@
# zosstorage example configuration (full surface)
# Copy to /etc/zosstorage/config.yaml on the target system, or pass with:
# - CLI: --config /path/to/your.yaml
# - Kernel cmdline: zosstorage.config=/path/to/your.yaml
# Precedence (highest to lowest):
# kernel cmdline > CLI flags > CLI --config file > /etc/zosstorage/config.yaml > built-in defaults
version: 1
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logging:
# one of: error, warn, info, debug
level: info
# when true, also logs to /run/zosstorage/zosstorage.log in initramfs
to_file: false
# -----------------------------------------------------------------------------
# Device selection rules
# - include_patterns: device paths that are considered
# - exclude_patterns: device paths to filter out
# - allow_removable: future toggle for removable media (kept false by default)
# - min_size_gib: ignore devices smaller than this size
# -----------------------------------------------------------------------------
device_selection:
include_patterns:
- "^/dev/sd\\w+$"
- "^/dev/nvme\\w+n\\d+$"
- "^/dev/vd\\w+$"
exclude_patterns:
- "^/dev/ram\\d+$"
- "^/dev/zram\\d+$"
- "^/dev/loop\\d+$"
- "^/dev/fd\\d+$"
allow_removable: false
min_size_gib: 10
# -----------------------------------------------------------------------------
# Desired topology (choose ONE)
# single : Single eligible disk; btrfs on data
# dual_independent : Two disks; independent btrfs on each
# ssd_hdd_bcachefs : SSD + HDD; bcachefs with SSD as cache/promote and HDD backing
# btrfs_raid1 : Optional mirrored btrfs across two disks (only when explicitly requested)
# -----------------------------------------------------------------------------
topology:
mode: single
# mode: dual_independent
# mode: ssd_hdd_bcachefs
# mode: btrfs_raid1
# -----------------------------------------------------------------------------
# Partitioning (GPT only)
# Reserved GPT names:
# - bios boot : "zosboot" (tiny BIOS boot partition, non-FS)
# - ESP : "zosboot" (FAT32)
# - Data : "zosdata"
# - Cache : "zoscache" (only for ssd_hdd_bcachefs)
# Reserved filesystem labels:
# - ESP : ZOSBOOT
# - Data (all filesystems including bcachefs): ZOSDATA
# -----------------------------------------------------------------------------
partitioning:
# 1 MiB alignment
alignment_mib: 1
# Abort if any target disk is not empty (required for safety)
require_empty_disks: true
bios_boot:
enabled: true
size_mib: 1
gpt_name: zosboot
esp:
size_mib: 512
label: ZOSBOOT
gpt_name: zosboot
data:
gpt_name: zosdata
# Only used in ssd_hdd_bcachefs
cache:
gpt_name: zoscache
# -----------------------------------------------------------------------------
# Filesystem options and tuning
# All data filesystems (btrfs or bcachefs) use label ZOSDATA
# ESP uses label ZOSBOOT
# -----------------------------------------------------------------------------
filesystem:
btrfs:
# Reserved; must be "ZOSDATA"
label: ZOSDATA
# e.g., "zstd:3", "zstd:5"
compression: zstd:3
# "none" | "raid1" (raid1 typically when topology.mode == btrfs_raid1)
raid_profile: none
bcachefs:
# Reserved; must be "ZOSDATA"
label: ZOSDATA
# "promote" (default) or "writeback" if supported by environment
cache_mode: promote
# Compression algorithm, e.g., "zstd"
compression: zstd
# Checksum algorithm, e.g., "crc32c"
checksum: crc32c
vfat:
# Reserved; must be "ZOSBOOT"
label: ZOSBOOT
# -----------------------------------------------------------------------------
# Mount scheme and optional fstab
# Default behavior mounts data filesystems under /var/cache/<UUID>
# -----------------------------------------------------------------------------
mount:
# Base directory for mounts
base_dir: /var/cache
# Scheme: per_uuid | custom (custom reserved for future)
scheme: per_uuid
# When true, zosstorage will generate /etc/fstab entries in deterministic order
fstab_enabled: false
# -----------------------------------------------------------------------------
# Report output
# JSON report is written after successful provisioning
# -----------------------------------------------------------------------------
report:
path: /run/zosstorage/state.json
# -----------------------------------------------------------------------------
# Examples for different topologies (uncomment and set topology.mode accordingly)
# -----------------------------------------------------------------------------
# Example: single disk (uses btrfs on data)
# topology:
# mode: single
# filesystem:
# btrfs:
# label: ZOSDATA
# compression: zstd:3
# raid_profile: none
# Example: dual independent btrfs (two disks)
# topology:
# mode: dual_independent
# filesystem:
# btrfs:
# label: ZOSDATA
# compression: zstd:5
# raid_profile: none
# Example: SSD + HDD with bcachefs
# topology:
# mode: ssd_hdd_bcachefs
# partitioning:
# cache:
# gpt_name: zoscache
# filesystem:
# bcachefs:
# label: ZOSDATA
# cache_mode: promote
# compression: zstd
# checksum: crc32c
# Example: btrfs RAID1 (two disks)
# topology:
# mode: btrfs_raid1
# filesystem:
# btrfs:
# label: ZOSDATA
# compression: zstd:3
# raid_profile: raid1
# -----------------------------------------------------------------------------
# Notes:
# - Never modify devices outside include_patterns or inside exclude_patterns.
# - Idempotency: if expected GPT names and filesystem labels are already present,
# zosstorage exits success without making changes.
# - --force flag is reserved and not implemented; will return an "unimplemented" error.
# - Kernel cmdline data: URLs for zosstorage.config= are currently unimplemented.
# -----------------------------------------------------------------------------

View File

@@ -75,7 +75,7 @@ Configuration types
- [struct Config](../src/types.rs:1) - [struct Config](../src/types.rs:1)
- The validated configuration used by the orchestrator, containing logging, device selection rules, topology, partitioning, filesystem options, mount scheme, and report path. - The validated configuration used by the orchestrator, containing logging, device selection rules, topology, partitioning, filesystem options, mount scheme, and report path.
- [enum Topology](../src/types.rs:1) - [enum Topology](../src/types.rs:1)
- Values: btrfs_single, bcachefs_single, dual_independent, bcachefs2_copy, ssd_hdd_bcachefs, btrfs_raid1 (opt-in). - Values: btrfs_single, bcachefs_single, dual_independent, bcachefs-2copy, ssd_hdd_bcachefs, btrfs_raid1 (opt-in).
- [struct DeviceSelection](../src/types.rs:1) - [struct DeviceSelection](../src/types.rs:1)
- Include and exclude regex patterns, minimum size, removable policy. - Include and exclude regex patterns, minimum size, removable policy.
- [struct Partitioning](../src/types.rs:1) - [struct Partitioning](../src/types.rs:1)
@@ -201,7 +201,7 @@ Behavioral notes and contracts
- btrfs_single: one data filesystem (btrfs) on the sole disk. - btrfs_single: one data filesystem (btrfs) on the sole disk.
- bcachefs_single: one data filesystem (bcachefs) on the sole disk. - bcachefs_single: one data filesystem (bcachefs) on the sole disk.
- dual_independent: independent btrfs filesystems on each eligible disk (one or more). - dual_independent: independent btrfs filesystems on each eligible disk (one or more).
- bcachefs2_copy: multi-device bcachefs across two or more data partitions with `--replicas=2` (data and metadata). - bcachefs-2copy: multi-device bcachefs across two or more data partitions with `--replicas=2` (data and metadata).
- ssd_hdd_bcachefs: bcachefs spanning SSD (cache/promote) and HDD (backing), labeled ZOSDATA. - ssd_hdd_bcachefs: bcachefs spanning SSD (cache/promote) and HDD (backing), labeled ZOSDATA.
- btrfs_raid1: only when explicitly requested; otherwise default to independent btrfs. - btrfs_raid1: only when explicitly requested; otherwise default to independent btrfs.
- UEFI vs BIOS: when running under UEFI (`/sys/firmware/efi` present), the BIOS boot partition is suppressed. - UEFI vs BIOS: when running under UEFI (`/sys/firmware/efi` present), the BIOS boot partition is suppressed.

View File

@@ -32,7 +32,7 @@ device_selection:
allow_removable: false # future option; default false allow_removable: false # future option; default false
min_size_gib: 10 # ignore devices smaller than this (default 10) min_size_gib: 10 # ignore devices smaller than this (default 10)
topology: # desired overall layout; see values below topology: # desired overall layout; see values below
mode: btrfs_single # btrfs_single | bcachefs_single | dual_independent | bcachefs2_copy | ssd_hdd_bcachefs | btrfs_raid1 mode: btrfs_single # btrfs_single | bcachefs_single | dual_independent | bcachefs-2copy | ssd_hdd_bcachefs | btrfs_raid1
partitioning: partitioning:
alignment_mib: 1 # GPT alignment in MiB alignment_mib: 1 # GPT alignment in MiB
require_empty_disks: true # abort if any partition or FS signatures exist require_empty_disks: true # abort if any partition or FS signatures exist
@@ -73,7 +73,7 @@ Topology modes
- btrfs_single: One eligible disk. Create BIOS boot (if enabled), ESP 512 MiB, remainder as data. Create a btrfs filesystem labeled ZOSDATA on the data partition. - btrfs_single: One eligible disk. Create BIOS boot (if enabled), ESP 512 MiB, remainder as data. Create a btrfs filesystem labeled ZOSDATA on the data partition.
- bcachefs_single: One eligible disk. Create BIOS boot (if enabled), ESP 512 MiB, remainder as data. Create a bcachefs filesystem labeled ZOSDATA on the data partition. - bcachefs_single: One eligible disk. Create BIOS boot (if enabled), ESP 512 MiB, remainder as data. Create a bcachefs filesystem labeled ZOSDATA on the data partition.
- dual_independent: One or more eligible disks. On each disk, create BIOS boot (if enabled) + ESP + data. Create an independent btrfs filesystem labeled ZOSDATA on each data partition. No RAID by default. - dual_independent: One or more eligible disks. On each disk, create BIOS boot (if enabled) + ESP + data. Create an independent btrfs filesystem labeled ZOSDATA on each data partition. No RAID by default.
- bcachefs2_copy: Two or more eligible disks (minimum 2). Create data partitions and then a single multi-device bcachefs labeled ZOSDATA spanning those data partitions. The mkfs step uses `--replicas=2` (data and metadata). - bcachefs-2copy: Two or more eligible disks (minimum 2). Create data partitions and then a single multi-device bcachefs labeled ZOSDATA spanning those data partitions. The mkfs step uses `--replicas=2` (data and metadata).
- ssd_hdd_bcachefs: One SSD/NVMe and one HDD. Create BIOS boot (if enabled) + ESP on both as required. Create cache (on SSD) and data/backing (on HDD) partitions named zoscache and zosdata respectively. Create a bcachefs labeled ZOSDATA across SSD(HDD) per policy (SSD cache/promote; HDD backing). - ssd_hdd_bcachefs: One SSD/NVMe and one HDD. Create BIOS boot (if enabled) + ESP on both as required. Create cache (on SSD) and data/backing (on HDD) partitions named zoscache and zosdata respectively. Create a bcachefs labeled ZOSDATA across SSD(HDD) per policy (SSD cache/promote; HDD backing).
- btrfs_raid1: Optional mode if explicitly requested. Create mirrored btrfs across two disks for the data role with raid1 profile. Not enabled by default. - btrfs_raid1: Optional mode if explicitly requested. Create mirrored btrfs across two disks for the data role with raid1 profile. Not enabled by default.

View File

@@ -184,7 +184,7 @@ Per-topology specifics
- btrfs_single: All roles on the single disk; data formatted as btrfs. - btrfs_single: All roles on the single disk; data formatted as btrfs.
- bcachefs_single: All roles on the single disk; data formatted as bcachefs. - bcachefs_single: All roles on the single disk; data formatted as bcachefs.
- dual_independent: On each eligible disk (one or more), create BIOS boot (if applicable), ESP, and data. - dual_independent: On each eligible disk (one or more), create BIOS boot (if applicable), ESP, and data.
- bcachefs_2copy: Create data partitions on two or more disks; later formatted as one multi-device bcachefs spanning all data partitions. - bcachefs-2copy: Create data partitions on two or more disks; later formatted as one multi-device bcachefs spanning all data partitions.
- ssd_hdd_bcachefs: SSD gets BIOS boot + ESP + zoscache; HDD gets BIOS boot + ESP + zosdata; combined later into one bcachefs. - ssd_hdd_bcachefs: SSD gets BIOS boot + ESP + zoscache; HDD gets BIOS boot + ESP + zosdata; combined later into one bcachefs.
- btrfs_raid1: Two disks minimum; data partitions mirrored via btrfs RAID1. - btrfs_raid1: Two disks minimum; data partitions mirrored via btrfs RAID1.
@@ -203,12 +203,12 @@ Application
Kinds Kinds
- Vfat for ESP, label ZOSBOOT. - Vfat for ESP, label ZOSBOOT.
- Btrfs for data in btrfs_single, dual_independent, and btrfs_raid1 (with RAID1 profile). - Btrfs for data in btrfs_single, dual_independent, and btrfs_raid1 (with RAID1 profile).
- Bcachefs for data in bcachefs_single, ssd_hdd_bcachefs (SSD cache + HDD backing), and bcachefs_2copy (multi-device). - Bcachefs for data in bcachefs_single, ssd_hdd_bcachefs (SSD cache + HDD backing), and bcachefs-2copy (multi-device).
- All data filesystems use label ZOSDATA. - All data filesystems use label ZOSDATA.
Defaults Defaults
- btrfs: compression zstd:3, raid_profile none unless explicitly set; for btrfs_raid1 use -m raid1 -d raid1. - btrfs: compression zstd:3, raid_profile none unless explicitly set; for btrfs_raid1 use -m raid1 -d raid1.
- bcachefs: cache_mode promote, compression zstd, checksum crc32c; for bcachefs_2copy use `--replicas=2` (data and metadata). - bcachefs: cache_mode promote, compression zstd, checksum crc32c; for bcachefs-2copy use `--replicas=2` (data and metadata).
- vfat: ESP label ZOSBOOT. - vfat: ESP label ZOSBOOT.
Planning and execution Planning and execution
@@ -267,7 +267,7 @@ Kernel cmdline
Help text sections Help text sections
- NAME, SYNOPSIS, DESCRIPTION - NAME, SYNOPSIS, DESCRIPTION
- CONFIG PRECEDENCE - CONFIG PRECEDENCE
- TOPOLOGIES: btrfs_single, bcachefs_single, dual_independent, bcachefs_2copy, ssd_hdd_bcachefs, btrfs_raid1 - TOPOLOGIES: btrfs_single, bcachefs_single, dual_independent, bcachefs-2copy, ssd_hdd_bcachefs, btrfs_raid1
- SAFETY AND IDEMPOTENCY - SAFETY AND IDEMPOTENCY
- REPORTS - REPORTS
- EXIT CODES: 0 success or already_provisioned, non-zero on error - EXIT CODES: 0 success or already_provisioned, non-zero on error
@@ -280,7 +280,7 @@ Scenarios to scaffold in [tests/](tests/)
- Single disk 40 GiB virtio: validates btrfs_single topology end-to-end smoke. - Single disk 40 GiB virtio: validates btrfs_single topology end-to-end smoke.
- Dual NVMe 40 GiB each: validates dual_independent topology (independent btrfs per disk). - Dual NVMe 40 GiB each: validates dual_independent topology (independent btrfs per disk).
- SSD NVMe + HDD virtio: validates ssd_hdd_bcachefs topology (bcachefs with SSD cache/promote, HDD backing). - SSD NVMe + HDD virtio: validates ssd_hdd_bcachefs topology (bcachefs with SSD cache/promote, HDD backing).
- Three disks: validates bcachefs_2copy across data partitions using `--replicas=2`. - Three disks: validates bcachefs-2copy across data partitions using `--replicas=2`.
- Negative: no eligible disks, or non-empty disk should abort. - Negative: no eligible disks, or non-empty disk should abort.
Test strategy Test strategy

View File

@@ -26,17 +26,17 @@ Decision
- Allowed cmdline overrides: btrfs_single, bcachefs_single - Allowed cmdline overrides: btrfs_single, bcachefs_single
- 2 eligible disks: - 2 eligible disks:
- Default: dual_independent - Default: dual_independent
- Allowed cmdline overrides: dual_independent, ssd_hdd_bcachefs, btrfs_raid1, bcachefs_2copy - Allowed cmdline overrides: dual_independent, ssd_hdd_bcachefs, btrfs_raid1, bcachefs-2copy
- >2 eligible disks: - >2 eligible disks:
- Default: btrfs_raid1 - Default: btrfs_raid1
- Allowed cmdline overrides: btrfs_raid1, bcachefs_2copy - Allowed cmdline overrides: btrfs_raid1, bcachefs-2copy
- Accept both snake_case and hyphenated forms for VALUE; normalize to [enum Topology](../../src/types.rs:1): - Accept both snake_case and hyphenated forms for VALUE; canonical for two-copy bcachefs is bcachefs-2copy; normalize to [enum Topology](../../src/types.rs:1):
- btrfs_single | btrfs-single - btrfs_single | btrfs-single
- bcachefs_single | bcachefs-single - bcachefs_single | bcachefs-single
- dual_independent | dual-independent - dual_independent | dual-independent
- ssd_hdd_bcachefs | ssd-hdd-bcachefs - ssd_hdd_bcachefs | ssd-hdd-bcachefs
- btrfs_raid1 | btrfs-raid1 - btrfs_raid1 | btrfs-raid1
- bcachefs_2copy | bcachefs-2copy - bcachefs-2copy
- Kernel cmdline parsing beyond topology is deferred; future extensions for VM workflows may be proposed separately. - Kernel cmdline parsing beyond topology is deferred; future extensions for VM workflows may be proposed separately.
Rationale Rationale
@@ -67,7 +67,7 @@ Defaults (authoritative)
- Filesystems: - Filesystems:
- ESP: vfat labeled ZOSBOOT - ESP: vfat labeled ZOSBOOT
- Data: label ZOSDATA - Data: label ZOSDATA
- Backend per topology (btrfs for btrfs_*; bcachefs for ssd_hdd_bcachefs and bcachefs_2copy) - Backend per topology (btrfs for btrfs_*; bcachefs for ssd_hdd_bcachefs and bcachefs-2copy)
- Mount scheme: - Mount scheme:
- Root-mount all data filesystems under /var/mounts/{UUID}; final subvolume/subdir mounts from the primary data FS to /var/cache/{system,etc,modules,vm-meta}; fstab remains optional. - Root-mount all data filesystems under /var/mounts/{UUID}; final subvolume/subdir mounts from the primary data FS to /var/cache/{system,etc,modules,vm-meta}; fstab remains optional.
- Idempotency: - Idempotency:

View File

@@ -85,24 +85,24 @@ pub struct Cli {
/// Include removable devices (e.g., USB sticks) during discovery (default: false) /// Include removable devices (e.g., USB sticks) during discovery (default: false)
#[arg(long = "allow-removable", default_value_t = false)] #[arg(long = "allow-removable", default_value_t = false)]
pub allow_removable: bool, pub allow_removable: bool,
/// Attempt to mount existing filesystems based on on-disk headers; no partitioning or mkfs. /// Attempt to mount existing filesystems based on on-disk headers; no partitioning or mkfs.
/// Non-destructive mounting flow; uses UUID= sources and policy from config. /// Non-destructive mounting flow; uses UUID= sources and policy from config.
#[arg(long = "mount-existing", default_value_t = false)] #[arg(long = "mount-existing", default_value_t = false)]
pub mount_existing: bool, pub mount_existing: bool,
/// Report current initialized filesystems and mounts without performing changes. /// Report current initialized filesystems and mounts without performing changes.
#[arg(long = "report-current", default_value_t = false)] #[arg(long = "report-current", default_value_t = false)]
pub report_current: bool, pub report_current: bool,
/// Print detection and planning summary as JSON to stdout (non-default) /// Print detection and planning summary as JSON to stdout (non-default)
#[arg(long = "show", default_value_t = false)] #[arg(long = "show", default_value_t = false)]
pub show: bool, pub show: bool,
/// Write detection/planning JSON report to the given path /// Write detection/planning JSON report to the given path
#[arg(long = "report")] #[arg(long = "report")]
pub report: Option<String>, pub report: Option<String>,
/// Execute destructive actions (apply mode). When false, runs preview-only. /// Execute destructive actions (apply mode). When false, runs preview-only.
#[arg(long = "apply", default_value_t = false)] #[arg(long = "apply", default_value_t = false)]
pub apply: bool, pub apply: bool,
@@ -111,4 +111,4 @@ pub struct Cli {
/// Parse CLI arguments (non-interactive; suitable for initramfs). /// Parse CLI arguments (non-interactive; suitable for initramfs).
pub fn from_args() -> Cli { pub fn from_args() -> Cli {
Cli::parse() Cli::parse()
} }

View File

@@ -10,4 +10,4 @@
pub mod args; pub mod args;
pub use args::*; pub use args::*;

View File

@@ -42,8 +42,8 @@
use std::fs; use std::fs;
use crate::{cli::Cli, Error, Result};
use crate::types::*; use crate::types::*;
use crate::{Error, Result, cli::Cli};
use serde_json::{Map, Value, json}; use serde_json::{Map, Value, json};
use tracing::warn; use tracing::warn;
@@ -77,17 +77,17 @@ pub fn load_and_merge(cli: &Cli) -> Result<Config> {
let cli_overlay = cli_overlay_value(cli); let cli_overlay = cli_overlay_value(cli);
merge_value(&mut merged, cli_overlay); merge_value(&mut merged, cli_overlay);
// 5) Kernel cmdline topology override only when CLI did not provide topology // 5) Kernel cmdline topology override only when CLI did not provide topology
if cli.topology.is_none() { if cli.topology.is_none() {
if let Some(topo) = kernel_cmdline_topology() { if let Some(topo) = kernel_cmdline_topology() {
merge_value(&mut merged, json!({"topology": topo.to_string()})); merge_value(&mut merged, json!({"topology": topo.to_string()}));
} }
} }
// Finalize // Finalize
let cfg: Config = serde_json::from_value(merged).map_err(|e| Error::Other(e.into()))?; let cfg: Config = serde_json::from_value(merged).map_err(|e| Error::Other(e.into()))?;
validate(&cfg)?; validate(&cfg)?;
Ok(cfg) Ok(cfg)
} }
/// Validate semantic correctness of the configuration. /// Validate semantic correctness of the configuration.
@@ -128,43 +128,50 @@ pub fn validate(cfg: &Config) -> Result<()> {
} }
// Reserved GPT names // Reserved GPT names
if cfg.partitioning.esp.gpt_name != "zosboot" { if cfg.partitioning.esp.gpt_name != GPT_NAME_ZOSBOOT {
return Err(Error::Validation( return Err(Error::Validation(format!(
"partitioning.esp.gpt_name must be 'zosboot'".into(), "partitioning.esp.gpt_name must be '{}'",
)); GPT_NAME_ZOSBOOT
)));
} }
if cfg.partitioning.data.gpt_name != "zosdata" { if cfg.partitioning.data.gpt_name != GPT_NAME_ZOSDATA {
return Err(Error::Validation( return Err(Error::Validation(format!(
"partitioning.data.gpt_name must be 'zosdata'".into(), "partitioning.data.gpt_name must be '{}'",
)); GPT_NAME_ZOSDATA
)));
} }
if cfg.partitioning.cache.gpt_name != "zoscache" { if cfg.partitioning.cache.gpt_name != GPT_NAME_ZOSCACHE {
return Err(Error::Validation( return Err(Error::Validation(format!(
"partitioning.cache.gpt_name must be 'zoscache'".into(), "partitioning.cache.gpt_name must be '{}'",
)); GPT_NAME_ZOSCACHE
)));
} }
// BIOS boot name is also 'zosboot' per current assumption // BIOS boot name is also 'zosboot' per current assumption
if cfg.partitioning.bios_boot.gpt_name != "zosboot" { if cfg.partitioning.bios_boot.gpt_name != GPT_NAME_ZOSBOOT {
return Err(Error::Validation( return Err(Error::Validation(format!(
"partitioning.bios_boot.gpt_name must be 'zosboot'".into(), "partitioning.bios_boot.gpt_name must be '{}'",
)); GPT_NAME_ZOSBOOT
)));
} }
// Reserved filesystem labels // Reserved filesystem labels
if cfg.filesystem.vfat.label != "ZOSBOOT" { if cfg.filesystem.vfat.label != LABEL_ZOSBOOT {
return Err(Error::Validation( return Err(Error::Validation(format!(
"filesystem.vfat.label must be 'ZOSBOOT'".into(), "filesystem.vfat.label must be '{}'",
)); LABEL_ZOSBOOT
)));
} }
if cfg.filesystem.btrfs.label != "ZOSDATA" { if cfg.filesystem.btrfs.label != LABEL_ZOSDATA {
return Err(Error::Validation( return Err(Error::Validation(format!(
"filesystem.btrfs.label must be 'ZOSDATA'".into(), "filesystem.btrfs.label must be '{}'",
)); LABEL_ZOSDATA
)));
} }
if cfg.filesystem.bcachefs.label != "ZOSDATA" { if cfg.filesystem.bcachefs.label != LABEL_ZOSDATA {
return Err(Error::Validation( return Err(Error::Validation(format!(
"filesystem.bcachefs.label must be 'ZOSDATA'".into(), "filesystem.bcachefs.label must be '{}'",
)); LABEL_ZOSDATA
)));
} }
// Mount scheme // Mount scheme
@@ -181,7 +188,9 @@ pub fn validate(cfg: &Config) -> Result<()> {
Topology::Bcachefs2Copy => {} Topology::Bcachefs2Copy => {}
Topology::BtrfsRaid1 => { Topology::BtrfsRaid1 => {
// No enforced requirement here beyond presence of two disks at runtime. // No enforced requirement here beyond presence of two disks at runtime.
if cfg.filesystem.btrfs.raid_profile != "raid1" && cfg.filesystem.btrfs.raid_profile != "none" { if cfg.filesystem.btrfs.raid_profile != "raid1"
&& cfg.filesystem.btrfs.raid_profile != "none"
{
return Err(Error::Validation( return Err(Error::Validation(
"filesystem.btrfs.raid_profile must be 'none' or 'raid1'".into(), "filesystem.btrfs.raid_profile must be 'none' or 'raid1'".into(),
)); ));
@@ -203,7 +212,6 @@ fn to_value<T: serde::Serialize>(t: T) -> Result<Value> {
serde_json::to_value(t).map_err(|e| Error::Other(e.into())) serde_json::to_value(t).map_err(|e| Error::Other(e.into()))
} }
/// Merge b into a in-place: /// Merge b into a in-place:
/// - Objects are merged key-by-key (recursively) /// - Objects are merged key-by-key (recursively)
/// - Arrays and scalars replace /// - Arrays and scalars replace
@@ -254,7 +262,7 @@ fn cli_overlay_value(cli: &Cli) -> Value {
if let Some(t) = cli.topology.as_ref() { if let Some(t) = cli.topology.as_ref() {
root.insert("topology".into(), Value::String(t.to_string())); root.insert("topology".into(), Value::String(t.to_string()));
} }
Value::Object(root) Value::Object(root)
} }
@@ -270,7 +278,9 @@ pub fn kernel_cmdline_topology() -> Option<Topology> {
val_opt = Some(v); val_opt = Some(v);
} }
if let Some(mut val) = val_opt { if let Some(mut val) = val_opt {
if (val.starts_with('"') && val.ends_with('"')) || (val.starts_with('\'') && val.ends_with('\'')) { if (val.starts_with('"') && val.ends_with('"'))
|| (val.starts_with('\'') && val.ends_with('\''))
{
val = &val[1..val.len() - 1]; val = &val[1..val.len() - 1];
} }
let val_norm = val.trim(); let val_norm = val.trim();
@@ -282,7 +292,8 @@ pub fn kernel_cmdline_topology() -> Option<Topology> {
None None
} }
/// Helper to parse known topology tokens in kebab- or snake-case. //// Helper to parse known topology tokens (canonical names only).
//// Note: underscores are normalized to hyphens prior to matching.
fn parse_topology_token(s: &str) -> Option<Topology> { fn parse_topology_token(s: &str) -> Option<Topology> {
let k = s.trim().to_ascii_lowercase().replace('_', "-"); let k = s.trim().to_ascii_lowercase().replace('_', "-");
match k.as_str() { match k.as_str() {
@@ -290,7 +301,8 @@ fn parse_topology_token(s: &str) -> Option<Topology> {
"bcachefs-single" => Some(Topology::BcachefsSingle), "bcachefs-single" => Some(Topology::BcachefsSingle),
"dual-independent" => Some(Topology::DualIndependent), "dual-independent" => Some(Topology::DualIndependent),
"ssd-hdd-bcachefs" => Some(Topology::SsdHddBcachefs), "ssd-hdd-bcachefs" => Some(Topology::SsdHddBcachefs),
"bcachefs2-copy" | "bcachefs-2copy" | "bcachefs-2-copy" => Some(Topology::Bcachefs2Copy), // Canonical single notation for two-copy bcachefs topology
"bcachefs-2copy" => Some(Topology::Bcachefs2Copy),
"btrfs-raid1" => Some(Topology::BtrfsRaid1), "btrfs-raid1" => Some(Topology::BtrfsRaid1),
_ => None, _ => None,
} }
@@ -365,4 +377,4 @@ fn default_config() -> Config {
path: "/run/zosstorage/state.json".into(), path: "/run/zosstorage/state.json".into(),
}, },
} }
} }

View File

@@ -11,5 +11,5 @@
pub mod loader; pub mod loader;
pub use loader::{load_and_merge, validate};
pub use crate::types::*; pub use crate::types::*;
pub use loader::{load_and_merge, validate};

View File

@@ -186,7 +186,10 @@ pub fn discover(filter: &DeviceFilter) -> Result<Vec<Disk>> {
discover_with_provider(&provider, filter) discover_with_provider(&provider, filter)
} }
fn discover_with_provider<P: DeviceProvider>(provider: &P, filter: &DeviceFilter) -> Result<Vec<Disk>> { fn discover_with_provider<P: DeviceProvider>(
provider: &P,
filter: &DeviceFilter,
) -> Result<Vec<Disk>> {
let mut candidates = provider.list_block_devices()?; let mut candidates = provider.list_block_devices()?;
// Probe properties if provider needs to enrich // Probe properties if provider needs to enrich
for d in &mut candidates { for d in &mut candidates {
@@ -210,10 +213,15 @@ fn discover_with_provider<P: DeviceProvider>(provider: &P, filter: &DeviceFilter
.collect(); .collect();
if filtered.is_empty() { if filtered.is_empty() {
return Err(Error::Device("no eligible disks found after applying filters".to_string())); return Err(Error::Device(
"no eligible disks found after applying filters".to_string(),
));
} }
debug!("eligible disks: {:?}", filtered.iter().map(|d| &d.path).collect::<Vec<_>>()); debug!(
"eligible disks: {:?}",
filtered.iter().map(|d| &d.path).collect::<Vec<_>>()
);
Ok(filtered) Ok(filtered)
} }
@@ -259,9 +267,10 @@ fn read_disk_size_bytes(name: &str) -> Result<u64> {
let p = sys_block_path(name).join("size"); let p = sys_block_path(name).join("size");
let sectors = fs::read_to_string(&p) let sectors = fs::read_to_string(&p)
.map_err(|e| Error::Device(format!("read {} failed: {}", p.display(), e)))?; .map_err(|e| Error::Device(format!("read {} failed: {}", p.display(), e)))?;
let sectors: u64 = sectors.trim().parse().map_err(|e| { let sectors: u64 = sectors
Error::Device(format!("parse sectors for {} failed: {}", name, e)) .trim()
})?; .parse()
.map_err(|e| Error::Device(format!("parse sectors for {} failed: {}", name, e)))?;
Ok(sectors.saturating_mul(512)) Ok(sectors.saturating_mul(512))
} }
@@ -287,11 +296,7 @@ fn read_optional_string(p: PathBuf) -> Option<String> {
while s.ends_with('\n') || s.ends_with('\r') { while s.ends_with('\n') || s.ends_with('\r') {
s.pop(); s.pop();
} }
if s.is_empty() { if s.is_empty() { None } else { Some(s) }
None
} else {
Some(s)
}
} }
Err(_) => None, Err(_) => None,
} }
@@ -324,9 +329,27 @@ mod tests {
fn filter_by_size_and_include_exclude() { fn filter_by_size_and_include_exclude() {
let provider = MockProvider { let provider = MockProvider {
disks: vec![ disks: vec![
Disk { path: "/dev/sda".into(), size_bytes: 500 * 1024 * 1024 * 1024, rotational: true, model: None, serial: None }, // 500 GiB Disk {
Disk { path: "/dev/nvme0n1".into(), size_bytes: 128 * 1024 * 1024 * 1024, rotational: false, model: None, serial: None }, // 128 GiB path: "/dev/sda".into(),
Disk { path: "/dev/loop0".into(), size_bytes: 8 * 1024 * 1024 * 1024, rotational: false, model: None, serial: None }, // 8 GiB pseudo (but mock provider supplies it) size_bytes: 500 * 1024 * 1024 * 1024,
rotational: true,
model: None,
serial: None,
}, // 500 GiB
Disk {
path: "/dev/nvme0n1".into(),
size_bytes: 128 * 1024 * 1024 * 1024,
rotational: false,
model: None,
serial: None,
}, // 128 GiB
Disk {
path: "/dev/loop0".into(),
size_bytes: 8 * 1024 * 1024 * 1024,
rotational: false,
model: None,
serial: None,
}, // 8 GiB pseudo (but mock provider supplies it)
], ],
}; };
@@ -346,7 +369,13 @@ mod tests {
fn no_match_returns_error() { fn no_match_returns_error() {
let provider = MockProvider { let provider = MockProvider {
disks: vec![ disks: vec![
Disk { path: "/dev/sdb".into(), size_bytes: 50 * 1024 * 1024 * 1024, rotational: true, model: None, serial: None }, // 50 GiB Disk {
path: "/dev/sdb".into(),
size_bytes: 50 * 1024 * 1024 * 1024,
rotational: true,
model: None,
serial: None,
}, // 50 GiB
], ],
}; };
@@ -363,4 +392,4 @@ mod tests {
other => panic!("unexpected error: {:?}", other), other => panic!("unexpected error: {:?}", other),
} }
} }
} }

View File

@@ -9,4 +9,4 @@
pub mod discovery; pub mod discovery;
pub use discovery::*; pub use discovery::*;

View File

@@ -53,4 +53,4 @@ pub enum Error {
} }
/// Crate-wide result alias. /// Crate-wide result alias.
pub type Result<T> = std::result::Result<T, Error>; pub type Result<T> = std::result::Result<T, Error>;

View File

@@ -9,4 +9,4 @@
pub mod plan; pub mod plan;
pub use plan::*; pub use plan::*;

View File

@@ -18,21 +18,21 @@
// ext: dry-run mode to emit mkfs commands without executing (future). // ext: dry-run mode to emit mkfs commands without executing (future).
// REGION: EXTENSION_POINTS-END // REGION: EXTENSION_POINTS-END
// //
// REGION: SAFETY // REGION: SAFETY
// safety: must not run mkfs on non-empty or unexpected partitions; assume prior validation enforced. // safety: must not run mkfs on non-empty or unexpected partitions; assume prior validation enforced.
// safety: ensure labels follow reserved semantics (ZOSBOOT for ESP, ZOSDATA for all data FS). // safety: ensure labels follow reserved semantics (ZOSBOOT for ESP, ZOSDATA for all data FS).
// safety: mkfs.btrfs uses -f in apply path immediately after partitioning to handle leftover signatures. // safety: mkfs.btrfs uses -f in apply path immediately after partitioning to handle leftover signatures.
// REGION: SAFETY-END // REGION: SAFETY-END
// //
// REGION: ERROR_MAPPING // REGION: ERROR_MAPPING
// errmap: external mkfs/blkid failures -> crate::Error::Tool with captured stderr. // errmap: external mkfs/blkid failures -> crate::Error::Tool with captured stderr.
// errmap: planning mismatches -> crate::Error::Filesystem with context. // errmap: planning mismatches -> crate::Error::Filesystem with context.
// REGION: ERROR_MAPPING-END // REGION: ERROR_MAPPING-END
// //
// REGION: TODO // REGION: TODO
// todo: bcachefs tuning flags mapping from config (compression/checksum/cache_mode) deferred // todo: bcachefs tuning flags mapping from config (compression/checksum/cache_mode) deferred
// todo: add UUID consistency checks across multi-device filesystems // todo: add UUID consistency checks across multi-device filesystems
// REGION: TODO-END // REGION: TODO-END
//! Filesystem planning and creation for zosstorage. //! Filesystem planning and creation for zosstorage.
//! //!
//! Maps partition results to concrete filesystems (vfat, btrfs, bcachefs) //! Maps partition results to concrete filesystems (vfat, btrfs, bcachefs)
@@ -42,14 +42,13 @@
//! [fn make_filesystems](plan.rs:1). //! [fn make_filesystems](plan.rs:1).
use crate::{ use crate::{
Result, Error, Result,
partition::{PartRole, PartitionResult},
types::{Config, Topology}, types::{Config, Topology},
partition::{PartitionResult, PartRole},
util::{run_cmd, run_cmd_capture, which_tool}, util::{run_cmd, run_cmd_capture, which_tool},
Error,
}; };
use tracing::{debug, warn};
use std::fs; use std::fs;
use tracing::{debug, warn};
/// Filesystem kinds supported by zosstorage. /// Filesystem kinds supported by zosstorage.
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
@@ -97,17 +96,14 @@ pub struct FsResult {
pub label: String, pub label: String,
} }
/// Determine which partitions get which filesystem based on topology. /// Determine which partitions get which filesystem based on topology.
/// ///
/// Rules: /// Rules:
/// - ESP partitions => Vfat with label from cfg.filesystem.vfat.label (reserved "ZOSBOOT") /// - ESP partitions => Vfat with label from cfg.filesystem.vfat.label (reserved "ZOSBOOT")
/// - Data partitions => Btrfs with label cfg.filesystem.btrfs.label ("ZOSDATA"), unless topology SsdHddBcachefs /// - Data partitions => Btrfs with label cfg.filesystem.btrfs.label ("ZOSDATA"), unless topology SsdHddBcachefs
/// - SsdHddBcachefs => pair one Cache partition (SSD) with one Data partition (HDD) into one Bcachefs FsSpec with devices [cache, data] and label cfg.filesystem.bcachefs.label ("ZOSDATA") /// - SsdHddBcachefs => pair one Cache partition (SSD) with one Data partition (HDD) into one Bcachefs FsSpec with devices [cache, data] and label cfg.filesystem.bcachefs.label ("ZOSDATA")
/// - DualIndependent/BtrfsRaid1 => map each Data partition to its own Btrfs FsSpec (raid profile concerns are handled later during mkfs) /// - DualIndependent/BtrfsRaid1 => map each Data partition to its own Btrfs FsSpec (raid profile concerns are handled later during mkfs)
pub fn plan_filesystems( pub fn plan_filesystems(parts: &[PartitionResult], cfg: &Config) -> Result<FsPlan> {
parts: &[PartitionResult],
cfg: &Config,
) -> Result<FsPlan> {
let mut specs: Vec<FsSpec> = Vec::new(); let mut specs: Vec<FsSpec> = Vec::new();
// Always map ESP partitions // Always map ESP partitions
@@ -122,10 +118,22 @@ pub fn plan_filesystems(
match cfg.topology { match cfg.topology {
Topology::SsdHddBcachefs => { Topology::SsdHddBcachefs => {
// Expect exactly one cache (SSD) and at least one data (HDD). Use the first data for pairing. // Expect exactly one cache (SSD) and at least one data (HDD). Use the first data for pairing.
let cache = parts.iter().find(|p| matches!(p.role, PartRole::Cache)) let cache = parts
.ok_or_else(|| Error::Filesystem("expected a Cache partition for SsdHddBcachefs topology".to_string()))?; .iter()
let data = parts.iter().find(|p| matches!(p.role, PartRole::Data)) .find(|p| matches!(p.role, PartRole::Cache))
.ok_or_else(|| Error::Filesystem("expected a Data partition for SsdHddBcachefs topology".to_string()))?; .ok_or_else(|| {
Error::Filesystem(
"expected a Cache partition for SsdHddBcachefs topology".to_string(),
)
})?;
let data = parts
.iter()
.find(|p| matches!(p.role, PartRole::Data))
.ok_or_else(|| {
Error::Filesystem(
"expected a Data partition for SsdHddBcachefs topology".to_string(),
)
})?;
specs.push(FsSpec { specs.push(FsSpec {
kind: FsKind::Bcachefs, kind: FsKind::Bcachefs,
@@ -173,8 +181,14 @@ pub fn plan_filesystems(
} }
Topology::BcachefsSingle => { Topology::BcachefsSingle => {
// Single-device bcachefs on the sole Data partition. // Single-device bcachefs on the sole Data partition.
let data = parts.iter().find(|p| matches!(p.role, PartRole::Data)) let data = parts
.ok_or_else(|| Error::Filesystem("expected a Data partition for BcachefsSingle topology".to_string()))?; .iter()
.find(|p| matches!(p.role, PartRole::Data))
.ok_or_else(|| {
Error::Filesystem(
"expected a Data partition for BcachefsSingle topology".to_string(),
)
})?;
specs.push(FsSpec { specs.push(FsSpec {
kind: FsKind::Bcachefs, kind: FsKind::Bcachefs,
devices: vec![data.device_path.clone()], devices: vec![data.device_path.clone()],
@@ -194,7 +208,9 @@ pub fn plan_filesystems(
} }
if specs.is_empty() { if specs.is_empty() {
return Err(Error::Filesystem("no filesystems to create from provided partitions".to_string())); return Err(Error::Filesystem(
"no filesystems to create from provided partitions".to_string(),
));
} }
Ok(FsPlan { specs }) Ok(FsPlan { specs })
@@ -215,7 +231,9 @@ pub fn make_filesystems(plan: &FsPlan, cfg: &Config) -> Result<Vec<FsResult>> {
let blkid_tool = which_tool("blkid")?; let blkid_tool = which_tool("blkid")?;
if blkid_tool.is_none() { if blkid_tool.is_none() {
return Err(Error::Filesystem("blkid not found in PATH; cannot capture filesystem UUIDs".into())); return Err(Error::Filesystem(
"blkid not found in PATH; cannot capture filesystem UUIDs".into(),
));
} }
let blkid = blkid_tool.unwrap(); let blkid = blkid_tool.unwrap();
@@ -248,7 +266,9 @@ pub fn make_filesystems(plan: &FsPlan, cfg: &Config) -> Result<Vec<FsResult>> {
return Err(Error::Filesystem("mkfs.btrfs not found in PATH".into())); return Err(Error::Filesystem("mkfs.btrfs not found in PATH".into()));
}; };
if spec.devices.is_empty() { if spec.devices.is_empty() {
return Err(Error::Filesystem("btrfs requires at least one device".into())); return Err(Error::Filesystem(
"btrfs requires at least one device".into(),
));
} }
// mkfs.btrfs -L LABEL [ -m raid1 -d raid1 (when multi-device/raid1) ] dev1 [dev2 ...] // mkfs.btrfs -L LABEL [ -m raid1 -d raid1 (when multi-device/raid1) ] dev1 [dev2 ...]
let mut args: Vec<String> = vec![mkfs.clone(), "-L".into(), spec.label.clone()]; let mut args: Vec<String> = vec![mkfs.clone(), "-L".into(), spec.label.clone()];
@@ -288,11 +308,18 @@ pub fn make_filesystems(plan: &FsPlan, cfg: &Config) -> Result<Vec<FsResult>> {
return Err(Error::Filesystem("bcachefs not found in PATH".into())); return Err(Error::Filesystem("bcachefs not found in PATH".into()));
}; };
if spec.devices.is_empty() { if spec.devices.is_empty() {
return Err(Error::Filesystem("bcachefs requires at least one device".into())); return Err(Error::Filesystem(
"bcachefs requires at least one device".into(),
));
} }
// bcachefs format --label LABEL [--replicas=2] dev1 [dev2 ...] // bcachefs format --label LABEL [--replicas=2] dev1 [dev2 ...]
// Apply replicas policy for Bcachefs2Copy topology (data+metadata replicas = 2) // Apply replicas policy for Bcachefs2Copy topology (data+metadata replicas = 2)
let mut args: Vec<String> = vec![mkfs.clone(), "format".into(), "--label".into(), spec.label.clone()]; let mut args: Vec<String> = vec![
mkfs.clone(),
"format".into(),
"--label".into(),
spec.label.clone(),
];
if matches!(cfg.topology, Topology::Bcachefs2Copy) { if matches!(cfg.topology, Topology::Bcachefs2Copy) {
args.push("--replicas=2".into()); args.push("--replicas=2".into());
} }
@@ -318,29 +345,32 @@ pub fn make_filesystems(plan: &FsPlan, cfg: &Config) -> Result<Vec<FsResult>> {
} }
fn capture_uuid(blkid: &str, dev: &str) -> Result<String> { fn capture_uuid(blkid: &str, dev: &str) -> Result<String> {
// blkid -o export /dev/... // blkid -o export /dev/...
let out = run_cmd_capture(&[blkid, "-o", "export", dev])?; let out = run_cmd_capture(&[blkid, "-o", "export", dev])?;
let map = parse_blkid_export(&out.stdout); let map = parse_blkid_export(&out.stdout);
// Prefer ID_FS_UUID if present, fall back to UUID // Prefer ID_FS_UUID if present, fall back to UUID
if let Some(u) = map.get("ID_FS_UUID") { if let Some(u) = map.get("ID_FS_UUID") {
return Ok(u.clone()); return Ok(u.clone());
} }
if let Some(u) = map.get("UUID") { if let Some(u) = map.get("UUID") {
return Ok(u.clone()); return Ok(u.clone());
} }
warn!("blkid did not report UUID for {}", dev); warn!("blkid did not report UUID for {}", dev);
Err(Error::Filesystem(format!("missing UUID in blkid output for {}", dev))) Err(Error::Filesystem(format!(
"missing UUID in blkid output for {}",
dev
)))
} }
/// Minimal parser for blkid -o export KEY=VAL lines. /// Minimal parser for blkid -o export KEY=VAL lines.
fn parse_blkid_export(s: &str) -> std::collections::HashMap<String, String> { fn parse_blkid_export(s: &str) -> std::collections::HashMap<String, String> {
let mut map = std::collections::HashMap::new(); let mut map = std::collections::HashMap::new();
for line in s.lines() { for line in s.lines() {
if let Some((k, v)) = line.split_once('=') { if let Some((k, v)) = line.split_once('=') {
map.insert(k.trim().to_string(), v.trim().to_string()); map.insert(k.trim().to_string(), v.trim().to_string());
} }
} }
map map
} }
/// Probe existing filesystems on the system and return their identities (kind, uuid, label). /// Probe existing filesystems on the system and return their identities (kind, uuid, label).
@@ -354,13 +384,16 @@ fn parse_blkid_export(s: &str) -> std::collections::HashMap<String, String> {
/// - Vec<FsResult> with at most one entry per filesystem UUID. /// - Vec<FsResult> with at most one entry per filesystem UUID.
pub fn probe_existing_filesystems() -> Result<Vec<FsResult>> { pub fn probe_existing_filesystems() -> Result<Vec<FsResult>> {
let Some(blkid) = which_tool("blkid")? else { let Some(blkid) = which_tool("blkid")? else {
return Err(Error::Filesystem("blkid not found in PATH; cannot probe existing filesystems".into())); return Err(Error::Filesystem(
"blkid not found in PATH; cannot probe existing filesystems".into(),
));
}; };
let content = fs::read_to_string("/proc/partitions") let content = fs::read_to_string("/proc/partitions")
.map_err(|e| Error::Filesystem(format!("/proc/partitions read error: {}", e)))?; .map_err(|e| Error::Filesystem(format!("/proc/partitions read error: {}", e)))?;
let mut results_by_uuid: std::collections::HashMap<String, FsResult> = std::collections::HashMap::new(); let mut results_by_uuid: std::collections::HashMap<String, FsResult> =
std::collections::HashMap::new();
for line in content.lines() { for line in content.lines() {
let line = line.trim(); let line = line.trim();
@@ -399,11 +432,13 @@ pub fn probe_existing_filesystems() -> Result<Vec<FsResult>> {
let map = parse_blkid_export(&out.stdout); let map = parse_blkid_export(&out.stdout);
let ty = map.get("TYPE").cloned().unwrap_or_default(); let ty = map.get("TYPE").cloned().unwrap_or_default();
let label = map let label = map
.get("ID_FS_LABEL").cloned() .get("ID_FS_LABEL")
.cloned()
.or_else(|| map.get("LABEL").cloned()) .or_else(|| map.get("LABEL").cloned())
.unwrap_or_default(); .unwrap_or_default();
let uuid = map let uuid = map
.get("ID_FS_UUID").cloned() .get("ID_FS_UUID")
.cloned()
.or_else(|| map.get("UUID").cloned()); .or_else(|| map.get("UUID").cloned());
let (kind_opt, expected_label) = match ty.as_str() { let (kind_opt, expected_label) = match ty.as_str() {
@@ -434,13 +469,13 @@ pub fn probe_existing_filesystems() -> Result<Vec<FsResult>> {
#[cfg(test)] #[cfg(test)]
mod tests_parse { mod tests_parse {
use super::parse_blkid_export; use super::parse_blkid_export;
#[test] #[test]
fn parse_export_ok() { fn parse_export_ok() {
let s = "ID_FS_UUID=abcd-1234\nUUID=abcd-1234\nTYPE=btrfs\n"; let s = "ID_FS_UUID=abcd-1234\nUUID=abcd-1234\nTYPE=btrfs\n";
let m = parse_blkid_export(s); let m = parse_blkid_export(s);
assert_eq!(m.get("ID_FS_UUID").unwrap(), "abcd-1234"); assert_eq!(m.get("ID_FS_UUID").unwrap(), "abcd-1234");
assert_eq!(m.get("TYPE").unwrap(), "btrfs"); assert_eq!(m.get("TYPE").unwrap(), "btrfs");
} }
} }

View File

@@ -28,14 +28,14 @@
//! disks are empty before making any destructive changes. //! disks are empty before making any destructive changes.
use crate::{ use crate::{
device::Disk,
report::{StateReport, REPORT_VERSION},
util::{run_cmd_capture, which_tool},
Error, Result, Error, Result,
device::Disk,
report::{REPORT_VERSION, StateReport},
util::{run_cmd_capture, which_tool},
}; };
use humantime::format_rfc3339;
use serde_json::json; use serde_json::json;
use std::{collections::HashMap, fs, path::Path}; use std::{collections::HashMap, fs, path::Path};
use humantime::format_rfc3339;
use tracing::{debug, warn}; use tracing::{debug, warn};
/// Return existing state if system is already provisioned; otherwise None. /// Return existing state if system is already provisioned; otherwise None.
@@ -155,7 +155,10 @@ pub fn is_empty_disk(disk: &Disk) -> Result<bool> {
// Probe with blkid -p // Probe with blkid -p
let Some(blkid) = which_tool("blkid")? else { let Some(blkid) = which_tool("blkid")? else {
warn!("blkid not found; conservatively treating {} as not empty", disk.path); warn!(
"blkid not found; conservatively treating {} as not empty",
disk.path
);
return Ok(false); return Ok(false);
}; };
@@ -237,7 +240,11 @@ fn is_partition_of(base: &str, name: &str) -> bool {
if name == base { if name == base {
return false; return false;
} }
let ends_with_digit = base.chars().last().map(|c| c.is_ascii_digit()).unwrap_or(false); let ends_with_digit = base
.chars()
.last()
.map(|c| c.is_ascii_digit())
.unwrap_or(false);
if ends_with_digit { if ends_with_digit {
// nvme0n1 -> nvme0n1p1 // nvme0n1 -> nvme0n1p1
if name.starts_with(base) { if name.starts_with(base) {
@@ -281,4 +288,4 @@ mod tests {
assert!(!is_partition_of("nvme0n1", "nvme0n1")); assert!(!is_partition_of("nvme0n1", "nvme0n1"));
assert!(!is_partition_of("nvme0n1", "nvme0n2p1")); assert!(!is_partition_of("nvme0n1", "nvme0n2p1"));
} }
} }

View File

@@ -1,20 +1,20 @@
//! Crate root for zosstorage: one-shot disk provisioning utility for initramfs. //! Crate root for zosstorage: one-shot disk provisioning utility for initramfs.
pub mod cli; pub mod cli;
pub mod logging;
pub mod config; pub mod config;
pub mod device; pub mod device;
pub mod partition;
pub mod fs;
pub mod mount;
pub mod report;
pub mod orchestrator;
pub mod idempotency;
pub mod util;
pub mod errors; pub mod errors;
pub mod types; // top-level types (moved from config/types.rs for visibility) pub mod fs;
pub mod idempotency;
pub mod logging;
pub mod mount;
pub mod orchestrator;
pub mod partition;
pub mod report;
pub mod types;
pub mod util; // top-level types (moved from config/types.rs for visibility)
pub use errors::{Error, Result}; pub use errors::{Error, Result};
/// Crate version string from Cargo. /// Crate version string from Cargo.
pub const VERSION: &str = env!("CARGO_PKG_VERSION"); pub const VERSION: &str = env!("CARGO_PKG_VERSION");

View File

@@ -36,10 +36,10 @@ use std::fs::OpenOptions;
use std::io::{self}; use std::io::{self};
use std::sync::OnceLock; use std::sync::OnceLock;
use tracing::Level; use tracing::Level;
use tracing_subscriber::filter::LevelFilter;
use tracing_subscriber::fmt; use tracing_subscriber::fmt;
use tracing_subscriber::prelude::*; use tracing_subscriber::prelude::*;
use tracing_subscriber::registry::Registry; use tracing_subscriber::registry::Registry;
use tracing_subscriber::filter::LevelFilter;
use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::util::SubscriberInitExt;
/// Logging options resolved from CLI and/or config. /// Logging options resolved from CLI and/or config.
@@ -116,21 +116,27 @@ pub fn init_logging(opts: &LogOptions) -> Result<()> {
.with(stderr_layer) .with(stderr_layer)
.with(file_layer) .with(file_layer)
.try_init() .try_init()
.map_err(|e| crate::Error::Other(anyhow::anyhow!("failed to set global logger: {}", e)))?; .map_err(|e| {
crate::Error::Other(anyhow::anyhow!("failed to set global logger: {}", e))
})?;
} else { } else {
// Fall back to stderr-only if file cannot be opened // Fall back to stderr-only if file cannot be opened
Registry::default() Registry::default()
.with(stderr_layer) .with(stderr_layer)
.try_init() .try_init()
.map_err(|e| crate::Error::Other(anyhow::anyhow!("failed to set global logger: {}", e)))?; .map_err(|e| {
crate::Error::Other(anyhow::anyhow!("failed to set global logger: {}", e))
})?;
} }
} else { } else {
Registry::default() Registry::default()
.with(stderr_layer) .with(stderr_layer)
.try_init() .try_init()
.map_err(|e| crate::Error::Other(anyhow::anyhow!("failed to set global logger: {}", e)))?; .map_err(|e| {
crate::Error::Other(anyhow::anyhow!("failed to set global logger: {}", e))
})?;
} }
let _ = INIT_GUARD.set(()); let _ = INIT_GUARD.set(());
Ok(()) Ok(())
} }

View File

@@ -56,6 +56,8 @@ fn real_main() -> Result<()> {
.with_report_current(cli.report_current) .with_report_current(cli.report_current)
.with_report_path(cli.report.clone()) .with_report_path(cli.report.clone())
.with_topology_from_cli(cli.topology.is_some()) .with_topology_from_cli(cli.topology.is_some())
.with_topology_from_cmdline(config::loader::kernel_cmdline_topology().is_some() && cli.topology.is_none()); .with_topology_from_cmdline(
config::loader::kernel_cmdline_topology().is_some() && cli.topology.is_none(),
);
orchestrator::run(&ctx) orchestrator::run(&ctx)
} }

View File

@@ -9,4 +9,4 @@
pub mod ops; pub mod ops;
pub use ops::*; pub use ops::*;

View File

@@ -7,13 +7,13 @@
// REGION: API-END // REGION: API-END
// //
// REGION: RESPONSIBILITIES // REGION: RESPONSIBILITIES
// - Implement mount phase only: plan root mounts under /var/mounts/{UUID}, ensure/plan subvols, and mount subvols to /var/cache/*. // - Implement mount phase only: plan root mounts under /var/mounts/{UUID} for data, mount ESP at /boot, ensure/plan subvols, and mount subvols to /var/cache/*.
// - Use UUID= sources, deterministic primary selection (first FsResult) for dual_independent. // - Use UUID= sources, deterministic primary selection (first FsResult) for dual_independent.
// - Generate fstab entries only for four subvol targets; exclude runtime root mounts. // - Generate fstab entries covering runtime roots (/var/mounts/{UUID}, /boot when present) followed by the four subvol targets.
// REGION: RESPONSIBILITIES-END // REGION: RESPONSIBILITIES-END
// //
// REGION: SAFETY // REGION: SAFETY
// - Never mount ESP; only Btrfs/Bcachefs data FS. Root btrfs mounts use subvolid=5 (top-level). // - Mount ESP (VFAT) read-write at /boot once; data roots use subvolid=5 (btrfs) or plain (bcachefs).
// - Create-if-missing subvolumes prior to subvol mounts; ensure directories exist. // - Create-if-missing subvolumes prior to subvol mounts; ensure directories exist.
// - Always use UUID= sources; no device paths. // - Always use UUID= sources; no device paths.
// - Bcachefs subvolume mounts use option key 'X-mount.subdir={name}' (not 'subvol='). // - Bcachefs subvolume mounts use option key 'X-mount.subdir={name}' (not 'subvol=').
@@ -36,18 +36,19 @@
#![allow(dead_code)] #![allow(dead_code)]
use crate::{ use crate::{
Error, Result,
fs::{FsKind, FsResult}, fs::{FsKind, FsResult},
types::Config, types::Config,
util::{run_cmd, run_cmd_capture, which_tool}, util::{run_cmd, run_cmd_capture, which_tool},
Error, Result,
}; };
use std::collections::HashMap; use std::collections::HashMap;
use std::fs::{create_dir_all, File}; use std::fs::{File, create_dir_all};
use std::io::Write; use std::io::Write;
use std::path::Path; use std::path::Path;
use tracing::info; use tracing::info;
const ROOT_BASE: &str = "/var/mounts"; const ROOT_BASE: &str = "/var/mounts";
const BOOT_TARGET: &str = "/boot";
const TARGET_SYSTEM: &str = "/var/cache/system"; const TARGET_SYSTEM: &str = "/var/cache/system";
const TARGET_ETC: &str = "/var/cache/etc"; const TARGET_ETC: &str = "/var/cache/etc";
const TARGET_MODULES: &str = "/var/cache/modules"; const TARGET_MODULES: &str = "/var/cache/modules";
@@ -119,21 +120,39 @@ fn source_matches_uuid(existing_source: &str, uuid: &str) -> bool {
false false
} }
fn disk_of_device(dev: &str) -> Option<String> {
let path = Path::new(dev);
let name = path.file_name()?.to_str()?;
let mut cutoff = name.len();
while cutoff > 0 && name.as_bytes()[cutoff - 1].is_ascii_digit() {
cutoff -= 1;
}
if cutoff == name.len() {
return Some(dev.to_string());
}
let mut disk = name[..cutoff].to_string();
if disk.ends_with('p') {
disk.pop();
}
let parent = path.parent()?.to_str().unwrap_or("/dev");
Some(format!("{}/{}", parent, disk))
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PlannedMount { pub struct PlannedMount {
pub uuid: String, // UUID string without prefix pub uuid: String, // UUID string without prefix
pub target: String, // absolute path pub target: String, // absolute path
pub fstype: String, // "btrfs" | "bcachefs" pub fstype: String, // "btrfs" | "bcachefs"
pub options: String, // e.g., "rw,noatime,subvolid=5" pub options: String, // e.g., "rw,noatime,subvolid=5"
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PlannedSubvolMount { pub struct PlannedSubvolMount {
pub uuid: String, // UUID of primary FS pub uuid: String, // UUID of primary FS
pub name: String, // subvol name (system/etc/modules/vm-meta) pub name: String, // subvol name (system/etc/modules/vm-meta)
pub target: String, // absolute final target pub target: String, // absolute final target
pub fstype: String, // "btrfs" | "bcachefs" pub fstype: String, // "btrfs" | "bcachefs"
pub options: String, // e.g., "rw,noatime,subvol=system" pub options: String, // e.g., "rw,noatime,subvol=system"
} }
/// Mount plan per policy. /// Mount plan per policy.
@@ -201,11 +220,36 @@ pub fn plan_mounts(fs_results: &[FsResult], _cfg: &Config) -> Result<MountPlan>
}); });
} }
// Determine primary UUID let primary = data[0];
let primary_uuid = Some(data[0].uuid.clone()); let primary_uuid = Some(primary.uuid.clone());
let primary_disk = primary.devices.first().and_then(|dev| disk_of_device(dev));
let mut chosen_esp: Option<&FsResult> = None;
let mut fallback_esp: Option<&FsResult> = None;
for esp in fs_results.iter().filter(|r| matches!(r.kind, FsKind::Vfat)) {
if fallback_esp.is_none() {
fallback_esp = Some(esp);
}
if let (Some(ref disk), Some(esp_disk)) = (
primary_disk.as_ref(),
esp.devices.first().and_then(|dev| disk_of_device(dev)),
) {
if esp_disk == **disk {
chosen_esp = Some(esp);
break;
}
}
}
if let Some(esp) = chosen_esp.or(fallback_esp) {
root_mounts.push(PlannedMount {
uuid: esp.uuid.clone(),
target: BOOT_TARGET.to_string(),
fstype: fstype_str(esp.kind).to_string(),
options: "rw".to_string(),
});
}
// Subvol mounts only from primary FS // Subvol mounts only from primary FS
let primary = data[0];
let mut subvol_mounts: Vec<PlannedSubvolMount> = Vec::new(); let mut subvol_mounts: Vec<PlannedSubvolMount> = Vec::new();
let fstype = fstype_str(primary.kind).to_string(); let fstype = fstype_str(primary.kind).to_string();
// Option key differs per filesystem: btrfs uses subvol=, bcachefs uses X-mount.subdir= // Option key differs per filesystem: btrfs uses subvol=, bcachefs uses X-mount.subdir=
@@ -349,14 +393,18 @@ pub fn apply_mounts(plan: &MountPlan) -> Result<Vec<MountResult>> {
if !exists { if !exists {
// Create subvolume // Create subvolume
let subvol_path = format!("{}/{}", root, sm.name); let subvol_path = format!("{}/{}", root, sm.name);
let args = [btrfs_tool.as_str(), "subvolume", "create", subvol_path.as_str()]; let args = [
btrfs_tool.as_str(),
"subvolume",
"create",
subvol_path.as_str(),
];
run_cmd(&args)?; run_cmd(&args)?;
} }
} }
} else if primary_kind == "bcachefs" { } else if primary_kind == "bcachefs" {
let bcachefs_tool = which_tool("bcachefs")?.ok_or_else(|| { let bcachefs_tool = which_tool("bcachefs")?
Error::Mount("required tool 'bcachefs' not found in PATH".into()) .ok_or_else(|| Error::Mount("required tool 'bcachefs' not found in PATH".into()))?;
})?;
for sm in &plan.subvol_mounts { for sm in &plan.subvol_mounts {
if &sm.uuid != primary_uuid { if &sm.uuid != primary_uuid {
continue; continue;
@@ -452,7 +500,7 @@ pub fn maybe_write_fstab(mounts: &[MountResult], cfg: &Config) -> Result<()> {
// Partition mount results into runtime root mounts and final subvolume targets. // Partition mount results into runtime root mounts and final subvolume targets.
let mut root_entries: Vec<&MountResult> = mounts let mut root_entries: Vec<&MountResult> = mounts
.iter() .iter()
.filter(|m| m.target.starts_with(ROOT_BASE)) .filter(|m| m.target.starts_with(ROOT_BASE) || m.target == BOOT_TARGET)
.collect(); .collect();
let wanted = [TARGET_ETC, TARGET_MODULES, TARGET_SYSTEM, TARGET_VM_META]; let wanted = [TARGET_ETC, TARGET_MODULES, TARGET_SYSTEM, TARGET_VM_META];
let mut subvol_entries: Vec<&MountResult> = mounts let mut subvol_entries: Vec<&MountResult> = mounts
@@ -468,10 +516,7 @@ pub fn maybe_write_fstab(mounts: &[MountResult], cfg: &Config) -> Result<()> {
let mut lines: Vec<String> = Vec::new(); let mut lines: Vec<String> = Vec::new();
for m in root_entries.into_iter().chain(subvol_entries.into_iter()) { for m in root_entries.into_iter().chain(subvol_entries.into_iter()) {
// m.source already "UUID=..." // m.source already "UUID=..."
let line = format!( let line = format!("{} {} {} {} 0 0", m.source, m.target, m.fstype, m.options);
"{} {} {} {} 0 0",
m.source, m.target, m.fstype, m.options
);
lines.push(line); lines.push(line);
} }
@@ -500,4 +545,4 @@ pub fn maybe_write_fstab(mounts: &[MountResult], cfg: &Config) -> Result<()> {
})?; })?;
Ok(()) Ok(())
} }

View File

@@ -3,4 +3,4 @@
//! Re-exports the concrete implementation from run.rs to avoid duplicating types/functions. //! Re-exports the concrete implementation from run.rs to avoid duplicating types/functions.
pub mod run; pub mod run;
pub use run::*; pub use run::*;

View File

@@ -43,14 +43,13 @@
//! - Report generation and write //! - Report generation and write
use crate::{ use crate::{
types::{Config, Topology}, Error, Result,
device::{DeviceFilter, Disk, discover},
fs as zfs, idempotency,
logging::LogOptions, logging::LogOptions,
device::{discover, DeviceFilter, Disk},
idempotency,
partition, partition,
report::StateReport, report::StateReport,
fs as zfs, types::{Config, Topology},
Error, Result,
}; };
use humantime::format_rfc3339; use humantime::format_rfc3339;
use regex::Regex; use regex::Regex;
@@ -191,9 +190,7 @@ pub fn run(ctx: &Context) -> Result<()> {
info!("orchestrator: starting run()"); info!("orchestrator: starting run()");
let selected_modes = let selected_modes =
(ctx.mount_existing as u8) + (ctx.mount_existing as u8) + (ctx.report_current as u8) + (ctx.apply as u8);
(ctx.report_current as u8) +
(ctx.apply as u8);
if selected_modes > 1 { if selected_modes > 1 {
return Err(Error::Validation( return Err(Error::Validation(
"choose only one mode: --mount-existing | --report-current | --apply".into(), "choose only one mode: --mount-existing | --report-current | --apply".into(),
@@ -242,7 +239,11 @@ fn auto_select_mode(ctx: &Context) -> Result<AutoSelection> {
info!("orchestrator: provisioned state detected; attempting mount-existing flow"); info!("orchestrator: provisioned state detected; attempting mount-existing flow");
return Ok(AutoSelection { return Ok(AutoSelection {
decision: AutoDecision::MountExisting, decision: AutoDecision::MountExisting,
fs_results: if fs_results.is_empty() { None } else { Some(fs_results) }, fs_results: if fs_results.is_empty() {
None
} else {
Some(fs_results)
},
state: Some(state), state: Some(state),
}); });
} }
@@ -275,6 +276,7 @@ fn run_report_current(ctx: &Context) -> Result<()> {
info!("orchestrator: report-current mode"); info!("orchestrator: report-current mode");
let fs_results = zfs::probe_existing_filesystems()?; let fs_results = zfs::probe_existing_filesystems()?;
// Read all mounts, filtering common system/uninteresting ones
let mounts_content = fs::read_to_string("/proc/mounts").unwrap_or_default(); let mounts_content = fs::read_to_string("/proc/mounts").unwrap_or_default();
let mounts_json: Vec<serde_json::Value> = mounts_content let mounts_json: Vec<serde_json::Value> = mounts_content
.lines() .lines()
@@ -284,21 +286,77 @@ fn run_report_current(ctx: &Context) -> Result<()> {
let target = it.next()?; let target = it.next()?;
let fstype = it.next()?; let fstype = it.next()?;
let options = it.next().unwrap_or(""); let options = it.next().unwrap_or("");
if target.starts_with("/var/mounts/")
|| target == "/var/cache/system" // Skip common pseudo/virtual filesystems and system mounts
|| target == "/var/cache/etc" if source.starts_with("devtmpfs")
|| target == "/var/cache/modules" || source.starts_with("tmpfs")
|| target == "/var/cache/vm-meta" || source.starts_with("proc")
|| source.starts_with("sysfs")
|| source.starts_with("cgroup")
|| source.starts_with("bpf")
|| source.starts_with("debugfs")
|| source.starts_with("securityfs")
|| source.starts_with("mqueue")
|| source.starts_with("pstore")
|| source.starts_with("tracefs")
|| source.starts_with("hugetlbfs")
|| source.starts_with("efivarfs")
|| source.starts_with("systemd-1")
|| target.starts_with("/proc")
|| target.starts_with("/sys")
|| target.starts_with("/dev")
|| target.starts_with("/run")
|| target.starts_with("/boot")
|| target.starts_with("/efi")
|| target.starts_with("/boot/efi")
{ {
Some(json!({ return None;
"source": source,
"target": target,
"fstype": fstype,
"options": options
}))
} else {
None
} }
// Include zosstorage target mounts and general data mounts
Some(json!({
"source": source,
"target": target,
"fstype": fstype,
"options": options
}))
})
.collect();
// Read partition information from /proc/partitions
let partitions_content = fs::read_to_string("/proc/partitions").unwrap_or_default();
let partitions_json: Vec<serde_json::Value> = partitions_content
.lines()
.filter_map(|line| {
let line = line.trim();
if line.is_empty() || line.starts_with("major") {
return None;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 4 {
return None;
}
let name = parts[3];
// Skip pseudo devices
if name.starts_with("loop")
|| name.starts_with("ram")
|| name.starts_with("zram")
|| name.starts_with("fd")
|| name.starts_with("dm-")
|| name.starts_with("md")
{
return None;
}
let major: u32 = parts[0].parse().ok()?;
let minor: u32 = parts[1].parse().ok()?;
let size_kb: u64 = parts[2].parse().ok()?;
Some(json!({
"name": name,
"major": major,
"minor": minor,
"size_kb": size_kb,
"size_gib": size_kb / (1024 * 1024)
}))
}) })
.collect(); .collect();
@@ -324,15 +382,15 @@ fn run_report_current(ctx: &Context) -> Result<()> {
"version": "v1", "version": "v1",
"timestamp": now, "timestamp": now,
"status": "observed", "status": "observed",
"partitions": partitions_json,
"filesystems": fs_json, "filesystems": fs_json,
"mounts": mounts_json "mounts": mounts_json
}); });
println!("{}", summary); println!("{}", summary);
if let Some(path) = &ctx.report_path_override { if let Some(path) = &ctx.report_path_override {
fs::write(path, summary.to_string()).map_err(|e| { fs::write(path, summary.to_string())
Error::Report(format!("failed to write report to {}: {}", path, e)) .map_err(|e| Error::Report(format!("failed to write report to {}: {}", path, e)))?;
})?;
info!("orchestrator: wrote report-current to {}", path); info!("orchestrator: wrote report-current to {}", path);
} }
Ok(()) Ok(())
@@ -409,9 +467,8 @@ fn run_mount_existing(
println!("{}", summary); println!("{}", summary);
} }
if let Some(path) = &ctx.report_path_override { if let Some(path) = &ctx.report_path_override {
fs::write(path, summary.to_string()).map_err(|e| { fs::write(path, summary.to_string())
Error::Report(format!("failed to write report to {}: {}", path, e)) .map_err(|e| Error::Report(format!("failed to write report to {}: {}", path, e)))?;
})?;
info!("orchestrator: wrote mount-existing report to {}", path); info!("orchestrator: wrote mount-existing report to {}", path);
} }
} }
@@ -445,7 +502,9 @@ fn run_provisioning(
enforce_empty_disks(&disks)?; enforce_empty_disks(&disks)?;
info!("orchestrator: all target disks verified empty"); info!("orchestrator: all target disks verified empty");
} else { } else {
warn!("orchestrator: preview mode detected (--show/--report); skipping empty-disk enforcement"); warn!(
"orchestrator: preview mode detected (--show/--report); skipping empty-disk enforcement"
);
} }
} else if matches!(mode, ProvisioningMode::Apply) { } else if matches!(mode, ProvisioningMode::Apply) {
warn!("orchestrator: require_empty_disks=false; proceeding without emptiness enforcement"); warn!("orchestrator: require_empty_disks=false; proceeding without emptiness enforcement");
@@ -506,7 +565,9 @@ fn run_provisioning(
return Ok(()); return Ok(());
} }
info!("orchestrator: pre-flight complete (idempotency checked, devices discovered, plan computed)"); info!(
"orchestrator: pre-flight complete (idempotency checked, devices discovered, plan computed)"
);
if preview_outputs { if preview_outputs {
let summary = build_summary_json(&disks, &plan, &effective_cfg)?; let summary = build_summary_json(&disks, &plan, &effective_cfg)?;
@@ -514,9 +575,8 @@ fn run_provisioning(
println!("{}", summary); println!("{}", summary);
} }
if let Some(path) = &ctx.report_path_override { if let Some(path) = &ctx.report_path_override {
fs::write(path, summary.to_string()).map_err(|e| { fs::write(path, summary.to_string())
Error::Report(format!("failed to write report to {}: {}", path, e)) .map_err(|e| Error::Report(format!("failed to write report to {}: {}", path, e)))?;
})?;
info!("orchestrator: wrote summary report to {}", path); info!("orchestrator: wrote summary report to {}", path);
} }
} }
@@ -536,15 +596,13 @@ fn build_device_filter(cfg: &Config) -> Result<DeviceFilter> {
let mut exclude = Vec::new(); let mut exclude = Vec::new();
for pat in &cfg.device_selection.include_patterns { for pat in &cfg.device_selection.include_patterns {
let re = Regex::new(pat).map_err(|e| { let re = Regex::new(pat)
Error::Validation(format!("invalid include regex '{}': {}", pat, e)) .map_err(|e| Error::Validation(format!("invalid include regex '{}': {}", pat, e)))?;
})?;
include.push(re); include.push(re);
} }
for pat in &cfg.device_selection.exclude_patterns { for pat in &cfg.device_selection.exclude_patterns {
let re = Regex::new(pat).map_err(|e| { let re = Regex::new(pat)
Error::Validation(format!("invalid exclude regex '{}': {}", pat, e)) .map_err(|e| Error::Validation(format!("invalid exclude regex '{}': {}", pat, e)))?;
})?;
exclude.push(re); exclude.push(re);
} }
@@ -598,7 +656,11 @@ fn role_str(role: partition::PartRole) -> &'static str {
/// - mount: scheme summary and target template (e.g., "/var/cache/{UUID}") /// - mount: scheme summary and target template (e.g., "/var/cache/{UUID}")
/// ///
/// This function is non-destructive and performs no probing beyond the provided inputs. /// This function is non-destructive and performs no probing beyond the provided inputs.
fn build_summary_json(disks: &[Disk], plan: &partition::PartitionPlan, cfg: &Config) -> Result<serde_json::Value> { fn build_summary_json(
disks: &[Disk],
plan: &partition::PartitionPlan,
cfg: &Config,
) -> Result<serde_json::Value> {
// Disks summary // Disks summary
let disks_json: Vec<serde_json::Value> = disks let disks_json: Vec<serde_json::Value> = disks
.iter() .iter()
@@ -730,4 +792,4 @@ fn build_summary_json(disks: &[Disk], plan: &partition::PartitionPlan, cfg: &Con
}); });
Ok(summary) Ok(summary)
} }

View File

@@ -9,4 +9,4 @@
pub mod plan; pub mod plan;
pub use plan::*; pub use plan::*;

View File

@@ -19,12 +19,12 @@
// ext: device-specific alignment or reserved areas configurable via cfg in the future. // ext: device-specific alignment or reserved areas configurable via cfg in the future.
// REGION: EXTENSION_POINTS-END // REGION: EXTENSION_POINTS-END
// //
// REGION: SAFETY // REGION: SAFETY
// safety: must verify require_empty_disks before any modification. // safety: must verify require_empty_disks before any modification.
// safety: when UEFI-booted, suppress creating BIOS boot partition to avoid unnecessary ef02 on UEFI systems. // safety: when UEFI-booted, suppress creating BIOS boot partition to avoid unnecessary ef02 on UEFI systems.
// safety: must ensure unique partition GUIDs; identical labels are allowed when expected (e.g., ESP ZOSBOOT). // safety: must ensure unique partition GUIDs; identical labels are allowed when expected (e.g., ESP ZOSBOOT).
// safety: must call udev settle after partition table writes. // safety: must call udev settle after partition table writes.
// REGION: SAFETY-END // REGION: SAFETY-END
// //
// REGION: ERROR_MAPPING // REGION: ERROR_MAPPING
// errmap: external tool failure -> crate::Error::Tool { tool, status, stderr }. // errmap: external tool failure -> crate::Error::Tool { tool, status, stderr }.
@@ -44,11 +44,11 @@
//! [fn apply_partitions](plan.rs:1). //! [fn apply_partitions](plan.rs:1).
use crate::{ use crate::{
types::{Config, Topology},
device::Disk,
util::{run_cmd, run_cmd_capture, which_tool, udev_settle, is_efi_boot},
idempotency,
Error, Result, Error, Result,
device::Disk,
idempotency,
types::{Config, Topology},
util::{is_efi_boot, run_cmd, run_cmd_capture, udev_settle, which_tool},
}; };
use tracing::{debug, warn}; use tracing::{debug, warn};
@@ -117,20 +117,20 @@ pub struct PartitionResult {
pub device_path: String, pub device_path: String,
} }
/// Compute GPT-only plan per topology and constraints. /// Compute GPT-only plan per topology and constraints.
/// ///
/// Layout defaults: /// Layout defaults:
/// - BIOS boot: cfg.partitioning.bios_boot if enabled (size_mib) /// - BIOS boot: cfg.partitioning.bios_boot if enabled (size_mib)
/// - ESP: cfg.partitioning.esp.size_mib, GPT name cfg.partitioning.esp.gpt_name (typically "zosboot") /// - ESP: cfg.partitioning.esp.size_mib, GPT name cfg.partitioning.esp.gpt_name (typically "zosboot")
/// - Data: remainder, GPT name cfg.partitioning.data.gpt_name ("zosdata") /// - Data: remainder, GPT name cfg.partitioning.data.gpt_name ("zosdata")
/// - Cache (only for SSD/HDD topology): remainder on SSD after boot/ESP, GPT name cfg.partitioning.cache.gpt_name ("zoscache") /// - Cache (only for SSD/HDD topology): remainder on SSD after boot/ESP, GPT name cfg.partitioning.cache.gpt_name ("zoscache")
/// ///
/// Topology mapping: /// Topology mapping:
/// - Single: use first eligible disk; create BIOS (opt) + ESP + Data /// - Single: use first eligible disk; create BIOS (opt) + ESP + Data
/// - DualIndependent: need at least 2 disks; disk0: BIOS (opt) + ESP + Data, disk1: Data /// - DualIndependent: need at least 2 disks; disk0: BIOS (opt) + ESP + Data, disk1: Data
/// - BtrfsRaid1: need at least 2 disks; disk0: BIOS (opt) + ESP + Data, disk1: Data /// - BtrfsRaid1: need at least 2 disks; disk0: BIOS (opt) + ESP + Data, disk1: Data
/// - SsdHddBcachefs: need >=1 SSD (rotational=false) and >=1 HDD (rotational=true); /// - SsdHddBcachefs: need >=1 SSD (rotational=false) and >=1 HDD (rotational=true);
/// SSD: BIOS (opt) + ESP + Cache; HDD: Data /// SSD: BIOS (opt) + ESP + Cache; HDD: Data
pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result<PartitionPlan> { pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result<PartitionPlan> {
let align = cfg.partitioning.alignment_mib; let align = cfg.partitioning.alignment_mib;
let require_empty = cfg.partitioning.require_empty_disks; let require_empty = cfg.partitioning.require_empty_disks;
@@ -138,7 +138,9 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result<PartitionPlan> {
let add_bios = cfg.partitioning.bios_boot.enabled && !is_efi_boot(); let add_bios = cfg.partitioning.bios_boot.enabled && !is_efi_boot();
if disks.is_empty() { if disks.is_empty() {
return Err(Error::Partition("no disks provided to partition planner".into())); return Err(Error::Partition(
"no disks provided to partition planner".into(),
));
} }
let mut plans: Vec<DiskPlan> = Vec::new(); let mut plans: Vec<DiskPlan> = Vec::new();
@@ -164,7 +166,10 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result<PartitionPlan> {
size_mib: None, size_mib: None,
gpt_name: cfg.partitioning.data.gpt_name.clone(), gpt_name: cfg.partitioning.data.gpt_name.clone(),
}); });
plans.push(DiskPlan { disk: d0.clone(), parts }); plans.push(DiskPlan {
disk: d0.clone(),
parts,
});
} }
Topology::BcachefsSingle => { Topology::BcachefsSingle => {
let d0 = &disks[0]; let d0 = &disks[0];
@@ -186,11 +191,16 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result<PartitionPlan> {
size_mib: None, size_mib: None,
gpt_name: cfg.partitioning.data.gpt_name.clone(), gpt_name: cfg.partitioning.data.gpt_name.clone(),
}); });
plans.push(DiskPlan { disk: d0.clone(), parts }); plans.push(DiskPlan {
disk: d0.clone(),
parts,
});
} }
Topology::DualIndependent => { Topology::DualIndependent => {
if disks.len() < 2 { if disks.len() < 2 {
return Err(Error::Partition("DualIndependent topology requires at least 2 disks".into())); return Err(Error::Partition(
"DualIndependent topology requires at least 2 disks".into(),
));
} }
let d0 = &disks[0]; let d0 = &disks[0];
let d1 = &disks[1]; let d1 = &disks[1];
@@ -214,7 +224,10 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result<PartitionPlan> {
size_mib: None, size_mib: None,
gpt_name: cfg.partitioning.data.gpt_name.clone(), gpt_name: cfg.partitioning.data.gpt_name.clone(),
}); });
plans.push(DiskPlan { disk: d0.clone(), parts: parts0 }); plans.push(DiskPlan {
disk: d0.clone(),
parts: parts0,
});
// Disk 1: Data only // Disk 1: Data only
let mut parts1 = Vec::new(); let mut parts1 = Vec::new();
@@ -223,11 +236,16 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result<PartitionPlan> {
size_mib: None, size_mib: None,
gpt_name: cfg.partitioning.data.gpt_name.clone(), gpt_name: cfg.partitioning.data.gpt_name.clone(),
}); });
plans.push(DiskPlan { disk: d1.clone(), parts: parts1 }); plans.push(DiskPlan {
disk: d1.clone(),
parts: parts1,
});
} }
Topology::BtrfsRaid1 => { Topology::BtrfsRaid1 => {
if disks.len() < 2 { if disks.len() < 2 {
return Err(Error::Partition("BtrfsRaid1 topology requires at least 2 disks".into())); return Err(Error::Partition(
"BtrfsRaid1 topology requires at least 2 disks".into(),
));
} }
let d0 = &disks[0]; let d0 = &disks[0];
let d1 = &disks[1]; let d1 = &disks[1];
@@ -251,7 +269,10 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result<PartitionPlan> {
size_mib: None, size_mib: None,
gpt_name: cfg.partitioning.data.gpt_name.clone(), gpt_name: cfg.partitioning.data.gpt_name.clone(),
}); });
plans.push(DiskPlan { disk: d0.clone(), parts: parts0 }); plans.push(DiskPlan {
disk: d0.clone(),
parts: parts0,
});
// Disk 1: Data only (for RAID1) // Disk 1: Data only (for RAID1)
let mut parts1 = Vec::new(); let mut parts1 = Vec::new();
@@ -260,11 +281,16 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result<PartitionPlan> {
size_mib: None, size_mib: None,
gpt_name: cfg.partitioning.data.gpt_name.clone(), gpt_name: cfg.partitioning.data.gpt_name.clone(),
}); });
plans.push(DiskPlan { disk: d1.clone(), parts: parts1 }); plans.push(DiskPlan {
disk: d1.clone(),
parts: parts1,
});
} }
Topology::Bcachefs2Copy => { Topology::Bcachefs2Copy => {
if disks.len() < 2 { if disks.len() < 2 {
return Err(Error::Partition("Bcachefs2Copy topology requires at least 2 disks".into())); return Err(Error::Partition(
"Bcachefs2Copy topology requires at least 2 disks".into(),
));
} }
let d0 = &disks[0]; let d0 = &disks[0];
let d1 = &disks[1]; let d1 = &disks[1];
@@ -288,7 +314,10 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result<PartitionPlan> {
size_mib: None, size_mib: None,
gpt_name: cfg.partitioning.data.gpt_name.clone(), gpt_name: cfg.partitioning.data.gpt_name.clone(),
}); });
plans.push(DiskPlan { disk: d0.clone(), parts: parts0 }); plans.push(DiskPlan {
disk: d0.clone(),
parts: parts0,
});
// Disk 1: Data only // Disk 1: Data only
let mut parts1 = Vec::new(); let mut parts1 = Vec::new();
@@ -297,14 +326,19 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result<PartitionPlan> {
size_mib: None, size_mib: None,
gpt_name: cfg.partitioning.data.gpt_name.clone(), gpt_name: cfg.partitioning.data.gpt_name.clone(),
}); });
plans.push(DiskPlan { disk: d1.clone(), parts: parts1 }); plans.push(DiskPlan {
disk: d1.clone(),
parts: parts1,
});
} }
Topology::SsdHddBcachefs => { Topology::SsdHddBcachefs => {
// Choose SSD (rotational=false) and HDD (rotational=true) // Choose SSD (rotational=false) and HDD (rotational=true)
let ssd = disks.iter().find(|d| !d.rotational) let ssd = disks.iter().find(|d| !d.rotational).ok_or_else(|| {
.ok_or_else(|| Error::Partition("SsdHddBcachefs requires an SSD (non-rotational) disk".into()))?; Error::Partition("SsdHddBcachefs requires an SSD (non-rotational) disk".into())
let hdd = disks.iter().find(|d| d.rotational) })?;
.ok_or_else(|| Error::Partition("SsdHddBcachefs requires an HDD (rotational) disk".into()))?; let hdd = disks.iter().find(|d| d.rotational).ok_or_else(|| {
Error::Partition("SsdHddBcachefs requires an HDD (rotational) disk".into())
})?;
// SSD: BIOS (opt) + ESP + Cache remainder // SSD: BIOS (opt) + ESP + Cache remainder
let mut parts_ssd = Vec::new(); let mut parts_ssd = Vec::new();
@@ -325,7 +359,10 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result<PartitionPlan> {
size_mib: None, size_mib: None,
gpt_name: cfg.partitioning.cache.gpt_name.clone(), gpt_name: cfg.partitioning.cache.gpt_name.clone(),
}); });
plans.push(DiskPlan { disk: ssd.clone(), parts: parts_ssd }); plans.push(DiskPlan {
disk: ssd.clone(),
parts: parts_ssd,
});
// HDD: Data remainder // HDD: Data remainder
let mut parts_hdd = Vec::new(); let mut parts_hdd = Vec::new();
@@ -334,7 +371,10 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result<PartitionPlan> {
size_mib: None, size_mib: None,
gpt_name: cfg.partitioning.data.gpt_name.clone(), gpt_name: cfg.partitioning.data.gpt_name.clone(),
}); });
plans.push(DiskPlan { disk: hdd.clone(), parts: parts_hdd }); plans.push(DiskPlan {
disk: hdd.clone(),
parts: parts_hdd,
});
} }
} }
@@ -389,10 +429,17 @@ pub fn apply_partitions(plan: &PartitionPlan) -> Result<Vec<PartitionResult>> {
if let Some(blockdev) = which_tool("blockdev")? { if let Some(blockdev) = which_tool("blockdev")? {
let out = run_cmd_capture(&[blockdev.as_str(), "--getss", disk_path])?; let out = run_cmd_capture(&[blockdev.as_str(), "--getss", disk_path])?;
let s = out.stdout.trim(); let s = out.stdout.trim();
return s.parse::<u64>() return s.parse::<u64>().map_err(|e| {
.map_err(|e| Error::Partition(format!("failed to parse sector size from blockdev for {}: {}", disk_path, e))); Error::Partition(format!(
"failed to parse sector size from blockdev for {}: {}",
disk_path, e
))
});
} }
warn!("blockdev not found; assuming 512-byte sectors for {}", disk_path); warn!(
"blockdev not found; assuming 512-byte sectors for {}",
disk_path
);
Ok(512) Ok(512)
} }
@@ -410,20 +457,29 @@ pub fn apply_partitions(plan: &PartitionPlan) -> Result<Vec<PartitionResult>> {
// Format: "First sector: 2048 (at 1024.0 KiB)" // Format: "First sector: 2048 (at 1024.0 KiB)"
let val = rest.trim().split_whitespace().next().unwrap_or(""); let val = rest.trim().split_whitespace().next().unwrap_or("");
if !val.is_empty() { if !val.is_empty() {
first = Some(val.parse::<u64>().map_err(|e| Error::Partition(format!("parse first sector: {}", e)))?); first = Some(
val.parse::<u64>()
.map_err(|e| Error::Partition(format!("parse first sector: {}", e)))?,
);
} }
} else if let Some(rest) = line.strip_prefix("Last sector:") { } else if let Some(rest) = line.strip_prefix("Last sector:") {
let val = rest.trim().split_whitespace().next().unwrap_or(""); let val = rest.trim().split_whitespace().next().unwrap_or("");
if !val.is_empty() { if !val.is_empty() {
last = Some(val.parse::<u64>().map_err(|e| Error::Partition(format!("parse last sector: {}", e)))?); last = Some(
val.parse::<u64>()
.map_err(|e| Error::Partition(format!("parse last sector: {}", e)))?,
);
} }
} }
} }
let first = first.ok_or_else(|| Error::Partition("sgdisk -i missing First sector".into()))?; let first =
first.ok_or_else(|| Error::Partition("sgdisk -i missing First sector".into()))?;
let last = last.ok_or_else(|| Error::Partition("sgdisk -i missing Last sector".into()))?; let last = last.ok_or_else(|| Error::Partition("sgdisk -i missing Last sector".into()))?;
if guid.is_empty() { if guid.is_empty() {
return Err(Error::Partition("sgdisk -i missing Partition unique GUID".into())); return Err(Error::Partition(
"sgdisk -i missing Partition unique GUID".into(),
));
} }
Ok((guid, first, last)) Ok((guid, first, last))
} }
@@ -467,9 +523,12 @@ pub fn apply_partitions(plan: &PartitionPlan) -> Result<Vec<PartitionResult>> {
run_cmd(&[ run_cmd(&[
sgdisk.as_str(), sgdisk.as_str(),
"-n", n_arg.as_str(), "-n",
"-t", t_arg.as_str(), n_arg.as_str(),
"-c", c_arg.as_str(), "-t",
t_arg.as_str(),
"-c",
c_arg.as_str(),
disk_path, disk_path,
])?; ])?;
} }
@@ -486,11 +545,7 @@ pub fn apply_partitions(plan: &PartitionPlan) -> Result<Vec<PartitionResult>> {
// Query sgdisk for partition info // Query sgdisk for partition info
let i_arg = format!("{}", part_num); let i_arg = format!("{}", part_num);
let info_out = run_cmd_capture(&[ let info_out = run_cmd_capture(&[sgdisk.as_str(), "-i", i_arg.as_str(), disk_path])?;
sgdisk.as_str(),
"-i", i_arg.as_str(),
disk_path,
])?;
let (unique_guid, first_sector, last_sector) = parse_sgdisk_info(&info_out.stdout)?; let (unique_guid, first_sector, last_sector) = parse_sgdisk_info(&info_out.stdout)?;
let sectors = if last_sector >= first_sector { let sectors = if last_sector >= first_sector {
@@ -516,6 +571,9 @@ pub fn apply_partitions(plan: &PartitionPlan) -> Result<Vec<PartitionResult>> {
} }
} }
debug!("apply_partitions: created {} partition entries", results.len()); debug!(
"apply_partitions: created {} partition entries",
results.len()
);
Ok(results) Ok(results)
} }

View File

@@ -9,4 +9,4 @@
pub mod state; pub mod state;
pub use state::*; pub use state::*;

View File

@@ -77,4 +77,4 @@ pub fn build_report(
/// Write the state report JSON to disk (default path in config: /run/zosstorage/state.json). /// Write the state report JSON to disk (default path in config: /run/zosstorage/state.json).
pub fn write_report(_report: &StateReport, _path: &str) -> Result<()> { pub fn write_report(_report: &StateReport, _path: &str) -> Result<()> {
todo!("serialize to JSON and persist atomically via tempfile and rename") todo!("serialize to JSON and persist atomically via tempfile and rename")
} }

View File

@@ -15,15 +15,25 @@
// - Keep field names and enums stable; update docs/SCHEMA.md when public surface changes. // - Keep field names and enums stable; update docs/SCHEMA.md when public surface changes.
// REGION: RESPONSIBILITIES-END // REGION: RESPONSIBILITIES-END
use serde::{Deserialize, Serialize};
use clap::ValueEnum; use clap::ValueEnum;
use serde::{Deserialize, Serialize};
/// Reserved filesystem labels.
pub const LABEL_ZOSBOOT: &str = "ZOSBOOT";
pub const LABEL_ZOSDATA: &str = "ZOSDATA";
pub const LABEL_ZOSCACHE: &str = "ZOSCACHE";
/// Reserved GPT partition names.
pub const GPT_NAME_ZOSBOOT: &str = "zosboot";
pub const GPT_NAME_ZOSDATA: &str = "zosdata";
pub const GPT_NAME_ZOSCACHE: &str = "zoscache";
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoggingConfig { pub struct LoggingConfig {
/// Log level: "error" | "warn" | "info" | "debug" /// Log level: "error" | "warn" | "info" | "debug"
pub level: String, // default "info" pub level: String, // default "info"
/// When true, also log to /run/zosstorage/zosstorage.log /// When true, also log to /run/zosstorage/zosstorage.log
pub to_file: bool, // default false pub to_file: bool, // default false
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -55,7 +65,9 @@ pub enum Topology {
#[value(alias = "ssd-hdd-bcachefs")] #[value(alias = "ssd-hdd-bcachefs")]
SsdHddBcachefs, SsdHddBcachefs,
/// Multi-device bcachefs with two replicas (data+metadata). /// Multi-device bcachefs with two replicas (data+metadata).
#[value(alias = "bcachefs2-copy", alias = "bcachefs-2copy", alias = "bcachefs-2-copy")] /// Canonical token: bcachefs-2copy
#[serde(rename = "bcachefs-2copy")]
#[value(alias = "bcachefs-2copy")]
Bcachefs2Copy, Bcachefs2Copy,
/// Optional mirrored btrfs across two disks when explicitly requested. /// Optional mirrored btrfs across two disks when explicitly requested.
#[value(alias = "btrfs-raid1")] #[value(alias = "btrfs-raid1")]
@@ -69,7 +81,8 @@ impl std::fmt::Display for Topology {
Topology::BcachefsSingle => "bcachefs_single", Topology::BcachefsSingle => "bcachefs_single",
Topology::DualIndependent => "dual_independent", Topology::DualIndependent => "dual_independent",
Topology::SsdHddBcachefs => "ssd_hdd_bcachefs", Topology::SsdHddBcachefs => "ssd_hdd_bcachefs",
Topology::Bcachefs2Copy => "bcachefs2_copy", // Canonical single notation for two-copy bcachefs topology
Topology::Bcachefs2Copy => "bcachefs-2copy",
Topology::BtrfsRaid1 => "btrfs_raid1", Topology::BtrfsRaid1 => "btrfs_raid1",
}; };
f.write_str(s) f.write_str(s)
@@ -205,4 +218,4 @@ pub struct Config {
pub mount: MountScheme, pub mount: MountScheme,
/// Report output configuration. /// Report output configuration.
pub report: ReportOptions, pub report: ReportOptions,
} }

View File

@@ -40,8 +40,8 @@
//! and consistent error handling. //! and consistent error handling.
use crate::{Error, Result}; use crate::{Error, Result};
use std::process::Command;
use std::path::Path; use std::path::Path;
use std::process::Command;
use tracing::{debug, warn}; use tracing::{debug, warn};
/// Captured output from an external tool invocation. /// Captured output from an external tool invocation.
@@ -77,9 +77,10 @@ pub fn run_cmd(args: &[&str]) -> Result<()> {
))); )));
} }
debug!(target: "util.run_cmd", "exec: {:?}", args); debug!(target: "util.run_cmd", "exec: {:?}", args);
let output = Command::new(args[0]).args(&args[1..]).output().map_err(|e| { let output = Command::new(args[0])
Error::Other(anyhow::anyhow!("failed to spawn {:?}: {}", args, e)) .args(&args[1..])
})?; .output()
.map_err(|e| Error::Other(anyhow::anyhow!("failed to spawn {:?}: {}", args, e)))?;
let status_code = output.status.code().unwrap_or(-1); let status_code = output.status.code().unwrap_or(-1);
if !output.status.success() { if !output.status.success() {
@@ -103,9 +104,10 @@ pub fn run_cmd_capture(args: &[&str]) -> Result<CmdOutput> {
))); )));
} }
debug!(target: "util.run_cmd_capture", "exec: {:?}", args); debug!(target: "util.run_cmd_capture", "exec: {:?}", args);
let output = Command::new(args[0]).args(&args[1..]).output().map_err(|e| { let output = Command::new(args[0])
Error::Other(anyhow::anyhow!("failed to spawn {:?}: {}", args, e)) .args(&args[1..])
})?; .output()
.map_err(|e| Error::Other(anyhow::anyhow!("failed to spawn {:?}: {}", args, e)))?;
let status_code = output.status.code().unwrap_or(-1); let status_code = output.status.code().unwrap_or(-1);
let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string();
@@ -205,4 +207,4 @@ mod tests {
// Should never fail even if udevadm is missing. // Should never fail even if udevadm is missing.
udev_settle(1000).expect("udev_settle should be non-fatal"); udev_settle(1000).expect("udev_settle should be non-fatal");
} }
} }