Add a repository-owned rustc wrapper that enforces execution ownership and dependency boundaries during ordinary Cargo commands, with compile-pass and compile-fail policy contracts. Move scheduling, timers, provider polling, provisioning, recovery, supervision, and shutdown decisions behind engine and actor APIs. Add deterministic component properties, stateful Myelin lifecycle coverage, persisted regression cases, and the bounded CI workflow. Tighten resource ownership by cancelling telemetry collectors, terminating reply observers, bounding dashboard projections, and releasing process file descriptors, child observers, and inode-verified Unix socket paths on every exit path.
2319 lines
85 KiB
Rust
2319 lines
85 KiB
Rust
#![cfg(target_os = "linux")]
|
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::fs::OpenOptions;
|
|
use std::io::{BufRead, BufReader, Read, Write};
|
|
use std::net::{TcpListener, TcpStream};
|
|
use std::os::unix::net::UnixStream;
|
|
use std::path::PathBuf;
|
|
use std::process::{Child, Command, Stdio};
|
|
use std::sync::OnceLock;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use proptest::prelude::*;
|
|
use proptest::test_runner::FileFailurePersistence;
|
|
use serde_json::{Value, json};
|
|
use swactor_process::{
|
|
child_kill, child_try_wait, child_wait, command_spawn, find_process_identities_by_environment,
|
|
request_child_termination, terminate_process_group,
|
|
};
|
|
use swactor_vastai::test_http::{TestHttpRoute, TestHttpServer};
|
|
|
|
const CASE_DEADLINE: Duration = Duration::from_secs(10);
|
|
const SUITE_DEADLINE: Duration = Duration::from_secs(30);
|
|
const POLL: Duration = Duration::from_millis(25);
|
|
const CENSUS_STABILITY: Duration = Duration::from_millis(25);
|
|
const ACTORS_PER_NODE_LIMIT: u64 = 32;
|
|
const OFFER_COUNT: usize = 8;
|
|
|
|
static SUITE_STARTED: OnceLock<Instant> = OnceLock::new();
|
|
std::thread_local! {
|
|
static OBSERVED_HTTP_FAILURES: std::cell::RefCell<Vec<String>> =
|
|
const { std::cell::RefCell::new(Vec::new()) };
|
|
}
|
|
|
|
fn http_failure_checkpoint() -> usize {
|
|
OBSERVED_HTTP_FAILURES.with(|failures| failures.borrow().len())
|
|
}
|
|
|
|
fn discard_http_failures_since(checkpoint: usize) {
|
|
OBSERVED_HTTP_FAILURES.with(|failures| failures.borrow_mut().truncate(checkpoint));
|
|
}
|
|
|
|
fn record_http_failure(failure: String) {
|
|
OBSERVED_HTTP_FAILURES.with(|failures| failures.borrow_mut().push(failure));
|
|
}
|
|
|
|
fn observed_http_failures() -> Vec<String> {
|
|
OBSERVED_HTTP_FAILURES.with(|failures| failures.borrow().clone())
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
enum RestartMode {
|
|
Graceful,
|
|
FlushSafeAbrupt,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
enum ExternalAction {
|
|
Search {
|
|
count: usize,
|
|
},
|
|
Provision {
|
|
command_slot: u8,
|
|
use_searched_offers: bool,
|
|
},
|
|
Query,
|
|
Kill {
|
|
node_slot: u8,
|
|
command_slot: u8,
|
|
},
|
|
Flush,
|
|
Restart {
|
|
mode: RestartMode,
|
|
},
|
|
EndpointProbe {
|
|
node_slot: u8,
|
|
},
|
|
ConcurrentQueries,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct E2eCase {
|
|
seed: u64,
|
|
node_seed: u8,
|
|
kill_mask: u8,
|
|
offer_offset: usize,
|
|
actions: Vec<ExternalAction>,
|
|
}
|
|
|
|
fn e2e_case() -> impl Strategy<Value = E2eCase> {
|
|
(
|
|
any::<u64>(),
|
|
any::<u8>(),
|
|
any::<u8>(),
|
|
0_usize..OFFER_COUNT,
|
|
any::<bool>(),
|
|
any::<bool>(),
|
|
any::<u8>(),
|
|
any::<u8>(),
|
|
proptest::collection::vec(any::<u8>(), 11),
|
|
)
|
|
.prop_map(
|
|
|(
|
|
seed,
|
|
node_seed,
|
|
kill_mask,
|
|
offer_offset,
|
|
invalid_search,
|
|
use_searched_offers,
|
|
node_slot,
|
|
command_slot,
|
|
ordering,
|
|
)| {
|
|
let mut keyed_actions = vec![
|
|
ExternalAction::Search {
|
|
count: if invalid_search { 0 } else { 2 },
|
|
},
|
|
ExternalAction::Provision {
|
|
command_slot,
|
|
use_searched_offers,
|
|
},
|
|
ExternalAction::Query,
|
|
ExternalAction::Kill {
|
|
node_slot,
|
|
command_slot,
|
|
},
|
|
ExternalAction::Flush,
|
|
ExternalAction::Restart {
|
|
mode: RestartMode::Graceful,
|
|
},
|
|
ExternalAction::EndpointProbe { node_slot },
|
|
ExternalAction::ConcurrentQueries,
|
|
ExternalAction::Query,
|
|
ExternalAction::Kill {
|
|
node_slot: node_slot.wrapping_add(1),
|
|
command_slot,
|
|
},
|
|
ExternalAction::Restart {
|
|
mode: RestartMode::FlushSafeAbrupt,
|
|
},
|
|
]
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(index, action)| ((ordering[index], index), action))
|
|
.collect::<Vec<_>>();
|
|
keyed_actions.sort_by_key(|(key, _)| *key);
|
|
E2eCase {
|
|
seed,
|
|
node_seed,
|
|
kill_mask,
|
|
offer_offset,
|
|
actions: keyed_actions
|
|
.into_iter()
|
|
.map(|(_, action)| action)
|
|
.collect(),
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
fn e2e_proptest_config() -> ProptestConfig {
|
|
let has_case_override = std::env::var_os("PROPTEST_CASES").is_some();
|
|
let mut config = ProptestConfig::default();
|
|
if !has_case_override {
|
|
config.cases = 4;
|
|
}
|
|
config.failure_persistence = Some(Box::new(FileFailurePersistence::Direct(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/proptest-regressions/tests/e2e_vastai.txt"
|
|
))));
|
|
config.max_shrink_iters = 0;
|
|
config
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
struct ActorCensus {
|
|
actors: u64,
|
|
orchestrator_actors: u64,
|
|
worker_actors: u64,
|
|
poisoned: u64,
|
|
type_mismatches: u64,
|
|
mailbox_depth: u64,
|
|
orchestrator_by_type: BTreeMap<String, u64>,
|
|
workers_by_stream: BTreeMap<String, BTreeMap<String, u64>>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct OracleObservation {
|
|
expected_survivors: BTreeSet<u64>,
|
|
expected_stopped: BTreeSet<u64>,
|
|
running_after_restart: BTreeSet<u64>,
|
|
stopped_after_restart: BTreeSet<u64>,
|
|
reachable_nodes: BTreeSet<u64>,
|
|
provider_resources: Vec<u64>,
|
|
provider_created: BTreeSet<u64>,
|
|
provider_destroyed: BTreeSet<u64>,
|
|
selected_offers: BTreeMap<u64, u64>,
|
|
searched_offers: BTreeSet<u64>,
|
|
actor_before_replays: ActorCensus,
|
|
actor_after_replays: ActorCensus,
|
|
actor_initial: ActorCensus,
|
|
actor_after_teardown: ActorCensus,
|
|
terminal_record_counts: BTreeMap<String, usize>,
|
|
unexpected_process_exits: Vec<String>,
|
|
http_failures: Vec<String>,
|
|
teardown_resources: Vec<u64>,
|
|
labeled_temp_resources: Vec<PathBuf>,
|
|
}
|
|
|
|
fn validate_oracle(observation: &OracleObservation) -> Result<(), String> {
|
|
if observation
|
|
.terminal_record_counts
|
|
.values()
|
|
.any(|count| *count != 1)
|
|
{
|
|
return Err(format!(
|
|
"accepted reply did not have exactly one terminal record: {:?}",
|
|
observation.terminal_record_counts
|
|
));
|
|
}
|
|
if !observation.unexpected_process_exits.is_empty() {
|
|
return Err(format!(
|
|
"unexpected process exit: {:?}",
|
|
observation.unexpected_process_exits
|
|
));
|
|
}
|
|
if !observation.http_failures.is_empty() {
|
|
return Err(format!(
|
|
"HTTP failure or disconnect: {:?}",
|
|
observation.http_failures
|
|
));
|
|
}
|
|
if observation.running_after_restart != observation.expected_survivors {
|
|
return Err(format!(
|
|
"restart-state loss: expected running {:?}, observed {:?}",
|
|
observation.expected_survivors, observation.running_after_restart
|
|
));
|
|
}
|
|
if observation.stopped_after_restart != observation.expected_stopped {
|
|
return Err(format!(
|
|
"destroy set mismatch: expected stopped {:?}, observed {:?}",
|
|
observation.expected_stopped, observation.stopped_after_restart
|
|
));
|
|
}
|
|
if observation.reachable_nodes != observation.expected_survivors {
|
|
return Err(format!(
|
|
"survivor reachability mismatch: expected {:?}, observed {:?}",
|
|
observation.expected_survivors, observation.reachable_nodes
|
|
));
|
|
}
|
|
let provider_set = observation
|
|
.provider_resources
|
|
.iter()
|
|
.copied()
|
|
.collect::<BTreeSet<_>>();
|
|
if provider_set.len() != observation.provider_resources.len() {
|
|
return Err(format!(
|
|
"duplicate provider resources: {:?}",
|
|
observation.provider_resources
|
|
));
|
|
}
|
|
if provider_set != observation.expected_survivors {
|
|
return Err(format!(
|
|
"provider ledger mismatch: expected {:?}, observed {:?}",
|
|
observation.expected_survivors, provider_set
|
|
));
|
|
}
|
|
let expected_all = observation
|
|
.expected_survivors
|
|
.union(&observation.expected_stopped)
|
|
.copied()
|
|
.collect::<BTreeSet<_>>();
|
|
if observation.provider_created != expected_all {
|
|
return Err(format!(
|
|
"provider create ledger mismatch: expected {:?}, observed {:?}",
|
|
expected_all, observation.provider_created
|
|
));
|
|
}
|
|
if observation.provider_destroyed != observation.expected_stopped {
|
|
return Err(format!(
|
|
"provider destroy ledger mismatch: expected {:?}, observed {:?}",
|
|
observation.expected_stopped, observation.provider_destroyed
|
|
));
|
|
}
|
|
if observation.selected_offers.len()
|
|
!= observation
|
|
.expected_survivors
|
|
.len()
|
|
.saturating_add(observation.expected_stopped.len())
|
|
|| observation
|
|
.selected_offers
|
|
.values()
|
|
.any(|offer| !observation.searched_offers.contains(offer))
|
|
{
|
|
return Err(format!(
|
|
"selected offer did not come from search response: selected={:?}, searched={:?}",
|
|
observation.selected_offers, observation.searched_offers
|
|
));
|
|
}
|
|
if observation.actor_initial.poisoned != 0
|
|
|| observation.actor_before_replays.poisoned != 0
|
|
|| observation.actor_after_replays.poisoned != 0
|
|
|| observation.actor_after_teardown.poisoned != 0
|
|
{
|
|
return Err(format!(
|
|
"actor poisoning observed: initial={:?}, before={:?}, after={:?}, teardown={:?}",
|
|
observation.actor_initial,
|
|
observation.actor_before_replays,
|
|
observation.actor_after_replays,
|
|
observation.actor_after_teardown,
|
|
));
|
|
}
|
|
if observation.actor_initial.type_mismatches != 0
|
|
|| observation.actor_before_replays.type_mismatches != 0
|
|
|| observation.actor_after_replays.type_mismatches != 0
|
|
|| observation.actor_after_teardown.type_mismatches != 0
|
|
{
|
|
return Err(format!(
|
|
"actor message type mismatch observed: initial={:?}, before={:?}, after={:?}, teardown={:?}",
|
|
observation.actor_initial,
|
|
observation.actor_before_replays,
|
|
observation.actor_after_replays,
|
|
observation.actor_after_teardown,
|
|
));
|
|
}
|
|
for (phase, census) in [
|
|
("before replay", &observation.actor_before_replays),
|
|
("after replay", &observation.actor_after_replays),
|
|
] {
|
|
let typed_worker_count = census
|
|
.workers_by_stream
|
|
.values()
|
|
.flat_map(|types| types.iter())
|
|
.map(|(actor_type, count)| {
|
|
if actor_type == "<unknown>" {
|
|
Err(format!(
|
|
"{phase} worker census contains an unknown actor type"
|
|
))
|
|
} else {
|
|
Ok(*count)
|
|
}
|
|
})
|
|
.collect::<Result<Vec<_>, _>>()?
|
|
.into_iter()
|
|
.sum::<u64>();
|
|
if census.workers_by_stream.len() != observation.expected_survivors.len()
|
|
|| typed_worker_count != census.worker_actors
|
|
{
|
|
return Err(format!(
|
|
"{phase} worker census is incomplete: expected_nodes={}, census={census:?}",
|
|
observation.expected_survivors.len()
|
|
));
|
|
}
|
|
}
|
|
let mut before_types = observation
|
|
.actor_before_replays
|
|
.orchestrator_by_type
|
|
.clone();
|
|
let mut after_types = observation.actor_after_replays.orchestrator_by_type.clone();
|
|
before_types.remove("myelin::orchestration::control::ControlReplyObserver");
|
|
after_types.remove("myelin::orchestration::control::ControlReplyObserver");
|
|
if observation.actor_before_replays.actors != observation.actor_after_replays.actors
|
|
|| observation.actor_before_replays.orchestrator_actors
|
|
!= observation.actor_after_replays.orchestrator_actors
|
|
|| observation.actor_before_replays.worker_actors
|
|
!= observation.actor_after_replays.worker_actors
|
|
|| observation.actor_before_replays.mailbox_depth
|
|
!= observation.actor_after_replays.mailbox_depth
|
|
|| observation.actor_before_replays.workers_by_stream
|
|
!= observation.actor_after_replays.workers_by_stream
|
|
|| before_types != after_types
|
|
{
|
|
return Err(format!(
|
|
"steady-state actor census changed under replay: before={:?}, after={:?}",
|
|
observation.actor_before_replays, observation.actor_after_replays
|
|
));
|
|
}
|
|
let live_node_count = observation.expected_survivors.len() as u64;
|
|
if observation.actor_before_replays.worker_actors
|
|
> live_node_count.saturating_mul(ACTORS_PER_NODE_LIMIT)
|
|
{
|
|
return Err(format!(
|
|
"per-node actor bound exceeded: live_nodes={live_node_count}, census={:?}",
|
|
observation.actor_before_replays
|
|
));
|
|
}
|
|
if observation.actor_after_teardown.orchestrator_actors
|
|
!= observation.actor_initial.orchestrator_actors
|
|
|| observation.actor_after_teardown.mailbox_depth != 0
|
|
{
|
|
return Err(format!(
|
|
"actor census did not return to baseline: initial={:?}, teardown={:?}",
|
|
observation.actor_initial, observation.actor_after_teardown
|
|
));
|
|
}
|
|
if !observation.teardown_resources.is_empty() || !observation.labeled_temp_resources.is_empty()
|
|
{
|
|
return Err(format!(
|
|
"teardown leak: provider={:?}, labeled_temp={:?}",
|
|
observation.teardown_resources, observation.labeled_temp_resources
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
struct ScenarioHarness {
|
|
state_dir: PathBuf,
|
|
provider_url: String,
|
|
dashboard_port: u16,
|
|
run_id: u64,
|
|
provider_ledger_path: PathBuf,
|
|
restart: usize,
|
|
child: Option<Child>,
|
|
log_paths: Vec<PathBuf>,
|
|
unexpected_exits: Vec<String>,
|
|
}
|
|
|
|
impl ScenarioHarness {
|
|
fn new(state_dir: PathBuf, provider_url: String, dashboard_port: u16, run_id: u64) -> Self {
|
|
let provider_ledger_path = state_dir.join("mock-vastai-lifecycle.jsonl");
|
|
Self {
|
|
state_dir,
|
|
provider_url,
|
|
dashboard_port,
|
|
run_id,
|
|
provider_ledger_path,
|
|
restart: 0,
|
|
child: None,
|
|
log_paths: Vec::new(),
|
|
unexpected_exits: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn start(&mut self) -> Result<(), String> {
|
|
if self.child.is_some() {
|
|
return Err("orchestrator is already running".to_owned());
|
|
}
|
|
let log_path = self
|
|
.state_dir
|
|
.join(format!("orchestrator-{}.log", self.restart));
|
|
let stdout = OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open(&log_path)
|
|
.map_err(|error| format!("open {}: {error}", log_path.display()))?;
|
|
let stderr = stdout
|
|
.try_clone()
|
|
.map_err(|error| format!("clone {}: {error}", log_path.display()))?;
|
|
let mut command = Command::new(env!("CARGO_BIN_EXE_myelin-orchestrator"));
|
|
command
|
|
.current_dir(&self.state_dir)
|
|
.args([
|
|
"--provider",
|
|
"vastai",
|
|
"--vastai-provisioning",
|
|
"mock",
|
|
"--dashboard",
|
|
"--state-dir",
|
|
])
|
|
.arg(&self.state_dir)
|
|
.args(["--run-id", &self.run_id.to_string()])
|
|
.env("VASTAI_BASE_URL", &self.provider_url)
|
|
.env("VAST_API_KEY", "stateful-e2e-secret")
|
|
.env("MYELIN_DASHBOARD_PORT", self.dashboard_port.to_string())
|
|
.env("MYELIN_MOCK_VASTAI_LEDGER_PATH", &self.provider_ledger_path)
|
|
.env("MYELIN_VASTAI_POLL_INTERVAL_SECS", "1")
|
|
.env("MYELIN_IROH_RELAY_MODE", "disabled")
|
|
.env("RUST_BACKTRACE", "1")
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::from(stdout))
|
|
.stderr(Stdio::from(stderr));
|
|
let child = command_spawn(&mut command)
|
|
.map_err(|error| format!("spawn Cargo-built Myelin orchestrator: {error}"))?;
|
|
self.child = Some(child);
|
|
self.log_paths.push(log_path);
|
|
self.restart += 1;
|
|
Ok(())
|
|
}
|
|
fn ensure_running(&mut self) -> Result<(), String> {
|
|
let child = self
|
|
.child
|
|
.as_mut()
|
|
.ok_or_else(|| "orchestrator process is absent".to_owned())?;
|
|
if let Some(status) = child_try_wait(child)
|
|
.map_err(|error| format!("inspect live orchestrator process: {error}"))?
|
|
{
|
|
let failure = format!("orchestrator exited unexpectedly with {status}");
|
|
self.unexpected_exits.push(failure.clone());
|
|
return Err(failure);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn restart_gracefully(&mut self, deadline: Instant) -> Result<(), String> {
|
|
self.stop_child_gracefully(deadline)?;
|
|
self.start()
|
|
}
|
|
|
|
fn restart_abruptly(&mut self) -> Result<(), String> {
|
|
self.stop_child_abruptly()?;
|
|
self.start()
|
|
}
|
|
|
|
fn stop_child_gracefully(&mut self, deadline: Instant) -> Result<(), String> {
|
|
let Some(mut child) = self.child.take() else {
|
|
return Ok(());
|
|
};
|
|
if child_try_wait(&mut child)
|
|
.map_err(|error| format!("inspect orchestrator process: {error}"))?
|
|
.is_none()
|
|
{
|
|
request_child_termination(&child)
|
|
.map_err(|error| format!("request graceful orchestrator stop: {error}"))?;
|
|
}
|
|
loop {
|
|
if let Some(status) = child_try_wait(&mut child)
|
|
.map_err(|error| format!("inspect graceful orchestrator stop: {error}"))?
|
|
{
|
|
if status.success() {
|
|
return Ok(());
|
|
}
|
|
return Err(format!(
|
|
"graceful orchestrator stop exited with {status}; logs={}",
|
|
self.logs()
|
|
));
|
|
}
|
|
if Instant::now() >= deadline {
|
|
let _ = child_kill(&mut child);
|
|
let _ = child_wait(&mut child);
|
|
return Err(format!(
|
|
"graceful orchestrator stop exceeded case deadline; logs={}",
|
|
self.logs()
|
|
));
|
|
}
|
|
std::thread::sleep(POLL);
|
|
}
|
|
}
|
|
|
|
fn stop_child_abruptly(&mut self) -> Result<(), String> {
|
|
let Some(mut child) = self.child.take() else {
|
|
return Ok(());
|
|
};
|
|
if child_try_wait(&mut child)
|
|
.map_err(|error| format!("inspect orchestrator process: {error}"))?
|
|
.is_none()
|
|
{
|
|
child_kill(&mut child)
|
|
.map_err(|error| format!("kill orchestrator process: {error}"))?;
|
|
}
|
|
child_wait(&mut child)
|
|
.map_err(|error| format!("wait for orchestrator process: {error}"))?;
|
|
Ok(())
|
|
}
|
|
|
|
fn base_url(&self) -> String {
|
|
format!("http://127.0.0.1:{}", self.dashboard_port)
|
|
}
|
|
|
|
fn logs(&self) -> String {
|
|
let mut paths = self.log_paths.clone();
|
|
if let Ok(entries) = std::fs::read_dir(self.state_dir.join("process-output")) {
|
|
paths.extend(entries.filter_map(|entry| entry.ok().map(|entry| entry.path())));
|
|
}
|
|
paths.sort();
|
|
paths
|
|
.iter()
|
|
.map(|path| {
|
|
std::fs::read_to_string(path)
|
|
.map(|text| format!("\n--- {} ---\n{text}", path.display()))
|
|
.unwrap_or_else(|error| {
|
|
format!("\n--- {} unavailable: {error} ---", path.display())
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn cleanup_workers(&self) {
|
|
let environment = [("MYELIN_RUN_ID".to_owned(), self.run_id.to_string())];
|
|
let Ok(processes) = find_process_identities_by_environment(&environment, true) else {
|
|
return;
|
|
};
|
|
for process in processes {
|
|
let _ = terminate_process_group(
|
|
&process,
|
|
Duration::from_secs(2),
|
|
Duration::from_millis(20),
|
|
);
|
|
}
|
|
}
|
|
fn remove_worker_sockets(&self, nodes: &BTreeSet<u64>) -> Result<(), String> {
|
|
for node_id in nodes {
|
|
let path = node_socket(self.run_id, *node_id);
|
|
match std::fs::remove_file(&path) {
|
|
Ok(()) => {}
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
|
Err(error) => {
|
|
return Err(format!(
|
|
"remove labeled worker socket {}: {error}",
|
|
path.display()
|
|
));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Drop for ScenarioHarness {
|
|
fn drop(&mut self) {
|
|
let _ = self.stop_child_abruptly();
|
|
self.cleanup_workers();
|
|
let possible_nodes = BTreeSet::from([1, 2]);
|
|
let _ = self.remove_worker_sockets(&possible_nodes);
|
|
}
|
|
}
|
|
|
|
fn production_offers() -> Value {
|
|
let offers = (0..OFFER_COUNT)
|
|
.map(|index| {
|
|
json!({
|
|
"id": 8_675_300_u64 + index as u64,
|
|
"gpu_name": "RTX 4090",
|
|
"num_gpus": 1,
|
|
"gpu_ram": 24_576.0,
|
|
"dph_total": 0.20 + index as f64 / 100.0,
|
|
"host_id": 90_000_u64 + index as u64,
|
|
"compute_cap": 890,
|
|
"verification": "verified",
|
|
"reliability2": 0.995,
|
|
"inet_down": 1_000.0,
|
|
"inet_up": 800.0,
|
|
"internet_down_cost_per_tb": 1.5,
|
|
"internet_up_cost_per_tb": 2.5,
|
|
"geolocation": "US",
|
|
"disk_bw": 2_000.0,
|
|
"duration": 86_400.0,
|
|
"rentable": true
|
|
})
|
|
})
|
|
.collect::<Vec<_>>();
|
|
json!({"offers": offers})
|
|
}
|
|
|
|
fn reserve_port() -> Result<u16, String> {
|
|
let listener = TcpListener::bind(("127.0.0.1", 0))
|
|
.map_err(|error| format!("reserve dashboard port: {error}"))?;
|
|
listener
|
|
.local_addr()
|
|
.map(|address| address.port())
|
|
.map_err(|error| format!("read reserved dashboard address: {error}"))
|
|
}
|
|
|
|
fn check_deadlines(case_deadline: Instant, trace: &[String]) -> Result<(), String> {
|
|
let now = Instant::now();
|
|
let suite_started = *SUITE_STARTED.get_or_init(Instant::now);
|
|
if now >= case_deadline {
|
|
return Err(format!(
|
|
"{}-second E2E case deadline exceeded; trace={trace:#?}",
|
|
CASE_DEADLINE.as_secs()
|
|
));
|
|
}
|
|
if now.duration_since(suite_started) >= SUITE_DEADLINE {
|
|
return Err(format!(
|
|
"{}-second E2E suite deadline exceeded; trace={trace:#?}",
|
|
SUITE_DEADLINE.as_secs()
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn wait_for<T>(
|
|
case_deadline: Instant,
|
|
trace: &[String],
|
|
description: &str,
|
|
mut probe: impl FnMut() -> Result<Option<T>, String>,
|
|
) -> Result<T, String> {
|
|
let mut last_error = None;
|
|
loop {
|
|
check_deadlines(case_deadline, trace)?;
|
|
match probe() {
|
|
Ok(Some(value)) => return Ok(value),
|
|
Ok(None) => {}
|
|
Err(error) => last_error = Some(error),
|
|
}
|
|
std::thread::sleep(POLL);
|
|
if Instant::now() >= case_deadline {
|
|
return Err(format!(
|
|
"timed out waiting for {description}; last_error={last_error:?}; trace={trace:#?}"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn request_json(
|
|
method: &str,
|
|
url: &str,
|
|
body: Option<&Value>,
|
|
) -> Result<(u16, Option<Value>), String> {
|
|
let agent = ureq::AgentBuilder::new()
|
|
.timeout_connect(Duration::from_millis(250))
|
|
.timeout_read(Duration::from_secs(3))
|
|
.timeout_write(Duration::from_secs(1))
|
|
.build();
|
|
let request = agent.request(method, url).set("Accept", "application/json");
|
|
let result = match body {
|
|
Some(body) => request
|
|
.set("Content-Type", "application/json")
|
|
.send_string(&body.to_string()),
|
|
None => request.call(),
|
|
};
|
|
let response = match result {
|
|
Ok(response) => response,
|
|
Err(ureq::Error::Status(_, response)) => response,
|
|
Err(error) => {
|
|
let failure = format!("{method} {url} disconnected: {error}");
|
|
record_http_failure(failure.clone());
|
|
return Err(failure);
|
|
}
|
|
};
|
|
let status = response.status();
|
|
if status >= 500 {
|
|
record_http_failure(format!("{method} {url} returned HTTP {status}"));
|
|
}
|
|
let raw = response.into_string().map_err(|error| {
|
|
let failure = format!("read {method} {url} response: {error}");
|
|
record_http_failure(failure.clone());
|
|
failure
|
|
})?;
|
|
let json = if raw.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(serde_json::from_str(&raw).map_err(|error| {
|
|
let failure = format!("parse {method} {url} response {raw:?}: {error}");
|
|
record_http_failure(failure.clone());
|
|
failure
|
|
})?)
|
|
};
|
|
Ok((status, json))
|
|
}
|
|
|
|
fn get_status(base_url: &str) -> Result<Value, String> {
|
|
let url = format!("{base_url}/api/control/status");
|
|
let (status, body) = request_json("GET", &url, None)?;
|
|
if status != 200 {
|
|
return Err(format!("status endpoint returned {status}: {body:?}"));
|
|
}
|
|
body.and_then(|body| body.get("Status").cloned())
|
|
.ok_or_else(|| "status response has no Status model".to_owned())
|
|
}
|
|
|
|
fn search_offers(base_url: &str, count: usize) -> Result<Vec<u64>, String> {
|
|
let url = format!("{base_url}/api/control/offers");
|
|
let request = json!({
|
|
"gpu_model": "RTX 4090",
|
|
"min_gpu_ram_mb": 20_000,
|
|
"min_compute_cap": 800,
|
|
"min_reliability": 0.99,
|
|
"require_verified": true,
|
|
"min_download_mbps": 500.0,
|
|
"min_upload_mbps": 400.0,
|
|
"max_hourly_price": 1.0,
|
|
"blacklist_hosts": [],
|
|
"count": count
|
|
});
|
|
let (status, body) = request_json("POST", &url, Some(&request))?;
|
|
if status != 200 {
|
|
return Err(format!("offer search returned {status}: {body:?}"));
|
|
}
|
|
body.and_then(|body| body.get("Offers").cloned())
|
|
.and_then(|offers| offers.as_array().cloned())
|
|
.ok_or_else(|| "offer response has no Offers array".to_owned())?
|
|
.into_iter()
|
|
.map(|offer| {
|
|
offer
|
|
.get("offer_id")
|
|
.and_then(Value::as_u64)
|
|
.ok_or_else(|| format!("offer has no offer_id: {offer}"))
|
|
})
|
|
.collect()
|
|
}
|
|
fn assert_typed_prerequisite_rejection(base_url: &str) -> Result<String, String> {
|
|
match search_offers(base_url, 0) {
|
|
Err(error) if error.contains("409") && error.contains("error") => Ok(error),
|
|
result => Err(format!(
|
|
"prerequisite-invalid action did not produce a typed rejection: {result:?}"
|
|
)),
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct AcceptedCommand {
|
|
path: String,
|
|
submissions: Vec<Value>,
|
|
terminal: Option<Value>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
struct ExternalReplyCollector {
|
|
accepted: BTreeMap<String, AcceptedCommand>,
|
|
public_replies: Vec<String>,
|
|
http_failures: Vec<String>,
|
|
}
|
|
|
|
impl ExternalReplyCollector {
|
|
fn submit(&mut self, base_url: &str, path: &str, body: &Value) -> Result<(), String> {
|
|
let command_id = body
|
|
.get("command_id")
|
|
.and_then(Value::as_str)
|
|
.ok_or_else(|| format!("external mutation has no command_id: {body}"))?
|
|
.to_owned();
|
|
let url = format!("{base_url}{path}");
|
|
let response = request_json("POST", &url, Some(body));
|
|
let (status, response_body) = match response {
|
|
Ok(response) => response,
|
|
Err(error) => {
|
|
self.http_failures
|
|
.push(format!("POST {path} disconnected: {error}"));
|
|
return Err(error);
|
|
}
|
|
};
|
|
if status >= 500 {
|
|
self.http_failures
|
|
.push(format!("POST {path} returned {status}: {response_body:?}"));
|
|
}
|
|
if status != 202 {
|
|
return Err(format!("POST {path} returned {status}: {response_body:?}"));
|
|
}
|
|
self.public_replies
|
|
.push(format!("POST {path} command_id={command_id} status=202"));
|
|
self.accepted
|
|
.entry(command_id)
|
|
.or_insert_with(|| AcceptedCommand {
|
|
path: path.to_owned(),
|
|
submissions: Vec::new(),
|
|
terminal: None,
|
|
})
|
|
.submissions
|
|
.push(body.clone());
|
|
Ok(())
|
|
}
|
|
|
|
fn observe_status(&mut self, status: &Value) -> Result<(), String> {
|
|
let commands = status
|
|
.get("commands")
|
|
.and_then(Value::as_array)
|
|
.ok_or_else(|| format!("status has no command ledger: {status}"))?;
|
|
for (command_id, accepted) in &mut self.accepted {
|
|
let matches = commands
|
|
.iter()
|
|
.filter(|command| {
|
|
command.get("command_id").and_then(Value::as_str) == Some(command_id.as_str())
|
|
})
|
|
.collect::<Vec<_>>();
|
|
if matches.len() > 1 {
|
|
return Err(format!(
|
|
"accepted command {command_id} has {} public records: {matches:?}",
|
|
matches.len()
|
|
));
|
|
}
|
|
let Some(record) = matches.first() else {
|
|
continue;
|
|
};
|
|
if record
|
|
.get("state")
|
|
.and_then(Value::as_str)
|
|
.is_some_and(|state| matches!(state, "succeeded" | "failed"))
|
|
{
|
|
if accepted
|
|
.terminal
|
|
.as_ref()
|
|
.is_some_and(|terminal| terminal != *record)
|
|
{
|
|
return Err(format!(
|
|
"accepted command {command_id} changed terminal reply: before={:?}, after={record}",
|
|
accepted.terminal
|
|
));
|
|
}
|
|
accepted.terminal = Some((*record).clone());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn all_terminal(&self) -> bool {
|
|
self.accepted
|
|
.values()
|
|
.all(|accepted| accepted.terminal.is_some())
|
|
}
|
|
|
|
fn terminal(&self, command_id: &str) -> Option<&Value> {
|
|
self.accepted
|
|
.get(command_id)
|
|
.and_then(|accepted| accepted.terminal.as_ref())
|
|
}
|
|
}
|
|
|
|
fn flush_control(base_url: &str) -> Result<(), String> {
|
|
let url = format!("{base_url}/api/control/flush");
|
|
let (status, response) = request_json("POST", &url, None)?;
|
|
if status != 200 || response != Some(Value::String("Flushed".to_owned())) {
|
|
return Err(format!("control flush returned {status}: {response:?}"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn wait_for_accepted_replies(
|
|
harness: &ScenarioHarness,
|
|
case_deadline: Instant,
|
|
trace: &[String],
|
|
replies: &mut ExternalReplyCollector,
|
|
) -> Result<Value, String> {
|
|
wait_for(case_deadline, trace, "all accepted command replies", || {
|
|
let status = get_status(&harness.base_url())?;
|
|
replies.observe_status(&status)?;
|
|
Ok(replies.all_terminal().then_some(status))
|
|
})
|
|
}
|
|
|
|
fn concurrent_status_requests(port: u16) -> Result<(), String> {
|
|
let request =
|
|
b"GET /api/control/status HTTP/1.1\r\nHost: 127.0.0.1\r\nAccept: application/json\r\nConnection: close\r\n\r\n";
|
|
let mut streams = Vec::new();
|
|
for _ in 0..2 {
|
|
let mut stream = TcpStream::connect(("127.0.0.1", port))
|
|
.map_err(|error| format!("connect concurrent dashboard request: {error}"))?;
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_secs(1)))
|
|
.map_err(|error| format!("set concurrent dashboard read timeout: {error}"))?;
|
|
stream
|
|
.write_all(request)
|
|
.map_err(|error| format!("write concurrent dashboard request: {error}"))?;
|
|
stream
|
|
.flush()
|
|
.map_err(|error| format!("flush concurrent dashboard request: {error}"))?;
|
|
streams.push(stream);
|
|
}
|
|
for mut stream in streams {
|
|
let mut response = String::new();
|
|
stream
|
|
.read_to_string(&mut response)
|
|
.map_err(|error| format!("read concurrent dashboard response: {error}"))?;
|
|
let status = response
|
|
.lines()
|
|
.next()
|
|
.and_then(|line| line.split_whitespace().nth(1))
|
|
.and_then(|status| status.parse::<u16>().ok())
|
|
.ok_or_else(|| format!("malformed concurrent dashboard response: {response:?}"))?;
|
|
if status != 200 {
|
|
return Err(format!(
|
|
"concurrent dashboard request returned {status}: {response:?}"
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn node_sets(status: &Value) -> Result<(BTreeSet<u64>, BTreeSet<u64>), String> {
|
|
let nodes = status
|
|
.get("nodes")
|
|
.and_then(Value::as_array)
|
|
.ok_or_else(|| format!("status has no nodes: {status}"))?;
|
|
let mut running = BTreeSet::new();
|
|
let mut stopped = BTreeSet::new();
|
|
for node in nodes {
|
|
let id = node
|
|
.get("logical_node_id")
|
|
.and_then(Value::as_u64)
|
|
.ok_or_else(|| format!("node has no logical_node_id: {node}"))?;
|
|
match node.get("phase").and_then(Value::as_str) {
|
|
Some("running") => {
|
|
if node.get("runtime").is_none_or(Value::is_null) {
|
|
return Err(format!("running node {id} has no runtime readiness facts"));
|
|
}
|
|
running.insert(id);
|
|
}
|
|
Some("stopped") => {
|
|
stopped.insert(id);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
Ok((running, stopped))
|
|
}
|
|
|
|
fn selected_offer_map(status: &Value) -> Result<BTreeMap<u64, u64>, String> {
|
|
status
|
|
.get("nodes")
|
|
.and_then(Value::as_array)
|
|
.ok_or_else(|| format!("status has no nodes: {status}"))?
|
|
.iter()
|
|
.map(|node| {
|
|
let node_id = node
|
|
.get("logical_node_id")
|
|
.and_then(Value::as_u64)
|
|
.ok_or_else(|| format!("node has no logical_node_id: {node}"))?;
|
|
let offer_id = node
|
|
.get("selected_offer_id")
|
|
.and_then(Value::as_u64)
|
|
.ok_or_else(|| format!("node {node_id} has no selected_offer_id"))?;
|
|
Ok((node_id, offer_id))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
struct ProviderSnapshot {
|
|
live: Vec<u64>,
|
|
created: Vec<u64>,
|
|
destroyed: Vec<u64>,
|
|
events: Vec<Value>,
|
|
}
|
|
|
|
fn provider_ledger_snapshot(path: &PathBuf, run_id: u64) -> Result<ProviderSnapshot, String> {
|
|
let contents = match std::fs::read_to_string(path) {
|
|
Ok(contents) => contents,
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
|
|
Err(error) => {
|
|
return Err(format!(
|
|
"read independent provider ledger {}: {error}",
|
|
path.display()
|
|
));
|
|
}
|
|
};
|
|
let mut resources = BTreeMap::<String, u64>::new();
|
|
let mut snapshot = ProviderSnapshot::default();
|
|
for (index, line) in contents.lines().enumerate() {
|
|
let event: Value = serde_json::from_str(line).map_err(|error| {
|
|
format!(
|
|
"parse independent provider ledger {} line {}: {error}; line={line:?}",
|
|
path.display(),
|
|
index + 1
|
|
)
|
|
})?;
|
|
if event.get("run_id").and_then(Value::as_u64) != Some(run_id) {
|
|
return Err(format!(
|
|
"independent provider ledger contains a foreign run: {event}"
|
|
));
|
|
}
|
|
let event_type = event
|
|
.get("event")
|
|
.and_then(Value::as_str)
|
|
.ok_or_else(|| format!("provider lifecycle observation has no event: {event}"))?;
|
|
let node_id = event
|
|
.get("node_id")
|
|
.and_then(Value::as_u64)
|
|
.ok_or_else(|| format!("provider lifecycle observation has no node_id: {event}"))?;
|
|
let provider_ref = event
|
|
.get("provider_ref")
|
|
.and_then(Value::as_str)
|
|
.ok_or_else(|| {
|
|
format!("provider lifecycle observation has no provider_ref: {event}")
|
|
})?;
|
|
match event_type {
|
|
"created" => {
|
|
if event
|
|
.get("selected_offer_id")
|
|
.and_then(Value::as_u64)
|
|
.is_none_or(|offer_id| offer_id == 0)
|
|
{
|
|
return Err(format!(
|
|
"provider create did not preserve a selected offer: {event}"
|
|
));
|
|
}
|
|
snapshot.created.push(node_id);
|
|
if let Some(previous_node) = resources.insert(provider_ref.to_owned(), node_id) {
|
|
return Err(format!(
|
|
"duplicate provider resource {provider_ref}: previous node {previous_node}, event={event}"
|
|
));
|
|
}
|
|
}
|
|
"adopted" | "recreated" => match resources.get(provider_ref) {
|
|
Some(resource_node) if *resource_node == node_id => {}
|
|
_ => {
|
|
return Err(format!(
|
|
"provider recovered a resource absent from the independent ledger: {event}; live={resources:?}"
|
|
));
|
|
}
|
|
},
|
|
"destroyed" => {
|
|
snapshot.destroyed.push(node_id);
|
|
if resources.remove(provider_ref) != Some(node_id) {
|
|
return Err(format!(
|
|
"provider destroyed a resource absent from the independent ledger: {event}; live={resources:?}"
|
|
));
|
|
}
|
|
}
|
|
other => {
|
|
return Err(format!(
|
|
"unknown independent provider lifecycle event {other:?}: {event}"
|
|
));
|
|
}
|
|
}
|
|
snapshot.events.push(event);
|
|
}
|
|
snapshot.live = resources.into_values().collect();
|
|
Ok(snapshot)
|
|
}
|
|
|
|
fn node_socket(run_id: u64, node_id: u64) -> PathBuf {
|
|
std::env::temp_dir().join(format!("myelin-node-debug-join-{run_id}-{node_id}.sock"))
|
|
}
|
|
|
|
fn process_count(run_id: u64, node_id: u64) -> Result<usize, String> {
|
|
find_process_identities_by_environment(
|
|
&[
|
|
("MYELIN_RUN_ID".to_owned(), run_id.to_string()),
|
|
("MYELIN_LOGICAL_NODE_ID".to_owned(), node_id.to_string()),
|
|
],
|
|
true,
|
|
)
|
|
.map(|matches| matches.len())
|
|
.map_err(|error| format!("find worker process for node {node_id}: {error}"))
|
|
}
|
|
|
|
fn reachable_nodes(run_id: u64, all_nodes: &BTreeSet<u64>) -> Result<BTreeSet<u64>, String> {
|
|
let mut reachable = BTreeSet::new();
|
|
for &node_id in all_nodes {
|
|
let count = process_count(run_id, node_id)?;
|
|
if count > 1 {
|
|
return Err(format!("node {node_id} owns {count} worker processes"));
|
|
}
|
|
if count == 1 && UnixStream::connect(node_socket(run_id, node_id)).is_ok() {
|
|
reachable.insert(node_id);
|
|
}
|
|
}
|
|
Ok(reachable)
|
|
}
|
|
|
|
fn node_endpoint_request(run_id: u64, node_id: u64, request: &str) -> Result<Value, String> {
|
|
let mut stream = UnixStream::connect(node_socket(run_id, node_id))
|
|
.map_err(|error| format!("connect node {node_id} debug endpoint: {error}"))?;
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_secs(1)))
|
|
.map_err(|error| format!("set node {node_id} endpoint read timeout: {error}"))?;
|
|
stream
|
|
.set_write_timeout(Some(Duration::from_secs(1)))
|
|
.map_err(|error| format!("set node {node_id} endpoint write timeout: {error}"))?;
|
|
stream
|
|
.write_all(request.as_bytes())
|
|
.map_err(|error| format!("write node {node_id} endpoint request: {error}"))?;
|
|
stream
|
|
.flush()
|
|
.map_err(|error| format!("flush node {node_id} endpoint request: {error}"))?;
|
|
let mut response = String::new();
|
|
BufReader::new(stream)
|
|
.read_line(&mut response)
|
|
.map_err(|error| format!("read node {node_id} endpoint response: {error}"))?;
|
|
serde_json::from_str(&response)
|
|
.map_err(|error| format!("parse node {node_id} endpoint response: {error}"))
|
|
}
|
|
|
|
fn probe_node_endpoint(run_id: u64, node_id: u64, status: &Value) -> Result<(), String> {
|
|
let node = status
|
|
.get("nodes")
|
|
.and_then(Value::as_array)
|
|
.and_then(|nodes| {
|
|
nodes
|
|
.iter()
|
|
.find(|node| node.get("logical_node_id").and_then(Value::as_u64) == Some(node_id))
|
|
})
|
|
.ok_or_else(|| format!("status has no node {node_id}: {status}"))?;
|
|
let endpoint_json = node
|
|
.pointer("/runtime/endpoint")
|
|
.and_then(Value::as_str)
|
|
.ok_or_else(|| format!("running node {node_id} has no endpoint: {node}"))?;
|
|
let _: Value = serde_json::from_str(endpoint_json)
|
|
.map_err(|error| format!("parse node {node_id} endpoint: {error}"))?;
|
|
let response = node_endpoint_request(run_id, node_id, "{}\n")?;
|
|
if response.get("type").and_then(Value::as_str) != Some("JoinRejected")
|
|
|| response.get("error").and_then(Value::as_str) != Some("MalformedCommand")
|
|
{
|
|
return Err(format!(
|
|
"node {node_id} endpoint did not complete a typed readiness round trip: {response}"
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn actor_census(
|
|
harness: &ScenarioHarness,
|
|
worker_nodes: &BTreeSet<u64>,
|
|
) -> Result<ActorCensus, String> {
|
|
let base_url = harness.base_url();
|
|
let actor_url = format!("{base_url}/api/control/actors");
|
|
let (actor_status, actor_body) = request_json("GET", &actor_url, None)?;
|
|
if actor_status != 200 {
|
|
return Err(format!(
|
|
"live orchestrator actor census returned {actor_status}: {actor_body:?}"
|
|
));
|
|
}
|
|
let actor_body =
|
|
actor_body.ok_or_else(|| "live orchestrator actor census returned no body".to_owned())?;
|
|
let mut census = ActorCensus::default();
|
|
census.orchestrator_actors = actor_body
|
|
.get("actors")
|
|
.and_then(Value::as_array)
|
|
.map_or(0, |actors| actors.len() as u64);
|
|
census.actors = census.orchestrator_actors;
|
|
if let Some(workers) = actor_body.get("workers").and_then(Value::as_array) {
|
|
for worker in workers {
|
|
census.mailbox_depth = census.mailbox_depth.saturating_add(
|
|
worker
|
|
.get("mailbox_depth")
|
|
.and_then(Value::as_u64)
|
|
.unwrap_or(0),
|
|
);
|
|
census.poisoned = census
|
|
.poisoned
|
|
.saturating_add(worker.get("panics").and_then(Value::as_u64).unwrap_or(0));
|
|
census.type_mismatches = census.type_mismatches.saturating_add(
|
|
worker
|
|
.get("type_mismatches")
|
|
.and_then(Value::as_u64)
|
|
.unwrap_or(0),
|
|
);
|
|
}
|
|
}
|
|
|
|
let url = format!("{base_url}/api/view/fleet");
|
|
let (status, body) = request_json("GET", &url, None)?;
|
|
if status != 200 {
|
|
return Err(format!("fleet actor census returned {status}: {body:?}"));
|
|
}
|
|
let body = body.ok_or_else(|| "fleet actor census returned no body".to_owned())?;
|
|
let live = body
|
|
.get("live")
|
|
.and_then(Value::as_array)
|
|
.ok_or_else(|| format!("fleet actor census has no live array: {body}"))?;
|
|
if live.is_empty() {
|
|
return Err("fleet actor census has no live streams".to_owned());
|
|
}
|
|
for node in live {
|
|
let Some(summary) = node.get("actor_summary") else {
|
|
continue;
|
|
};
|
|
let _actors = summary.get("actors").and_then(Value::as_u64).unwrap_or(0);
|
|
let orchestrator = node
|
|
.pointer("/stream/origin")
|
|
.and_then(Value::as_str)
|
|
.is_some_and(|origin| origin == "orchestrator");
|
|
let mut by_type = BTreeMap::new();
|
|
if let Some(roster) = node.get("roster").and_then(Value::as_array) {
|
|
for actor in roster {
|
|
let actor_type = actor
|
|
.get("actor_type")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("<unknown>")
|
|
.to_owned();
|
|
*by_type.entry(actor_type).or_insert(0) += 1;
|
|
}
|
|
}
|
|
if orchestrator {
|
|
for (actor_type, count) in by_type {
|
|
*census.orchestrator_by_type.entry(actor_type).or_insert(0) += count;
|
|
}
|
|
}
|
|
}
|
|
for &node_id in worker_nodes {
|
|
let response =
|
|
node_endpoint_request(harness.run_id, node_id, "{\"type\":\"RuntimeStats\"}\n")?;
|
|
if response.get("type").and_then(Value::as_str) != Some("RuntimeStats") {
|
|
return Err(format!(
|
|
"node {node_id} rejected runtime census request: {response}"
|
|
));
|
|
}
|
|
let stats = response
|
|
.get("stats")
|
|
.ok_or_else(|| format!("node {node_id} runtime census has no stats: {response}"))?;
|
|
let details = stats
|
|
.get("actors")
|
|
.and_then(Value::as_array)
|
|
.ok_or_else(|| format!("node {node_id} runtime census has no actors: {stats}"))?;
|
|
if details.is_empty() {
|
|
return Err(format!(
|
|
"node {node_id} runtime census is not populated yet"
|
|
));
|
|
}
|
|
let actors = details.len() as u64;
|
|
census.worker_actors = census.worker_actors.saturating_add(actors);
|
|
census.actors = census.actors.saturating_add(actors);
|
|
census.poisoned = census.poisoned.saturating_add(
|
|
details
|
|
.iter()
|
|
.filter(|actor| actor.get("poisoned").and_then(Value::as_bool) == Some(true))
|
|
.count() as u64,
|
|
);
|
|
census.type_mismatches = census.type_mismatches.saturating_add(
|
|
stats
|
|
.get("workers")
|
|
.and_then(Value::as_array)
|
|
.into_iter()
|
|
.flatten()
|
|
.filter_map(|worker| worker.get("type_mismatches").and_then(Value::as_u64))
|
|
.sum::<u64>(),
|
|
);
|
|
census.mailbox_depth = census.mailbox_depth.saturating_add(
|
|
details
|
|
.iter()
|
|
.filter_map(|actor| actor.get("mailbox_depth").and_then(Value::as_u64))
|
|
.sum(),
|
|
);
|
|
let mut by_type = BTreeMap::new();
|
|
for actor in details {
|
|
let actor_type = actor
|
|
.get("actor_type")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("<unknown>")
|
|
.to_owned();
|
|
*by_type.entry(actor_type).or_insert(0) += 1;
|
|
}
|
|
census
|
|
.workers_by_stream
|
|
.insert(node_id.to_string(), by_type);
|
|
}
|
|
let typed_orchestrator_actors = census.orchestrator_by_type.values().sum::<u64>();
|
|
if typed_orchestrator_actors == 0 && !worker_nodes.is_empty() {
|
|
return Err(format!(
|
|
"live orchestrator roster is not populated yet: fleet={body}"
|
|
));
|
|
}
|
|
if typed_orchestrator_actors != 0 && typed_orchestrator_actors != census.orchestrator_actors {
|
|
return Err(format!(
|
|
"orchestrator actor roster disagrees with the live runtime: \
|
|
roster={typed_orchestrator_actors}, runtime={}; fleet={body}; runtime_stats={actor_body}",
|
|
census.orchestrator_actors
|
|
));
|
|
}
|
|
if census.orchestrator_actors == 0 {
|
|
return Err(format!(
|
|
"live orchestrator actor census has no actors: {actor_body}"
|
|
));
|
|
}
|
|
Ok(census)
|
|
}
|
|
|
|
fn wait_for_stable_actor_census(
|
|
harness: &ScenarioHarness,
|
|
case_deadline: Instant,
|
|
trace: &[String],
|
|
worker_nodes: &BTreeSet<u64>,
|
|
) -> Result<ActorCensus, String> {
|
|
let mut candidate: Option<(ActorCensus, Instant)> = None;
|
|
wait_for(case_deadline, trace, "stable actor census", || {
|
|
let census = actor_census(harness, worker_nodes)?;
|
|
if census.mailbox_depth != 0 {
|
|
candidate = None;
|
|
return Ok(None);
|
|
}
|
|
match &candidate {
|
|
Some((previous, since)) if previous == &census => {
|
|
Ok((since.elapsed() >= CENSUS_STABILITY).then_some(census))
|
|
}
|
|
_ => {
|
|
candidate = Some((census, Instant::now()));
|
|
Ok(None)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
fn wait_for_baseline_actor_census(
|
|
harness: &ScenarioHarness,
|
|
case_deadline: Instant,
|
|
trace: &[String],
|
|
baseline: &ActorCensus,
|
|
) -> Result<ActorCensus, String> {
|
|
wait_for(case_deadline, trace, "baseline actor census", || {
|
|
let census = actor_census(harness, &BTreeSet::new())?;
|
|
Ok((census.actors == baseline.actors
|
|
&& census.orchestrator_actors == baseline.orchestrator_actors
|
|
&& census.worker_actors == baseline.worker_actors
|
|
&& census.poisoned == baseline.poisoned
|
|
&& census.type_mismatches == baseline.type_mismatches
|
|
&& census.mailbox_depth == baseline.mailbox_depth)
|
|
.then_some(census))
|
|
})
|
|
}
|
|
|
|
fn wait_for_state(
|
|
harness: &ScenarioHarness,
|
|
case_deadline: Instant,
|
|
trace: &[String],
|
|
expected_running: &BTreeSet<u64>,
|
|
expected_stopped: &BTreeSet<u64>,
|
|
) -> Result<Value, String> {
|
|
wait_for(case_deadline, trace, "durable node state", || {
|
|
let status = get_status(&harness.base_url())?;
|
|
let (running, stopped) = node_sets(&status)?;
|
|
Ok((running == *expected_running && stopped == *expected_stopped).then_some(status))
|
|
})
|
|
}
|
|
|
|
fn wait_for_reachability(
|
|
harness: &ScenarioHarness,
|
|
case_deadline: Instant,
|
|
trace: &[String],
|
|
all_nodes: &BTreeSet<u64>,
|
|
expected: &BTreeSet<u64>,
|
|
) -> Result<BTreeSet<u64>, String> {
|
|
wait_for(case_deadline, trace, "worker reachability", || {
|
|
let reachable = reachable_nodes(harness.run_id, all_nodes)?;
|
|
Ok((reachable == *expected).then_some(reachable))
|
|
})
|
|
}
|
|
|
|
fn wait_for_provider_ledger(
|
|
harness: &ScenarioHarness,
|
|
case_deadline: Instant,
|
|
trace: &[String],
|
|
expected_created: &BTreeSet<u64>,
|
|
expected_destroyed: &BTreeSet<u64>,
|
|
expected_live: &BTreeSet<u64>,
|
|
) -> Result<ProviderSnapshot, String> {
|
|
wait_for(
|
|
case_deadline,
|
|
trace,
|
|
"independent mock-provider lifecycle ledger",
|
|
|| {
|
|
let snapshot = provider_ledger_snapshot(&harness.provider_ledger_path, harness.run_id)?;
|
|
let live_set = snapshot.live.iter().copied().collect::<BTreeSet<_>>();
|
|
let created_set = snapshot.created.iter().copied().collect::<BTreeSet<_>>();
|
|
let destroyed_set = snapshot.destroyed.iter().copied().collect::<BTreeSet<_>>();
|
|
Ok((created_set == *expected_created
|
|
&& snapshot.created.len() == expected_created.len()
|
|
&& destroyed_set == *expected_destroyed
|
|
&& snapshot.destroyed.len() == expected_destroyed.len()
|
|
&& live_set == *expected_live
|
|
&& snapshot.live.len() == expected_live.len())
|
|
.then_some(snapshot))
|
|
},
|
|
)
|
|
}
|
|
#[derive(Clone, Debug)]
|
|
struct LifecyclePlan {
|
|
selected_offers: Vec<u64>,
|
|
all_nodes: BTreeSet<u64>,
|
|
stopped: BTreeSet<u64>,
|
|
survivors: BTreeSet<u64>,
|
|
}
|
|
|
|
fn lifecycle_plan(case: &E2eCase, searched: &[u64]) -> Result<LifecyclePlan, String> {
|
|
let usable = searched.len().min(2);
|
|
if usable == 0 {
|
|
return Err("cannot derive lifecycle plan without a usable search result".to_owned());
|
|
}
|
|
let node_count = 1 + usize::from(case.node_seed) % usable;
|
|
let selected_offers = (0..node_count)
|
|
.map(|offset| searched[(case.offer_offset + offset) % searched.len()])
|
|
.collect::<Vec<_>>();
|
|
let all_nodes = (1..=node_count as u64).collect::<BTreeSet<_>>();
|
|
let protected_survivor = 1 + case.seed % node_count as u64;
|
|
let mut stopped = BTreeSet::new();
|
|
for node_id in &all_nodes {
|
|
if *node_id != protected_survivor
|
|
&& case.kill_mask & (1_u8 << ((*node_id as usize - 1) % 8)) != 0
|
|
{
|
|
stopped.insert(*node_id);
|
|
}
|
|
}
|
|
let survivors = all_nodes
|
|
.difference(&stopped)
|
|
.copied()
|
|
.collect::<BTreeSet<_>>();
|
|
Ok(LifecyclePlan {
|
|
selected_offers,
|
|
all_nodes,
|
|
stopped,
|
|
survivors,
|
|
})
|
|
}
|
|
|
|
fn labeled_temp_resources(harness: &ScenarioHarness, all_nodes: &BTreeSet<u64>) -> Vec<PathBuf> {
|
|
let mut leaked = all_nodes
|
|
.iter()
|
|
.map(|node_id| node_socket(harness.run_id, *node_id))
|
|
.filter(|path| path.exists())
|
|
.collect::<Vec<_>>();
|
|
let registry_path = harness.state_dir.join("process-nodes.json");
|
|
let registry_leaked = match std::fs::read_to_string(®istry_path) {
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
|
|
Ok(contents) => !matches!(
|
|
serde_json::from_str::<Value>(&contents),
|
|
Ok(Value::Object(registry)) if registry.is_empty()
|
|
),
|
|
Err(_) => true,
|
|
};
|
|
if registry_leaked {
|
|
leaked.push(registry_path);
|
|
}
|
|
leaked
|
|
}
|
|
|
|
fn setup_failure_with_diagnostics(error: String, case: &E2eCase, trace: &[String]) -> String {
|
|
format!(
|
|
"{error}; seed={}; generated_actions={:#?}; trace={trace:#?}; \
|
|
public_replies=[]; http_failures=[]; process_logs=<not launched>; \
|
|
census=<not launched>; reachability=<not launched>; provider_ledger=<not launched>",
|
|
case.seed, case.actions
|
|
)
|
|
}
|
|
|
|
fn failure_with_diagnostics(
|
|
error: String,
|
|
case: &E2eCase,
|
|
trace: &[String],
|
|
replies: &ExternalReplyCollector,
|
|
census_snapshots: &[ActorCensus],
|
|
harness: &ScenarioHarness,
|
|
provider: &TestHttpServer,
|
|
) -> String {
|
|
let status = get_status(&harness.base_url());
|
|
let node_sets = status
|
|
.as_ref()
|
|
.ok()
|
|
.and_then(|status| node_sets(status).ok());
|
|
let observed_nodes = node_sets
|
|
.as_ref()
|
|
.map(|(running, stopped)| running.union(stopped).copied().collect())
|
|
.unwrap_or_default();
|
|
let reachability = reachable_nodes(harness.run_id, &observed_nodes);
|
|
let census = node_sets
|
|
.as_ref()
|
|
.map(|(running, _)| actor_census(harness, running))
|
|
.transpose();
|
|
let provider_ledger = provider_ledger_snapshot(&harness.provider_ledger_path, harness.run_id);
|
|
let worker_processes = find_process_identities_by_environment(
|
|
&[("MYELIN_RUN_ID".to_owned(), harness.run_id.to_string())],
|
|
true,
|
|
);
|
|
let worker_sockets = observed_nodes
|
|
.iter()
|
|
.map(|node_id| (*node_id, node_socket(harness.run_id, *node_id).exists()))
|
|
.collect::<BTreeMap<_, _>>();
|
|
format!(
|
|
"{error}; seed={}; generated_actions={:#?}; trace={trace:#?}; \
|
|
public_replies={:#?}; http_failures={:#?}; observed_http_failures={:#?}; \
|
|
unexpected_process_exits={:#?}; \
|
|
status={status:#?}; census_snapshots={census_snapshots:#?}; current_census={census:#?}; \
|
|
reachability={reachability:#?}; worker_processes={worker_processes:#?}; \
|
|
worker_sockets={worker_sockets:#?}; provider_ledger={provider_ledger:#?}; \
|
|
provider_http_requests={:#?}; process_logs={}",
|
|
case.seed,
|
|
case.actions,
|
|
replies.public_replies,
|
|
replies.http_failures,
|
|
observed_http_failures(),
|
|
harness.unexpected_exits,
|
|
provider.requests(),
|
|
harness.logs(),
|
|
)
|
|
}
|
|
|
|
fn run_stateful_case(case: E2eCase) -> Result<(), String> {
|
|
let case_started = Instant::now();
|
|
let case_deadline = case_started + CASE_DEADLINE;
|
|
let mut trace = vec![format!(
|
|
"seed={} generated_actions={:#?}",
|
|
case.seed, case.actions
|
|
)];
|
|
discard_http_failures_since(0);
|
|
check_deadlines(case_deadline, &trace)
|
|
.map_err(|error| setup_failure_with_diagnostics(error, &case, &trace))?;
|
|
|
|
let provider = TestHttpServer::start(vec![TestHttpRoute::json(
|
|
"GET",
|
|
"/api/v0/bundles/",
|
|
200,
|
|
production_offers(),
|
|
)])
|
|
.map_err(|error| setup_failure_with_diagnostics(error, &case, &trace))?;
|
|
let temp = tempfile::tempdir().map_err(|error| {
|
|
setup_failure_with_diagnostics(format!("create E2E state dir: {error}"), &case, &trace)
|
|
})?;
|
|
let run_id = 7_000_000_u64 + case.seed % 1_000_000;
|
|
let dashboard_port =
|
|
reserve_port().map_err(|error| setup_failure_with_diagnostics(error, &case, &trace))?;
|
|
let mut harness = ScenarioHarness::new(
|
|
temp.path().to_path_buf(),
|
|
provider.uri(),
|
|
dashboard_port,
|
|
run_id,
|
|
);
|
|
let mut replies = ExternalReplyCollector::default();
|
|
let mut census_snapshots = Vec::new();
|
|
|
|
let execution = (|| -> Result<OracleObservation, String> {
|
|
harness.start()?;
|
|
trace.push("launch direct Cargo-built Myelin orchestrator".to_owned());
|
|
let readiness_http_checkpoint = http_failure_checkpoint();
|
|
wait_for(case_deadline, &trace, "dashboard readiness", || {
|
|
get_status(&harness.base_url()).map(Some)
|
|
})?;
|
|
discard_http_failures_since(readiness_http_checkpoint);
|
|
harness.ensure_running()?;
|
|
let mut generated_restart_mode = None;
|
|
concurrent_status_requests(harness.dashboard_port)?;
|
|
trace.push("two concurrent independent status requests completed".to_owned());
|
|
let actor_initial =
|
|
wait_for_stable_actor_census(&harness, case_deadline, &trace, &BTreeSet::new())?;
|
|
census_snapshots.push(actor_initial.clone());
|
|
|
|
let mut searched = Vec::new();
|
|
let mut generated_restarts = 0_usize;
|
|
let mut typed_rejections = 0_usize;
|
|
for (action_index, action) in case.actions.clone().into_iter().enumerate() {
|
|
check_deadlines(case_deadline, &trace)?;
|
|
harness.ensure_running()?;
|
|
trace.push(format!("generated action {action_index}: {action:?}"));
|
|
match action {
|
|
ExternalAction::Search { count } => match search_offers(&harness.base_url(), count)
|
|
{
|
|
Ok(offers) if count > 0 => {
|
|
searched = offers;
|
|
trace.push(format!("search returned usable offers {searched:?}"));
|
|
}
|
|
Ok(offers) => {
|
|
return Err(format!(
|
|
"prerequisite-invalid search unexpectedly succeeded: {offers:?}"
|
|
));
|
|
}
|
|
Err(error)
|
|
if count == 0 && error.contains("409") && error.contains("error") =>
|
|
{
|
|
typed_rejections += 1;
|
|
trace.push(format!("typed invalid-search rejection: {error}"));
|
|
}
|
|
Err(error) => {
|
|
replies
|
|
.http_failures
|
|
.push(format!("offer search failed: {error}"));
|
|
return Err(error);
|
|
}
|
|
},
|
|
ExternalAction::Provision {
|
|
command_slot,
|
|
use_searched_offers,
|
|
} => {
|
|
let Some(plan) = use_searched_offers
|
|
.then(|| lifecycle_plan(&case, &searched).ok())
|
|
.flatten()
|
|
else {
|
|
let rejection = assert_typed_prerequisite_rejection(&harness.base_url())?;
|
|
typed_rejections += 1;
|
|
trace.push(format!(
|
|
"provision rejected before usable search selection: {rejection}"
|
|
));
|
|
continue;
|
|
};
|
|
let request = json!({
|
|
"command_id": format!("generated-provision-{command_slot}"),
|
|
"count": plan.selected_offers.len(),
|
|
"selected_offer_ids": plan.selected_offers,
|
|
});
|
|
replies.submit(&harness.base_url(), "/api/control/provision", &request)?;
|
|
}
|
|
ExternalAction::Query => {
|
|
let status = get_status(&harness.base_url()).map_err(|error| {
|
|
replies
|
|
.http_failures
|
|
.push(format!("status query failed: {error}"));
|
|
error
|
|
})?;
|
|
replies.observe_status(&status)?;
|
|
}
|
|
ExternalAction::Kill {
|
|
node_slot,
|
|
command_slot,
|
|
} => {
|
|
let Some(plan) = lifecycle_plan(&case, &searched).ok() else {
|
|
let rejection = assert_typed_prerequisite_rejection(&harness.base_url())?;
|
|
typed_rejections += 1;
|
|
trace.push(format!(
|
|
"kill rejected before usable search selection: {rejection}"
|
|
));
|
|
continue;
|
|
};
|
|
let Some(target) = (!plan.stopped.is_empty()).then(|| {
|
|
let index = usize::from(node_slot) % plan.stopped.len();
|
|
*plan.stopped.iter().nth(index).expect("bounded kill index")
|
|
}) else {
|
|
let rejection = assert_typed_prerequisite_rejection(&harness.base_url())?;
|
|
typed_rejections += 1;
|
|
trace.push(format!(
|
|
"kill rejected because its generated subset is empty: {rejection}"
|
|
));
|
|
continue;
|
|
};
|
|
let status = get_status(&harness.base_url())?;
|
|
replies.observe_status(&status)?;
|
|
let (running, stopped) = node_sets(&status)?;
|
|
if !running.contains(&target) && !stopped.contains(&target) {
|
|
let rejection = assert_typed_prerequisite_rejection(&harness.base_url())?;
|
|
typed_rejections += 1;
|
|
trace.push(format!(
|
|
"kill of node {target} rejected before provision: {rejection}"
|
|
));
|
|
continue;
|
|
}
|
|
let request = json!({
|
|
"command_id": format!("generated-kill-{command_slot}"),
|
|
"logical_node_id": target,
|
|
});
|
|
replies.submit(&harness.base_url(), "/api/control/kill", &request)?;
|
|
}
|
|
ExternalAction::Flush => {
|
|
flush_control(&harness.base_url()).map_err(|error| {
|
|
replies
|
|
.http_failures
|
|
.push(format!("control flush failed: {error}"));
|
|
error
|
|
})?;
|
|
replies
|
|
.public_replies
|
|
.push("POST /api/control/flush reply=Flushed".to_owned());
|
|
}
|
|
ExternalAction::Restart { mode } => {
|
|
if replies.accepted.is_empty() {
|
|
trace.push(format!(
|
|
"restart {mode:?} rejected by external model before any acknowledged mutation"
|
|
));
|
|
continue;
|
|
}
|
|
if generated_restarts == 1 {
|
|
trace.push(format!(
|
|
"restart {mode:?} rejected by the bounded external model after one generated restart"
|
|
));
|
|
continue;
|
|
}
|
|
flush_control(&harness.base_url())?;
|
|
replies.public_replies.push(format!(
|
|
"POST /api/control/flush reply=Flushed before {mode:?}"
|
|
));
|
|
match mode {
|
|
RestartMode::Graceful => {
|
|
harness.restart_gracefully(case_deadline)?;
|
|
}
|
|
RestartMode::FlushSafeAbrupt => {
|
|
harness.restart_abruptly()?;
|
|
}
|
|
}
|
|
generated_restarts += 1;
|
|
generated_restart_mode = Some(mode);
|
|
let readiness_http_checkpoint = http_failure_checkpoint();
|
|
wait_for(
|
|
case_deadline,
|
|
&trace,
|
|
"dashboard after generated restart",
|
|
|| get_status(&harness.base_url()).map(Some),
|
|
)?;
|
|
discard_http_failures_since(readiness_http_checkpoint);
|
|
trace.push(format!(
|
|
"completed generated {mode:?} restart between acknowledged operations"
|
|
));
|
|
}
|
|
ExternalAction::EndpointProbe { node_slot } => {
|
|
let status = get_status(&harness.base_url())?;
|
|
replies.observe_status(&status)?;
|
|
let (running, _) = node_sets(&status)?;
|
|
if !running.is_empty() {
|
|
let index = usize::from(node_slot) % running.len();
|
|
let node_id = *running.iter().nth(index).expect("bounded probe index");
|
|
probe_node_endpoint(run_id, node_id, &status)?;
|
|
typed_rejections += 1;
|
|
trace.push(format!(
|
|
"node {node_id} returned typed MalformedCommand rejection"
|
|
));
|
|
} else {
|
|
trace.push(
|
|
"endpoint probe rejected by external model before provision".to_owned(),
|
|
);
|
|
}
|
|
}
|
|
ExternalAction::ConcurrentQueries => {
|
|
concurrent_status_requests(harness.dashboard_port).map_err(|error| {
|
|
replies
|
|
.http_failures
|
|
.push(format!("concurrent status requests failed: {error}"));
|
|
error
|
|
})?;
|
|
}
|
|
}
|
|
}
|
|
|
|
if searched.is_empty() {
|
|
searched = search_offers(&harness.base_url(), 2)?;
|
|
trace.push(format!(
|
|
"convergence search returned usable offers {searched:?}"
|
|
));
|
|
}
|
|
let plan = lifecycle_plan(&case, &searched)?;
|
|
if plan.selected_offers.len() > 2 || plan.survivors.is_empty() {
|
|
return Err(format!("invalid bounded lifecycle plan: {plan:?}"));
|
|
}
|
|
|
|
let mut status = if replies.accepted.is_empty() {
|
|
get_status(&harness.base_url())?
|
|
} else {
|
|
wait_for_accepted_replies(&harness, case_deadline, &trace, &mut replies)?
|
|
};
|
|
for (command_id, accepted) in &replies.accepted {
|
|
if let Some(terminal) = &accepted.terminal
|
|
&& terminal.get("state").and_then(Value::as_str) == Some("failed")
|
|
{
|
|
if terminal.get("error").and_then(Value::as_str).is_none() {
|
|
return Err(format!(
|
|
"failed command {command_id} has no typed rejection: {terminal}"
|
|
));
|
|
}
|
|
typed_rejections += 1;
|
|
}
|
|
}
|
|
|
|
let (running, stopped) = node_sets(&status)?;
|
|
let observed = running.union(&stopped).copied().collect::<BTreeSet<_>>();
|
|
if observed.is_empty() {
|
|
let request = json!({
|
|
"command_id": format!("converge-provision-{}", case.seed),
|
|
"count": plan.selected_offers.len(),
|
|
"selected_offer_ids": plan.selected_offers,
|
|
});
|
|
replies.submit(&harness.base_url(), "/api/control/provision", &request)?;
|
|
wait_for_accepted_replies(&harness, case_deadline, &trace, &mut replies)?;
|
|
let terminal = replies
|
|
.terminal(request["command_id"].as_str().expect("command id"))
|
|
.expect("accepted command has terminal reply");
|
|
if terminal.get("state").and_then(Value::as_str) != Some("succeeded") {
|
|
return Err(format!("convergence provision failed: {terminal}"));
|
|
}
|
|
} else if observed != plan.all_nodes {
|
|
return Err(format!(
|
|
"accepted replies produced an unexpected node set: expected {:?}, observed {observed:?}, status={status}",
|
|
plan.all_nodes
|
|
));
|
|
}
|
|
|
|
status = wait_for(
|
|
case_deadline,
|
|
&trace,
|
|
"provisioned state from accepted replies",
|
|
|| {
|
|
let status = get_status(&harness.base_url())?;
|
|
let (running, stopped) = node_sets(&status)?;
|
|
let observed = running.union(&stopped).copied().collect::<BTreeSet<_>>();
|
|
Ok((observed == plan.all_nodes).then_some(status))
|
|
},
|
|
)?;
|
|
let (_, already_stopped) = node_sets(&status)?;
|
|
if !already_stopped.is_subset(&plan.stopped) {
|
|
return Err(format!(
|
|
"generated successful kills exceeded the survivor-preserving subset: desired={:?}, observed={already_stopped:?}",
|
|
plan.stopped
|
|
));
|
|
}
|
|
for node_id in plan.stopped.difference(&already_stopped) {
|
|
let request = json!({
|
|
"command_id": format!("converge-kill-{}-{node_id}", case.seed),
|
|
"logical_node_id": node_id,
|
|
});
|
|
replies.submit(&harness.base_url(), "/api/control/kill", &request)?;
|
|
}
|
|
wait_for_accepted_replies(&harness, case_deadline, &trace, &mut replies)?;
|
|
for command_id in replies
|
|
.accepted
|
|
.keys()
|
|
.filter(|command_id| command_id.starts_with("converge-kill-"))
|
|
{
|
|
let terminal = replies
|
|
.terminal(command_id)
|
|
.expect("accepted kill has terminal reply");
|
|
if terminal.get("state").and_then(Value::as_str) != Some("succeeded") {
|
|
return Err(format!("convergence kill failed: {terminal}"));
|
|
}
|
|
}
|
|
|
|
status = wait_for_state(
|
|
&harness,
|
|
case_deadline,
|
|
&trace,
|
|
&plan.survivors,
|
|
&plan.stopped,
|
|
)?;
|
|
wait_for_reachability(
|
|
&harness,
|
|
case_deadline,
|
|
&trace,
|
|
&plan.all_nodes,
|
|
&plan.survivors,
|
|
)?;
|
|
wait_for_provider_ledger(
|
|
&harness,
|
|
case_deadline,
|
|
&trace,
|
|
&plan.all_nodes,
|
|
&plan.stopped,
|
|
&plan.survivors,
|
|
)?;
|
|
let selected_offer_by_node = selected_offer_map(&status)?;
|
|
let expected_offer_by_node = plan
|
|
.selected_offers
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, offer_id)| (index as u64 + 1, *offer_id))
|
|
.collect::<BTreeMap<_, _>>();
|
|
if selected_offer_by_node != expected_offer_by_node {
|
|
return Err(format!(
|
|
"public selected offers differ from searched selection: expected={expected_offer_by_node:?}, observed={selected_offer_by_node:?}"
|
|
));
|
|
}
|
|
|
|
if typed_rejections == 0 {
|
|
match search_offers(&harness.base_url(), 0) {
|
|
Err(error) if error.contains("409") && error.contains("error") => {
|
|
typed_rejections += 1;
|
|
trace.push(format!("typed convergence rejection: {error}"));
|
|
}
|
|
result => {
|
|
return Err(format!(
|
|
"could not establish a typed prerequisite rejection: {result:?}"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
if typed_rejections == 0 {
|
|
return Err("no typed rejection was observed".to_owned());
|
|
}
|
|
|
|
let convergence_restarts = match generated_restart_mode {
|
|
Some(RestartMode::Graceful) => vec![RestartMode::FlushSafeAbrupt],
|
|
Some(RestartMode::FlushSafeAbrupt) => vec![RestartMode::Graceful],
|
|
None if case.seed & 1 == 0 => {
|
|
vec![RestartMode::Graceful, RestartMode::FlushSafeAbrupt]
|
|
}
|
|
None => vec![RestartMode::FlushSafeAbrupt, RestartMode::Graceful],
|
|
};
|
|
for mode in convergence_restarts {
|
|
flush_control(&harness.base_url())?;
|
|
replies.public_replies.push(format!(
|
|
"POST /api/control/flush reply=Flushed before convergence {mode:?}"
|
|
));
|
|
match mode {
|
|
RestartMode::Graceful => harness.restart_gracefully(case_deadline)?,
|
|
RestartMode::FlushSafeAbrupt => harness.restart_abruptly()?,
|
|
}
|
|
let readiness_http_checkpoint = http_failure_checkpoint();
|
|
wait_for(
|
|
case_deadline,
|
|
&trace,
|
|
"dashboard after convergence restart",
|
|
|| get_status(&harness.base_url()).map(Some),
|
|
)?;
|
|
discard_http_failures_since(readiness_http_checkpoint);
|
|
trace.push(format!("convergence tail completed {mode:?} restart"));
|
|
}
|
|
status = wait_for_state(
|
|
&harness,
|
|
case_deadline,
|
|
&trace,
|
|
&plan.survivors,
|
|
&plan.stopped,
|
|
)?;
|
|
wait_for_reachability(
|
|
&harness,
|
|
case_deadline,
|
|
&trace,
|
|
&plan.all_nodes,
|
|
&plan.survivors,
|
|
)?;
|
|
wait_for_provider_ledger(
|
|
&harness,
|
|
case_deadline,
|
|
&trace,
|
|
&plan.all_nodes,
|
|
&plan.stopped,
|
|
&plan.survivors,
|
|
)?;
|
|
for node_id in &plan.survivors {
|
|
probe_node_endpoint(run_id, *node_id, &status)?;
|
|
}
|
|
|
|
let actor_before_replays =
|
|
wait_for_stable_actor_census(&harness, case_deadline, &trace, &plan.survivors)?;
|
|
census_snapshots.push(actor_before_replays.clone());
|
|
let replay_requests = replies
|
|
.accepted
|
|
.values()
|
|
.filter_map(|accepted| {
|
|
accepted
|
|
.submissions
|
|
.first()
|
|
.cloned()
|
|
.map(|request| (accepted.path.clone(), request))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
for (path, request) in replay_requests {
|
|
replies.submit(&harness.base_url(), &path, &request)?;
|
|
}
|
|
for _ in 0..2 {
|
|
let read = get_status(&harness.base_url())?;
|
|
replies.observe_status(&read)?;
|
|
}
|
|
concurrent_status_requests(harness.dashboard_port)?;
|
|
status = wait_for_accepted_replies(&harness, case_deadline, &trace, &mut replies)?;
|
|
let actor_after_replays =
|
|
wait_for_stable_actor_census(&harness, case_deadline, &trace, &plan.survivors)?;
|
|
census_snapshots.push(actor_after_replays.clone());
|
|
let (running_after_restart, stopped_after_restart) = node_sets(&status)?;
|
|
let reachable = reachable_nodes(run_id, &plan.all_nodes)?;
|
|
let provider_snapshot =
|
|
provider_ledger_snapshot(&harness.provider_ledger_path, harness.run_id)?;
|
|
|
|
for node_id in &plan.survivors {
|
|
let request = json!({
|
|
"command_id": format!("teardown-kill-{}-{node_id}", case.seed),
|
|
"logical_node_id": node_id,
|
|
});
|
|
replies.submit(&harness.base_url(), "/api/control/kill", &request)?;
|
|
replies.submit(&harness.base_url(), "/api/control/kill", &request)?;
|
|
}
|
|
wait_for_accepted_replies(&harness, case_deadline, &trace, &mut replies)?;
|
|
wait_for_state(
|
|
&harness,
|
|
case_deadline,
|
|
&trace,
|
|
&BTreeSet::new(),
|
|
&plan.all_nodes,
|
|
)?;
|
|
wait_for_reachability(
|
|
&harness,
|
|
case_deadline,
|
|
&trace,
|
|
&plan.all_nodes,
|
|
&BTreeSet::new(),
|
|
)?;
|
|
let final_provider = wait_for_provider_ledger(
|
|
&harness,
|
|
case_deadline,
|
|
&trace,
|
|
&plan.all_nodes,
|
|
&plan.all_nodes,
|
|
&BTreeSet::new(),
|
|
)?;
|
|
let actor_after_teardown =
|
|
wait_for_baseline_actor_census(&harness, case_deadline, &trace, &actor_initial)?;
|
|
census_snapshots.push(actor_after_teardown.clone());
|
|
let final_status = get_status(&harness.base_url())?;
|
|
replies.observe_status(&final_status)?;
|
|
if !replies.all_terminal() {
|
|
return Err(format!(
|
|
"accepted replies remained nonterminal: {:?}",
|
|
replies.accepted
|
|
));
|
|
}
|
|
let commands = final_status
|
|
.get("commands")
|
|
.and_then(Value::as_array)
|
|
.ok_or_else(|| format!("final status has no command ledger: {final_status}"))?;
|
|
let terminal_record_counts = replies
|
|
.accepted
|
|
.keys()
|
|
.map(|command_id| {
|
|
let count = commands
|
|
.iter()
|
|
.filter(|command| {
|
|
command.get("command_id").and_then(Value::as_str)
|
|
== Some(command_id.as_str())
|
|
&& command
|
|
.get("state")
|
|
.and_then(Value::as_str)
|
|
.is_some_and(|state| matches!(state, "succeeded" | "failed"))
|
|
})
|
|
.count();
|
|
(command_id.clone(), count)
|
|
})
|
|
.collect::<BTreeMap<_, _>>();
|
|
|
|
flush_control(&harness.base_url())?;
|
|
harness.stop_child_gracefully(case_deadline)?;
|
|
wait_for(
|
|
case_deadline,
|
|
&trace,
|
|
"all worker processes stopped",
|
|
|| {
|
|
let live = plan
|
|
.all_nodes
|
|
.iter()
|
|
.map(|node_id| process_count(run_id, *node_id))
|
|
.collect::<Result<Vec<_>, _>>()?
|
|
.into_iter()
|
|
.sum::<usize>();
|
|
Ok((live == 0).then_some(()))
|
|
},
|
|
)?;
|
|
let labeled_temp_resources = labeled_temp_resources(&harness, &plan.all_nodes);
|
|
let mut http_failures = observed_http_failures();
|
|
http_failures.extend(replies.http_failures.clone());
|
|
|
|
Ok(OracleObservation {
|
|
expected_survivors: plan.survivors,
|
|
expected_stopped: plan.stopped,
|
|
running_after_restart,
|
|
stopped_after_restart,
|
|
reachable_nodes: reachable,
|
|
provider_resources: provider_snapshot.live,
|
|
provider_created: provider_snapshot.created.into_iter().collect(),
|
|
provider_destroyed: provider_snapshot.destroyed.into_iter().collect(),
|
|
selected_offers: selected_offer_by_node,
|
|
searched_offers: searched.into_iter().collect(),
|
|
actor_before_replays,
|
|
actor_after_replays,
|
|
actor_initial,
|
|
actor_after_teardown,
|
|
terminal_record_counts,
|
|
unexpected_process_exits: harness.unexpected_exits.clone(),
|
|
http_failures,
|
|
teardown_resources: final_provider.live,
|
|
labeled_temp_resources,
|
|
})
|
|
})();
|
|
|
|
let observation = execution.map_err(|error| {
|
|
failure_with_diagnostics(
|
|
error,
|
|
&case,
|
|
&trace,
|
|
&replies,
|
|
&census_snapshots,
|
|
&harness,
|
|
&provider,
|
|
)
|
|
})?;
|
|
validate_oracle(&observation).map_err(|error| {
|
|
failure_with_diagnostics(
|
|
format!("{error}; observation={observation:#?}"),
|
|
&case,
|
|
&trace,
|
|
&replies,
|
|
&census_snapshots,
|
|
&harness,
|
|
&provider,
|
|
)
|
|
})?;
|
|
if !provider
|
|
.requests()
|
|
.iter()
|
|
.any(|request| request.method == "GET" && request.path == "/api/v0/bundles/")
|
|
{
|
|
return Err(failure_with_diagnostics(
|
|
"provider search boundary was never exercised".to_owned(),
|
|
&case,
|
|
&trace,
|
|
&replies,
|
|
&census_snapshots,
|
|
&harness,
|
|
&provider,
|
|
));
|
|
}
|
|
|
|
let teardown_logs = harness.logs();
|
|
let teardown_ledger = provider_ledger_snapshot(&harness.provider_ledger_path, harness.run_id);
|
|
let provider_requests = provider.requests();
|
|
drop(harness);
|
|
drop(provider);
|
|
temp.close().map_err(|error| {
|
|
format!(
|
|
"remove labeled E2E state directory: {error}; seed={}; actions={:#?}; \
|
|
replies={:#?}; observation={observation:#?}; ledger={teardown_ledger:#?}; \
|
|
provider_requests={provider_requests:#?}; logs={teardown_logs}",
|
|
case.seed, case.actions, replies.public_replies
|
|
)
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
proptest! {
|
|
#![proptest_config(e2e_proptest_config())]
|
|
|
|
#[test]
|
|
#[ignore = "nightly process E2E; run with --ignored"]
|
|
fn stateful_vastai_dashboard_control_survives_restarts(case in e2e_case()) {
|
|
if let Err(error) = run_stateful_case(case) {
|
|
prop_assert!(false, "{error}");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn e2e_oracle_rejects_controlled_lifecycle_faults() {
|
|
let baseline = OracleObservation {
|
|
expected_survivors: BTreeSet::from([2]),
|
|
expected_stopped: BTreeSet::from([1]),
|
|
running_after_restart: BTreeSet::from([2]),
|
|
stopped_after_restart: BTreeSet::from([1]),
|
|
reachable_nodes: BTreeSet::from([2]),
|
|
provider_resources: vec![2],
|
|
provider_created: BTreeSet::from([1, 2]),
|
|
provider_destroyed: BTreeSet::from([1]),
|
|
selected_offers: BTreeMap::from([(1, 101), (2, 102)]),
|
|
searched_offers: BTreeSet::from([101, 102]),
|
|
actor_before_replays: ActorCensus {
|
|
actors: 10,
|
|
orchestrator_actors: 5,
|
|
worker_actors: 5,
|
|
orchestrator_by_type: BTreeMap::from([("orchestrator".to_owned(), 5)]),
|
|
workers_by_stream: BTreeMap::from([(
|
|
"worker-2".to_owned(),
|
|
BTreeMap::from([("worker".to_owned(), 5)]),
|
|
)]),
|
|
..ActorCensus::default()
|
|
},
|
|
actor_after_replays: ActorCensus {
|
|
actors: 10,
|
|
orchestrator_actors: 5,
|
|
worker_actors: 5,
|
|
orchestrator_by_type: BTreeMap::from([("orchestrator".to_owned(), 5)]),
|
|
workers_by_stream: BTreeMap::from([(
|
|
"worker-2".to_owned(),
|
|
BTreeMap::from([("worker".to_owned(), 5)]),
|
|
)]),
|
|
..ActorCensus::default()
|
|
},
|
|
actor_initial: ActorCensus {
|
|
actors: 5,
|
|
orchestrator_actors: 5,
|
|
orchestrator_by_type: BTreeMap::from([("orchestrator".to_owned(), 5)]),
|
|
..ActorCensus::default()
|
|
},
|
|
actor_after_teardown: ActorCensus {
|
|
actors: 5,
|
|
orchestrator_actors: 5,
|
|
orchestrator_by_type: BTreeMap::from([("orchestrator".to_owned(), 5)]),
|
|
..ActorCensus::default()
|
|
},
|
|
terminal_record_counts: BTreeMap::from([("command-1".to_owned(), 1)]),
|
|
unexpected_process_exits: Vec::new(),
|
|
http_failures: Vec::new(),
|
|
teardown_resources: Vec::new(),
|
|
labeled_temp_resources: Vec::new(),
|
|
};
|
|
validate_oracle(&baseline).expect("baseline oracle observation");
|
|
|
|
let mut survivor_destroy = baseline.clone();
|
|
survivor_destroy.provider_resources.clear();
|
|
survivor_destroy.provider_destroyed.insert(2);
|
|
assert!(
|
|
validate_oracle(&survivor_destroy)
|
|
.unwrap_err()
|
|
.contains("provider ledger mismatch")
|
|
);
|
|
|
|
let mut restart_loss = baseline.clone();
|
|
restart_loss.running_after_restart.clear();
|
|
assert!(
|
|
validate_oracle(&restart_loss)
|
|
.unwrap_err()
|
|
.contains("restart-state loss")
|
|
);
|
|
let mut duplicate_provider = baseline.clone();
|
|
duplicate_provider.provider_resources.push(2);
|
|
assert!(
|
|
validate_oracle(&duplicate_provider)
|
|
.unwrap_err()
|
|
.contains("duplicate provider resources")
|
|
);
|
|
|
|
let mut actor_poison = baseline.clone();
|
|
actor_poison.actor_after_replays.poisoned = 1;
|
|
assert!(
|
|
validate_oracle(&actor_poison)
|
|
.unwrap_err()
|
|
.contains("actor poisoning")
|
|
);
|
|
let mut unexpected_exit = baseline.clone();
|
|
unexpected_exit
|
|
.unexpected_process_exits
|
|
.push("exit status 1".to_owned());
|
|
assert!(
|
|
validate_oracle(&unexpected_exit)
|
|
.unwrap_err()
|
|
.contains("unexpected process exit")
|
|
);
|
|
|
|
let mut http_failure = baseline.clone();
|
|
http_failure
|
|
.http_failures
|
|
.push("HTTP 503 or disconnect".to_owned());
|
|
assert!(
|
|
validate_oracle(&http_failure)
|
|
.unwrap_err()
|
|
.contains("HTTP failure or disconnect")
|
|
);
|
|
|
|
let mut duplicate_terminal = baseline.clone();
|
|
duplicate_terminal
|
|
.terminal_record_counts
|
|
.insert("command-1".to_owned(), 2);
|
|
assert!(
|
|
validate_oracle(&duplicate_terminal)
|
|
.unwrap_err()
|
|
.contains("exactly one terminal record")
|
|
);
|
|
|
|
let mut actor_leak = baseline.clone();
|
|
actor_leak.actor_after_replays.actors = 13;
|
|
assert!(
|
|
validate_oracle(&actor_leak)
|
|
.unwrap_err()
|
|
.contains("steady-state actor census")
|
|
);
|
|
let mut teardown_leak = baseline;
|
|
teardown_leak.teardown_resources.push(2);
|
|
teardown_leak
|
|
.labeled_temp_resources
|
|
.push(PathBuf::from("process-nodes.json"));
|
|
assert!(
|
|
validate_oracle(&teardown_leak)
|
|
.unwrap_err()
|
|
.contains("teardown leak")
|
|
);
|
|
}
|