Route lifecycle admission through the control registry

This commit is contained in:
ms2k 2026-07-16 16:08:12 +09:00
commit 510dfccf5a

View file

@ -1,36 +1,138 @@
use std::convert::Infallible;
use std::fs::OpenOptions;
use std::sync::Mutex;
use std::sync::{Arc, Mutex};
use lazaret_core::identity::{
ClientId, ConfigurationFingerprint, CoordinatorId, DomainId, GenerationId, HostPath,
NetworkSidecarId, SandboxId, SessionAnchorId, SessionId,
};
use lazaret_core::records::{Domain, DurableDecision, Generation, Session, SessionPhase};
use lazaret_host_coordinator::domain_store::HostCoordinatorDomainStore;
use lazaret_host_coordinator::domain_store::{DomainStoreMutation, HostCoordinatorDomainStore};
use lazaret_host_coordinator::lifecycle::{
DomainPhase, HostCoordinatorLifecycle, LifecycleAction, LifecycleAdapter, LifecycleError,
SessionAdmissionIdentity, SessionLifecycleAdapter,
};
use lazaret_linux::filesystem::try_exclusive_flock;
#[derive(Default)]
#[derive(Clone, Default)]
struct RecordingAdapter {
actions: Vec<LifecycleAction>,
actions: Arc<Mutex<Vec<LifecycleAction>>>,
}
impl LifecycleAdapter for RecordingAdapter {
type Error = Infallible;
type Error = &'static str;
fn execute(
&mut self,
action: LifecycleAction,
_generation: &Generation,
) -> Result<(), Self::Error> {
self.actions.push(action);
self.actions.lock().unwrap().push(action);
Ok(())
}
}
#[derive(Clone)]
struct TestAdmission {
session: Session,
fingerprint: ConfigurationFingerprint,
}
#[derive(Clone)]
struct RecordingSessionAdapter {
actions: Arc<Mutex<Vec<LifecycleAction>>>,
}
impl SessionLifecycleAdapter for RecordingSessionAdapter {
type Error = &'static str;
type Admission = TestAdmission;
fn admission_identity(&self, admission: &Self::Admission) -> SessionAdmissionIdentity {
SessionAdmissionIdentity {
generation_id: admission.session.generation_id,
session_id: admission.session.session_id,
configuration_fingerprint: admission.fingerprint,
}
}
fn register_session(
&self,
store: &HostCoordinatorDomainStore,
entry_action: LifecycleAction,
admission: Self::Admission,
) -> Result<Session, Self::Error> {
let snapshot = store.snapshot().map_err(|_| "snapshot")?;
if snapshot.sessions.iter().any(|current| {
current.session_id == admission.session.session_id
|| (matches!(
current.session_phase,
SessionPhase::Waiting
| SessionPhase::Registering
| SessionPhase::Attached
| SessionPhase::Prompting
| SessionPhase::HostExecuting
) && (current.cli_id == admission.session.cli_id
|| current.session_anchor_id == admission.session.session_anchor_id))
}) && entry_action == LifecycleAction::StartReadySession
{
return Err("duplicate identity");
}
self.actions.lock().unwrap().push(entry_action);
self.actions
.lock()
.unwrap()
.push(LifecycleAction::RegisterSession);
let registering = Session {
session_phase: SessionPhase::Registering,
..admission.session
};
let attached = Session {
session_phase: SessionPhase::Attached,
..registering.clone()
};
store
.apply(&[
DomainStoreMutation::PutSession(registering),
DomainStoreMutation::PutSession(attached.clone()),
])
.map_err(|_| "registration")?;
Ok(attached)
}
fn disconnect_session(
&self,
store: &HostCoordinatorDomainStore,
session: &Session,
) -> Result<(), Self::Error> {
self.actions
.lock()
.unwrap()
.push(LifecycleAction::DisconnectSession);
store
.apply(&[DomainStoreMutation::PutSession(Session {
session_phase: SessionPhase::Closed,
..session.clone()
})])
.map_err(|_| "disconnect")
}
}
fn adapters() -> (RecordingAdapter, RecordingSessionAdapter) {
let actions = Arc::new(Mutex::new(Vec::new()));
(
RecordingAdapter {
actions: Arc::clone(&actions),
},
RecordingSessionAdapter { actions },
)
}
fn admission(session: Session) -> TestAdmission {
TestAdmission {
session,
fingerprint: fingerprint(2),
}
}
fn bytes(value: u8) -> [u8; 32] {
[value; 32]
}
@ -82,7 +184,9 @@ fn open_store(directory: &tempfile::TempDir) -> HostCoordinatorDomainStore {
.unwrap()
}
fn complete_startup(lifecycle: &mut HostCoordinatorLifecycle<'_, RecordingAdapter>) {
fn complete_startup(
lifecycle: &mut HostCoordinatorLifecycle<'_, RecordingAdapter, RecordingSessionAdapter>,
) {
lifecycle.create_trust_anchor().unwrap();
lifecycle.start_runtime_supervisor().unwrap();
lifecycle.lease_prepared_lower().unwrap();
@ -99,7 +203,8 @@ fn complete_startup(lifecycle: &mut HostCoordinatorLifecycle<'_, RecordingAdapte
fn exact_start_join_disconnect_and_teardown_actions_are_ordered() {
let directory = tempfile::tempdir().unwrap();
let store = open_store(&directory);
let mut lifecycle = HostCoordinatorLifecycle::new(&store, RecordingAdapter::default());
let (domain_adapter, session_adapter) = adapters();
let mut lifecycle = HostCoordinatorLifecycle::new(&store, domain_adapter, session_adapter);
let owner = session(10, 1);
let waiter = session(11, 1);
@ -108,14 +213,8 @@ fn exact_start_join_disconnect_and_teardown_actions_are_ordered() {
.unwrap();
lifecycle.wait_for_domain(waiter.clone()).unwrap();
complete_startup(&mut lifecycle);
lifecycle
.advance_waiting_session(owner.session_id, fingerprint(2))
.unwrap();
lifecycle.register_session(owner.session_id).unwrap();
lifecycle
.advance_waiting_session(waiter.session_id, fingerprint(2))
.unwrap();
lifecycle.register_session(waiter.session_id).unwrap();
lifecycle.admit_session(admission(owner.clone())).unwrap();
lifecycle.admit_session(admission(waiter.clone())).unwrap();
lifecycle.disconnect_session(owner.session_id).unwrap();
assert!(!lifecycle.is_teardown_ready());
lifecycle.disconnect_session(waiter.session_id).unwrap();
@ -131,7 +230,7 @@ fn exact_start_join_disconnect_and_teardown_actions_are_ordered() {
assert_eq!(lifecycle.phase(), DomainPhase::Absent);
assert!(!lifecycle.is_published());
assert_eq!(
lifecycle.into_adapter().actions,
lifecycle.into_adapters().0.actions.lock().unwrap().clone(),
vec![
LifecycleAction::StartNewDomain,
LifecycleAction::WaitForDomain,
@ -177,7 +276,8 @@ fn exact_start_join_disconnect_and_teardown_actions_are_ordered() {
fn publication_and_invalid_transitions_fail_before_adapter_execution() {
let directory = tempfile::tempdir().unwrap();
let store = open_store(&directory);
let mut lifecycle = HostCoordinatorLifecycle::new(&store, RecordingAdapter::default());
let (domain_adapter, session_adapter) = adapters();
let mut lifecycle = HostCoordinatorLifecycle::new(&store, domain_adapter, session_adapter);
lifecycle
.start_new_domain(generation(1), session(10, 1))
.unwrap();
@ -204,7 +304,7 @@ fn publication_and_invalid_transitions_fail_before_adapter_execution() {
})
));
assert_eq!(
lifecycle.into_adapter().actions,
lifecycle.into_adapters().0.actions.lock().unwrap().clone(),
vec![LifecycleAction::StartNewDomain]
);
assert_eq!(
@ -213,11 +313,40 @@ fn publication_and_invalid_transitions_fail_before_adapter_execution() {
);
}
#[test]
fn aliased_generation_role_identities_are_refused_before_adapter_or_store() {
let directory = tempfile::tempdir().unwrap();
let store = open_store(&directory);
let (domain_adapter, session_adapter) = adapters();
let mut lifecycle = HostCoordinatorLifecycle::new(&store, domain_adapter, session_adapter);
let mut aliased = generation(1);
aliased.network_sidecar_id = NetworkSidecarId::new(*aliased.coordinator_id.as_bytes());
assert!(matches!(
lifecycle.start_new_domain(aliased, session(10, 1)),
Err(LifecycleError::InvalidTransition {
action: LifecycleAction::StartNewDomain,
..
})
));
assert!(store.snapshot().unwrap().generations.is_empty());
assert!(
lifecycle
.into_adapters()
.0
.actions
.lock()
.unwrap()
.is_empty()
);
}
#[test]
fn configuration_mismatch_is_refused_without_registration() {
let directory = tempfile::tempdir().unwrap();
let store = open_store(&directory);
let mut lifecycle = HostCoordinatorLifecycle::new(&store, RecordingAdapter::default());
let (domain_adapter, session_adapter) = adapters();
let mut lifecycle = HostCoordinatorLifecycle::new(&store, domain_adapter, session_adapter);
let owner = session(10, 1);
lifecycle
.start_new_domain(generation(1), owner.clone())
@ -226,7 +355,10 @@ fn configuration_mismatch_is_refused_without_registration() {
let mismatched = session(11, 1);
assert!(matches!(
lifecycle.start_ready_session(mismatched.clone(), fingerprint(9)),
lifecycle.admit_session(TestAdmission {
session: mismatched.clone(),
fingerprint: fingerprint(9),
}),
Err(LifecycleError::InvalidTransition {
action: LifecycleAction::StartReadySession,
..
@ -246,26 +378,35 @@ fn configuration_mismatch_is_refused_without_registration() {
fn waiting_session_restarts_after_normal_teardown() {
let directory = tempfile::tempdir().unwrap();
let store = open_store(&directory);
let mut lifecycle = HostCoordinatorLifecycle::new(&store, RecordingAdapter::default());
let (domain_adapter, session_adapter) = adapters();
let mut lifecycle = HostCoordinatorLifecycle::new(&store, domain_adapter, session_adapter);
let owner = session(10, 1);
lifecycle
.start_new_domain(generation(1), owner.clone())
.unwrap();
complete_startup(&mut lifecycle);
lifecycle
.advance_waiting_session(owner.session_id, fingerprint(2))
.unwrap();
lifecycle.register_session(owner.session_id).unwrap();
lifecycle.admit_session(admission(owner.clone())).unwrap();
lifecycle.disconnect_session(owner.session_id).unwrap();
lifecycle.begin_teardown().unwrap();
let waiter = session(11, 1);
let second_waiter = session(12, 1);
lifecycle.wait_for_domain(waiter.clone()).unwrap();
lifecycle.wait_for_domain(second_waiter.clone()).unwrap();
lifecycle.stop_sandbox().unwrap();
lifecycle.stop_sidecar().unwrap();
lifecycle.unmount_state_anchor().unwrap();
lifecycle.release_prepared_lower_lease().unwrap();
lifecycle.graceful_supervisor_exit().unwrap();
lifecycle.finish_teardown().unwrap();
let mut aliased_successor = generation(2);
aliased_successor.sandbox_id = SandboxId::new(*aliased_successor.coordinator_id.as_bytes());
assert!(matches!(
lifecycle.restart_waiting_domain(aliased_successor, waiter.session_id),
Err(LifecycleError::InvalidTransition {
action: LifecycleAction::RestartWaitingDomain,
..
})
));
lifecycle
.restart_waiting_domain(generation(2), waiter.session_id)
.unwrap();
@ -282,13 +423,66 @@ fn waiting_session_restarts_after_normal_teardown() {
&& row.session_id == waiter.session_id
&& row.session_phase == SessionPhase::Waiting
}));
assert!(snapshot.sessions.iter().any(|row| {
row.generation_id == GenerationId::new(2)
&& row.session_id == second_waiter.session_id
&& row.session_phase == SessionPhase::Waiting
}));
drop(snapshot);
complete_startup(&mut lifecycle);
lifecycle
.admit_session(admission(Session {
generation_id: GenerationId::new(2),
..second_waiter
}))
.unwrap();
assert_eq!(
lifecycle.session_phase(SessionId::new(bytes(12))),
Some(SessionPhase::Attached)
);
}
#[test]
fn admission_adapter_refuses_duplicate_cli_and_anchor_identities() {
let directory = tempfile::tempdir().unwrap();
let store = open_store(&directory);
let (domain_adapter, session_adapter) = adapters();
let mut lifecycle = HostCoordinatorLifecycle::new(&store, domain_adapter, session_adapter);
let owner = session(10, 1);
lifecycle
.start_new_domain(generation(1), owner.clone())
.unwrap();
complete_startup(&mut lifecycle);
lifecycle.admit_session(admission(owner.clone())).unwrap();
let duplicate = Session {
session_id: SessionId::new(bytes(11)),
session_anchor_id: SessionAnchorId::new(bytes(99)),
..owner
};
assert!(matches!(
lifecycle.admit_session(admission(duplicate)),
Err(LifecycleError::Adapter("duplicate identity"))
));
assert_eq!(lifecycle.registered_sessions(), 1);
assert_eq!(
store
.snapshot()
.unwrap()
.sessions
.iter()
.filter(|row| row.session_phase == SessionPhase::Attached)
.count(),
1
);
}
#[test]
fn serialized_concurrent_joins_register_distinct_sessions() {
let directory = tempfile::tempdir().unwrap();
let store = open_store(&directory);
let mut lifecycle = HostCoordinatorLifecycle::new(&store, RecordingAdapter::default());
let (domain_adapter, session_adapter) = adapters();
let mut lifecycle = HostCoordinatorLifecycle::new(&store, domain_adapter, session_adapter);
let owner = session(10, 1);
lifecycle
.start_new_domain(generation(1), owner.clone())
@ -302,10 +496,7 @@ fn serialized_concurrent_joins_register_distinct_sessions() {
scope.spawn(move || {
let candidate = session(id, 1);
let mut guard = lifecycle.lock().unwrap();
guard
.start_ready_session(candidate.clone(), fingerprint(2))
.unwrap();
guard.register_session(candidate.session_id).unwrap();
guard.admit_session(admission(candidate)).unwrap();
});
}
});
@ -328,7 +519,8 @@ fn serialized_concurrent_joins_register_distinct_sessions() {
fn bootstrap_owner_disconnect_enters_failed_without_teardown_or_recovery() {
let directory = tempfile::tempdir().unwrap();
let store = open_store(&directory);
let mut lifecycle = HostCoordinatorLifecycle::new(&store, RecordingAdapter::default());
let (domain_adapter, session_adapter) = adapters();
let mut lifecycle = HostCoordinatorLifecycle::new(&store, domain_adapter, session_adapter);
let owner = session(10, 1);
lifecycle
.start_new_domain(generation(1), owner.clone())