swactor/xtask/src/provisioning_demo/provider.rs
Zachery Aaron Shores-Chmielewski 6d52b3c623 provisioning-reconciler-demo: wire-announce readiness + --docker node kind
Replace the per-attempt key-file side channel (DEMO_NODE_KEY_FILE,
read_key_report, JoinCheck) with a control-plane announce: node roles
send a tagged gossip frame {attempt, logical_node, key_hex,
endpoint_addr_json} after joining and every heartbeat thereafter.

- iroh-driver: IrohDriver::send_tagged_gossip — fire-and-forget
  tag-routed gossip egress for bridge-less clients (reuses cached/join
  connections, dials with backoff).
- provisioning: BootstrapMsg::Announce — first delivery while
  bootstrapping completes the attempt (collector + exactly-once
  Bootstrapped report); duplicates, misrouted attempts, and
  terminal-phase announces drop. Unit-tested.
- xtask demo: AnnounceActor decodes the tag-routed frame and forwards
  by attempt to the owning bootstrap actor; last_announce_ms is the
  wire heartbeat. LocalProcessLogic keeps only process lifecycle.
- --docker: DockerProcessLogic (kind "docker") — attached
  "docker run --rm" child on a per-run labeled bridge network
  (foreign-node masking: per-container IPs, gateway-dialed
  supervisor). Standalone scratch image from the static-musl xtask
  binary (37MB), staged one-file build context. The container is
  force-removed on every terminal path so a SIGKILLed docker CLI
  cannot orphan a running container.
- Cleanup: no volumes/mounts; label-filtered exit sweep (SIGINT and
  SIGTERM both drain first) + startup sweep of stale demo resources;
  images persist per run token.

Verified live: process kind (kill -> replacement in 3.4s, provision/
remove/kill waves, zero orphans) and docker kind (8-node abuse waves
across docker kill, mid-provision control kills, CLI SIGKILL orphans
force-removed, SIGKILL-crash leftovers swept on restart, clean exits
leave zero containers/networks/CLIs). provisioning 22 + iroh-driver
13 tests pass.
2026-08-16 20:34:15 +04:00

520 lines
18 KiB
Rust

