lazaret/crates/lazaret-domain-state/src/state_maintenance.rs

497 lines
18 KiB
Rust
Raw Normal View History

use std::ffi::OsString;
use std::fmt;
use std::os::unix::ffi::OsStringExt;
use std::path::{Path, PathBuf};
use lazaret_core::configuration::ResolvedDomainConfiguration;
use lazaret_core::records::{
DurableDecision, ParticipantState, SessionPhase, SessionTerminalState,
};
pub use lazaret_state_anchor::StateAnchorEntryKind;
use lazaret_state_anchor::{
StateAnchorEntry, StateAnchorInspection, StateAnchorLayout, StateAnchorResetScope,
require_no_orphan_reset_quarantine, require_state_anchor_absent,
};
use crate::domain_store::{HostCoordinatorDomainStore, StateResetIntentV1, StateResetScopeV1};
#[derive(Debug)]
pub struct StateMaintenanceError(String);
impl fmt::Display for StateMaintenanceError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl std::error::Error for StateMaintenanceError {}
fn failure(error: impl fmt::Display) -> StateMaintenanceError {
StateMaintenanceError(error.to_string())
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct StateAnchorReport {
pub container_path: String,
pub changes: Vec<StateAnchorEntry>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DomainStateReport {
pub profile: String,
pub workspace: Vec<u8>,
pub domain_id: [u8; 32],
pub anchors: Vec<StateAnchorReport>,
}
/// Returns an empty report only after proving that no physical State Anchor exists.
///
/// # Errors
/// Refuses changed authority, links, inaccessible paths, or any persistent or runtime anchor.
pub fn inspect_absent_domain_state(
configuration: &ResolvedDomainConfiguration,
) -> Result<DomainStateReport, StateMaintenanceError> {
require_state_anchor_absent(configuration).map_err(failure)?;
Ok(report(
configuration,
&StateAnchorInspection {
entries: Vec::new(),
},
))
}
/// Recovers a durable reset before any maintenance observer or runtime mount can expose state.
///
/// # Errors
/// Refuses malformed intent, changed authority, unsupported scope, orphan quarantine, or cleanup
/// failure.
pub fn recover_state_reset(
configuration: &ResolvedDomainConfiguration,
store: &HostCoordinatorDomainStore,
) -> Result<(), StateMaintenanceError> {
let Some(intent) = store.state_reset_intent().map_err(failure)? else {
return require_no_orphan_reset_quarantine(configuration).map_err(failure);
};
require_intent(configuration, &intent)?;
let layout = StateAnchorLayout::create(configuration).map_err(failure)?;
complete_intent(&layout, &intent)?;
store.finish_state_reset(&intent).map_err(failure)
}
/// Returns content-free State Anchor metadata after mandatory recovery and structural checks.
///
/// # Errors
/// Refuses active or malformed Domain state, changed authority, mounts, links in fixed paths, and
/// unsupported upper-layer entries.
pub fn inspect_domain_state(
configuration: &ResolvedDomainConfiguration,
store: &HostCoordinatorDomainStore,
) -> Result<DomainStateReport, StateMaintenanceError> {
recover_state_reset(configuration, store)?;
require_inactive(store)?;
let layout = StateAnchorLayout::create(configuration).map_err(failure)?;
let inspection = layout.inspect_state().map_err(failure)?;
Ok(report(configuration, &inspection))
}
/// Commits and completes one all-or-selected State Anchor reset generation.
///
/// # Errors
/// Refuses active state, an arbitrary target, malformed durable state, changed authority, mounts,
/// links in fixed paths, quarantine collisions, and unsupported upper-layer entries.
pub fn reset_domain_state(
configuration: &ResolvedDomainConfiguration,
store: &HostCoordinatorDomainStore,
scope: StateResetScopeV1,
) -> Result<DomainStateReport, StateMaintenanceError> {
recover_state_reset(configuration, store)?;
require_inactive(store)?;
validate_scope(configuration, &scope)?;
let intent = StateResetIntentV1::new(configuration.domain_id(), scope);
store.begin_state_reset(&intent).map_err(failure)?;
let layout = StateAnchorLayout::create(configuration).map_err(failure)?;
complete_intent(&layout, &intent)?;
store.finish_state_reset(&intent).map_err(failure)?;
inspect_domain_state(configuration, store)
}
fn complete_intent(
layout: &StateAnchorLayout<'_>,
intent: &StateResetIntentV1,
) -> Result<(), StateMaintenanceError> {
match intent.scope() {
StateResetScopeV1::All => layout
.complete_state_reset(StateAnchorResetScope::All)
.map_err(failure),
StateResetScopeV1::Selected(path) => {
let path = PathBuf::from(OsString::from_vec(path.clone()));
layout
.complete_state_reset(StateAnchorResetScope::Selected(&path))
.map_err(failure)
}
}
}
fn require_intent(
configuration: &ResolvedDomainConfiguration,
intent: &StateResetIntentV1,
) -> Result<(), StateMaintenanceError> {
if intent.domain_id() != configuration.domain_id() {
return Err(failure(
"reset intent Domain differs from resolved authority",
));
}
validate_scope(configuration, intent.scope())
}
fn validate_scope(
configuration: &ResolvedDomainConfiguration,
scope: &StateResetScopeV1,
) -> Result<(), StateMaintenanceError> {
let StateResetScopeV1::Selected(bytes) = scope else {
return Ok(());
};
let selected = PathBuf::from(OsString::from_vec(bytes.clone()));
if configuration
.fingerprint_record()
.persistent_dirs
.iter()
.any(|configured| Path::new(configured) == selected)
{
Ok(())
} else {
Err(failure(
"reset target is not an exact configured persistent container path",
))
}
}
fn require_inactive(store: &HostCoordinatorDomainStore) -> Result<(), StateMaintenanceError> {
let snapshot = store.snapshot().map_err(failure)?;
let inactive = snapshot
.generations
.iter()
.all(|generation| generation.durable_decision == DurableDecision::Clean)
&& snapshot
.participants
.iter()
.all(|participant| participant.state() == ParticipantState::Clean)
&& snapshot.sessions.iter().all(|session| {
matches!(
session.session_phase,
SessionPhase::Closed | SessionPhase::Failed
)
})
&& snapshot
.session_terminals
.iter()
.all(|terminal| terminal.state() == SessionTerminalState::Clean)
&& snapshot
.mailboxes
.iter()
.all(|mailbox| mailbox.state == ParticipantState::Clean);
if inactive {
Ok(())
} else {
Err(failure("Domain is active or has unfinished runtime state"))
}
}
fn report(
configuration: &ResolvedDomainConfiguration,
inspection: &StateAnchorInspection,
) -> DomainStateReport {
let anchors = configuration
.fingerprint_record()
.persistent_dirs
.iter()
.map(|container_path| {
let relative = container_path.trim_start_matches('/').as_bytes();
let path = Path::new(container_path.trim_start_matches('/'));
let mut whiteout = path
.parent()
.unwrap_or(Path::new(""))
.as_os_str()
.as_encoded_bytes()
.to_vec();
if !whiteout.is_empty() {
whiteout.push(b'/');
}
whiteout.extend_from_slice(b".wh.");
whiteout.extend_from_slice(
path.file_name()
.expect("validated persistent path has a name")
.as_encoded_bytes(),
);
let changes = inspection
.entries
.iter()
.filter(|entry| {
entry.path == relative
|| entry.path == whiteout
|| entry
.path
.strip_prefix(relative)
.is_some_and(|suffix| suffix.first() == Some(&b'/'))
})
.cloned()
.collect();
StateAnchorReport {
container_path: container_path.clone(),
changes,
}
})
.collect();
DomainStateReport {
profile: configuration.selected_profile().to_owned(),
workspace: configuration
.workspace()
.canonical_label()
.as_bytes()
.to_vec(),
domain_id: *configuration.domain_id().as_bytes(),
anchors,
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::fs::{self, File, OpenOptions};
use std::os::unix::ffi::OsStringExt;
use std::os::unix::fs::symlink;
use std::path::{Path, PathBuf};
use lazaret_core::configuration::{
ConfigurationLoadRequest, ResolvedDomainConfiguration, load_trusted_configuration,
};
use lazaret_core::identity::{
ConfigurationFingerprint, CoordinatorId, GenerationId, NetworkSidecarId, SandboxId,
};
use lazaret_core::records::{Domain, Generation};
use lazaret_linux::filesystem::try_exclusive_flock;
use rustix::fs::{FileType, Mode, XattrFlags, fsetxattr, makedev, mknodat};
use super::*;
#[derive(Clone, Copy)]
enum EntryCase {
Directory,
File,
Symlink,
WhiteoutFile,
WhiteoutDevice,
}
#[test]
fn every_reset_boundary_recovers_to_new_empty() {
for entry_case in [
EntryCase::Directory,
EntryCase::File,
EntryCase::Symlink,
EntryCase::WhiteoutFile,
EntryCase::WhiteoutDevice,
] {
for boundary in 0..9 {
let root = tempfile::tempdir().unwrap();
let configuration = test_configuration(root.path());
let store = open_store(&configuration);
let layout = StateAnchorLayout::create(&configuration).unwrap();
let anchor = domain_root(&configuration).join("state-anchor");
let upper = anchor.join("upper");
fs::create_dir(upper.join("unrelated")).unwrap();
fs::write(upper.join("unrelated/value"), b"unchanged").unwrap();
create_entry(&upper, entry_case);
let intent = StateResetIntentV1::new(
configuration.domain_id(),
StateResetScopeV1::Selected(b"/usr".to_vec()),
);
store.begin_state_reset(&intent).unwrap();
stage_boundary(&anchor, &upper, entry_case, boundary);
if boundary == 8 {
store.finish_state_reset(&intent).unwrap();
}
drop(layout);
recover_state_reset(&configuration, &store).unwrap();
assert!(!upper.join("usr").exists());
assert!(!upper.join("usr").is_symlink());
assert!(!upper.join(".wh.usr").exists());
assert_eq!(
fs::read(upper.join("unrelated/value")).unwrap(),
b"unchanged"
);
assert!(store.state_reset_intent().unwrap().is_none());
assert!(!anchor.join("reset-quarantine").exists());
}
}
}
#[test]
fn corrupt_mismatched_and_orphan_recovery_state_refuses() {
let root = tempfile::tempdir().unwrap();
let configuration = test_configuration(root.path());
let store = open_store(&configuration);
let foreign = StateResetIntentV1::new(
configuration.domain_id(),
StateResetScopeV1::Selected(b"/etc".to_vec()),
);
store.begin_state_reset(&foreign).unwrap();
assert!(recover_state_reset(&configuration, &store).is_err());
let root = tempfile::tempdir().unwrap();
let configuration = test_configuration(root.path());
let store = open_store(&configuration);
StateAnchorLayout::create(&configuration).unwrap();
fs::create_dir(domain_root(&configuration).join("state-anchor/reset-quarantine")).unwrap();
assert!(recover_state_reset(&configuration, &store).is_err());
}
#[test]
fn active_domain_refuses_inspection_and_reset() {
let root = tempfile::tempdir().unwrap();
let configuration = test_configuration(root.path());
let store = open_store(&configuration);
store
.apply(&[crate::domain_store::DomainStoreMutation::PutGeneration(
Generation {
domain_id: configuration.domain_id(),
generation_id: GenerationId::new(1),
configuration_fingerprint: ConfigurationFingerprint::new([2; 32]),
coordinator_id: CoordinatorId::new([3; 32]),
network_sidecar_id: NetworkSidecarId::new([4; 32]),
sandbox_id: SandboxId::new([5; 32]),
durable_decision: DurableDecision::None,
},
)])
.unwrap();
assert!(inspect_domain_state(&configuration, &store).is_err());
assert!(reset_domain_state(&configuration, &store, StateResetScopeV1::All).is_err());
assert!(store.state_reset_intent().unwrap().is_none());
}
fn create_entry(upper: &Path, entry_case: EntryCase) {
match entry_case {
EntryCase::Directory => {
fs::create_dir(upper.join("usr")).unwrap();
fs::write(upper.join("usr/value"), b"state").unwrap();
fs::write(upper.join("usr/.wh..wh..opq"), b"").unwrap();
let directory = File::open(upper.join("usr")).unwrap();
fsetxattr(
directory,
"user.fuseoverlayfs.opaque",
b"y",
XattrFlags::empty(),
)
.unwrap();
}
EntryCase::File => fs::write(upper.join("usr"), b"state").unwrap(),
EntryCase::Symlink => symlink("/foreign", upper.join("usr")).unwrap(),
EntryCase::WhiteoutFile => fs::write(upper.join(".wh.usr"), b"").unwrap(),
EntryCase::WhiteoutDevice => mknodat(
rustix::fs::CWD,
upper.join("usr"),
FileType::CharacterDevice,
Mode::empty(),
makedev(0, 0),
)
.unwrap(),
}
}
fn stage_boundary(anchor: &Path, upper: &Path, entry_case: EntryCase, boundary: usize) {
if boundary == 0 {
return;
}
let quarantine = anchor.join("reset-quarantine");
fs::create_dir(&quarantine).unwrap();
if boundary == 1 {
return;
}
let (source, destination) = match entry_case {
EntryCase::WhiteoutFile => (upper.join(".wh.usr"), quarantine.join("whiteout")),
_ => (upper.join("usr"), quarantine.join("entry")),
};
fs::rename(source, &destination).unwrap();
if boundary <= 3 {
return;
}
remove_entry(&destination);
if boundary <= 5 {
return;
}
fs::remove_dir(&quarantine).unwrap();
}
fn remove_entry(path: &Path) {
let metadata = fs::symlink_metadata(path).unwrap();
if metadata.file_type().is_dir() {
fs::remove_dir_all(path).unwrap();
} else {
fs::remove_file(path).unwrap();
}
}
fn open_store(configuration: &ResolvedDomainConfiguration) -> HostCoordinatorDomainStore {
let root = domain_root(configuration);
let lock = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(root.join("domain.lock"))
.unwrap();
HostCoordinatorDomainStore::open(
try_exclusive_flock(lock).unwrap(),
&root.join("domain.redb"),
&Domain {
domain_id: configuration.domain_id(),
profile_name: configuration.selected_profile().to_owned(),
canonical_workspace: configuration.workspace().canonical_label().clone(),
},
)
.unwrap()
}
fn domain_root(configuration: &ResolvedDomainConfiguration) -> PathBuf {
PathBuf::from(std::ffi::OsString::from_vec(
configuration
.persistent_domain_root()
.canonical_label()
.as_bytes()
.to_vec(),
))
}
fn test_configuration(root: &Path) -> ResolvedDomainConfiguration {
let configuration_directory = root.join("configuration");
let workspace = root.join("workspace");
let runtime = root.join("runtime");
fs::create_dir(&configuration_directory).unwrap();
fs::create_dir(&workspace).unwrap();
fs::create_dir(&runtime).unwrap();
let configuration_path = configuration_directory.join("lazaret.toml");
fs::write(
&configuration_path,
format!(
"schema = \"lazaret-trusted-host-config-v1\"\n[storage]\ndata_root = \"{}\"\n[network]\ndefault_allow = [\"192.0.2.10/32\"]\n[profiles.default]\nimage = \"example.invalid/state:v1\"\npersistent_dirs = [\"/usr\"]\n[profiles.default.readiness]\ndenied = [{{ address = \"10.20.30.40\", port = 8443 }}]\npermitted = [{{ address = \"192.0.2.10\", port = 443 }}]\ndns = [{{ resolver = \"198.51.100.53\", query = \"example.invalid\" }}]\n",
root.join("data").display()
),
)
.unwrap();
load_trusted_configuration(ConfigurationLoadRequest {
explicit_configuration_path: Some(configuration_path),
selected_profile: "default".into(),
workspace,
home: root.join("home"),
xdg_config_home: None,
xdg_data_home: None,
xdg_runtime_dir: runtime,
environment: BTreeMap::new(),
sandbox_layout: lazaret_core::configuration::SandboxLayoutV1::default_layout().unwrap(),
})
.unwrap()
}
}