lazaret/crates/lazaret-client/src/state.rs

204 lines
7.2 KiB
Rust

use std::io::{self, Write};
use std::os::fd::AsFd;
use lazaret_core::records::{Domain, StateResetScopeV1};
use lazaret_domain_state::domain_store::HostCoordinatorDomainStore;
use lazaret_domain_state::state_maintenance::{
DomainStateReport, StateAnchorEntryKind, inspect_absent_domain_state, inspect_domain_state,
reset_domain_state,
};
use lazaret_linux::filesystem::try_exclusive_flock;
use rustix::fs::{Mode, OFlags, ResolveFlags, openat2};
use serde_json::json;
use crate::ClientCommand;
use crate::coordinator_backend::{load_configuration, open_private_domain_lock};
const OUTPUT_SCHEMA: &str = "lazaret-state-output-v1";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum StateOperation {
List,
Inspect,
Verify,
Reset,
}
#[derive(Debug)]
pub struct StateCommand {
pub(crate) operation: StateOperation,
pub(crate) context: ClientCommand,
pub(crate) machine: bool,
pub(crate) yes: bool,
pub(crate) scope: Option<StateResetScopeV1>,
}
/// Runs one state-maintenance command using resolved configuration and exclusive Domain authority.
///
/// # Errors
/// Refuses invalid arguments, active Domains, arbitrary targets, authority changes, malformed
/// durable state, structural failures, and reset without confirmation.
pub fn run_command(command: StateCommand) -> Result<i32, Box<dyn std::error::Error>> {
let configuration = load_configuration(&command.context)?;
if let Some(StateResetScopeV1::Selected(path)) = &command.scope
&& !configuration
.fingerprint_record()
.persistent_dirs
.iter()
.any(|configured| configured.as_bytes() == path)
{
return Err("reset target is not an exact configured persistent container path".into());
}
let root = configuration.persistent_domain_root().open_authority()?;
let lock = try_exclusive_flock(open_private_domain_lock(&root)?)?;
let database = match openat2(
root.as_fd(),
"domain.redb",
OFlags::RDWR | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::empty(),
ResolveFlags::BENEATH
| ResolveFlags::NO_SYMLINKS
| ResolveFlags::NO_MAGICLINKS
| ResolveFlags::NO_XDEV,
) {
Ok(database) => database,
Err(rustix::io::Errno::NOENT) => {
let report = inspect_absent_domain_state(&configuration)?;
write_report(&report, command.operation, command.machine);
return Ok(0);
}
Err(error) => return Err(io::Error::from(error).into()),
};
let expected = Domain {
domain_id: configuration.domain_id(),
profile_name: configuration.selected_profile().to_owned(),
canonical_workspace: configuration.workspace().canonical_label().clone(),
};
let store = HostCoordinatorDomainStore::open_file(lock, database.into(), &expected)?;
let report = match command.operation {
StateOperation::List | StateOperation::Inspect | StateOperation::Verify => {
inspect_domain_state(&configuration, &store)?
}
StateOperation::Reset => {
let scope = command.scope.ok_or("reset scope is missing")?;
if !command.yes {
confirm(&configuration, &scope)?;
}
reset_domain_state(&configuration, &store, scope)?
}
};
write_report(&report, command.operation, command.machine);
Ok(0)
}
fn confirm(
configuration: &lazaret_core::configuration::ResolvedDomainConfiguration,
scope: &StateResetScopeV1,
) -> Result<(), Box<dyn std::error::Error>> {
let scope = match scope {
StateResetScopeV1::All => "all configured State Anchors".to_owned(),
StateResetScopeV1::Selected(path) => {
format!("State Anchor {}", String::from_utf8_lossy(path))
}
};
eprintln!(
"Reset Domain {} profile {} workspace {} scope: {}? [y/N]",
hex(configuration.domain_id().as_bytes()),
configuration.selected_profile(),
String::from_utf8_lossy(configuration.workspace().canonical_label().as_bytes()),
scope
);
io::stderr().flush()?;
let mut answer = String::new();
io::stdin().read_line(&mut answer)?;
if matches!(answer.trim(), "y" | "Y" | "yes" | "YES") {
Ok(())
} else {
Err("reset was not confirmed".into())
}
}
fn write_report(report: &DomainStateReport, operation: StateOperation, machine: bool) {
if machine {
let anchors = report
.anchors
.iter()
.map(|anchor| {
json!({
"container_path": anchor.container_path,
"changes": anchor.changes.iter().map(|entry| json!({
"path_hex": hex(&entry.path),
"kind": entry_kind(entry.kind),
"mode": entry.mode,
"uid": entry.uid,
"gid": entry.gid,
"size": entry.size,
})).collect::<Vec<_>>(),
})
})
.collect::<Vec<_>>();
println!(
"{}",
json!({
"schema": OUTPUT_SCHEMA,
"operation": operation_name(operation),
"domain_id": hex(&report.domain_id),
"profile": report.profile,
"workspace_hex": hex(&report.workspace),
"anchors": anchors,
"contents_included": false,
})
);
} else {
println!("Domain {}", hex(&report.domain_id));
println!("Profile: {}", report.profile);
println!("Workspace: {}", String::from_utf8_lossy(&report.workspace));
for anchor in &report.anchors {
println!(
"State Anchor {}: {} structural change(s)",
anchor.container_path,
anchor.changes.len()
);
if matches!(operation, StateOperation::Inspect | StateOperation::Verify) {
for entry in &anchor.changes {
println!(
" {} {} mode {:o} uid {} gid {} size {}",
String::from_utf8_lossy(&entry.path),
entry_kind(entry.kind),
entry.mode,
entry.uid,
entry.gid,
entry.size
);
}
}
}
println!("Persisted file contents were not read or printed.");
}
}
const fn operation_name(operation: StateOperation) -> &'static str {
match operation {
StateOperation::List => "list",
StateOperation::Inspect => "inspect",
StateOperation::Verify => "verify",
StateOperation::Reset => "reset",
}
}
const fn entry_kind(kind: StateAnchorEntryKind) -> &'static str {
match kind {
StateAnchorEntryKind::Directory => "directory",
StateAnchorEntryKind::RegularFile => "regular-file",
StateAnchorEntryKind::Symlink => "symlink",
StateAnchorEntryKind::Whiteout => "whiteout",
}
}
fn hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
bytes.iter().fold(String::new(), |mut output, byte| {
write!(output, "{byte:02x}").expect("writing to a String cannot fail");
output
})
}