2320 lines
85 KiB
Rust
2320 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")
|
||
|
|
);
|
||
|
|
}
|