Orchestrate normal domain lifecycle through adapters
This commit is contained in:
parent
49d5def1a9
commit
671473106c
1 changed files with 1266 additions and 0 deletions
347
crates/lazaret-host-coordinator/tests/lifecycle.rs
Normal file
347
crates/lazaret-host-coordinator/tests/lifecycle.rs
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
use std::convert::Infallible;
|
||||
use std::fs::OpenOptions;
|
||||
use std::sync::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::lifecycle::{
|
||||
DomainPhase, HostCoordinatorLifecycle, LifecycleAction, LifecycleAdapter, LifecycleError,
|
||||
};
|
||||
use lazaret_linux::filesystem::try_exclusive_flock;
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingAdapter {
|
||||
actions: Vec<LifecycleAction>,
|
||||
}
|
||||
|
||||
impl LifecycleAdapter for RecordingAdapter {
|
||||
type Error = Infallible;
|
||||
|
||||
fn execute(
|
||||
&mut self,
|
||||
action: LifecycleAction,
|
||||
_generation: &Generation,
|
||||
) -> Result<(), Self::Error> {
|
||||
self.actions.push(action);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn bytes(value: u8) -> [u8; 32] {
|
||||
[value; 32]
|
||||
}
|
||||
|
||||
fn domain() -> Domain {
|
||||
Domain {
|
||||
domain_id: DomainId::new(bytes(1)),
|
||||
profile_name: "trusted".into(),
|
||||
canonical_workspace: HostPath::new(b"/workspace/project".to_vec()),
|
||||
}
|
||||
}
|
||||
|
||||
fn fingerprint(value: u8) -> ConfigurationFingerprint {
|
||||
ConfigurationFingerprint::new(bytes(value))
|
||||
}
|
||||
|
||||
fn generation(id: u64) -> Generation {
|
||||
Generation {
|
||||
domain_id: domain().domain_id,
|
||||
generation_id: GenerationId::new(id),
|
||||
configuration_fingerprint: fingerprint(2),
|
||||
coordinator_id: CoordinatorId::new(bytes(u8::try_from(id * 3).unwrap())),
|
||||
network_sidecar_id: NetworkSidecarId::new(bytes(u8::try_from(id * 3 + 1).unwrap())),
|
||||
sandbox_id: SandboxId::new(bytes(u8::try_from(id * 3 + 2).unwrap())),
|
||||
durable_decision: DurableDecision::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn session(id: u8, generation_id: u64) -> Session {
|
||||
Session {
|
||||
generation_id: GenerationId::new(generation_id),
|
||||
session_id: SessionId::new(bytes(id)),
|
||||
cli_id: ClientId::new(bytes(id.wrapping_add(32))),
|
||||
session_anchor_id: SessionAnchorId::new(bytes(id.wrapping_add(64))),
|
||||
session_phase: SessionPhase::New,
|
||||
}
|
||||
}
|
||||
|
||||
fn open_store(directory: &tempfile::TempDir) -> HostCoordinatorDomainStore {
|
||||
let lock_file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(directory.path().join("domain.lock"))
|
||||
.unwrap();
|
||||
let lock = try_exclusive_flock(lock_file).unwrap();
|
||||
HostCoordinatorDomainStore::open(lock, &directory.path().join("domain.redb"), &domain())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn complete_startup(lifecycle: &mut HostCoordinatorLifecycle<'_, RecordingAdapter>) {
|
||||
lifecycle.create_trust_anchor().unwrap();
|
||||
lifecycle.start_runtime_supervisor().unwrap();
|
||||
lifecycle.lease_prepared_lower().unwrap();
|
||||
lifecycle.mount_state_anchor().unwrap();
|
||||
lifecycle.start_sidecar().unwrap();
|
||||
lifecycle.start_connectivity_helper().unwrap();
|
||||
lifecycle.mark_network_ready().unwrap();
|
||||
lifecycle.arm_runtime_causal_edge().unwrap();
|
||||
lifecycle.start_sandbox().unwrap();
|
||||
lifecycle.publish_domain().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 owner = session(10, 1);
|
||||
let waiter = session(11, 1);
|
||||
|
||||
lifecycle
|
||||
.start_new_domain(generation(1), owner.clone())
|
||||
.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.disconnect_session(owner.session_id).unwrap();
|
||||
assert!(!lifecycle.is_teardown_ready());
|
||||
lifecycle.disconnect_session(waiter.session_id).unwrap();
|
||||
assert!(lifecycle.is_teardown_ready());
|
||||
lifecycle.begin_teardown().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();
|
||||
|
||||
assert_eq!(lifecycle.phase(), DomainPhase::Absent);
|
||||
assert!(!lifecycle.is_published());
|
||||
assert_eq!(
|
||||
lifecycle.into_adapter().actions,
|
||||
vec![
|
||||
LifecycleAction::StartNewDomain,
|
||||
LifecycleAction::WaitForDomain,
|
||||
LifecycleAction::CreateTrustAnchor,
|
||||
LifecycleAction::StartRuntimeSupervisor,
|
||||
LifecycleAction::LeasePreparedLower,
|
||||
LifecycleAction::MountStateAnchor,
|
||||
LifecycleAction::StartSidecar,
|
||||
LifecycleAction::StartConnectivityHelper,
|
||||
LifecycleAction::MarkNetworkReady,
|
||||
LifecycleAction::ArmRuntimeCausalEdge,
|
||||
LifecycleAction::StartSandbox,
|
||||
LifecycleAction::PublishDomain,
|
||||
LifecycleAction::AdvanceWaitingSession,
|
||||
LifecycleAction::RegisterSession,
|
||||
LifecycleAction::AdvanceWaitingSession,
|
||||
LifecycleAction::RegisterSession,
|
||||
LifecycleAction::DisconnectSession,
|
||||
LifecycleAction::DisconnectSession,
|
||||
LifecycleAction::BeginTeardown,
|
||||
LifecycleAction::StopSandbox,
|
||||
LifecycleAction::StopSidecar,
|
||||
LifecycleAction::UnmountStateAnchor,
|
||||
LifecycleAction::ReleasePreparedLowerLease,
|
||||
LifecycleAction::GracefulSupervisorExit,
|
||||
LifecycleAction::FinishTeardown,
|
||||
]
|
||||
);
|
||||
let snapshot = store.snapshot().unwrap();
|
||||
assert_eq!(
|
||||
snapshot.generations[0].durable_decision,
|
||||
DurableDecision::Clean
|
||||
);
|
||||
assert!(
|
||||
snapshot
|
||||
.sessions
|
||||
.iter()
|
||||
.all(|row| row.session_phase == SessionPhase::Closed)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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());
|
||||
lifecycle
|
||||
.start_new_domain(generation(1), session(10, 1))
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
lifecycle.publish_domain(),
|
||||
Err(LifecycleError::InvalidTransition {
|
||||
action: LifecycleAction::PublishDomain,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(matches!(
|
||||
lifecycle.start_sandbox(),
|
||||
Err(LifecycleError::InvalidTransition {
|
||||
action: LifecycleAction::StartSandbox,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(matches!(
|
||||
lifecycle.mount_state_anchor(),
|
||||
Err(LifecycleError::InvalidTransition {
|
||||
action: LifecycleAction::MountStateAnchor,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert_eq!(
|
||||
lifecycle.into_adapter().actions,
|
||||
vec![LifecycleAction::StartNewDomain]
|
||||
);
|
||||
assert_eq!(
|
||||
store.snapshot().unwrap().generations[0].durable_decision,
|
||||
DurableDecision::None
|
||||
);
|
||||
}
|
||||
|
||||
#[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 owner = session(10, 1);
|
||||
lifecycle
|
||||
.start_new_domain(generation(1), owner.clone())
|
||||
.unwrap();
|
||||
complete_startup(&mut lifecycle);
|
||||
let mismatched = session(11, 1);
|
||||
|
||||
assert!(matches!(
|
||||
lifecycle.start_ready_session(mismatched.clone(), fingerprint(9)),
|
||||
Err(LifecycleError::InvalidTransition {
|
||||
action: LifecycleAction::StartReadySession,
|
||||
..
|
||||
})
|
||||
));
|
||||
lifecycle
|
||||
.reject_new_configuration_mismatch(mismatched.clone(), fingerprint(9))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
lifecycle.session_phase(mismatched.session_id),
|
||||
Some(SessionPhase::Failed)
|
||||
);
|
||||
assert_eq!(lifecycle.registered_sessions(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 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.disconnect_session(owner.session_id).unwrap();
|
||||
lifecycle.begin_teardown().unwrap();
|
||||
let waiter = session(11, 1);
|
||||
lifecycle.wait_for_domain(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();
|
||||
lifecycle
|
||||
.restart_waiting_domain(generation(2), waiter.session_id)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(lifecycle.phase(), DomainPhase::Starting);
|
||||
let snapshot = store.snapshot().unwrap();
|
||||
assert!(snapshot.sessions.iter().any(|row| {
|
||||
row.generation_id == GenerationId::new(1)
|
||||
&& row.session_id == waiter.session_id
|
||||
&& row.session_phase == SessionPhase::Closed
|
||||
}));
|
||||
assert!(snapshot.sessions.iter().any(|row| {
|
||||
row.generation_id == GenerationId::new(2)
|
||||
&& row.session_id == waiter.session_id
|
||||
&& row.session_phase == SessionPhase::Waiting
|
||||
}));
|
||||
}
|
||||
|
||||
#[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 owner = session(10, 1);
|
||||
lifecycle
|
||||
.start_new_domain(generation(1), owner.clone())
|
||||
.unwrap();
|
||||
complete_startup(&mut lifecycle);
|
||||
let lifecycle = Mutex::new(lifecycle);
|
||||
|
||||
std::thread::scope(|scope| {
|
||||
for id in 11..=18 {
|
||||
let lifecycle = &lifecycle;
|
||||
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();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let lifecycle = lifecycle.into_inner().unwrap();
|
||||
assert_eq!(lifecycle.registered_sessions(), 8);
|
||||
assert_eq!(
|
||||
store
|
||||
.snapshot()
|
||||
.unwrap()
|
||||
.sessions
|
||||
.iter()
|
||||
.filter(|row| row.session_phase == SessionPhase::Attached)
|
||||
.count(),
|
||||
8
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 owner = session(10, 1);
|
||||
lifecycle
|
||||
.start_new_domain(generation(1), owner.clone())
|
||||
.unwrap();
|
||||
lifecycle.disconnect_session(owner.session_id).unwrap();
|
||||
|
||||
assert_eq!(lifecycle.phase(), DomainPhase::Failed);
|
||||
assert!(!lifecycle.is_published());
|
||||
assert!(matches!(
|
||||
lifecycle.begin_teardown(),
|
||||
Err(LifecycleError::InvalidTransition {
|
||||
action: LifecycleAction::BeginTeardown,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
Loading…
Reference in a new issue