use std::collections::VecDeque; use std::convert::Infallible; use std::ffi::OsString; use std::io::Write; use std::path::PathBuf; use lazaret_client::{ BootstrapAcquisition, ClientBackend, ClientCommand, ClientError, DomainStatus, parse_command_line, run, }; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum Endpoint { Existing, Started, Published, } #[derive(Default)] struct FakeBackend { observations: VecDeque>, acquisition: Option, bootstrap: Option>>, waited: Option>>, execution: Option>>, actions: Vec<&'static str>, validation: Option>, } impl ClientBackend for FakeBackend { type Endpoint = Endpoint; type Error = Infallible; fn validate(&mut self, _command: &ClientCommand) -> Result<(), ClientError> { self.actions.push("validate"); self.validation.take().map_or(Ok(()), Err) } fn observe(&mut self) -> Result, Self::Error> { self.actions.push("observe"); Ok(self.observations.pop_front().unwrap()) } fn acquire_bootstrap(&mut self) -> Result { self.actions.push("acquire"); Ok(self.acquisition.unwrap()) } fn bootstrap(&mut self) -> Result> { self.actions.push("bootstrap"); self.bootstrap.take().unwrap() } fn wait_until_ready(&mut self) -> Result> { self.actions.push("wait"); self.waited.take().unwrap() } fn execute( &mut self, _endpoint: Self::Endpoint, _argv: &[Vec], stdout: &mut dyn Write, stderr: &mut dyn Write, ) -> Result> { self.actions.push("execute"); stdout.write_all(b"out").unwrap(); stderr.write_all(b"err").unwrap(); self.execution.take().unwrap_or(Ok(0)) } } fn command() -> ClientCommand { ClientCommand { profile: "agent".into(), workspace: PathBuf::from("/workspace"), configuration: None, argv: vec![b"sh".to_vec(), b"-c".to_vec(), b"exit 0".to_vec()], } } fn invoke(backend: &mut FakeBackend) -> Result<(i32, Vec, Vec), String> { let mut stdout = Vec::new(); let mut stderr = Vec::new(); run(backend, &command(), &mut stdout, &mut stderr) .map(|status| (status, stdout, stderr)) .map_err(|error| error.to_string()) } #[test] fn cold_start_leader_bootstraps_before_forwarding_streams() { let mut backend = FakeBackend { observations: [DomainStatus::Absent].into(), acquisition: Some(BootstrapAcquisition::Leader), bootstrap: Some(Ok(Endpoint::Started)), ..FakeBackend::default() }; assert_eq!( invoke(&mut backend), Ok((0, b"out".to_vec(), b"err".to_vec())) ); assert_eq!( backend.actions, ["validate", "observe", "acquire", "bootstrap", "execute"] ); } #[test] fn ready_domain_is_joined_without_bootstrap() { let mut backend = FakeBackend { observations: [DomainStatus::Ready(Endpoint::Existing)].into(), ..FakeBackend::default() }; assert_eq!(invoke(&mut backend).unwrap().0, 0); assert_eq!(backend.actions, ["validate", "observe", "execute"]); } #[test] fn bootstrap_owner_disconnect_fails_that_closed_session() { let mut backend = FakeBackend { observations: [DomainStatus::Absent].into(), acquisition: Some(BootstrapAcquisition::Leader), bootstrap: Some(Err(ClientError::BootstrapOwnerDisconnected)), ..FakeBackend::default() }; assert!( invoke(&mut backend) .unwrap_err() .contains("bootstrap owner disconnected") ); assert_eq!( backend.actions, ["validate", "observe", "acquire", "bootstrap"] ); } #[test] fn distinct_waiter_joins_after_publication() { let mut backend = FakeBackend { observations: [DomainStatus::Starting].into(), waited: Some(Ok(Endpoint::Published)), ..FakeBackend::default() }; assert_eq!(invoke(&mut backend).unwrap().0, 0); assert_eq!(backend.actions, ["validate", "observe", "wait", "execute"]); } #[test] fn distinct_waiter_is_not_restarted_after_owner_loss() { let mut backend = FakeBackend { observations: [DomainStatus::Starting].into(), waited: Some(Err(ClientError::WaitingDomainFailed)), ..FakeBackend::default() }; assert!( invoke(&mut backend) .unwrap_err() .contains("failed while this Client was waiting") ); assert_eq!(backend.actions, ["validate", "observe", "wait"]); } #[test] fn nonzero_guest_exit_is_the_cli_status() { let mut backend = FakeBackend { observations: [DomainStatus::Ready(Endpoint::Existing)].into(), execution: Some(Ok(23)), ..FakeBackend::default() }; assert_eq!(invoke(&mut backend).unwrap().0, 23); } #[test] fn stale_domain_enters_lock_arbitration_while_live_stale_and_lost_faces_refuse() { let mut unowned = FakeBackend { observations: [DomainStatus::Stale].into(), acquisition: Some(BootstrapAcquisition::Leader), bootstrap: Some(Ok(Endpoint::Published)), ..FakeBackend::default() }; assert_eq!(invoke(&mut unowned).unwrap().0, 0); assert_eq!( unowned.actions, ["validate", "observe", "acquire", "bootstrap", "execute"] ); let mut live = FakeBackend { observations: [DomainStatus::Stale].into(), acquisition: Some(BootstrapAcquisition::Waiter), waited: Some(Err(ClientError::StaleCoordinator)), ..FakeBackend::default() }; assert!(invoke(&mut live).unwrap_err().contains("stale")); assert_eq!(live.actions, ["validate", "observe", "acquire", "wait"]); let mut lost = FakeBackend { observations: [DomainStatus::Ready(Endpoint::Existing)].into(), execution: Some(Err(ClientError::LostConnection)), ..FakeBackend::default() }; assert!( invoke(&mut lost) .unwrap_err() .contains("connection was lost") ); } #[test] fn unsupported_host_and_configuration_errors_keep_their_cause() { for (error, expected) in [ ( ClientError::UnsupportedHost("Linux kernel 6.8 is below the required 6.9 floor".into()), "host is unsupported: Linux kernel 6.8 is below the required 6.9 floor", ), ( ClientError::Configuration("profile `agent` is not configured".into()), "configuration is invalid: profile `agent` is not configured", ), ] { let mut backend = FakeBackend { validation: Some(error), ..FakeBackend::default() }; assert_eq!(invoke(&mut backend).unwrap_err(), expected); assert_eq!(backend.actions, ["validate"]); } } #[test] fn command_line_requires_an_explicit_profile_and_preserves_command_bytes() { let parsed = parse_command_line( [ "--profile", "agent", "--workspace", "/project", "--config", "/host/config.toml", "--", "tool", "argument", ] .map(OsString::from), PathBuf::from("/current"), ) .unwrap(); assert_eq!(parsed.profile, "agent"); assert_eq!(parsed.workspace, PathBuf::from("/project")); assert_eq!( parsed.configuration, Some(PathBuf::from("/host/config.toml")) ); assert_eq!(parsed.argv, [b"tool".to_vec(), b"argument".to_vec()]); assert!( parse_command_line( ["--", "tool"].map(OsString::from), PathBuf::from("/current") ) .unwrap_err() .to_string() .contains("profile") ); }