Add crash-consistent State Anchor maintenance
This commit is contained in:
parent
5cd0772866
commit
24d7d5b6e0
6 changed files with 1590 additions and 7 deletions
|
|
@ -20,6 +20,7 @@ rustix.workspace = true
|
|||
rustls.workspace = true
|
||||
sha2.workspace = true
|
||||
nix.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "=3.27.0"
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ use sha2::{Digest, Sha256};
|
|||
use crate::terminal::{HostTerminal, InteractiveSessionTransport, run_interactive_session};
|
||||
|
||||
pub mod production;
|
||||
pub mod state;
|
||||
pub mod terminal;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
|
|
|
|||
|
|
@ -7,6 +7,15 @@ fn main() {
|
|||
}
|
||||
return;
|
||||
}
|
||||
if arguments.first().and_then(|value| value.to_str()) == Some("state") {
|
||||
match lazaret_client::state::run_command(&arguments[1..]) {
|
||||
Ok(status) => std::process::exit(status),
|
||||
Err(error) => {
|
||||
eprintln!("Lazaret state maintenance failed: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
let current_workspace = match std::env::current_dir() {
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
|
|
|
|||
|
|
@ -384,7 +384,7 @@ impl ProductionClientBackend {
|
|||
}
|
||||
}
|
||||
|
||||
fn open_private_domain_lock(root: &impl AsFd) -> Result<File, ProductionClientError> {
|
||||
pub(crate) fn open_private_domain_lock(root: &impl AsFd) -> Result<File, ProductionClientError> {
|
||||
let file = File::from(
|
||||
openat(
|
||||
root,
|
||||
|
|
@ -416,7 +416,7 @@ fn publication_matches(
|
|||
== configuration.configuration_fingerprint()
|
||||
}
|
||||
|
||||
fn load_configuration(
|
||||
pub(crate) fn load_configuration(
|
||||
command: &ClientCommand,
|
||||
) -> Result<ResolvedDomainConfiguration, Box<dyn std::error::Error>> {
|
||||
Ok(load_trusted_configuration(ConfigurationLoadRequest {
|
||||
|
|
|
|||
367
crates/lazaret-client/src/state.rs
Normal file
367
crates/lazaret-client/src/state.rs
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
use std::ffi::OsString;
|
||||
use std::io::{self, Write};
|
||||
use std::os::fd::AsFd;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use lazaret_core::records::Domain;
|
||||
use lazaret_host_coordinator::domain_store::{HostCoordinatorDomainStore, StateResetScopeV1};
|
||||
use lazaret_host_coordinator::state_maintenance::{
|
||||
DomainStateReport, StateAnchorEntryKind, 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::production::{load_configuration, open_private_domain_lock};
|
||||
|
||||
const OUTPUT_SCHEMA: &str = "lazaret-state-output-v1";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum Operation {
|
||||
List,
|
||||
Inspect,
|
||||
Verify,
|
||||
Reset,
|
||||
}
|
||||
|
||||
struct StateCommand {
|
||||
operation: Operation,
|
||||
context: ClientCommand,
|
||||
machine: bool,
|
||||
yes: bool,
|
||||
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(arguments: &[OsString]) -> Result<i32, Box<dyn std::error::Error>> {
|
||||
let command = parse(arguments)?;
|
||||
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) => {
|
||||
return Ok(write_absent(
|
||||
&configuration,
|
||||
command.operation,
|
||||
command.machine,
|
||||
));
|
||||
}
|
||||
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 {
|
||||
Operation::List | Operation::Inspect | Operation::Verify => {
|
||||
inspect_domain_state(&configuration, &store)?
|
||||
}
|
||||
Operation::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 parse(arguments: &[OsString]) -> Result<StateCommand, &'static str> {
|
||||
let operation = match arguments.first().and_then(|value| value.to_str()) {
|
||||
Some("list") => Operation::List,
|
||||
Some("inspect") => Operation::Inspect,
|
||||
Some("verify") => Operation::Verify,
|
||||
Some("reset") => Operation::Reset,
|
||||
_ => return Err("expected `state list|inspect|verify|reset`"),
|
||||
};
|
||||
let mut profile = None;
|
||||
let mut workspace = None;
|
||||
let mut configuration = None;
|
||||
let mut machine = false;
|
||||
let mut yes = false;
|
||||
let mut scope = None;
|
||||
let mut index = 1;
|
||||
while index < arguments.len() {
|
||||
let option = arguments[index].to_str().ok_or("options must be UTF-8")?;
|
||||
index += 1;
|
||||
match option {
|
||||
"--profile" => profile = Some(value(arguments, &mut index, "--profile")?),
|
||||
"--workspace" => workspace = Some(PathBuf::from(value_os(arguments, &mut index)?)),
|
||||
"--config" => configuration = Some(PathBuf::from(value_os(arguments, &mut index)?)),
|
||||
"--json" => machine = true,
|
||||
"--yes" => yes = true,
|
||||
"--all" => set_scope(&mut scope, StateResetScopeV1::All)?,
|
||||
"--path" => {
|
||||
let path = value(arguments, &mut index, "--path")?;
|
||||
set_scope(&mut scope, StateResetScopeV1::Selected(path.into_bytes()))?;
|
||||
}
|
||||
_ => return Err("unknown state-maintenance option"),
|
||||
}
|
||||
}
|
||||
let profile = profile.ok_or("`--profile <name>` is required")?;
|
||||
let workspace = match workspace {
|
||||
Some(workspace) => workspace,
|
||||
None => std::env::current_dir().map_err(|_| "cannot determine current workspace")?,
|
||||
};
|
||||
if operation == Operation::Reset && scope.is_none() {
|
||||
return Err("reset requires exactly one of `--all` or `--path <container-path>`");
|
||||
}
|
||||
if operation != Operation::Reset && (scope.is_some() || yes) {
|
||||
return Err("reset scope and confirmation options require `state reset`");
|
||||
}
|
||||
Ok(StateCommand {
|
||||
operation,
|
||||
context: ClientCommand {
|
||||
profile,
|
||||
workspace,
|
||||
configuration,
|
||||
argv: Vec::new(),
|
||||
},
|
||||
machine,
|
||||
yes,
|
||||
scope,
|
||||
})
|
||||
}
|
||||
|
||||
fn value(
|
||||
arguments: &[OsString],
|
||||
index: &mut usize,
|
||||
option: &'static str,
|
||||
) -> Result<String, &'static str> {
|
||||
value_os(arguments, index)?
|
||||
.into_string()
|
||||
.map_err(|_| match option {
|
||||
"--profile" => "profile name must be UTF-8",
|
||||
"--path" => "container path must be UTF-8",
|
||||
_ => "option value must be UTF-8",
|
||||
})
|
||||
}
|
||||
|
||||
fn value_os(arguments: &[OsString], index: &mut usize) -> Result<OsString, &'static str> {
|
||||
let value = arguments
|
||||
.get(*index)
|
||||
.ok_or("option requires a value")?
|
||||
.clone();
|
||||
*index += 1;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn set_scope(
|
||||
current: &mut Option<StateResetScopeV1>,
|
||||
scope: StateResetScopeV1,
|
||||
) -> Result<(), &'static str> {
|
||||
if current.replace(scope).is_some() {
|
||||
Err("reset accepts exactly one scope")
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
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_absent(
|
||||
configuration: &lazaret_core::configuration::ResolvedDomainConfiguration,
|
||||
operation: Operation,
|
||||
machine: bool,
|
||||
) -> i32 {
|
||||
let report = DomainStateReport {
|
||||
profile: configuration.selected_profile().to_owned(),
|
||||
workspace: configuration
|
||||
.workspace()
|
||||
.canonical_label()
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
domain_id: *configuration.domain_id().as_bytes(),
|
||||
anchors: configuration
|
||||
.fingerprint_record()
|
||||
.persistent_dirs
|
||||
.iter()
|
||||
.map(
|
||||
|path| lazaret_host_coordinator::state_maintenance::StateAnchorReport {
|
||||
container_path: path.clone(),
|
||||
changes: Vec::new(),
|
||||
},
|
||||
)
|
||||
.collect(),
|
||||
};
|
||||
write_report(&report, operation, machine);
|
||||
0
|
||||
}
|
||||
|
||||
fn write_report(report: &DomainStateReport, operation: Operation, 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, Operation::Inspect | Operation::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: Operation) -> &'static str {
|
||||
match operation {
|
||||
Operation::List => "list",
|
||||
Operation::Inspect => "inspect",
|
||||
Operation::Verify => "verify",
|
||||
Operation::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
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parser_requires_explicit_reset_scope_and_rejects_arbitrary_options() {
|
||||
assert!(parse(&["reset", "--profile", "default"].map(OsString::from)).is_err());
|
||||
assert!(
|
||||
parse(
|
||||
&["reset", "--profile", "default", "--all", "--path", "/usr",].map(OsString::from)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(parse(&["inspect", "--profile", "default", "--yes"].map(OsString::from)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_preserves_only_container_scope_not_a_host_upper_path() {
|
||||
let command = parse(
|
||||
&[
|
||||
"reset",
|
||||
"--profile",
|
||||
"default",
|
||||
"--path",
|
||||
"/usr",
|
||||
"--yes",
|
||||
"--json",
|
||||
]
|
||||
.map(OsString::from),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
command.scope,
|
||||
Some(StateResetScopeV1::Selected(b"/usr".to_vec()))
|
||||
);
|
||||
assert!(command.yes);
|
||||
assert!(command.machine);
|
||||
}
|
||||
}
|
||||
80
crates/lazaret-client/tests/state_cli.rs
Normal file
80
crates/lazaret-client/tests/state_cli.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
use std::fs;
|
||||
|
||||
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||
#[test]
|
||||
fn machine_and_human_discovery_are_versioned_and_content_free() {
|
||||
let fixture = Fixture::new();
|
||||
let machine = fixture.command("list").arg("--json").output().unwrap();
|
||||
assert!(
|
||||
machine.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&machine.stderr)
|
||||
);
|
||||
let document: serde_json::Value = serde_json::from_slice(&machine.stdout).unwrap();
|
||||
assert_eq!(document["schema"], "lazaret-state-output-v1");
|
||||
assert_eq!(document["contents_included"], false);
|
||||
assert_eq!(document["anchors"][0]["container_path"], "/usr");
|
||||
|
||||
let human = fixture.command("inspect").output().unwrap();
|
||||
assert!(
|
||||
human.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&human.stderr)
|
||||
);
|
||||
let output = String::from_utf8(human.stdout).unwrap();
|
||||
assert!(output.contains("State Anchor /usr"));
|
||||
assert!(output.contains("Persisted file contents were not read or printed."));
|
||||
}
|
||||
|
||||
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||
#[test]
|
||||
fn reset_refuses_an_arbitrary_container_or_host_path_before_state_access() {
|
||||
let fixture = Fixture::new();
|
||||
let output = fixture
|
||||
.command("reset")
|
||||
.args(["--path", "/tmp/host-upper", "--yes"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
.contains("not an exact configured persistent container path")
|
||||
);
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
root: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
for name in ["workspace", "runtime", "home"] {
|
||||
fs::create_dir(root.path().join(name)).unwrap();
|
||||
}
|
||||
fs::write(
|
||||
root.path().join("config.toml"),
|
||||
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.path().join("data").display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
Self { root }
|
||||
}
|
||||
|
||||
#[allow(clippy::disallowed_types)]
|
||||
fn command(&self, operation: &str) -> std::process::Command {
|
||||
let mut command = std::process::Command::new(env!("CARGO_BIN_EXE_lazaret"));
|
||||
command
|
||||
.arg("state")
|
||||
.arg(operation)
|
||||
.args(["--profile", "default", "--workspace"])
|
||||
.arg(self.root.path().join("workspace"))
|
||||
.arg("--config")
|
||||
.arg(self.root.path().join("config.toml"))
|
||||
.env("HOME", self.root.path().join("home"))
|
||||
.env("XDG_RUNTIME_DIR", self.root.path().join("runtime"));
|
||||
command
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue