Add crash-consistent State Anchor maintenance
This commit is contained in:
parent
5cd0772866
commit
24d7d5b6e0
6 changed files with 1590 additions and 7 deletions
|
|
@ -15,6 +15,7 @@ use redb::{
|
|||
CommitError, Database, DatabaseError, ReadableDatabase, ReadableTable, StorageError,
|
||||
TableDefinition, TableError, TransactionError,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const METADATA: TableDefinition<u8, &[u8]> = TableDefinition::new("metadata");
|
||||
const DOMAINS: TableDefinition<u8, &[u8]> = TableDefinition::new("domains");
|
||||
|
|
@ -24,7 +25,51 @@ const SESSIONS: TableDefinition<&[u8; 40], &[u8]> = TableDefinition::new("sessio
|
|||
const SESSION_TERMINALS: TableDefinition<&[u8; 40], &[u8]> =
|
||||
TableDefinition::new("session_terminals");
|
||||
const MAILBOXES: TableDefinition<u64, &[u8]> = TableDefinition::new("mailboxes");
|
||||
const STATE_RESET: TableDefinition<u8, &[u8]> = TableDefinition::new("state_reset");
|
||||
const SINGLETON_KEY: u8 = 0;
|
||||
const STATE_RESET_SCHEMA_V1: &str = "lazaret-state-reset-v1";
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub enum StateResetScopeV1 {
|
||||
All,
|
||||
Selected(Vec<u8>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct StateResetIntentV1 {
|
||||
schema: String,
|
||||
domain_id: lazaret_core::identity::DomainId,
|
||||
scope: StateResetScopeV1,
|
||||
}
|
||||
|
||||
impl StateResetIntentV1 {
|
||||
#[must_use]
|
||||
pub fn new(domain_id: lazaret_core::identity::DomainId, scope: StateResetScopeV1) -> Self {
|
||||
Self {
|
||||
schema: STATE_RESET_SCHEMA_V1.to_owned(),
|
||||
domain_id,
|
||||
scope,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn domain_id(&self) -> lazaret_core::identity::DomainId {
|
||||
self.domain_id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn scope(&self) -> &StateResetScopeV1 {
|
||||
&self.scope
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), DomainStoreError> {
|
||||
if self.schema == STATE_RESET_SCHEMA_V1 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(DomainStoreError::InvalidRow("State Reset"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum DomainStoreMutation {
|
||||
|
|
@ -231,6 +276,71 @@ impl HostCoordinatorDomainStore {
|
|||
self.write_snapshot(&snapshot)
|
||||
}
|
||||
|
||||
/// Reads the typed reset recovery point from the versioned Domain Store.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses malformed, unknown-version, or mismatched-Domain intent.
|
||||
pub fn state_reset_intent(&self) -> Result<Option<StateResetIntentV1>, DomainStoreError> {
|
||||
self.validate_metadata()?;
|
||||
let read = self.database.begin_read()?;
|
||||
let table = match read.open_table(STATE_RESET) {
|
||||
Ok(table) => table,
|
||||
Err(TableError::TableDoesNotExist(_)) => return Ok(None),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let Some(value) = table.get(SINGLETON_KEY)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let intent = decode::<StateResetIntentV1>(value.value())?;
|
||||
intent.validate()?;
|
||||
if intent.domain_id != self.snapshot()?.domain.domain_id {
|
||||
return Err(DomainStoreError::DomainMismatch);
|
||||
}
|
||||
Ok(Some(intent))
|
||||
}
|
||||
|
||||
/// Commits the reset recovery point in one immediate-durability transaction.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses an invalid, foreign, or already-present intent.
|
||||
pub fn begin_state_reset(&self, intent: &StateResetIntentV1) -> Result<(), DomainStoreError> {
|
||||
intent.validate()?;
|
||||
if intent.domain_id != self.snapshot()?.domain.domain_id {
|
||||
return Err(DomainStoreError::DomainMismatch);
|
||||
}
|
||||
let write = self.database.begin_write()?;
|
||||
{
|
||||
let mut table = write.open_table(STATE_RESET)?;
|
||||
if table.get(SINGLETON_KEY)?.is_some() {
|
||||
return Err(DomainStoreError::DuplicateRow("State Reset"));
|
||||
}
|
||||
let encoded = encode(intent)?;
|
||||
table.insert(SINGLETON_KEY, encoded.as_slice())?;
|
||||
}
|
||||
write.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes the exact completed intent in one immediate-durability transaction.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses missing or changed intent and storage failures.
|
||||
pub fn finish_state_reset(
|
||||
&self,
|
||||
expected: &StateResetIntentV1,
|
||||
) -> Result<(), DomainStoreError> {
|
||||
if self.state_reset_intent()?.as_ref() != Some(expected) {
|
||||
return Err(DomainStoreError::InvalidRow("State Reset"));
|
||||
}
|
||||
let write = self.database.begin_write()?;
|
||||
{
|
||||
let mut table = write.open_table(STATE_RESET)?;
|
||||
table.remove(SINGLETON_KEY)?;
|
||||
}
|
||||
write.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn initialize(&self, domain: &Domain) -> Result<(), DomainStoreError> {
|
||||
let snapshot = DomainStore {
|
||||
metadata: DomainStoreMetadata::v1(),
|
||||
|
|
|
|||
|
|
@ -138,6 +138,65 @@ fn open_store(directory: &TestDirectory) -> HostCoordinatorDomainStore {
|
|||
HostCoordinatorDomainStore::open(lock, &directory.path("domain.redb"), &domain()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_intent_is_typed_transactional_and_domain_bound() {
|
||||
let directory = TestDirectory::new("state-reset");
|
||||
let store = open_store(&directory);
|
||||
let intent = StateResetIntentV1::new(
|
||||
domain().domain_id,
|
||||
StateResetScopeV1::Selected(b"/usr".to_vec()),
|
||||
);
|
||||
|
||||
assert_eq!(store.state_reset_intent().unwrap(), None);
|
||||
store.begin_state_reset(&intent).unwrap();
|
||||
assert_eq!(store.state_reset_intent().unwrap(), Some(intent.clone()));
|
||||
assert!(store.begin_state_reset(&intent).is_err());
|
||||
store.finish_state_reset(&intent).unwrap();
|
||||
assert_eq!(store.state_reset_intent().unwrap(), None);
|
||||
|
||||
let foreign = StateResetIntentV1::new(DomainId::new(bytes(99)), StateResetScopeV1::All);
|
||||
assert!(matches!(
|
||||
store.begin_state_reset(&foreign),
|
||||
Err(DomainStoreError::DomainMismatch)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_intent_unknown_version_refuses_before_scope_use() {
|
||||
let directory = TestDirectory::new("state-reset-version");
|
||||
let store = open_store(&directory);
|
||||
let intent = StateResetIntentV1 {
|
||||
schema: "lazaret-state-reset-v2".into(),
|
||||
domain_id: domain().domain_id,
|
||||
scope: StateResetScopeV1::All,
|
||||
};
|
||||
assert!(matches!(
|
||||
store.begin_state_reset(&intent),
|
||||
Err(DomainStoreError::InvalidRow("State Reset"))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_persisted_reset_intent_refuses_without_fallback() {
|
||||
let directory = TestDirectory::new("state-reset-malformed");
|
||||
drop(open_store(&directory));
|
||||
let database = Database::open(directory.path("domain.redb")).unwrap();
|
||||
let write = database.begin_write().unwrap();
|
||||
{
|
||||
let mut table = write.open_table(STATE_RESET).unwrap();
|
||||
table
|
||||
.insert(SINGLETON_KEY, b"not-postcard".as_slice())
|
||||
.unwrap();
|
||||
}
|
||||
write.commit().unwrap();
|
||||
drop(database);
|
||||
|
||||
assert!(matches!(
|
||||
open_store(&directory).state_reset_intent(),
|
||||
Err(DomainStoreError::Codec(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptor_store_open_cannot_be_redirected_by_path_replacement() {
|
||||
let directory = TestDirectory::new("descriptor-open");
|
||||
|
|
|
|||
|
|
@ -13,5 +13,6 @@ pub mod recovery;
|
|||
pub mod runtime;
|
||||
pub mod sandbox_init;
|
||||
pub mod state_anchor;
|
||||
pub mod state_maintenance;
|
||||
pub mod terminal;
|
||||
pub mod waiter;
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ use crate::runtime::StateAnchorAbsenceObservation;
|
|||
use crate::state_anchor::{
|
||||
StateAnchorCoordinator, StateAnchorCoordinatorError, StateAnchorRecoveryOwner,
|
||||
};
|
||||
use crate::state_maintenance::recover_state_reset;
|
||||
|
||||
const PROCESS_EXIT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
|
|
@ -627,6 +628,7 @@ where
|
|||
Open: FnMut(PidfsProcessIdentityV1) -> io::Result<PidfsRecoveryMatch>,
|
||||
Delete: FnMut(&std::os::fd::OwnedFd, &str) -> Result<(), String>,
|
||||
{
|
||||
recover_state_reset(configuration, store).map_err(|error| error.to_string())?;
|
||||
failpoint(ProductionRecoveryBoundary::Snapshot, GenerationId::new(0))?;
|
||||
let snapshot = store.snapshot().map_err(|error| error.to_string())?;
|
||||
if snapshot.domain.domain_id != configuration.domain_id() {
|
||||
|
|
|
|||
482
crates/lazaret-host-coordinator/src/state_maintenance.rs
Normal file
482
crates/lazaret-host-coordinator/src/state_maintenance.rs
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
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,
|
||||
};
|
||||
|
||||
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>,
|
||||
}
|
||||
|
||||
/// 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 lazaret_runtime_supervisor::sandbox::production_sandbox_layout;
|
||||
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: production_sandbox_layout().unwrap(),
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
|
@ -6,9 +6,9 @@ use std::sync::{Arc, Mutex};
|
|||
|
||||
use ed25519_dalek::SigningKey;
|
||||
use lazaret_core::identity::{
|
||||
BootId, BundleDigest, ClientId, ConfigurationFingerprint, HostPath, HostPidLocator, MountId,
|
||||
MountIdentity, NetworkSidecarId, PidfsDeviceId, PidfsInodeId, PidfsProcessAuthorityV1,
|
||||
ProcessStartTimeTicks, RequestId, SandboxId, SessionAnchorId,
|
||||
BootId, BundleDigest, ClientId, ConfigurationFingerprint, DeviceId, HostPath, HostPidLocator,
|
||||
InodeId, MountId, MountIdentity, NetworkSidecarId, PidfsDeviceId, PidfsInodeId,
|
||||
PidfsProcessAuthorityV1, ProcessStartTimeTicks, RequestId, SandboxId, SessionAnchorId,
|
||||
};
|
||||
use lazaret_core::protocol::{VerifiedSession, encode_signed, issue_session_capability};
|
||||
use lazaret_core::records::{
|
||||
|
|
|
|||
Loading…
Reference in a new issue