//! The provisioning test kit: a reusable conformance suite for the
//! reconciler crate and any `ProvisionPlugin` implementation.
//!
//! Style: stateful property-based testing (an Erlang-QuickCheck-style
//! command-sequence test without the framework). A trace is an explicit
//! `Vec`; the harness folds it into a real `ClusterDriver` plus a
//! real `IdempotentEffectExecutor` over a backend, settling the machine
//! to quiescence after every input and checking guarantees. The oracle
//! is the invariant checker only — there is no reference model.
//! Failures panic with the seed and a shrunk minimal trace; paste that
//! trace into a plain `#[test]` to pin a regression.
//!
//! Conformance levels (one battery, three backends):
//! - `FakeBackend`: the reference in-memory substrate (fastest).
//! - `PluginBackendAdapter` over an in-memory `TestablePlugin`.
//! - `PluginBackendAdapter` over a real process-spawning plugin.
//!
//! Guarantee index (each item names its enforcement site):
//! - identity: attempts unique, ordered, never reused (oracle)
//! - correlation: pending ops belong to the current attempt (oracle)
//! - dead nodes hold nothing: Destroyed => no lease/session/pending (oracle)
//! - deleting nodes are not ready (oracle)
//! - session coherence: active session => bootstrap facts (oracle)
//! - readiness implies full facts and desired match (oracle)
//! - attempt-fact ownership: lease/session facts never leak across
//! attempts (oracle; fixture identities are attempt-encoded)
//! - resource conservation: a converged machine leaks nothing
//! (`HarnessedBackend::leaked`, checked at convergence)
//! - quiescence: a forced pass on a quiescent machine is a no-op, a
//! converged machine requeues nothing, and results are drained
//! (harness, after every settle)
//! - monotonic generation: observed_generation never regresses (harness)
//! - replay determinism: identical traces yield identical state (test)
//! - fair convergence: fault-free tails converge in bounded rounds and
//! bounded replacement attempts (harness fair tail)
//! - latest-desired-wins: a late shape change converges to it (test)
//! - run-order confluence: FIFO vs LIFO work execution converge to the
//! same state (test)
//! - deadline boundary: operations expire exactly at their deadline (test)
//! - clock extremes: saturated arithmetic never panics (test)
//! - allocator exhaustion: reported as an error, not a spin (test)
//!
//! Note on backend calls for retired attempts: an effect dispatched
//! before expiry may legitimately complete at the provider after its
//! attempt is retired (real providers have latency). The executor's
//! identity/adoption contract plus the driver's stale-result rejection
//! make that safe; what must never happen — old-attempt facts surviving
//! into a live attempt — is the attempt-fact-ownership oracle line.
// Shared across test binaries; each binary uses a different subset.
use parking_lot::Mutex;
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::convert::Infallible;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use provisioning::plugin::*;
use provisioning::*;
// ── fixtures ──────────────────────────────────────────────────────────
pub fn group_with_role(id: &str, count: u32, role: &str) -> RunNodeGroupSpec {
RunNodeGroupSpec {
run_id: RunId(7),
group_id: NodeGroupId(id.to_owned()),
role: RoleId(role.to_owned()),
count,
provider: ProviderKind::new("mock"),
shape: DesiredNodeShape {
image: "node:v1".to_owned(),
disk_gb: 20,
gpu_name: None,
min_gpu_ram_mb: None,
min_down_mbps: None,
min_up_mbps: None,
min_reliability: None,
require_verified: false,
provider_labels: BTreeMap::new(),
},
boot: BootSpec {
ssh_user: "root".to_owned(),
verify_commands: vec!["true".to_owned()],
start_swactor_command: "swactor".to_owned(),
stdout_sources: Vec::new(),
stderr_sources: Vec::new(),
env: Vec::new(),
args: Vec::new(),
mounts: Vec::new(),
},
swarm_join: SwarmJoinTemplate {
orch_swactor_addr: "127.0.0.1:9000".to_owned(),
join_token_ref: "token".to_owned(),
},
}
}
pub fn group(id: &str, count: u32) -> RunNodeGroupSpec {
group_with_role(id, count, "worker")
}
pub fn shape(generation: u64, groups: Vec) -> ClusterShape {
ClusterShape {
run_id: RunId(7),
generation,
groups,
}
}
pub fn ssh_endpoint() -> SshEndpoint {
SshEndpoint {
host: "127.0.0.1".to_owned(),
port: 22,
user: "root".to_owned(),
auth_ref: "test-key".to_owned(),
}
}
/// Lease identity encodes the owning attempt, so fact leakage across
/// attempts is detectable in pure state.
pub fn lease_result(attempt: NodeAttemptId, endpoint: bool) -> CreateLeaseResult {
let provider = ProviderKind::new("mock");
let lease_id = ProviderLeaseId(format!("lease-{}", attempt.0));
CreateLeaseResult {
lease: LeaseFacts {
provider: provider.clone(),
lease_id: lease_id.clone(),
provider_contract_id: format!("contract-{}", attempt.0),
offer_id: None,
destroy_handle: DestroyHandle {
provider,
lease_id,
provider_contract_id: format!("contract-{}", attempt.0),
},
provider_metadata: BTreeMap::new(),
},
endpoint: endpoint.then(ssh_endpoint),
}
}
/// Bootstrap session identity encodes the owning attempt (sessions are
/// `attempt * 1_000_000 + sequence`, sequences start at 1).
pub const SESSION_SEQ_SPACE: u64 = 1_000_000;
pub fn session_id_for(operation: OperationId) -> BootstrapSessionId {
assert!(
operation.sequence < SESSION_SEQ_SPACE,
"session id encoding exhausted"
);
BootstrapSessionId(operation.attempt.0 * SESSION_SEQ_SPACE + operation.sequence)
}
#[derive(Default)]
pub struct RecordingExecutor {
pub submitted: usize,
}
impl EffectExecutor for RecordingExecutor {
type SubmitError = Infallible;
fn submit(&mut self, _effect: &PlannedEffect) -> Result<(), Self::SubmitError> {
self.submitted += 1;
Ok(())
}
}
pub struct NullSink;
impl PluginObservationSink for NullSink {
fn observe(&self, _observation: PluginObservation) {}
}
pub fn null_sink() -> PluginSink {
PluginSink::new(Arc::new(NullSink))
}
/// A `NodeProvisionSpec` for a concrete attempt, for direct plugin calls.
pub fn plugin_spec(attempt: u64) -> NodeProvisionSpec {
NodeProvisionSpec {
run_id: 7,
node_id: attempt,
attempt_id: attempt,
stage_index: None,
image: "kit-node".to_owned(),
env: Vec::new(),
args: Vec::new(),
mounts: Vec::new(),
}
}
// ── backend contract ──────────────────────────────────────────────────
/// Scripted answer for the next backend call. An empty script means
/// `Succeed`. `NoEndpoint` only affects lease creation (forces the
/// endpoint-probe path); every other kind treats it as `Succeed`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Reply {
Succeed,
NoEndpoint,
Definite(&'static str),
Ambiguous(&'static str),
Panic,
}
/// A backend the harness can drive: scripted faults, a healed (fair)
/// mode, and a resource-conservation probe.
pub trait HarnessedBackend: EffectBackend + Clone {
/// Script the next fault. `Reply::Succeed` means "no fault".
fn script(&mut self, reply: Reply);
/// Enter the fault-free mode (fair tail).
fn heal(&mut self);
/// Resources alive but not owned by any live lease (leaks).
fn leaked(&self) -> Vec;
}
// ── reference backend: fake substrate ─────────────────────────────────
#[derive(Default)]
struct BackendShared {
scripted: Mutex>,
calls: Mutex>,
}
#[derive(Clone, Default)]
pub struct FakeBackend {
shared: Arc,
}
impl FakeBackend {
pub fn calls(&self) -> Vec {
self.shared.calls.lock().clone()
}
}
impl HarnessedBackend for FakeBackend {
fn script(&mut self, reply: Reply) {
self.shared.scripted.lock().push_back(reply);
}
fn heal(&mut self) {
self.shared.scripted.lock().clear();
}
fn leaked(&self) -> Vec {
Vec::new()
}
}
impl EffectBackend for FakeBackend {
fn execute(&self, effect: &PlannedEffect) -> Result {
self.shared.calls.lock().push(effect.clone());
let reply = self
.shared
.scripted
.lock()
.pop_front()
.unwrap_or(Reply::Succeed);
match reply {
Reply::Definite(reason) => return Err(EffectError::definite(reason)),
Reply::Ambiguous(reason) => return Err(EffectError::ambiguous(reason)),
Reply::Panic => panic!("scripted backend panic"),
Reply::Succeed | Reply::NoEndpoint => {}
}
let endpoint = !matches!(reply, Reply::NoEndpoint);
Ok(match &effect.command {
NodeManagerCommand::CreateLease(_) => OperationOutcome::LeaseCreated(Box::new(
lease_result(effect.operation.attempt, endpoint),
)),
NodeManagerCommand::LookupEndpoint(_) => {
OperationOutcome::EndpointLookup(Some(ssh_endpoint()))
}
NodeManagerCommand::StartBootstrap(_) => OperationOutcome::BootstrapStarted {
session_id: session_id_for(effect.operation),
},
NodeManagerCommand::BootstrapConvergenceObserved { .. } => {
OperationOutcome::BootstrapConvergenceAccepted
}
NodeManagerCommand::CancelBootstrap { .. } => OperationOutcome::BootstrapCancelled,
NodeManagerCommand::DestroyLease(_) => OperationOutcome::LeaseDestroyed,
})
}
}
// ── plugin-level kit ──────────────────────────────────────────────────
/// Fault a `TestablePlugin` can inject into its own behavior.
/// The error string a `TestablePlugin` returns for `Fault::Ambiguous`.
/// The `PluginBackendAdapter` reclassifies it as an ambiguous effect
/// error; every other plugin error is definite.
pub const AMBIGUOUS_FAULT_MARKER: &str = "kit-ambiguous: work may have happened";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Fault {
/// Fail cleanly: the call errors and nothing is created.
Definite,
/// Fail ambiguously: the resource is created but the call errors.
/// A retry with the same attempt must adopt the resource, not
/// create a second one. The plugin reports this by returning
/// `AMBIGUOUS_FAULT_MARKER` from the call; the adapter reclassifies
/// that error as an ambiguous effect error so the driver records
/// `ambiguous_operation` and adopts on retry.
Ambiguous,
/// Panic inside the plugin call.
Panic,
/// Clear all injected faults.
Heal,
}
/// A `ProvisionPlugin` the kit can drive and probe. All probe methods
/// use interior mutability so the plugin can live behind the adapter.
pub trait TestablePlugin: ProvisionPlugin {
/// Queue the next fault (`Fault::Heal` clears all).
fn apply_fault(&self, fault: Fault);
/// Resources alive but not owned by `live_handles`.
fn leaked_resources(&self, live_handles: &[u64]) -> Vec;
/// Total resources ever created (adoption must not increment this).
fn resources_created(&self) -> usize;
}
struct LiveLease {
handle: PluginNodeHandle,
endpoint: Option,
}
struct AdapterShared
{
plugin: P,
live: BTreeMap,
withhold_endpoint: bool,
}
fn classify_plugin_error(error: String) -> EffectError {
if error == AMBIGUOUS_FAULT_MARKER {
EffectError::ambiguous(error)
} else {
EffectError::definite(error)
}
}
/// The kit's `EffectBackend` over a `ProvisionPlugin`: maps
/// `NodeManagerCommand`s to plugin calls, records live leases per
/// attempt, and synthesizes lease/session identities using the oracle's
/// attempt-encoded conventions. Create adopts: an existing live lease
/// for the same attempt is returned instead of calling the plugin
/// again (mirrors provider-side idempotency keys).
pub struct PluginBackendAdapter