2026-07-16 16:28:18 +09:00
|
|
|
use std::collections::VecDeque;
|
|
|
|
|
use std::convert::Infallible;
|
|
|
|
|
use std::ffi::OsString;
|
|
|
|
|
use std::io::Write;
|
2026-07-18 23:25:40 +09:00
|
|
|
use std::os::unix::ffi::OsStringExt;
|
2026-07-16 16:28:18 +09:00
|
|
|
use std::path::PathBuf;
|
|
|
|
|
|
|
|
|
|
use lazaret_client::{
|
2026-07-18 23:25:40 +09:00
|
|
|
BootstrapAcquisition, ClientBackend, ClientCommand, ClientError, DomainStatus, run,
|
2026-07-16 16:28:18 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
#[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]
|
2026-07-18 05:47:15 +09:00
|
|
|
fn stale_domain_enters_lock_arbitration_while_live_stale_and_lost_faces_refuse() {
|
|
|
|
|
let mut unowned = FakeBackend {
|
2026-07-16 16:28:18 +09:00
|
|
|
observations: [DomainStatus::Stale].into(),
|
2026-07-18 05:47:15 +09:00
|
|
|
acquisition: Some(BootstrapAcquisition::Leader),
|
|
|
|
|
bootstrap: Some(Ok(Endpoint::Published)),
|
2026-07-16 16:28:18 +09:00
|
|
|
..FakeBackend::default()
|
|
|
|
|
};
|
2026-07-18 05:47:15 +09:00
|
|
|
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"]);
|
2026-07-16 16:28:18 +09:00
|
|
|
|
|
|
|
|
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]
|
2026-07-19 13:59:43 +09:00
|
|
|
fn command_line_accepts_profile_entry_and_preserves_explicit_command_bytes() {
|
|
|
|
|
let parsed =
|
|
|
|
|
lazaret_client::cli::parse(["agent"].map(OsString::from), PathBuf::from("/current"))
|
|
|
|
|
.unwrap();
|
|
|
|
|
let lazaret_client::cli::Command::Run(parsed) = parsed else {
|
|
|
|
|
panic!("expected run command");
|
|
|
|
|
};
|
|
|
|
|
assert_eq!(parsed.profile, "agent");
|
|
|
|
|
assert_eq!(parsed.workspace, PathBuf::from("/current"));
|
|
|
|
|
assert_eq!(parsed.configuration, None);
|
|
|
|
|
assert_eq!(parsed.argv, [b"/bin/bash".to_vec()]);
|
|
|
|
|
|
|
|
|
|
let parsed = lazaret_client::cli::parse(
|
|
|
|
|
["run", "--profile", "state"].map(OsString::from),
|
|
|
|
|
PathBuf::from("/current"),
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
let lazaret_client::cli::Command::Run(parsed) = parsed else {
|
|
|
|
|
panic!("expected run command");
|
|
|
|
|
};
|
|
|
|
|
assert_eq!(parsed.profile, "state");
|
|
|
|
|
assert_eq!(parsed.argv, [b"/bin/bash".to_vec()]);
|
|
|
|
|
|
2026-07-18 23:25:40 +09:00
|
|
|
let parsed = lazaret_client::cli::parse(
|
2026-07-16 16:28:18 +09:00
|
|
|
[
|
|
|
|
|
"agent",
|
|
|
|
|
"--workspace",
|
|
|
|
|
"/project",
|
|
|
|
|
"--config",
|
|
|
|
|
"/host/config.toml",
|
|
|
|
|
"--",
|
|
|
|
|
"tool",
|
|
|
|
|
"argument",
|
|
|
|
|
]
|
|
|
|
|
.map(OsString::from),
|
|
|
|
|
PathBuf::from("/current"),
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
2026-07-18 23:25:40 +09:00
|
|
|
let lazaret_client::cli::Command::Run(parsed) = parsed else {
|
|
|
|
|
panic!("expected run command");
|
|
|
|
|
};
|
2026-07-16 16:28:18 +09:00
|
|
|
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!(
|
2026-07-18 23:25:40 +09:00
|
|
|
lazaret_client::cli::parse(
|
|
|
|
|
["run", "--", "tool"].map(OsString::from),
|
2026-07-16 16:28:18 +09:00
|
|
|
PathBuf::from("/current")
|
|
|
|
|
)
|
|
|
|
|
.unwrap_err()
|
|
|
|
|
.to_string()
|
|
|
|
|
.contains("profile")
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-07-18 23:25:40 +09:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn command_line_preserves_non_utf8_paths_and_command_arguments() {
|
|
|
|
|
let workspace = OsString::from_vec(b"/project-\xff".to_vec());
|
|
|
|
|
let argument = OsString::from_vec(b"argument-\xfe".to_vec());
|
|
|
|
|
let parsed = lazaret_client::cli::parse(
|
|
|
|
|
[
|
|
|
|
|
OsString::from("run"),
|
|
|
|
|
OsString::from("--profile"),
|
|
|
|
|
OsString::from("agent"),
|
|
|
|
|
OsString::from("--workspace"),
|
|
|
|
|
workspace.clone(),
|
|
|
|
|
OsString::from("--"),
|
|
|
|
|
OsString::from("tool"),
|
|
|
|
|
argument,
|
|
|
|
|
],
|
|
|
|
|
PathBuf::from("/current"),
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
let lazaret_client::cli::Command::Run(parsed) = parsed else {
|
|
|
|
|
panic!("expected run command");
|
|
|
|
|
};
|
|
|
|
|
assert_eq!(parsed.workspace, PathBuf::from(workspace));
|
|
|
|
|
assert_eq!(parsed.argv, [b"tool".to_vec(), b"argument-\xfe".to_vec()]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn public_help_explains_commands_and_options() {
|
|
|
|
|
for (arguments, expected) in [
|
|
|
|
|
(
|
|
|
|
|
&["--help"][..],
|
|
|
|
|
&[
|
|
|
|
|
"Run development tools in an isolated gVisor sandbox",
|
2026-07-19 13:59:43 +09:00
|
|
|
"Usage: lazaret <PROFILE>",
|
|
|
|
|
"lazaret PROFILE",
|
2026-07-18 23:25:40 +09:00
|
|
|
"run",
|
|
|
|
|
"state",
|
|
|
|
|
][..],
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
&["run", "--help"],
|
|
|
|
|
&[
|
|
|
|
|
"Agent profile to use",
|
|
|
|
|
"Project directory to share",
|
2026-07-19 13:59:43 +09:00
|
|
|
"default `/bin/bash`",
|
2026-07-18 23:25:40 +09:00
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
&["state", "--help"],
|
|
|
|
|
&["list", "inspect", "verify", "reset"],
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
&["state", "reset", "--help"],
|
|
|
|
|
&[
|
|
|
|
|
"Reset every configured persistent sandbox path",
|
|
|
|
|
"Reset one exact configured container path",
|
|
|
|
|
"interactive confirmation",
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
] {
|
|
|
|
|
let error = lazaret_client::cli::parse(
|
|
|
|
|
arguments.iter().map(OsString::from),
|
|
|
|
|
PathBuf::from("/current"),
|
|
|
|
|
)
|
|
|
|
|
.unwrap_err();
|
|
|
|
|
assert_eq!(error.kind(), clap::error::ErrorKind::DisplayHelp);
|
|
|
|
|
let help = error.to_string();
|
|
|
|
|
for text in expected {
|
|
|
|
|
assert!(
|
|
|
|
|
help.contains(text),
|
|
|
|
|
"{arguments:?} did not explain {text:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-07-19 13:59:43 +09:00
|
|
|
fn clap_rejects_missing_profile_and_ambiguous_reset_scope() {
|
2026-07-18 23:25:40 +09:00
|
|
|
let workspace = PathBuf::from("/current");
|
|
|
|
|
for arguments in [
|
|
|
|
|
vec!["run", "--", "tool"],
|
|
|
|
|
vec!["state", "reset", "--profile", "agent"],
|
|
|
|
|
vec![
|
|
|
|
|
"state",
|
|
|
|
|
"reset",
|
|
|
|
|
"--profile",
|
|
|
|
|
"agent",
|
|
|
|
|
"--all",
|
|
|
|
|
"--path",
|
|
|
|
|
"/usr",
|
|
|
|
|
],
|
|
|
|
|
] {
|
|
|
|
|
let error = lazaret_client::cli::parse(
|
|
|
|
|
arguments.into_iter().map(OsString::from),
|
|
|
|
|
workspace.clone(),
|
|
|
|
|
)
|
|
|
|
|
.unwrap_err();
|
|
|
|
|
assert_eq!(error.exit_code(), 2);
|
|
|
|
|
}
|
|
|
|
|
}
|