Implement exact State Anchor mount and lease lifecycle
This commit is contained in:
parent
27bc3d835e
commit
e6d6406c64
11 changed files with 1852 additions and 0 deletions
|
|
@ -12,6 +12,8 @@ path = "src/main.rs"
|
|||
[dependencies]
|
||||
lazaret-core = { version = "=0.0.0", path = "../lazaret-core" }
|
||||
lazaret-linux = { version = "=0.0.0", path = "../lazaret-linux" }
|
||||
lazaret-state-anchor = { version = "=0.0.0", path = "../lazaret-state-anchor" }
|
||||
lazaret-storage-catalog = { version = "=0.0.0", path = "../lazaret-storage-catalog" }
|
||||
redb.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
#![doc = "Host-side coordination authority for one Lazaret Domain."]
|
||||
|
||||
pub mod domain_store;
|
||||
pub mod state_anchor;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,11 @@
|
|||
fn main() {
|
||||
let arguments = std::env::args_os().skip(1).collect::<Vec<_>>();
|
||||
if let Some(result) = lazaret_linux::process::dispatch_private_process_role(&arguments) {
|
||||
if let Err(error) = result {
|
||||
eprintln!("private process role failed: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let _ = lazaret_core::PROTOCOL_NAME;
|
||||
}
|
||||
|
|
|
|||
577
crates/lazaret-host-coordinator/src/state_anchor.rs
Normal file
577
crates/lazaret-host-coordinator/src/state_anchor.rs
Normal file
|
|
@ -0,0 +1,577 @@
|
|||
use std::fmt;
|
||||
use std::io;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use lazaret_core::identity::{GenerationId, MountIdentity, PidfsProcessIdentityV1};
|
||||
use lazaret_core::records::{
|
||||
ArtifactLease, HostProcessOwnershipSlot, Participant, ParticipantOwnership, ParticipantRole,
|
||||
ParticipantState, PersistedHostProcessOwnership, PreparedArtifact, PreparedLowerOwnership,
|
||||
ProcessOwnershipCommitAck, StagedProcessOwnership, StateAnchorOwnership,
|
||||
};
|
||||
use lazaret_state_anchor::{StateAnchorError, StateAnchorLayout, require_mount_absent};
|
||||
use lazaret_storage_catalog::{Catalog, CatalogError};
|
||||
|
||||
use crate::domain_store::{DomainStoreError, DomainStoreMutation, HostCoordinatorDomainStore};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum StateAnchorCoordinatorError {
|
||||
DomainStore(DomainStoreError),
|
||||
Catalog(CatalogError),
|
||||
Physical(StateAnchorError),
|
||||
InvalidState(&'static str),
|
||||
}
|
||||
|
||||
impl fmt::Display for StateAnchorCoordinatorError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::DomainStore(error) => write!(formatter, "Domain Store failed: {error}"),
|
||||
Self::Catalog(error) => write!(formatter, "Storage Catalog failed: {error}"),
|
||||
Self::Physical(error) => write!(formatter, "State Anchor failed: {error}"),
|
||||
Self::InvalidState(detail) => write!(formatter, "State Anchor state refused: {detail}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for StateAnchorCoordinatorError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::DomainStore(error) => Some(error),
|
||||
Self::Catalog(error) => Some(error),
|
||||
Self::Physical(error) => Some(error),
|
||||
Self::InvalidState(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DomainStoreError> for StateAnchorCoordinatorError {
|
||||
fn from(error: DomainStoreError) -> Self {
|
||||
Self::DomainStore(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CatalogError> for StateAnchorCoordinatorError {
|
||||
fn from(error: CatalogError) -> Self {
|
||||
Self::Catalog(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StateAnchorError> for StateAnchorCoordinatorError {
|
||||
fn from(error: StateAnchorError) -> Self {
|
||||
Self::Physical(error)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct StateAnchorReleaseAuthority {
|
||||
generation_id: GenerationId,
|
||||
lease: ArtifactLease,
|
||||
merged: PathBuf,
|
||||
mount: MountIdentity,
|
||||
daemon: PidfsProcessIdentityV1,
|
||||
}
|
||||
|
||||
impl StateAnchorReleaseAuthority {
|
||||
#[must_use]
|
||||
pub const fn generation_id(&self) -> GenerationId {
|
||||
self.generation_id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn merged(&self) -> &Path {
|
||||
&self.merged
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn mount(&self) -> MountIdentity {
|
||||
self.mount
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn daemon(&self) -> PidfsProcessIdentityV1 {
|
||||
self.daemon
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StateAnchorCoordinator<'a> {
|
||||
domain_store: &'a HostCoordinatorDomainStore,
|
||||
catalog: &'a Catalog,
|
||||
}
|
||||
|
||||
impl<'a> StateAnchorCoordinator<'a> {
|
||||
#[must_use]
|
||||
pub const fn new(domain_store: &'a HostCoordinatorDomainStore, catalog: &'a Catalog) -> Self {
|
||||
Self {
|
||||
domain_store,
|
||||
catalog,
|
||||
}
|
||||
}
|
||||
|
||||
/// Commits prepared-lower intent, exact catalog lease, `LeaseHeld`, and State Anchor intent.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses existing participants, artifact drift, or any durable transition failure.
|
||||
pub fn begin_acquisition(
|
||||
&self,
|
||||
generation_id: GenerationId,
|
||||
artifact: &PreparedArtifact,
|
||||
layout: &StateAnchorLayout,
|
||||
) -> Result<ArtifactLease, StateAnchorCoordinatorError> {
|
||||
let snapshot = self.domain_store.snapshot()?;
|
||||
require_absent_role(
|
||||
&snapshot.participants,
|
||||
generation_id,
|
||||
ParticipantRole::PreparedLower,
|
||||
)?;
|
||||
require_absent_role(
|
||||
&snapshot.participants,
|
||||
generation_id,
|
||||
ParticipantRole::StateAnchor,
|
||||
)?;
|
||||
let lease = ArtifactLease {
|
||||
prepared_artifact_id: artifact.prepared_artifact_id,
|
||||
domain_id: snapshot.domain.domain_id,
|
||||
generation_id,
|
||||
};
|
||||
let prepared_intent = PreparedLowerOwnership {
|
||||
prepared_artifact_id: Some(artifact.prepared_artifact_id),
|
||||
source_identity: Some(artifact.source_identity.clone()),
|
||||
lease_state: Some(ParticipantState::Intent),
|
||||
path: Some(artifact.path.clone()),
|
||||
device: Some(artifact.device),
|
||||
inode: Some(artifact.inode),
|
||||
};
|
||||
self.domain_store.apply(&[
|
||||
DomainStoreMutation::PutParticipant(Participant::bare_intent(
|
||||
generation_id,
|
||||
ParticipantRole::PreparedLower,
|
||||
)),
|
||||
DomainStoreMutation::PutParticipant(participant(
|
||||
generation_id,
|
||||
ParticipantRole::PreparedLower,
|
||||
ParticipantState::Intent,
|
||||
ParticipantOwnership::PreparedLower(prepared_intent.clone()),
|
||||
)?),
|
||||
])?;
|
||||
self.catalog.acquire_lease_checked(artifact, &lease)?;
|
||||
let lease_held = PreparedLowerOwnership {
|
||||
lease_state: Some(ParticipantState::LeaseHeld),
|
||||
..prepared_intent
|
||||
};
|
||||
self.domain_store.apply(&[
|
||||
DomainStoreMutation::PutParticipant(participant(
|
||||
generation_id,
|
||||
ParticipantRole::PreparedLower,
|
||||
ParticipantState::LeaseHeld,
|
||||
ParticipantOwnership::PreparedLower(lease_held),
|
||||
)?),
|
||||
DomainStoreMutation::PutParticipant(Participant::bare_intent(
|
||||
generation_id,
|
||||
ParticipantRole::StateAnchor,
|
||||
)),
|
||||
DomainStoreMutation::PutParticipant(participant(
|
||||
generation_id,
|
||||
ParticipantRole::StateAnchor,
|
||||
ParticipantState::Intent,
|
||||
ParticipantOwnership::StateAnchor(
|
||||
layout.ownership_intent(artifact.prepared_artifact_id),
|
||||
),
|
||||
)?),
|
||||
])?;
|
||||
Ok(lease)
|
||||
}
|
||||
|
||||
/// Commits the exact Runtime Supervisor-reported staged daemon acquisition.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses the wrong Domain, generation, slot, or participant phase.
|
||||
pub fn stage_daemon(
|
||||
&self,
|
||||
staged: StagedProcessOwnership,
|
||||
) -> Result<(), StateAnchorCoordinatorError> {
|
||||
self.require_staged_identity(staged)?;
|
||||
self.update_daemon(
|
||||
staged.generation_id(),
|
||||
PersistedHostProcessOwnership::staged(staged.identity()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Commits the exact daemon and returns the acknowledgment that releases its mount command.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses a staged identity mismatch or participant transition failure.
|
||||
pub fn commit_daemon(
|
||||
&self,
|
||||
staged: StagedProcessOwnership,
|
||||
) -> Result<ProcessOwnershipCommitAck, StateAnchorCoordinatorError> {
|
||||
self.require_staged_identity(staged)?;
|
||||
self.update_daemon(
|
||||
staged.generation_id(),
|
||||
PersistedHostProcessOwnership::committed(staged.identity()),
|
||||
)?;
|
||||
Ok(ProcessOwnershipCommitAck::v1(staged))
|
||||
}
|
||||
|
||||
/// Commits Prepared only after the Runtime Supervisor reports exact mount identity.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses noncommitted daemon ownership, artifact drift, or replacement of mount evidence.
|
||||
pub fn commit_prepared(
|
||||
&self,
|
||||
generation_id: GenerationId,
|
||||
prepared_artifact_id: lazaret_core::identity::PreparedArtifactId,
|
||||
mount: MountIdentity,
|
||||
) -> Result<(), StateAnchorCoordinatorError> {
|
||||
let current = self.participant(generation_id, ParticipantRole::StateAnchor)?;
|
||||
let Some(ParticipantOwnership::StateAnchor(ownership)) = current.ownership() else {
|
||||
return Err(StateAnchorCoordinatorError::InvalidState(
|
||||
"State Anchor ownership is invalid",
|
||||
));
|
||||
};
|
||||
if current.state() != ParticipantState::Intent
|
||||
|| ownership.lower_artifact_id != Some(prepared_artifact_id)
|
||||
|| !ownership.daemon.is_current_committed()
|
||||
|| ownership.mount.is_some()
|
||||
{
|
||||
return Err(StateAnchorCoordinatorError::InvalidState(
|
||||
"State Anchor is not ready for mount commit",
|
||||
));
|
||||
}
|
||||
let prepared = StateAnchorOwnership {
|
||||
mount: Some(mount),
|
||||
..ownership.clone()
|
||||
};
|
||||
self.domain_store
|
||||
.apply(&[DomainStoreMutation::PutParticipant(participant(
|
||||
generation_id,
|
||||
ParticipantRole::StateAnchor,
|
||||
ParticipantState::Prepared,
|
||||
ParticipantOwnership::StateAnchor(prepared),
|
||||
)?)])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commits `ReleaseIntent` and returns the exact authority needed by the Runtime Supervisor.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses participants that are not Prepared and `LeaseHeld` or incomplete physical evidence.
|
||||
pub fn begin_release(
|
||||
&self,
|
||||
generation_id: GenerationId,
|
||||
) -> Result<StateAnchorReleaseAuthority, StateAnchorCoordinatorError> {
|
||||
let snapshot = self.domain_store.snapshot()?;
|
||||
let anchor = role(
|
||||
&snapshot.participants,
|
||||
generation_id,
|
||||
ParticipantRole::StateAnchor,
|
||||
)?;
|
||||
let prepared = role(
|
||||
&snapshot.participants,
|
||||
generation_id,
|
||||
ParticipantRole::PreparedLower,
|
||||
)?;
|
||||
let authority = release_authority(&snapshot.domain, anchor, prepared)?;
|
||||
self.domain_store
|
||||
.apply(&[DomainStoreMutation::PutParticipant(participant(
|
||||
generation_id,
|
||||
ParticipantRole::StateAnchor,
|
||||
ParticipantState::ReleaseIntent,
|
||||
anchor
|
||||
.ownership()
|
||||
.cloned()
|
||||
.ok_or(StateAnchorCoordinatorError::InvalidState(
|
||||
"State Anchor ownership is absent",
|
||||
))?,
|
||||
)?)])?;
|
||||
Ok(authority)
|
||||
}
|
||||
|
||||
/// Completes Domain Clean, exact catalog deletion, and prepared-lower Clean after unmount.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses a stale authority, any remaining mount, or release-evidence mismatch.
|
||||
pub fn complete_release_after_unmount(
|
||||
&self,
|
||||
authority: &StateAnchorReleaseAuthority,
|
||||
) -> Result<(), StateAnchorCoordinatorError> {
|
||||
require_mount_absent(&authority.merged)?;
|
||||
let anchor = self.participant(authority.generation_id, ParticipantRole::StateAnchor)?;
|
||||
let prepared = self.participant(authority.generation_id, ParticipantRole::PreparedLower)?;
|
||||
match (anchor.state(), prepared.state()) {
|
||||
(ParticipantState::ReleaseIntent, ParticipantState::LeaseHeld) => {
|
||||
if release_authority(&self.domain_store.snapshot()?.domain, &anchor, &prepared)?
|
||||
!= *authority
|
||||
{
|
||||
return Err(StateAnchorCoordinatorError::InvalidState(
|
||||
"release authority does not match durable state",
|
||||
));
|
||||
}
|
||||
self.domain_store.apply(&[
|
||||
DomainStoreMutation::CleanParticipantAfterAbsence(clean_participant(
|
||||
authority.generation_id,
|
||||
ParticipantRole::StateAnchor,
|
||||
)?),
|
||||
DomainStoreMutation::PutParticipant(prepared_release_intent(&prepared)?),
|
||||
])?;
|
||||
}
|
||||
(ParticipantState::Clean, ParticipantState::ReleaseIntent) => {}
|
||||
_ => {
|
||||
return Err(StateAnchorCoordinatorError::InvalidState(
|
||||
"durable release checkpoints do not match the authority",
|
||||
));
|
||||
}
|
||||
}
|
||||
self.release_catalog_lease_if_present(authority)?;
|
||||
self.domain_store
|
||||
.apply(&[DomainStoreMutation::CleanParticipantAfterAbsence(
|
||||
clean_participant(authority.generation_id, ParticipantRole::PreparedLower)?,
|
||||
)])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_staged_identity(
|
||||
&self,
|
||||
staged: StagedProcessOwnership,
|
||||
) -> Result<(), StateAnchorCoordinatorError> {
|
||||
let snapshot = self.domain_store.snapshot()?;
|
||||
let generation_matches = snapshot.generations.iter().any(|generation| {
|
||||
generation.generation_id == staged.generation_id()
|
||||
&& generation.coordinator_id == staged.coordinator_id()
|
||||
});
|
||||
if staged.domain_id() == snapshot.domain.domain_id
|
||||
&& staged.slot() == HostProcessOwnershipSlot::StateAnchorDaemon
|
||||
&& generation_matches
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(StateAnchorCoordinatorError::InvalidState(
|
||||
"staged daemon context mismatch",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn update_daemon(
|
||||
&self,
|
||||
generation_id: GenerationId,
|
||||
daemon: PersistedHostProcessOwnership,
|
||||
) -> Result<(), StateAnchorCoordinatorError> {
|
||||
let current = self.participant(generation_id, ParticipantRole::StateAnchor)?;
|
||||
let Some(ParticipantOwnership::StateAnchor(ownership)) = current.ownership() else {
|
||||
return Err(StateAnchorCoordinatorError::InvalidState(
|
||||
"State Anchor ownership is invalid",
|
||||
));
|
||||
};
|
||||
if current.state() != ParticipantState::Intent {
|
||||
return Err(StateAnchorCoordinatorError::InvalidState(
|
||||
"State Anchor daemon acquisition is not in Intent",
|
||||
));
|
||||
}
|
||||
let next = StateAnchorOwnership {
|
||||
daemon,
|
||||
..ownership.clone()
|
||||
};
|
||||
self.domain_store
|
||||
.apply(&[DomainStoreMutation::PutParticipant(participant(
|
||||
generation_id,
|
||||
ParticipantRole::StateAnchor,
|
||||
ParticipantState::Intent,
|
||||
ParticipantOwnership::StateAnchor(next),
|
||||
)?)])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn participant(
|
||||
&self,
|
||||
generation_id: GenerationId,
|
||||
participant_role: ParticipantRole,
|
||||
) -> Result<Participant, StateAnchorCoordinatorError> {
|
||||
let snapshot = self.domain_store.snapshot()?;
|
||||
Ok(role(&snapshot.participants, generation_id, participant_role)?.clone())
|
||||
}
|
||||
|
||||
fn release_catalog_lease_if_present(
|
||||
&self,
|
||||
authority: &StateAnchorReleaseAuthority,
|
||||
) -> Result<(), StateAnchorCoordinatorError> {
|
||||
let snapshot = self.catalog.snapshot()?;
|
||||
let generation_lease = snapshot.artifact_leases.iter().find(|lease| {
|
||||
lease.domain_id == authority.lease.domain_id
|
||||
&& lease.generation_id == authority.lease.generation_id
|
||||
});
|
||||
match generation_lease {
|
||||
None => return Ok(()),
|
||||
Some(lease) if *lease == authority.lease => {}
|
||||
Some(_) => {
|
||||
return Err(StateAnchorCoordinatorError::InvalidState(
|
||||
"catalog generation lease does not match release authority",
|
||||
));
|
||||
}
|
||||
}
|
||||
self.catalog.release_lease_checked(&authority.lease, || {
|
||||
let snapshot = self
|
||||
.domain_store
|
||||
.snapshot()
|
||||
.map_err(|error| CatalogError::Io(io::Error::other(error)))?;
|
||||
let anchor = role(
|
||||
&snapshot.participants,
|
||||
authority.generation_id,
|
||||
ParticipantRole::StateAnchor,
|
||||
)
|
||||
.map_err(|error| CatalogError::Io(io::Error::other(error)))?;
|
||||
let prepared = role(
|
||||
&snapshot.participants,
|
||||
authority.generation_id,
|
||||
ParticipantRole::PreparedLower,
|
||||
)
|
||||
.map_err(|error| CatalogError::Io(io::Error::other(error)))?;
|
||||
if anchor.state() != ParticipantState::Clean
|
||||
|| prepared.state() != ParticipantState::ReleaseIntent
|
||||
{
|
||||
return Err(CatalogError::Io(io::Error::other(
|
||||
"Domain release evidence is not committed",
|
||||
)));
|
||||
}
|
||||
require_mount_absent(&authority.merged)
|
||||
.map_err(|error| CatalogError::Io(io::Error::other(error)))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn release_authority(
|
||||
domain: &lazaret_core::records::Domain,
|
||||
anchor: &Participant,
|
||||
prepared: &Participant,
|
||||
) -> Result<StateAnchorReleaseAuthority, StateAnchorCoordinatorError> {
|
||||
if !matches!(
|
||||
anchor.state(),
|
||||
ParticipantState::Prepared | ParticipantState::ReleaseIntent
|
||||
) || prepared.state() != ParticipantState::LeaseHeld
|
||||
{
|
||||
return Err(StateAnchorCoordinatorError::InvalidState(
|
||||
"participants are not Prepared and LeaseHeld",
|
||||
));
|
||||
}
|
||||
let Some(ParticipantOwnership::StateAnchor(anchor_ownership)) = anchor.ownership() else {
|
||||
return Err(StateAnchorCoordinatorError::InvalidState(
|
||||
"State Anchor ownership is invalid",
|
||||
));
|
||||
};
|
||||
let Some(ParticipantOwnership::PreparedLower(prepared_ownership)) = prepared.ownership() else {
|
||||
return Err(StateAnchorCoordinatorError::InvalidState(
|
||||
"prepared-lower ownership is invalid",
|
||||
));
|
||||
};
|
||||
let artifact = prepared_ownership.prepared_artifact_id.ok_or(
|
||||
StateAnchorCoordinatorError::InvalidState("prepared artifact identity is absent"),
|
||||
)?;
|
||||
if anchor_ownership.lower_artifact_id != Some(artifact) {
|
||||
return Err(StateAnchorCoordinatorError::InvalidState(
|
||||
"State Anchor and prepared lower differ",
|
||||
));
|
||||
}
|
||||
Ok(StateAnchorReleaseAuthority {
|
||||
generation_id: anchor.generation_id(),
|
||||
lease: ArtifactLease {
|
||||
prepared_artifact_id: artifact,
|
||||
domain_id: domain.domain_id,
|
||||
generation_id: anchor.generation_id(),
|
||||
},
|
||||
merged: path_from_record(anchor_ownership.merged_path.as_ref().ok_or(
|
||||
StateAnchorCoordinatorError::InvalidState("State Anchor merged path is absent"),
|
||||
)?)
|
||||
.to_owned(),
|
||||
mount: anchor_ownership
|
||||
.mount
|
||||
.ok_or(StateAnchorCoordinatorError::InvalidState(
|
||||
"State Anchor mount identity is absent",
|
||||
))?,
|
||||
daemon: *anchor_ownership
|
||||
.daemon
|
||||
.serviceable_pidfs()
|
||||
.map_err(|_| StateAnchorCoordinatorError::InvalidState("daemon is not committed"))?,
|
||||
})
|
||||
}
|
||||
|
||||
fn require_absent_role(
|
||||
participants: &[Participant],
|
||||
generation_id: GenerationId,
|
||||
participant_role: ParticipantRole,
|
||||
) -> Result<(), StateAnchorCoordinatorError> {
|
||||
if participants.iter().any(|participant| {
|
||||
participant.generation_id() == generation_id && participant.role() == participant_role
|
||||
}) {
|
||||
Err(StateAnchorCoordinatorError::InvalidState(
|
||||
"participant already exists",
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn role(
|
||||
participants: &[Participant],
|
||||
generation_id: GenerationId,
|
||||
participant_role: ParticipantRole,
|
||||
) -> Result<&Participant, StateAnchorCoordinatorError> {
|
||||
participants
|
||||
.iter()
|
||||
.find(|participant| {
|
||||
participant.generation_id() == generation_id && participant.role() == participant_role
|
||||
})
|
||||
.ok_or(StateAnchorCoordinatorError::InvalidState(
|
||||
"required participant is absent",
|
||||
))
|
||||
}
|
||||
|
||||
fn participant(
|
||||
generation_id: GenerationId,
|
||||
participant_role: ParticipantRole,
|
||||
state: ParticipantState,
|
||||
ownership: ParticipantOwnership,
|
||||
) -> Result<Participant, StateAnchorCoordinatorError> {
|
||||
Participant::snapshot(generation_id, participant_role, state, Some(ownership)).map_err(|_| {
|
||||
StateAnchorCoordinatorError::InvalidState("participant snapshot violates schema")
|
||||
})
|
||||
}
|
||||
|
||||
fn clean_participant(
|
||||
generation_id: GenerationId,
|
||||
participant_role: ParticipantRole,
|
||||
) -> Result<Participant, StateAnchorCoordinatorError> {
|
||||
participant(
|
||||
generation_id,
|
||||
participant_role,
|
||||
ParticipantState::Clean,
|
||||
ParticipantOwnership::empty(participant_role),
|
||||
)
|
||||
}
|
||||
|
||||
fn prepared_release_intent(
|
||||
prepared: &Participant,
|
||||
) -> Result<Participant, StateAnchorCoordinatorError> {
|
||||
let ownership = match prepared.ownership() {
|
||||
Some(ParticipantOwnership::PreparedLower(ownership)) => PreparedLowerOwnership {
|
||||
lease_state: Some(ParticipantState::ReleaseIntent),
|
||||
..ownership.clone()
|
||||
},
|
||||
_ => {
|
||||
return Err(StateAnchorCoordinatorError::InvalidState(
|
||||
"prepared-lower ownership is invalid",
|
||||
));
|
||||
}
|
||||
};
|
||||
participant(
|
||||
prepared.generation_id(),
|
||||
ParticipantRole::PreparedLower,
|
||||
ParticipantState::ReleaseIntent,
|
||||
ParticipantOwnership::PreparedLower(ownership),
|
||||
)
|
||||
}
|
||||
|
||||
fn path_from_record(path: &lazaret_core::identity::HostPath) -> &Path {
|
||||
Path::new(std::ffi::OsStr::from_bytes(path.as_bytes()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
290
crates/lazaret-host-coordinator/src/state_anchor/tests.rs
Normal file
290
crates/lazaret-host-coordinator/src/state_anchor/tests.rs
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use lazaret_core::configuration::{
|
||||
ConfigurationLoadRequest, SandboxLayoutV1, load_trusted_configuration,
|
||||
};
|
||||
use lazaret_core::identity::{
|
||||
BootId, ConfigurationFingerprint, CoordinatorId, DeviceId, GenerationId, HostPath,
|
||||
HostPidLocator, IdMapDigest, InodeId, MountId, MountIdentity, NetworkSidecarId, PidfsDeviceId,
|
||||
PidfsInodeId, PidfsProcessAuthorityV1, PidfsProcessIdentityV1, PolicyId, PrepareOperationId,
|
||||
PreparedArtifactId, ProcessStartTimeTicks, SandboxId, SelectedManifestDigest, SourceIdentity,
|
||||
};
|
||||
use lazaret_core::records::{
|
||||
ArtifactState, Domain, DurableDecision, Generation, ParticipantRole, ParticipantState,
|
||||
PrepareOperation, PrepareStageState, PreparedArtifact, StagedProcessOwnership,
|
||||
};
|
||||
use lazaret_linux::filesystem::try_exclusive_flock;
|
||||
use lazaret_state_anchor::StateAnchorLayout;
|
||||
use lazaret_storage_catalog::Catalog;
|
||||
|
||||
use super::*;
|
||||
|
||||
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn durable_phases_resume_release_without_collapsing_lease_order() {
|
||||
let tree = TestTree::new();
|
||||
let configuration = configuration(&tree);
|
||||
let lower = tree.path.join("prepared-lower");
|
||||
fs::create_dir(&lower).unwrap();
|
||||
let artifact = artifact(&lower);
|
||||
let catalog = Catalog::new(
|
||||
tree.path.join("catalog.redb"),
|
||||
tree.path.join("storage-catalog.lock"),
|
||||
);
|
||||
catalog.initialize().unwrap();
|
||||
publish(&catalog, &artifact);
|
||||
let domain = Domain {
|
||||
domain_id: configuration.domain_id(),
|
||||
profile_name: configuration.selected_profile().to_owned(),
|
||||
canonical_workspace: configuration.workspace().canonical_label().clone(),
|
||||
};
|
||||
let lock_file = OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.open(tree.path.join("domain.lock"))
|
||||
.unwrap();
|
||||
let domain_store = HostCoordinatorDomainStore::open(
|
||||
try_exclusive_flock(lock_file).unwrap(),
|
||||
&tree.path.join("domain.redb"),
|
||||
&domain,
|
||||
)
|
||||
.unwrap();
|
||||
let generation_id = GenerationId::new(7);
|
||||
let coordinator_id = CoordinatorId::new([3; 32]);
|
||||
domain_store
|
||||
.apply(&[DomainStoreMutation::PutGeneration(Generation {
|
||||
domain_id: domain.domain_id,
|
||||
generation_id,
|
||||
configuration_fingerprint: ConfigurationFingerprint::new([2; 32]),
|
||||
coordinator_id,
|
||||
network_sidecar_id: NetworkSidecarId::new([4; 32]),
|
||||
sandbox_id: SandboxId::new([5; 32]),
|
||||
durable_decision: DurableDecision::None,
|
||||
})])
|
||||
.unwrap();
|
||||
let layout = StateAnchorLayout::create(&configuration).unwrap();
|
||||
let coordinator = StateAnchorCoordinator::new(&domain_store, &catalog);
|
||||
let lease = coordinator
|
||||
.begin_acquisition(generation_id, &artifact, &layout)
|
||||
.unwrap();
|
||||
assert_eq!(catalog.snapshot().unwrap().artifact_leases, vec![lease]);
|
||||
assert_states(
|
||||
&domain_store,
|
||||
ParticipantState::LeaseHeld,
|
||||
ParticipantState::Intent,
|
||||
);
|
||||
|
||||
let staged = StagedProcessOwnership::v1(
|
||||
domain.domain_id,
|
||||
generation_id,
|
||||
coordinator_id,
|
||||
HostProcessOwnershipSlot::StateAnchorDaemon,
|
||||
process_identity(),
|
||||
);
|
||||
let wrong_coordinator = StagedProcessOwnership::v1(
|
||||
domain.domain_id,
|
||||
generation_id,
|
||||
CoordinatorId::new([99; 32]),
|
||||
HostProcessOwnershipSlot::StateAnchorDaemon,
|
||||
process_identity(),
|
||||
);
|
||||
assert!(coordinator.stage_daemon(wrong_coordinator).is_err());
|
||||
coordinator.stage_daemon(staged).unwrap();
|
||||
let acknowledgment = coordinator.commit_daemon(staged).unwrap();
|
||||
assert!(acknowledgment.acknowledges(&staged));
|
||||
coordinator
|
||||
.commit_prepared(
|
||||
generation_id,
|
||||
artifact.prepared_artifact_id,
|
||||
MountIdentity {
|
||||
mount_id: MountId::new(90),
|
||||
device: DeviceId::new(91),
|
||||
inode: InodeId::new(92),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_states(
|
||||
&domain_store,
|
||||
ParticipantState::LeaseHeld,
|
||||
ParticipantState::Prepared,
|
||||
);
|
||||
|
||||
let authority = coordinator.begin_release(generation_id).unwrap();
|
||||
assert_states(
|
||||
&domain_store,
|
||||
ParticipantState::LeaseHeld,
|
||||
ParticipantState::ReleaseIntent,
|
||||
);
|
||||
assert_eq!(catalog.snapshot().unwrap().artifact_leases, vec![lease]);
|
||||
let snapshot = domain_store.snapshot().unwrap();
|
||||
let prepared = role(
|
||||
&snapshot.participants,
|
||||
generation_id,
|
||||
ParticipantRole::PreparedLower,
|
||||
)
|
||||
.unwrap();
|
||||
domain_store
|
||||
.apply(&[
|
||||
DomainStoreMutation::CleanParticipantAfterAbsence(
|
||||
clean_participant(generation_id, ParticipantRole::StateAnchor).unwrap(),
|
||||
),
|
||||
DomainStoreMutation::PutParticipant(prepared_release_intent(prepared).unwrap()),
|
||||
])
|
||||
.unwrap();
|
||||
coordinator
|
||||
.complete_release_after_unmount(&authority)
|
||||
.unwrap();
|
||||
assert_states(
|
||||
&domain_store,
|
||||
ParticipantState::Clean,
|
||||
ParticipantState::Clean,
|
||||
);
|
||||
assert!(catalog.snapshot().unwrap().artifact_leases.is_empty());
|
||||
}
|
||||
|
||||
fn assert_states(
|
||||
store: &HostCoordinatorDomainStore,
|
||||
prepared: ParticipantState,
|
||||
anchor: ParticipantState,
|
||||
) {
|
||||
let snapshot = store.snapshot().unwrap();
|
||||
assert_eq!(
|
||||
role(
|
||||
&snapshot.participants,
|
||||
GenerationId::new(7),
|
||||
ParticipantRole::PreparedLower,
|
||||
)
|
||||
.unwrap()
|
||||
.state(),
|
||||
prepared
|
||||
);
|
||||
assert_eq!(
|
||||
role(
|
||||
&snapshot.participants,
|
||||
GenerationId::new(7),
|
||||
ParticipantRole::StateAnchor,
|
||||
)
|
||||
.unwrap()
|
||||
.state(),
|
||||
anchor
|
||||
);
|
||||
}
|
||||
|
||||
fn artifact(path: &Path) -> PreparedArtifact {
|
||||
let metadata = path.metadata().unwrap();
|
||||
PreparedArtifact {
|
||||
prepared_artifact_id: PreparedArtifactId::new([8; 32]),
|
||||
source_identity: SourceIdentity::new(b"fixture".to_vec()),
|
||||
selected_manifest_digest: SelectedManifestDigest::new([9; 32]),
|
||||
policy_id: PolicyId::new([10; 32]),
|
||||
id_map_digest: IdMapDigest::new([11; 32]),
|
||||
path: HostPath::new(path.as_os_str().as_encoded_bytes().to_vec()),
|
||||
device: DeviceId::new(metadata.dev()),
|
||||
inode: InodeId::new(metadata.ino()),
|
||||
state: ArtifactState::Available,
|
||||
}
|
||||
}
|
||||
|
||||
fn publish(catalog: &Catalog, artifact: &PreparedArtifact) {
|
||||
let intent = PrepareOperation {
|
||||
operation_id: PrepareOperationId::new([12; 32]),
|
||||
expected_artifact_id: artifact.prepared_artifact_id,
|
||||
source_identity: artifact.source_identity.clone(),
|
||||
selected_manifest_digest: artifact.selected_manifest_digest,
|
||||
policy_id: artifact.policy_id,
|
||||
id_map_digest: artifact.id_map_digest,
|
||||
stage_path: HostPath::new(b"stages/fixture".to_vec()),
|
||||
stage_state: PrepareStageState::Intent,
|
||||
device: None,
|
||||
inode: None,
|
||||
};
|
||||
catalog.record_prepare(&intent).unwrap();
|
||||
let owned = PrepareOperation {
|
||||
stage_state: PrepareStageState::Owned,
|
||||
device: Some(artifact.device),
|
||||
inode: Some(artifact.inode),
|
||||
..intent.clone()
|
||||
};
|
||||
catalog.bind_prepare_stage(&intent, &owned).unwrap();
|
||||
catalog.publish(&owned, artifact).unwrap();
|
||||
}
|
||||
|
||||
fn process_identity() -> PidfsProcessIdentityV1 {
|
||||
PidfsProcessIdentityV1::new(
|
||||
PidfsProcessAuthorityV1 {
|
||||
boot_id: BootId::new([1; 16]),
|
||||
pidfs_st_dev: PidfsDeviceId::new(2),
|
||||
pidfs_st_ino: PidfsInodeId::new(3),
|
||||
},
|
||||
HostPidLocator::new(4),
|
||||
ProcessStartTimeTicks::new(5),
|
||||
)
|
||||
}
|
||||
|
||||
struct TestTree {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TestTree {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"lazaret-state-anchor-durable-{}-{}",
|
||||
std::process::id(),
|
||||
SEQUENCE.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self { path }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestTree {
|
||||
fn drop(&mut self) {
|
||||
fs::remove_dir_all(&self.path).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn configuration(tree: &TestTree) -> lazaret_core::configuration::ResolvedDomainConfiguration {
|
||||
let configuration_directory = tree.path.join("configuration");
|
||||
let workspace = tree.path.join("workspace");
|
||||
let runtime = tree.path.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[profiles.default]\nimage = \"example.invalid/fixture:v1\"\npersistent_dirs = [\"/usr\"]\n",
|
||||
tree.path.join("data").display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let layout = SandboxLayoutV1::from_production_layout([
|
||||
"/workspace".to_owned(),
|
||||
"/proc".to_owned(),
|
||||
"/sys".to_owned(),
|
||||
"/dev".to_owned(),
|
||||
"/run/lazaret".to_owned(),
|
||||
])
|
||||
.unwrap();
|
||||
load_trusted_configuration(ConfigurationLoadRequest {
|
||||
explicit_configuration_path: Some(configuration_path),
|
||||
selected_profile: "default".to_owned(),
|
||||
workspace,
|
||||
home: tree.path.join("home"),
|
||||
xdg_config_home: None,
|
||||
xdg_data_home: None,
|
||||
xdg_runtime_dir: runtime,
|
||||
environment: BTreeMap::new(),
|
||||
sandbox_layout: layout,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
|
@ -1,3 +1,11 @@
|
|||
fn main() {
|
||||
let arguments = std::env::args_os().skip(1).collect::<Vec<_>>();
|
||||
if let Some(result) = lazaret_linux::process::dispatch_private_process_role(&arguments) {
|
||||
if let Err(error) = result {
|
||||
eprintln!("private process role failed: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let _ = lazaret_core::PROTOCOL_NAME;
|
||||
}
|
||||
|
|
|
|||
19
crates/lazaret-state-anchor/Cargo.toml
Normal file
19
crates/lazaret-state-anchor/Cargo.toml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "lazaret-state-anchor"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
lazaret-core = { version = "=0.0.0", path = "../lazaret-core" }
|
||||
lazaret-linux = { version = "=0.0.0", path = "../lazaret-linux" }
|
||||
rustix.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[test]]
|
||||
name = "state-anchor-lifecycle"
|
||||
path = "tests/state-anchor-lifecycle.rs"
|
||||
harness = false
|
||||
587
crates/lazaret-state-anchor/src/lib.rs
Normal file
587
crates/lazaret-state-anchor/src/lib.rs
Normal file
|
|
@ -0,0 +1,587 @@
|
|||
#![doc = "Physical State Anchor layout and exact fuse-overlayfs mount lifecycle."]
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::fmt;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io;
|
||||
use std::os::fd::{AsFd, OwnedFd};
|
||||
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use lazaret_core::configuration::ResolvedDomainConfiguration;
|
||||
use lazaret_core::identity::{HostPath, MountIdentity, PreparedArtifactId};
|
||||
use lazaret_core::records::{
|
||||
HostProcessOwnershipSlot, PersistedHostProcessOwnership, PreparedArtifact,
|
||||
ProcessOwnershipCommitAck, StagedProcessOwnership, StateAnchorOwnership,
|
||||
};
|
||||
use lazaret_linux::filesystem::{atomic_replace, current_mount_identity, require_mount_identity};
|
||||
use lazaret_linux::process::{
|
||||
ProcessOwnershipContext, RetainedProcess, TargetCommand, child_broker,
|
||||
};
|
||||
use rustix::fs::{FileType, Mode, OFlags, ResolveFlags, mkdirat, openat2};
|
||||
|
||||
const MOUNT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const MOUNT_POLL_INTERVAL: Duration = Duration::from_millis(2);
|
||||
pub const PROTECTED_RUNTIME_FILES: [&str; 3] = ["resolv.conf", "hosts", "hostname"];
|
||||
|
||||
pub struct ProtectedRuntimeFiles<'a> {
|
||||
pub resolv_conf: &'a [u8],
|
||||
pub hosts: &'a [u8],
|
||||
pub hostname: &'a [u8],
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum StateAnchorError {
|
||||
Io(io::Error),
|
||||
InvalidLayout(&'static str),
|
||||
MountTimeout,
|
||||
DaemonExited,
|
||||
OwnershipMismatch,
|
||||
}
|
||||
|
||||
impl fmt::Display for StateAnchorError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Io(error) => write!(formatter, "State Anchor I/O failed: {error}"),
|
||||
Self::InvalidLayout(detail) => {
|
||||
write!(formatter, "invalid State Anchor layout: {detail}")
|
||||
}
|
||||
Self::MountTimeout => formatter.write_str("fuse-overlayfs mount timed out"),
|
||||
Self::DaemonExited => formatter.write_str("fuse-overlayfs exited before mounting"),
|
||||
Self::OwnershipMismatch => formatter.write_str("State Anchor mount identity mismatch"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for StateAnchorError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Io(error) => Some(error),
|
||||
Self::InvalidLayout(_)
|
||||
| Self::MountTimeout
|
||||
| Self::DaemonExited
|
||||
| Self::OwnershipMismatch => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for StateAnchorError {
|
||||
fn from(error: io::Error) -> Self {
|
||||
Self::Io(error)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct StateAnchorLayout {
|
||||
persistent_root: OwnedFd,
|
||||
runtime_root: OwnedFd,
|
||||
upper: PathBuf,
|
||||
work: PathBuf,
|
||||
merged: PathBuf,
|
||||
runtime_files: PathBuf,
|
||||
persistent_destinations: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl StateAnchorLayout {
|
||||
/// Creates the fixed layout beneath retained, revalidated Domain authorities.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses changed roots, non-directory fixed paths, and unsafe persistent destinations.
|
||||
pub fn create(configuration: &ResolvedDomainConfiguration) -> Result<Self, StateAnchorError> {
|
||||
let persistent_root = configuration
|
||||
.persistent_domain_root()
|
||||
.open_authority()
|
||||
.map_err(|_| StateAnchorError::InvalidLayout("persistent Domain authority changed"))?;
|
||||
let runtime_root = configuration
|
||||
.runtime_domain_root()
|
||||
.open_authority()
|
||||
.map_err(|_| StateAnchorError::InvalidLayout("runtime Domain authority changed"))?;
|
||||
let persistent_base =
|
||||
path_from_host(configuration.persistent_domain_root().canonical_label());
|
||||
let runtime_base = path_from_host(configuration.runtime_domain_root().canonical_label());
|
||||
create_directory(&persistent_root, "state-anchor")?;
|
||||
let persistent_anchor = open_directory(&persistent_root, Path::new("state-anchor"))?;
|
||||
for name in ["upper", "work"] {
|
||||
create_directory(&persistent_anchor, name)?;
|
||||
}
|
||||
create_directory(&runtime_root, "state-anchor")?;
|
||||
let runtime_anchor = open_directory(&runtime_root, Path::new("state-anchor"))?;
|
||||
for name in ["merged", "runtime-files"] {
|
||||
create_directory(&runtime_anchor, name)?;
|
||||
}
|
||||
let runtime_files = runtime_base.join("state-anchor/runtime-files");
|
||||
for name in PROTECTED_RUNTIME_FILES {
|
||||
let path = runtime_files.join(name);
|
||||
match OpenOptions::new().create_new(true).write(true).open(&path) {
|
||||
Ok(file) => file.sync_all()?,
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
|
||||
require_regular_file(&path)?;
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
rustix::fs::fsync(&runtime_anchor).map_err(io::Error::from)?;
|
||||
rustix::fs::fsync(&persistent_anchor).map_err(io::Error::from)?;
|
||||
let persistent_destinations = configuration
|
||||
.fingerprint_record()
|
||||
.persistent_dirs
|
||||
.iter()
|
||||
.map(|destination| PathBuf::from(destination.strip_prefix('/').unwrap_or(destination)))
|
||||
.collect();
|
||||
Ok(Self {
|
||||
persistent_root,
|
||||
runtime_root,
|
||||
upper: persistent_base.join("state-anchor/upper"),
|
||||
work: persistent_base.join("state-anchor/work"),
|
||||
merged: runtime_base.join("state-anchor/merged"),
|
||||
runtime_files,
|
||||
persistent_destinations,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn upper(&self) -> &Path {
|
||||
&self.upper
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn work(&self) -> &Path {
|
||||
&self.work
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn merged(&self) -> &Path {
|
||||
&self.merged
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn runtime_files(&self) -> &Path {
|
||||
&self.runtime_files
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn persistent_destinations(&self) -> &[PathBuf] {
|
||||
&self.persistent_destinations
|
||||
}
|
||||
|
||||
/// Revalidates every fixed directory through the retained Domain authorities.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses replacement, symlink, mount crossing, or a non-directory fixed path.
|
||||
pub fn revalidate(&self) -> Result<(), StateAnchorError> {
|
||||
for path in ["state-anchor/upper", "state-anchor/work"] {
|
||||
drop(open_directory(&self.persistent_root, Path::new(path))?);
|
||||
}
|
||||
for path in ["state-anchor/merged", "state-anchor/runtime-files"] {
|
||||
drop(open_directory(&self.runtime_root, Path::new(path))?);
|
||||
}
|
||||
for name in PROTECTED_RUNTIME_FILES {
|
||||
require_regular_file(&self.runtime_files.join(name))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomically replaces sandbox runtime files in host-private transient storage.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses changed layout and incomplete durable replacement.
|
||||
pub fn replace_protected_runtime_files(
|
||||
&self,
|
||||
files: &ProtectedRuntimeFiles<'_>,
|
||||
) -> Result<(), StateAnchorError> {
|
||||
self.revalidate()?;
|
||||
for (name, bytes) in [
|
||||
("resolv.conf", files.resolv_conf),
|
||||
("hosts", files.hosts),
|
||||
("hostname", files.hostname),
|
||||
] {
|
||||
atomic_replace(&self.runtime_files.join(name), bytes)?;
|
||||
}
|
||||
self.revalidate()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn ownership_intent(&self, artifact_id: PreparedArtifactId) -> StateAnchorOwnership {
|
||||
StateAnchorOwnership {
|
||||
lower_artifact_id: Some(artifact_id),
|
||||
upper_path: Some(host_path(&self.upper)),
|
||||
work_path: Some(host_path(&self.work)),
|
||||
merged_path: Some(host_path(&self.merged)),
|
||||
daemon: PersistedHostProcessOwnership::unassigned(),
|
||||
mount: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct StateAnchorSandboxView {
|
||||
lower: PathBuf,
|
||||
lower_identity: (u64, u64),
|
||||
merged: PathBuf,
|
||||
mount: MountIdentity,
|
||||
runtime_files: PathBuf,
|
||||
persistent_destinations: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl StateAnchorSandboxView {
|
||||
/// Binds a serviceable State Anchor view to exact prepared-lower and mount evidence.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses changed layout, prepared lower, or mount identity.
|
||||
pub fn from_mounted(
|
||||
layout: &StateAnchorLayout,
|
||||
artifact: &PreparedArtifact,
|
||||
mount: MountIdentity,
|
||||
) -> Result<Self, StateAnchorError> {
|
||||
let view = Self {
|
||||
lower: path_from_host(&artifact.path),
|
||||
lower_identity: (artifact.device.get(), artifact.inode.get()),
|
||||
merged: layout.merged.clone(),
|
||||
mount,
|
||||
runtime_files: layout.runtime_files.clone(),
|
||||
persistent_destinations: layout.persistent_destinations.clone(),
|
||||
};
|
||||
layout.revalidate()?;
|
||||
view.revalidate()?;
|
||||
Ok(view)
|
||||
}
|
||||
|
||||
/// Revalidates the immutable lower, exact overlay mount, and host-private runtime files.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses replacement or unreadable physical evidence.
|
||||
pub fn revalidate(&self) -> Result<(), StateAnchorError> {
|
||||
require_directory_identity(&self.lower, self.lower_identity)?;
|
||||
require_mount_identity(&self.merged, self.mount)
|
||||
.map_err(|_| StateAnchorError::OwnershipMismatch)?;
|
||||
for name in PROTECTED_RUNTIME_FILES {
|
||||
require_regular_file(&self.runtime_files.join(name))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn lower(&self) -> &Path {
|
||||
&self.lower
|
||||
}
|
||||
|
||||
pub fn persistent_mounts(&self) -> impl Iterator<Item = (PathBuf, PathBuf)> + '_ {
|
||||
self.persistent_destinations.iter().map(|destination| {
|
||||
(
|
||||
self.merged.join(destination),
|
||||
Path::new("/").join(destination),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn protected_runtime_mounts(&self) -> impl Iterator<Item = (PathBuf, PathBuf)> + '_ {
|
||||
PROTECTED_RUNTIME_FILES
|
||||
.into_iter()
|
||||
.map(|name| (self.runtime_files.join(name), Path::new("/etc").join(name)))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FuseOverlayfsMount {
|
||||
process: RetainedProcess,
|
||||
mount: MountIdentity,
|
||||
merged: PathBuf,
|
||||
}
|
||||
|
||||
impl FuseOverlayfsMount {
|
||||
/// Starts fuse-overlayfs through causal child acquisition and records staged/committed identity
|
||||
/// through caller-owned durable callbacks.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses changed layout or lower identity, callback failure, daemon loss, and mount timeout.
|
||||
pub fn acquire<Stage, Acknowledge>(
|
||||
layout: &StateAnchorLayout,
|
||||
lower: &Path,
|
||||
expected_lower: (u64, u64),
|
||||
expected_parent: lazaret_core::identity::PidfsProcessIdentityV1,
|
||||
ownership: ProcessOwnershipContext,
|
||||
stage: Stage,
|
||||
acknowledge: Acknowledge,
|
||||
) -> Result<Self, StateAnchorError>
|
||||
where
|
||||
Stage: FnOnce(StagedProcessOwnership) -> io::Result<()>,
|
||||
Acknowledge: FnOnce(StagedProcessOwnership) -> io::Result<ProcessOwnershipCommitAck>,
|
||||
{
|
||||
if ownership.slot != HostProcessOwnershipSlot::StateAnchorDaemon {
|
||||
return Err(StateAnchorError::InvalidLayout(
|
||||
"process slot is not State Anchor daemon",
|
||||
));
|
||||
}
|
||||
layout.revalidate()?;
|
||||
require_directory_identity(lower, expected_lower)?;
|
||||
let baseline = current_mount_identity(layout.merged())?;
|
||||
let options = overlay_options(lower, layout.upper(), layout.work());
|
||||
let target = TargetCommand::new("fuse-overlayfs")
|
||||
.argument("-f")
|
||||
.argument("-o")
|
||||
.argument(options)
|
||||
.argument(layout.merged().as_os_str());
|
||||
let process =
|
||||
child_broker().acquire(expected_parent, ownership, &target, stage, acknowledge)?;
|
||||
let deadline = Instant::now() + MOUNT_TIMEOUT;
|
||||
loop {
|
||||
if process.has_exited()? {
|
||||
child_broker().reap(process)?;
|
||||
return Err(StateAnchorError::DaemonExited);
|
||||
}
|
||||
let observed = current_mount_identity(layout.merged())?;
|
||||
if observed.mount_id != baseline.mount_id {
|
||||
return Ok(Self {
|
||||
process,
|
||||
mount: observed,
|
||||
merged: layout.merged().to_owned(),
|
||||
});
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
process.send_signal(rustix::process::Signal::KILL)?;
|
||||
while !process.has_exited()? {
|
||||
thread::yield_now();
|
||||
}
|
||||
if match current_mount_identity(layout.merged()) {
|
||||
Ok(identity) => identity.mount_id != baseline.mount_id,
|
||||
Err(_) => true,
|
||||
} {
|
||||
let _ = rootless_unmount(layout.merged());
|
||||
}
|
||||
child_broker().reap(process)?;
|
||||
return Err(StateAnchorError::MountTimeout);
|
||||
}
|
||||
thread::sleep(MOUNT_POLL_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn mount_identity(&self) -> MountIdentity {
|
||||
self.mount
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn process_ownership(&self) -> PersistedHostProcessOwnership {
|
||||
PersistedHostProcessOwnership::committed(self.process.identity())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn process(&self) -> &RetainedProcess {
|
||||
&self.process
|
||||
}
|
||||
|
||||
/// Verifies and unmounts only the exact retained State Anchor, then reaps its daemon.
|
||||
///
|
||||
/// # Errors
|
||||
/// A missing, replaced, renamed, or remounted target is left untouched.
|
||||
pub fn unmount(self) -> Result<(), StateAnchorError> {
|
||||
require_mount_record(&self.merged, self.mount)?;
|
||||
rootless_unmount(&self.merged)?;
|
||||
if has_mount_record(&self.merged, self.mount)? {
|
||||
return Err(StateAnchorError::OwnershipMismatch);
|
||||
}
|
||||
child_broker().reap(self.process)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reaps the retained daemon after an independently verified external unmount.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses while the exact recorded mount remains or when exact reaping fails.
|
||||
pub fn finish_after_external_unmount(self) -> Result<(), StateAnchorError> {
|
||||
if has_mount_record(&self.merged, self.mount)? {
|
||||
return Err(StateAnchorError::OwnershipMismatch);
|
||||
}
|
||||
child_broker().reap(self.process)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies and unmounts an exact recorded mount without opening either durable store.
|
||||
///
|
||||
/// # Errors
|
||||
/// A missing or mismatched mount is left untouched.
|
||||
pub fn unmount_after_coordinator_loss(
|
||||
merged: &Path,
|
||||
expected: MountIdentity,
|
||||
) -> Result<(), StateAnchorError> {
|
||||
require_mount_record(merged, expected)?;
|
||||
rootless_unmount(merged)?;
|
||||
if has_mount_record(merged, expected)? {
|
||||
return Err(StateAnchorError::OwnershipMismatch);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Independently verifies that no mount is attached at the exact merged path.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses unreadable mount information or an inaccessible merged directory.
|
||||
pub fn require_mount_absent(merged: &Path) -> Result<(), StateAnchorError> {
|
||||
current_mount_identity(merged)?;
|
||||
let expected_path = merged.as_os_str().as_bytes();
|
||||
for line in std::fs::read("/proc/self/mountinfo")?.split(|byte| *byte == b'\n') {
|
||||
let fields = line.split(|byte| *byte == b' ').take(5).collect::<Vec<_>>();
|
||||
if fields.len() == 5 && decode_mount_field(fields[4]) == expected_path {
|
||||
return Err(StateAnchorError::OwnershipMismatch);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_directory(parent: impl AsFd, name: &str) -> Result<(), StateAnchorError> {
|
||||
match mkdirat(parent.as_fd(), name, Mode::from_raw_mode(0o700)) {
|
||||
Ok(()) | Err(rustix::io::Errno::EXIST) => {}
|
||||
Err(error) => return Err(io::Error::from(error).into()),
|
||||
}
|
||||
drop(open_directory(parent, Path::new(name))?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_directory(parent: impl AsFd, path: &Path) -> Result<OwnedFd, StateAnchorError> {
|
||||
let descriptor = openat2(
|
||||
parent.as_fd(),
|
||||
path,
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||
Mode::empty(),
|
||||
ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS,
|
||||
)
|
||||
.map_err(io::Error::from)?;
|
||||
let metadata = rustix::fs::fstat(&descriptor).map_err(io::Error::from)?;
|
||||
if !FileType::from_raw_mode(metadata.st_mode).is_dir() {
|
||||
return Err(StateAnchorError::InvalidLayout(
|
||||
"fixed path is not a directory",
|
||||
));
|
||||
}
|
||||
Ok(descriptor)
|
||||
}
|
||||
|
||||
fn require_regular_file(path: &Path) -> Result<(), StateAnchorError> {
|
||||
let metadata = path.symlink_metadata()?;
|
||||
if metadata.file_type().is_file() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(StateAnchorError::InvalidLayout(
|
||||
"protected runtime path is not a regular file",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn require_directory_identity(path: &Path, expected: (u64, u64)) -> Result<(), StateAnchorError> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let file = File::open(path)?;
|
||||
let metadata = file.metadata()?;
|
||||
if metadata.is_dir() && (metadata.dev(), metadata.ino()) == expected {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(StateAnchorError::OwnershipMismatch)
|
||||
}
|
||||
}
|
||||
|
||||
fn overlay_options(lower: &Path, upper: &Path, work: &Path) -> OsString {
|
||||
let mut bytes = b"lowerdir=".to_vec();
|
||||
append_fuse_option_path(&mut bytes, lower);
|
||||
bytes.extend_from_slice(b",upperdir=");
|
||||
append_fuse_option_path(&mut bytes, upper);
|
||||
bytes.extend_from_slice(b",workdir=");
|
||||
append_fuse_option_path(&mut bytes, work);
|
||||
OsString::from_vec(bytes)
|
||||
}
|
||||
|
||||
fn append_fuse_option_path(target: &mut Vec<u8>, path: &Path) {
|
||||
for byte in path.as_os_str().as_bytes() {
|
||||
if *byte == b':' {
|
||||
target.push(b'\\');
|
||||
target.push(b'\\');
|
||||
} else if *byte == b',' {
|
||||
target.push(b'\\');
|
||||
}
|
||||
target.push(*byte);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||
fn rootless_unmount(path: &Path) -> Result<(), StateAnchorError> {
|
||||
let status = std::process::Command::new("fusermount3")
|
||||
.arg("-u")
|
||||
.arg(path)
|
||||
.status()?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(StateAnchorError::Io(io::Error::other(format!(
|
||||
"fusermount3 exited with {status}"
|
||||
))))
|
||||
}
|
||||
}
|
||||
|
||||
fn require_mount_record(path: &Path, expected: MountIdentity) -> Result<(), StateAnchorError> {
|
||||
if has_mount_record(path, expected)? {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(StateAnchorError::OwnershipMismatch)
|
||||
}
|
||||
}
|
||||
|
||||
fn has_mount_record(path: &Path, expected: MountIdentity) -> Result<bool, StateAnchorError> {
|
||||
let expected_path = path.as_os_str().as_bytes();
|
||||
for line in std::fs::read("/proc/self/mountinfo")?.split(|byte| *byte == b'\n') {
|
||||
let fields = line.split(|byte| *byte == b' ').take(5).collect::<Vec<_>>();
|
||||
if fields.len() != 5 {
|
||||
continue;
|
||||
}
|
||||
let Some(mount_id) = parse_decimal(fields[0]) else {
|
||||
continue;
|
||||
};
|
||||
let Some((major, minor)) = parse_device(fields[2]) else {
|
||||
continue;
|
||||
};
|
||||
if decode_mount_field(fields[4]) == expected_path
|
||||
&& mount_id == expected.mount_id.get()
|
||||
&& rustix::fs::makedev(major, minor) == expected.device.get()
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn parse_decimal(bytes: &[u8]) -> Option<u64> {
|
||||
std::str::from_utf8(bytes).ok()?.parse().ok()
|
||||
}
|
||||
|
||||
fn parse_device(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
let separator = bytes.iter().position(|byte| *byte == b':')?;
|
||||
let (major, minor) = (&bytes[..separator], &bytes[separator + 1..]);
|
||||
Some((
|
||||
u32::try_from(parse_decimal(major)?).ok()?,
|
||||
u32::try_from(parse_decimal(minor)?).ok()?,
|
||||
))
|
||||
}
|
||||
|
||||
fn decode_mount_field(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut decoded = Vec::with_capacity(bytes.len());
|
||||
let mut index = 0;
|
||||
while index < bytes.len() {
|
||||
if bytes[index] == b'\\' && index + 3 < bytes.len() {
|
||||
let digits = &bytes[index + 1..index + 4];
|
||||
if digits.iter().all(|digit| matches!(digit, b'0'..=b'7')) {
|
||||
decoded.push((digits[0] - b'0') * 64 + (digits[1] - b'0') * 8 + digits[2] - b'0');
|
||||
index += 4;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
decoded.push(bytes[index]);
|
||||
index += 1;
|
||||
}
|
||||
decoded
|
||||
}
|
||||
|
||||
fn path_from_host(path: &HostPath) -> PathBuf {
|
||||
PathBuf::from(OsString::from_vec(path.as_bytes().to_vec()))
|
||||
}
|
||||
|
||||
fn host_path(path: &Path) -> HostPath {
|
||||
HostPath::new(path.as_os_str().as_bytes().to_vec())
|
||||
}
|
||||
242
crates/lazaret-state-anchor/tests/state-anchor-lifecycle.rs
Normal file
242
crates/lazaret-state-anchor/tests/state-anchor-lifecycle.rs
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use lazaret_core::configuration::{
|
||||
ConfigurationLoadRequest, SandboxLayoutV1, load_trusted_configuration,
|
||||
};
|
||||
use lazaret_core::identity::{CoordinatorId, DomainId, GenerationId};
|
||||
use lazaret_core::records::{
|
||||
ArtifactState, HostProcessOwnershipSlot, PreparedArtifact, ProcessOwnershipCommitAck,
|
||||
};
|
||||
use lazaret_linux::process::{
|
||||
ProcessOwnershipContext, current_process_identity, dispatch_private_process_role,
|
||||
};
|
||||
use lazaret_state_anchor::{
|
||||
FuseOverlayfsMount, ProtectedRuntimeFiles, StateAnchorLayout, StateAnchorSandboxView,
|
||||
unmount_after_coordinator_loss,
|
||||
};
|
||||
use rustix::process::Signal;
|
||||
|
||||
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let arguments = std::env::args_os().skip(1).collect::<Vec<_>>();
|
||||
if let Some(result) = dispatch_private_process_role(&arguments) {
|
||||
return exit(result);
|
||||
}
|
||||
exit(run())
|
||||
}
|
||||
|
||||
fn exit(result: io::Result<()>) -> ExitCode {
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("State Anchor lifecycle fixture failed: {error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn run() -> io::Result<()> {
|
||||
match fs::metadata("/dev/fuse") {
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||
eprintln!("skipped: /dev/fuse is unavailable");
|
||||
return Ok(());
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
let tree = TestTree::new()?;
|
||||
let configuration = configuration(&tree)?;
|
||||
let lower = tree.path.join("prepared-lower");
|
||||
fs::create_dir_all(lower.join("usr/bin"))?;
|
||||
fs::create_dir_all(lower.join("etc"))?;
|
||||
fs::write(lower.join("usr/bin/base-tool"), b"base")?;
|
||||
for name in ["resolv.conf", "hosts", "hostname"] {
|
||||
fs::write(lower.join("etc").join(name), b"image")?;
|
||||
}
|
||||
let metadata = lower.metadata()?;
|
||||
let artifact = PreparedArtifact {
|
||||
prepared_artifact_id: lazaret_core::identity::PreparedArtifactId::new([8; 32]),
|
||||
source_identity: lazaret_core::identity::SourceIdentity::new(b"fixture".to_vec()),
|
||||
selected_manifest_digest: lazaret_core::identity::SelectedManifestDigest::new([9; 32]),
|
||||
policy_id: lazaret_core::identity::PolicyId::new([10; 32]),
|
||||
id_map_digest: lazaret_core::identity::IdMapDigest::new([11; 32]),
|
||||
path: lazaret_core::identity::HostPath::new(lower.as_os_str().as_encoded_bytes().to_vec()),
|
||||
device: lazaret_core::identity::DeviceId::new(metadata.dev()),
|
||||
inode: lazaret_core::identity::InodeId::new(metadata.ino()),
|
||||
state: ArtifactState::Available,
|
||||
};
|
||||
let layout = StateAnchorLayout::create(&configuration).map_err(io::Error::other)?;
|
||||
|
||||
let first = acquire(&layout, &artifact)?;
|
||||
fs::write(
|
||||
layout.merged().join("usr/bin/installed-tool"),
|
||||
b"persistent",
|
||||
)?;
|
||||
fs::write(layout.merged().join("etc/developer.conf"), b"persistent")?;
|
||||
if lower.join("usr/bin/installed-tool").exists()
|
||||
|| lower.join("etc/developer.conf").exists()
|
||||
|| !layout.upper().join("usr/bin/installed-tool").exists()
|
||||
|| !layout.upper().join("etc/developer.conf").exists()
|
||||
{
|
||||
return Err(io::Error::other(
|
||||
"writes did not remain upper-only over the read-only lower",
|
||||
));
|
||||
}
|
||||
let view = StateAnchorSandboxView::from_mounted(&layout, &artifact, first.mount_identity())
|
||||
.map_err(io::Error::other)?;
|
||||
view.revalidate().map_err(io::Error::other)?;
|
||||
layout
|
||||
.replace_protected_runtime_files(&ProtectedRuntimeFiles {
|
||||
resolv_conf: b"nameserver 192.0.2.53\n",
|
||||
hosts: b"127.0.0.1 localhost\n",
|
||||
hostname: b"lazaret\n",
|
||||
})
|
||||
.map_err(io::Error::other)?;
|
||||
let runtime_sources = view.protected_runtime_mounts().collect::<Vec<_>>();
|
||||
if runtime_sources.len() != 3
|
||||
|| runtime_sources
|
||||
.iter()
|
||||
.any(|(source, _)| source.starts_with(&lower) || source.starts_with(layout.upper()))
|
||||
{
|
||||
return Err(io::Error::other(
|
||||
"protected runtime files are not host-private runtime material",
|
||||
));
|
||||
}
|
||||
if lower.join("etc/resolv.conf").read_link().is_ok()
|
||||
|| fs::read(lower.join("etc/resolv.conf"))? != b"image"
|
||||
|| layout.upper().join("etc/resolv.conf").exists()
|
||||
|| fs::read(&runtime_sources[0].0)? != b"nameserver 192.0.2.53\n"
|
||||
{
|
||||
return Err(io::Error::other(
|
||||
"runtime-file replacement escaped transient storage",
|
||||
));
|
||||
}
|
||||
let wrong = lazaret_linux::filesystem::current_mount_identity(layout.upper())?;
|
||||
if unmount_after_coordinator_loss(layout.merged(), wrong).is_ok() {
|
||||
return Err(io::Error::other(
|
||||
"ownership mismatch unmounted foreign identity",
|
||||
));
|
||||
}
|
||||
first.unmount().map_err(io::Error::other)?;
|
||||
|
||||
let second = acquire(&layout, &artifact)?;
|
||||
if fs::read(layout.merged().join("usr/bin/installed-tool"))? != b"persistent"
|
||||
|| fs::read(layout.merged().join("etc/developer.conf"))? != b"persistent"
|
||||
{
|
||||
return Err(io::Error::other(
|
||||
"upper state did not persist across remount",
|
||||
));
|
||||
}
|
||||
let identity = second.mount_identity();
|
||||
second.process().send_signal(Signal::KILL)?;
|
||||
while !second.process().has_exited()? {
|
||||
std::thread::yield_now();
|
||||
}
|
||||
unmount_after_coordinator_loss(layout.merged(), identity).map_err(io::Error::other)?;
|
||||
second
|
||||
.finish_after_external_unmount()
|
||||
.map_err(io::Error::other)?;
|
||||
drop(tree);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn acquire(
|
||||
layout: &StateAnchorLayout,
|
||||
artifact: &PreparedArtifact,
|
||||
) -> io::Result<FuseOverlayfsMount> {
|
||||
let expected = std::cell::Cell::new(None);
|
||||
FuseOverlayfsMount::acquire(
|
||||
layout,
|
||||
Path::new(std::ffi::OsStr::from_bytes(artifact.path.as_bytes())),
|
||||
(artifact.device.get(), artifact.inode.get()),
|
||||
current_process_identity()?,
|
||||
ProcessOwnershipContext {
|
||||
domain_id: DomainId::new([1; 32]),
|
||||
generation_id: GenerationId::new(2),
|
||||
coordinator_id: CoordinatorId::new([3; 32]),
|
||||
slot: HostProcessOwnershipSlot::StateAnchorDaemon,
|
||||
},
|
||||
|staged| {
|
||||
expected.set(Some(staged));
|
||||
Ok(())
|
||||
},
|
||||
|staged| {
|
||||
if expected.get() != Some(staged) {
|
||||
return Err(io::Error::other("staged process identity changed"));
|
||||
}
|
||||
Ok(ProcessOwnershipCommitAck::v1(staged))
|
||||
},
|
||||
)
|
||||
.map_err(io::Error::other)
|
||||
}
|
||||
|
||||
struct TestTree {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TestTree {
|
||||
fn new() -> io::Result<Self> {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"lazaret state-anchor,:{}-{}",
|
||||
std::process::id(),
|
||||
SEQUENCE.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir(&path)?;
|
||||
Ok(Self { path })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestTree {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
fn configuration(
|
||||
tree: &TestTree,
|
||||
) -> io::Result<lazaret_core::configuration::ResolvedDomainConfiguration> {
|
||||
let configuration_directory = tree.path.join("configuration");
|
||||
let workspace = tree.path.join("workspace");
|
||||
let runtime = tree.path.join("runtime");
|
||||
fs::create_dir(&configuration_directory)?;
|
||||
fs::create_dir(&workspace)?;
|
||||
fs::create_dir(&runtime)?;
|
||||
let configuration_path = configuration_directory.join("lazaret.toml");
|
||||
fs::write(
|
||||
&configuration_path,
|
||||
format!(
|
||||
"schema = \"lazaret-trusted-host-config-v1\"\n[storage]\ndata_root = \"{}\"\n[profiles.default]\nimage = \"example.invalid/fixture:v1\"\npersistent_dirs = [\"/usr\", \"/etc\"]\n",
|
||||
tree.path.join("data").display()
|
||||
),
|
||||
)?;
|
||||
let layout = SandboxLayoutV1::from_production_layout([
|
||||
"/workspace".to_owned(),
|
||||
"/proc".to_owned(),
|
||||
"/sys".to_owned(),
|
||||
"/dev".to_owned(),
|
||||
"/run/lazaret".to_owned(),
|
||||
])
|
||||
.map_err(io::Error::other)?;
|
||||
load_trusted_configuration(ConfigurationLoadRequest {
|
||||
explicit_configuration_path: Some(configuration_path),
|
||||
selected_profile: "default".to_owned(),
|
||||
workspace,
|
||||
home: tree.path.join("home"),
|
||||
xdg_config_home: None,
|
||||
xdg_data_home: None,
|
||||
xdg_runtime_dir: runtime,
|
||||
environment: BTreeMap::<String, OsString>::new(),
|
||||
sandbox_layout: layout,
|
||||
})
|
||||
.map_err(io::Error::other)
|
||||
}
|
||||
|
|
@ -330,6 +330,32 @@ impl Catalog {
|
|||
/// Returns an error if the artifact is absent or reclaiming, the lease exists, or the
|
||||
/// transaction fails.
|
||||
pub fn acquire_lease(&self, lease: &ArtifactLease) -> Result<(), CatalogError> {
|
||||
self.acquire_lease_with(lease, None)
|
||||
}
|
||||
|
||||
/// Inserts one lease only for an exactly matching available artifact row.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error for artifact identity drift, absence, reclaim, lease conflict, or a
|
||||
/// transaction failure.
|
||||
pub fn acquire_lease_checked(
|
||||
&self,
|
||||
expected_artifact: &PreparedArtifact,
|
||||
lease: &ArtifactLease,
|
||||
) -> Result<(), CatalogError> {
|
||||
if expected_artifact.prepared_artifact_id != lease.prepared_artifact_id {
|
||||
return Err(CatalogError::OwnershipMismatch {
|
||||
row: CatalogRow::PreparedArtifact,
|
||||
});
|
||||
}
|
||||
self.acquire_lease_with(lease, Some(expected_artifact))
|
||||
}
|
||||
|
||||
fn acquire_lease_with(
|
||||
&self,
|
||||
lease: &ArtifactLease,
|
||||
expected_artifact: Option<&PreparedArtifact>,
|
||||
) -> Result<(), CatalogError> {
|
||||
self.write(|transaction| {
|
||||
let artifact_key = artifact_key(lease.prepared_artifact_id);
|
||||
let artifacts = transaction
|
||||
|
|
@ -337,6 +363,11 @@ impl Catalog {
|
|||
.map_err(CatalogError::redb)?;
|
||||
let artifact: PreparedArtifact =
|
||||
require_row(&artifacts, &artifact_key, CatalogRow::PreparedArtifact)?;
|
||||
if expected_artifact.is_some_and(|expected| *expected != artifact) {
|
||||
return Err(CatalogError::OwnershipMismatch {
|
||||
row: CatalogRow::PreparedArtifact,
|
||||
});
|
||||
}
|
||||
if artifact.state != ArtifactState::Available {
|
||||
return Err(CatalogError::ArtifactUnavailable);
|
||||
}
|
||||
|
|
@ -359,7 +390,21 @@ impl Catalog {
|
|||
///
|
||||
/// Returns an error when the exact row is absent, forged, or the transaction fails.
|
||||
pub fn release_lease(&self, expected: &ArtifactLease) -> Result<(), CatalogError> {
|
||||
self.release_lease_checked(expected, || Ok(()))
|
||||
}
|
||||
|
||||
/// Removes one exact lease only after caller-owned Domain and mount evidence is rechecked
|
||||
/// while the catalog lock is held.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns the evidence refusal or any exact-row and transaction failure without removal.
|
||||
pub fn release_lease_checked(
|
||||
&self,
|
||||
expected: &ArtifactLease,
|
||||
verify_release: impl FnOnce() -> Result<(), CatalogError>,
|
||||
) -> Result<(), CatalogError> {
|
||||
self.write(|transaction| {
|
||||
verify_release()?;
|
||||
let key = lease_key(expected);
|
||||
let mut table = transaction.open_table(LEASES).map_err(CatalogError::redb)?;
|
||||
require_exact(&table, &key, expected, CatalogRow::ArtifactLease)?;
|
||||
|
|
|
|||
|
|
@ -174,6 +174,66 @@ fn shared_artifact_waits_for_every_lease() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_evidence_is_checked_while_lease_remains_locked() {
|
||||
let fixture = Fixture::new();
|
||||
let artifact = fixture.publish(31);
|
||||
let exact = lease(&artifact, 1, 3);
|
||||
fixture.catalog.acquire_lease(&exact).unwrap();
|
||||
|
||||
let refused = fixture.catalog.release_lease_checked(&exact, || {
|
||||
Err(CatalogError::Io(std::io::Error::other(
|
||||
"injected release-evidence mismatch",
|
||||
)))
|
||||
});
|
||||
assert!(matches!(refused, Err(CatalogError::Io(_))));
|
||||
assert_eq!(
|
||||
fixture.catalog.snapshot().unwrap().artifact_leases,
|
||||
vec![exact]
|
||||
);
|
||||
|
||||
fixture
|
||||
.catalog
|
||||
.release_lease_checked(&exact, || Ok(()))
|
||||
.unwrap();
|
||||
assert!(
|
||||
fixture
|
||||
.catalog
|
||||
.snapshot()
|
||||
.unwrap()
|
||||
.artifact_leases
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lease_acquisition_binds_the_exact_artifact_row() {
|
||||
let fixture = Fixture::new();
|
||||
let artifact = fixture.publish(32);
|
||||
let exact = lease(&artifact, 2, 4);
|
||||
let mut forged = artifact.clone();
|
||||
forged.inode = InodeId::new(9_999);
|
||||
|
||||
assert!(matches!(
|
||||
fixture.catalog.acquire_lease_checked(&forged, &exact),
|
||||
Err(CatalogError::OwnershipMismatch {
|
||||
row: CatalogRow::PreparedArtifact
|
||||
})
|
||||
));
|
||||
assert!(
|
||||
fixture
|
||||
.catalog
|
||||
.snapshot()
|
||||
.unwrap()
|
||||
.artifact_leases
|
||||
.is_empty()
|
||||
);
|
||||
fixture
|
||||
.catalog
|
||||
.acquire_lease_checked(&artifact, &exact)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_domain_generation_cannot_bind_two_artifacts() {
|
||||
let fixture = Fixture::new();
|
||||
|
|
|
|||
Loading…
Reference in a new issue