//! The demo provider seam: real node-role children behind the reconciler's
//! effect executor.
//!
//! `DemoProvider` is the demo's `ProvisionPlugin` — create spawns a
//! `swactor-process` actor whose child re-execs this binary in node role
//! (`--demo-node`), stop goes through the real process-actor command path.
//! `DemoBackend` adapts the plugin to `NodeManagerCommand` effects, mirroring
//! the conformance kit's adapter semantics (attempt-keyed identity, adoption
//! on retry).
//!
//! Actor spawning needs an actor context, so spawn requests travel over a
//! channel to the supervisor actor; the plugin call blocks for the reply on
//! an executor blocking thread.
//!
//! Children run with stdio null, so the supervisor learns a node's iroh
//! identity and liveness from the wire announce (see `node.rs`): the
//! [`AnnounceActor`] routes it to the owning bootstrap actor and records
//! `last_announce_ms` in the [`NodeManager`] registry.
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
use swactor::runtime::ExternalSender;
use swactor_process::{ExitStatus, ProcessOutput};
use provisioning::executor::{EffectBackend, EffectError};
use provisioning::node::{
BootstrapSessionId, CreateLeaseResult, DestroyHandle, LeaseFacts, NodeManagerCommand,
ProviderKind, ProviderLeaseId, SshEndpoint,
};
use provisioning::plugin::{NodeProvisionSpec, PluginNodeHandle, PluginSink, ProvisionPlugin};
use provisioning::reconciler::{OperationId, OperationOutcome, PlannedEffect};
use telemetry::{ChannelContent, StreamDescriptor, TelemetryEndpoint, TelemetryProducer};
/// How long a blocking plugin call waits for the supervisor actor.
pub const BACKEND_WAIT: Duration = Duration::from_secs(20);
/// One provisioned node's runtime facts.
#[derive(Clone)]
pub struct NodeRuntime {
pub attempt: u64,
pub logical_node: String,
/// The bootstrap actor that owns this attempt's lifecycle.
pub bootstrap: ActorAddress,
pub pid: Option<u32>,
pub exited: Option<ExitStatus>,
pub spawn_failed: Option<String>,
/// Wall-clock ms of the last wire announce from the node (None until
/// the first announce). The node re-announces every heartbeat period,
/// so staleness here means the control-plane path is dead.
pub last_announce_ms: Option<u64>,
}
/// Spawn request from the plugin (blocking thread) to the supervisor actor.
#[derive(Clone)]
pub struct SpawnNodeRequest {
pub attempt: u64,
pub logical_node: String,
pub reply: std::sync::mpsc::Sender<Result<NodeRuntime, String>>,
}
/// Shared node registry + spawn queue: hub between executor blocking threads,
/// relay actors, and the supervisor actor.
#[derive(Clone, Default)]
pub struct NodeManager {
inner: Arc<Mutex<NodeManagerInner>>,
}
#[derive(Default)]
struct NodeManagerInner {
nodes: BTreeMap<u64, NodeRuntime>,
spawn_tx: Option<std::sync::mpsc::Sender<SpawnNodeRequest>>,
}
impl NodeManager {
pub fn new() -> Self {
Self::default()
}
pub fn set_spawn_channel(&self, sender: std::sync::mpsc::Sender<SpawnNodeRequest>) {
self.inner.lock().expect("node manager").spawn_tx = Some(sender);
}
pub fn request_spawn(&self, attempt: u64, logical_node: String) -> Result<NodeRuntime, String> {
let (reply_tx, reply_rx) = std::sync::mpsc::channel();
let request = SpawnNodeRequest {
attempt,
logical_node,
reply: reply_tx,
};
{
let inner = self.inner.lock().expect("node manager");
let sender = inner
.spawn_tx
.as_ref()
.ok_or_else(|| "supervisor spawn channel not installed".to_owned())?;
sender
.send(request)
.map_err(|_| "supervisor actor gone".to_owned())?;
}
reply_rx
.recv_timeout(BACKEND_WAIT)
.map_err(|_| "timed out waiting for node spawn".to_owned())?
}
pub fn register(&self, runtime: NodeRuntime) {
self.inner
.lock()
.expect("node manager")
.nodes
.insert(runtime.attempt, runtime);
}
pub fn get(&self, attempt: u64) -> Option<NodeRuntime> {
self.inner
.lock()
.expect("node manager")
.nodes
.get(&attempt)
.cloned()
}
pub fn find_by_stream_node(&self, node: &str) -> Option<NodeRuntime> {
self.inner
.lock()
.expect("node manager")
.nodes
.values()
.find(|runtime| runtime.logical_node == node)
.cloned()
}
fn update(&self, attempt: u64, mutate: impl FnOnce(&mut NodeRuntime)) {
if let Some(runtime) = self
.inner
.lock()
.expect("node manager")
.nodes
.get_mut(&attempt)
{
mutate(runtime);
}
}
pub fn set_pid(&self, attempt: u64, pid: u32) {
self.update(attempt, |runtime| runtime.pid = Some(pid));
}
pub fn set_exited(&self, attempt: u64, status: ExitStatus) {
self.update(attempt, |runtime| runtime.exited = Some(status));
}
pub fn set_spawn_failed(&self, attempt: u64, reason: String) {
self.update(attempt, |runtime| runtime.spawn_failed = Some(reason));
}
pub fn set_announce(&self, attempt: u64, at_ms: u64) {
self.update(attempt, |runtime| runtime.last_announce_ms = Some(at_ms));
}
pub fn remove(&self, attempt: u64) {
self.inner
.lock()
.expect("node manager")
.nodes
.remove(&attempt);
}
}
/// The demo `ProvisionPlugin`: resources are node-role child processes.
pub struct DemoProvider {
manager: NodeManager,
}
impl DemoProvider {
pub fn new(manager: NodeManager) -> Self {
Self { manager }
}
}
impl ProvisionPlugin for DemoProvider {
fn create_node(
&mut self,
spec: NodeProvisionSpec,
_sink: PluginSink,
) -> Result<PluginNodeHandle, String> {
let attempt = spec.attempt_id;
if let Some(runtime) = self.manager.get(attempt) {
// Adoption: the child for this attempt already exists.
return Ok(PluginNodeHandle {
id: attempt,
provider_process_id: runtime.pid,
});
}
let logical_node = spec
.env
.iter()
.find(|(key, _)| key == "DEMO_LOGICAL_NODE")
.map(|(_, value)| value.clone())
.ok_or_else(|| "spec missing DEMO_LOGICAL_NODE".to_owned())?;
let runtime = self.manager.request_spawn(attempt, logical_node)?;
Ok(PluginNodeHandle {
id: attempt,
provider_process_id: runtime.pid,
})
}
fn start_bootstrap(&mut self, _handle: &PluginNodeHandle) -> Result<(), String> {
Ok(())
}
fn cancel_bootstrap(&mut self, _handle: &PluginNodeHandle) -> Result<(), String> {
Ok(())
}
fn complete_bootstrap(&mut self, _handle: &PluginNodeHandle) -> Result<(), String> {
Ok(())
}
fn stop_node(&mut self, handle: &PluginNodeHandle) -> Result<(), String> {
// The backend path sends the process Stop command before calling
// here; direct plugin-level stop just drops the registration.
self.manager.remove(handle.id);
Ok(())
}
}
/// Per-node telemetry: one endpoint/producer per provisioned node so each
/// lands on its own dashboard stream (one fleet card per node). Carries the
/// stream descriptor's origin/label so the dashboard can classify the stream
/// (the frame path itself has no catalog).
pub struct NodeTelemetry {
pub endpoint: TelemetryEndpoint,
pub producer: TelemetryProducer,
pub origin: &'static str,
pub label: String,
}
impl NodeTelemetry {
pub fn new(logical_node: &str, life: u64) -> Self {
let stream = telemetry::frame::StreamId::new(
telemetry::frame::NodeId::new(logical_node),
telemetry::frame::Lifetime(life),
);
let endpoint = TelemetryEndpoint::with_descriptor(
StreamDescriptor {
stream,
label: Some(format!("demo node {logical_node}")),
origin: telemetry::frame::StreamOrigin::RemoteNode,
},
256,
16,
);
let producer = endpoint.producer();
Self {
endpoint,
producer,
origin: "remote_node",
label: format!("demo node {logical_node}"),
}
}
}
/// Relay actor: folds process-actor lifecycle reports into the registry.
pub struct NodeRelayActor {
manager: NodeManager,
attempt: u64,
}
impl NodeRelayActor {
pub fn new(manager: NodeManager, attempt: u64) -> Self {
Self { manager, attempt }
}
}
impl ActorInterface for NodeRelayActor {
type Incoming = ProcessOutput;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, output: ProcessOutput) {
match output {
ProcessOutput::Started { pid } => self.manager.set_pid(self.attempt, pid),
ProcessOutput::Exited { status } => self.manager.set_exited(self.attempt, status),
ProcessOutput::SpawnFailed { error } => {
eprintln!("demo node {}: spawn failed: {error}", self.attempt);
self.manager
.set_spawn_failed(self.attempt, error.to_string());
}
ProcessOutput::Error { error } => {
eprintln!("demo node {}: process actor error: {error}", self.attempt);
}
}
}
}
/// Supervisor-side announce relay. The iroh actor bridge decodes inbound
/// [`NodeAnnounce`] gossip frames and routes them here by wire tag; this
/// actor correlates by attempt token and forwards to the owning bootstrap
/// actor ([`provisioning::BootstrapMsg::Announce`]) — the handoff from
/// bootstrap to control plane. Announces for unknown attempts (deregistered
/// lease, stale container from a dead attempt) are dropped with a log line.
pub struct AnnounceActor {
manager: NodeManager,
sender: ExternalSender,
}
impl AnnounceActor {
pub fn new(manager: NodeManager, sender: ExternalSender) -> Self {
Self { manager, sender }
}
}
impl ActorInterface for AnnounceActor {
type Incoming = crate::provisioning_demo::node::NodeAnnounce;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, announce: crate::provisioning_demo::node::NodeAnnounce) {
self.manager.set_announce(announce.attempt, announce.at_ms);
let Some(runtime) = self.manager.get(announce.attempt) else {
let key = &announce.key_hex;
eprintln!(
"demo: announce for unknown attempt {} (key {}…) dropped",
announce.attempt,
&key[..8.min(key.len())]
);
return;
};
let identity = provisioning::NodeIdentity {
attempt: announce.attempt,
logical_node: announce.logical_node,
key_hex: announce.key_hex,
transport_addr: announce.endpoint_addr_json,
};
let _ = self.sender.send_to(
runtime.bootstrap,
provisioning::BootstrapMsg::Announce(identity),
);
}
}
/// Demo lease/session identity (attempt-encoded, kit conventions).
pub fn demo_lease(attempt: u64) -> LeaseFacts {
let provider = ProviderKind::new("demo");
let lease_id = ProviderLeaseId(format!("demo-lease-{attempt}"));
LeaseFacts {
destroy_handle: DestroyHandle {
provider: provider.clone(),
lease_id: lease_id.clone(),
provider_contract_id: format!("demo-contract-{attempt}"),
},
provider,
lease_id,
provider_contract_id: format!("demo-contract-{attempt}"),
offer_id: None,
provider_metadata: BTreeMap::new(),
}
}
pub fn demo_endpoint() -> SshEndpoint {
SshEndpoint {
host: "127.0.0.1".to_owned(),
port: 0,
user: "demo".to_owned(),
auth_ref: "demo-key".to_owned(),
}
}
/// Session identity: one bootstrap session per attempt (production
/// convention — the supervisor's close path addresses it the same way).
pub fn session_id_for(operation: OperationId) -> BootstrapSessionId {
BootstrapSessionId(operation.attempt.0)
}
fn runtime_exited(manager: &NodeManager, attempt: u64) -> bool {
manager
.get(attempt)
.is_some_and(|runtime| runtime.exited.is_some())
}
/// The demo `EffectBackend`: routes effects through the plugin.
pub struct DemoBackend {
/// The plugin lives behind a mutex: `EffectBackend::execute` is `&self`
/// while `ProvisionPlugin` methods are `&mut self` (kit adapter shape).
pub plugin: Arc<Mutex<DemoProvider>>,
pub manager: NodeManager,
pub sender: ExternalSender,
}
impl DemoBackend {
fn handle_for(&self, attempt: u64) -> PluginNodeHandle {
let pid = self.manager.get(attempt).and_then(|r| r.pid);
PluginNodeHandle {
id: attempt,
provider_process_id: pid,
}
}
}
impl EffectBackend for DemoBackend {
fn execute(&self, effect: &PlannedEffect) -> Result<OperationOutcome, EffectError> {
let attempt = effect.operation.attempt.0;
match &effect.command {
NodeManagerCommand::CreateLease(request) => {
let spec = NodeProvisionSpec {
run_id: request.spec.run_id.0,
node_id: attempt,
attempt_id: attempt,
stage_index: None,
image: request.spec.shape.image.clone(),
env: vec![(
"DEMO_LOGICAL_NODE".to_owned(),
request.spec.logical_node_id.0.clone(),
)],
args: Vec::new(),
mounts: Vec::new(),
};
self.plugin
.lock()
.expect("demo provider")
.create_node(spec, null_sink())
.map_err(EffectError::definite)?;
Ok(OperationOutcome::LeaseCreated(CreateLeaseResult {
lease: demo_lease(attempt),
endpoint: Some(demo_endpoint()),
}))
}
NodeManagerCommand::LookupEndpoint(_) => {
Ok(OperationOutcome::EndpointLookup(Some(demo_endpoint())))
}
NodeManagerCommand::StartBootstrap(_) => {
let handle = self.handle_for(attempt);
self.plugin
.lock()
.expect("demo provider")
.start_bootstrap(&handle)
.map_err(EffectError::definite)?;
Ok(OperationOutcome::BootstrapStarted {
session_id: session_id_for(effect.operation),
})
}
NodeManagerCommand::BootstrapConvergenceObserved { .. } => {
let handle = self.handle_for(attempt);
self.plugin
.lock()
.expect("demo provider")
.complete_bootstrap(&handle)
.map_err(EffectError::definite)?;
Ok(OperationOutcome::BootstrapConvergenceAccepted)
}
NodeManagerCommand::CancelBootstrap { .. } => {
let handle = self.handle_for(attempt);
self.plugin
.lock()
.expect("demo provider")
.cancel_bootstrap(&handle)
.map_err(EffectError::definite)?;
Ok(OperationOutcome::BootstrapCancelled)
}
NodeManagerCommand::DestroyLease(_) => {
let handle = self.handle_for(attempt);
// stop_node needs the sender; route it explicitly.
stop_node_with_sender(&self.plugin, &self.manager, &handle, &self.sender)
.map_err(EffectError::definite)?;
Ok(OperationOutcome::LeaseDestroyed)
}
}
}
}
fn stop_node_with_sender(
plugin: &Arc<Mutex<DemoProvider>>,
manager: &NodeManager,
handle: &PluginNodeHandle,
sender: &ExternalSender,
) -> Result<(), String> {
if let Some(runtime) = manager.get(handle.id) {
if runtime.pid.is_some() && runtime.exited.is_none() {
let _ = sender.send_to(
runtime.bootstrap,
provisioning::BootstrapMsg::Stop {
kill_after: Some(Duration::from_secs(1)),
},
);
}
let deadline = std::time::Instant::now() + BACKEND_WAIT;
while !runtime_exited(manager, handle.id) {
if std::time::Instant::now() > deadline {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
}
plugin.lock().expect("demo provider").stop_node(handle)
}
fn null_sink() -> PluginSink {
struct NullSink;
impl provisioning::plugin::PluginObservationSink for NullSink {
fn observe(&self, _observation: provisioning::plugin::PluginObservation) {}
}
PluginSink::new(Arc::new(NullSink))
}
/// Register per-node supplementary channels; returns the `node.status`
/// channel id for the supervisor's liveness heartbeats.
pub fn register_node_channels(producer: &TelemetryProducer) -> telemetry::ChannelId {
producer.register_channel(
"node.status",
ChannelContent::JsonRecord {
schema: Some("demo.node.status.v1".to_owned()),
},
)
}
pub fn unix_ms(now: SystemTime) -> u64 {
now.duration_since(SystemTime::UNIX_EPOCH)
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0)
}