crates/appliance-grow/src/md.rs
//! **The volume half of a [`crate::World`]** — the data disks, over
//! korp-installer's `grow::runtime` (the SAME engine korp-start grows the set
//! with at boot). Feature `md`.
//!
//! Moved AS IS out of gunnar-server's `BoxWorld` (gunnar 18a44e3): everything
//! in it that touches the set and nothing that touches gunnar — the mount
//! reading, the unmount with its read-only guard tmpfs, the md stop, the PCI
//! rescan, the survey, G1–G6 + mount, the journal records and the spent-nonce
//! ledger beside the journal. A product's world holds one [`MdVolumes`] and
//! delegates its volume verbs to it; its gate, its stores and its twin stay
//! its own.
//!
//! What only a VM proves: every method here is a real syscall on a real block
//! device (umount2, md `array_state`, `/sys/bus/pci/rescan`, XFS_IOC_FSGROWFSDATA),
//! so the native tests drive the machine over a scripted world instead and
//! gunnar-growbench / the mock-upcloud functest drive THIS.
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use korp_installer::grow::{self as engine, runtime};
use crate::world::{Grown, MemberFacts, SurveyFacts};
use crate::Step;
/// **The data set's volumes**, as one product's world holds them.
pub struct MdVolumes {
/// The journal, opened at the first record and held for the growth's
/// whole life (one writer per box, like `korp-start`'s).
journal: Mutex<Option<engine::Journal>>,
mountpoint: PathBuf,
}
impl std::fmt::Debug for MdVolumes {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MdVolumes").field("mountpoint", &self.mountpoint).finish()
}
}
impl Default for MdVolumes {
fn default() -> Self {
Self::new()
}
}
impl MdVolumes {
/// Over korp's own mountpoint (`/data`).
pub fn new() -> Self {
MdVolumes { journal: Mutex::new(None), mountpoint: runtime::data_mountpoint().to_path_buf() }
}
/// The mountpoint the set is served at.
pub fn mountpoint(&self) -> &Path {
&self.mountpoint
}
fn with_journal<T>(&self, f: impl FnOnce(&mut engine::Journal) -> Result<T, String>) -> Result<T, String> {
let mut guard = self.journal.lock().unwrap_or_else(|p| p.into_inner());
if guard.is_none() {
let path = engine::journal_path();
let j = engine::Journal::open(&path).map_err(|e| format!("open the grow journal {}: {e} — is /serialdat mounted?", path.display()))?;
*guard = Some(j);
}
f(guard.as_mut().expect("opened above"))
}
fn log(line: &str) {
tracing::info!(target: crate::TARGET, event = "grow.engine", "{line}");
}
/// `/serialdat/grow-journal`.
pub fn journal_path(&self) -> PathBuf {
engine::journal_path()
}
/// Is the set mounted at the mountpoint?
pub fn is_mounted(&self) -> bool {
runtime::is_mounted(&self.mountpoint)
}
/// `/data`'s size as mounted, 0 when not.
pub fn mounted_bytes(&self) -> u64 {
if !self.is_mounted() {
return 0;
}
rustix::fs::statvfs(&self.mountpoint).map(|s| s.f_blocks * s.f_frsize).unwrap_or(0)
}
/// sync + `umount2` (never lazy; an EBUSY names the descriptors this
/// process still holds), then a read-only tmpfs over the empty
/// mountpoint: anything that still writes by path under /data during the
/// window meets EROFS — an error it can report — instead of a file in the
/// root filesystem's /data directory that the remount would hide.
pub fn sync_and_unmount(&self) -> Result<(), String> {
runtime::unmount(&self.mountpoint)?;
nix::mount::mount(Some("none"), &self.mountpoint, Some("tmpfs"), nix::mount::MsFlags::MS_RDONLY, Some("size=64k"))
.map_err(|e| format!("guard tmpfs over {}: {e}", self.mountpoint.display()))
}
/// md `array_state ← clear`, the stale node removed.
pub fn stop_array(&self) -> Result<(), String> {
runtime::stop_array(std::time::Duration::from_secs(10))
}
/// `/sys/bus/pci/rescan`.
pub fn rescan(&self) -> Result<(), String> {
runtime::pci_rescan()
}
/// The members as they are NOW (read-only; safe while the array runs or
/// is stopped).
pub fn survey(&self) -> SurveyFacts {
let s = runtime::survey();
SurveyFacts {
verdict: s.verdict.clone(),
raid_disks: s.raid_disks,
eligible: s.eligible(),
target_sectors: s.target_sectors(),
set_uuid: s.set_uuid.clone(),
description: s.describe(),
members: s
.members
.iter()
.map(|m| MemberFacts {
index: m.index,
device: m.disk.clone(),
partition: m.partition.clone(),
disk_bytes: m.disk_sectors * 512,
set_bytes: m.set_sectors * 512,
offered_bytes: m.device_offers * 512,
larger: m.larger,
})
.collect(),
}
}
/// G1–G3 → assemble → G4 → mount → G5 → G6, with the serve-at-old-size
/// fallback inside; `Err` only when nothing could be assembled or mounted.
pub fn grow_and_mount(&self) -> Result<Grown, String> {
// The guard tmpfs goes first; a mount over a mount would hide it and
// leave it to pin nothing forever.
if self.is_mounted() {
nix::mount::umount2(&self.mountpoint, nix::mount::MntFlags::empty()).map_err(|e| format!("unguard {}: {e}", self.mountpoint.display()))?;
}
let mountpoint = self.mountpoint.clone();
let mounted = self.with_journal(|j| runtime::grow_and_mount(j, &mountpoint, &mut Self::log))?;
Ok(Grown {
grew: mounted.grew,
array_bytes: mounted.array_bytes,
refusal: mounted.refusal,
xfs: match &mounted.xfs {
None => String::new(),
Some(Ok((plan, after))) => format!("{plan:?} → {after} blocks"),
Some(Err(e)) => format!("FAILED: {e}"),
},
})
}
/// The epoch a growth toward `target_sectors` belongs to.
pub fn epoch_for(&self, target_sectors: u64) -> u64 {
self.with_journal(|j| Ok(j.epoch_for(target_sectors))).unwrap_or(0)
}
/// One runtime record in the engine's own line format.
pub fn note(&self, epoch: u64, target_sectors: u64, step: Step) -> Result<(), String> {
let step = match step {
Step::Drain => engine::Step::Drain,
Step::Unmount => engine::Step::Unmount,
Step::Stop => engine::Step::Stop,
Step::Wait => engine::Step::Wait,
Step::Rescan => engine::Step::Rescan,
Step::Reopen => engine::Step::Reopen,
Step::Abort => engine::Step::Abort,
Step::Fail => engine::Step::Fail,
};
self.with_journal(|j| runtime::note(j, epoch, target_sectors, step).map(|_| ()))
}
/// The spent-nonce ledger beside the journal (off the stripe).
pub fn remember_nonce(&self, nonce: &str) -> Result<bool, String> {
let ledger = engine::journal_path().with_file_name(crate::world::NONCE_LEDGER_FILE);
crate::world::remember_nonce_in(&ledger, nonce)
}
}
impl crate::world::Volumes for MdVolumes {
fn engine_present(&self) -> bool {
true
}
fn journal_path(&self) -> PathBuf {
MdVolumes::journal_path(self)
}
fn is_mounted(&self) -> bool {
MdVolumes::is_mounted(self)
}
fn mounted_bytes(&self) -> u64 {
MdVolumes::mounted_bytes(self)
}
fn sync_and_unmount(&self) -> Result<(), String> {
MdVolumes::sync_and_unmount(self)
}
fn stop_array(&self) -> Result<(), String> {
MdVolumes::stop_array(self)
}
fn rescan(&self) -> Result<(), String> {
MdVolumes::rescan(self)
}
fn survey(&self) -> SurveyFacts {
MdVolumes::survey(self)
}
fn grow_and_mount(&self) -> Result<Grown, String> {
MdVolumes::grow_and_mount(self)
}
fn epoch_for(&self, target_sectors: u64) -> u64 {
MdVolumes::epoch_for(self, target_sectors)
}
fn note(&self, epoch: u64, target_sectors: u64, step: Step) -> Result<(), String> {
MdVolumes::note(self, epoch, target_sectors, step)
}
fn remember_nonce(&self, nonce: &str) -> Result<bool, String> {
MdVolumes::remember_nonce(self, nonce)
}
}