Run non-interactive sessions through production coordination

This commit is contained in:
ms2k 2026-07-16 16:28:18 +09:00
commit 71b0266cf2

View file

@ -0,0 +1,255 @@
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<DomainStatus<Endpoint>>,
acquisition: Option<BootstrapAcquisition>,
bootstrap: Option<Result<Endpoint, ClientError<Infallible>>>,
waited: Option<Result<Endpoint, ClientError<Infallible>>>,
execution: Option<Result<i32, ClientError<Infallible>>>,
actions: Vec<&'static str>,
validation: Option<ClientError<Infallible>>,
}
impl ClientBackend for FakeBackend {
type Endpoint = Endpoint;
type Error = Infallible;
fn validate(&mut self, _command: &ClientCommand) -> Result<(), ClientError<Self::Error>> {
self.actions.push("validate");
self.validation.take().map_or(Ok(()), Err)
}
fn observe(&mut self) -> Result<DomainStatus<Self::Endpoint>, Self::Error> {
self.actions.push("observe");
Ok(self.observations.pop_front().unwrap())
}
fn acquire_bootstrap(&mut self) -> Result<BootstrapAcquisition, Self::Error> {
self.actions.push("acquire");
Ok(self.acquisition.unwrap())
}
fn bootstrap(&mut self) -> Result<Self::Endpoint, ClientError<Self::Error>> {
self.actions.push("bootstrap");
self.bootstrap.take().unwrap()
}
fn wait_until_ready(&mut self) -> Result<Self::Endpoint, ClientError<Self::Error>> {
self.actions.push("wait");
self.waited.take().unwrap()
}
fn execute(
&mut self,
_endpoint: Self::Endpoint,
_argv: &[Vec<u8>],
stdout: &mut dyn Write,
stderr: &mut dyn Write,
) -> Result<i32, ClientError<Self::Error>> {
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<u8>, Vec<u8>), 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_and_lost_coordinators_are_actionable_failures() {
let mut stale = FakeBackend {
observations: [DomainStatus::Stale].into(),
..FakeBackend::default()
};
assert!(invoke(&mut stale).unwrap_err().contains("stale"));
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")
);
}