diff --git a/hive-c0re/src/workers/wanted.rs b/hive-c0re/src/workers/wanted.rs index ad1a90c1..b2eece6d 100644 --- a/hive-c0re/src/workers/wanted.rs +++ b/hive-c0re/src/workers/wanted.rs @@ -212,6 +212,7 @@ async fn converge(coord: &Arc, declared: &HiveWanted) -> Result<()> deploy = plan.deploy.len(), start = plan.start.len(), stop = plan.stop.len(), + destroy = plan.destroy.len(), "wanted state: converging" ); @@ -234,8 +235,16 @@ async fn converge(coord: &Arc, declared: &HiveWanted) -> Result<()> .await .context("queueing the declared stops")?; } + // No purge: this issue's own scope is tearing the container down, not + // wiping its persistent state. `actions::destroy` submits its own DAG + // node and emits its own queue snapshot, so there is nothing to await + // or fold into `queued` here — same fire-and-forget contract every other + // lifecycle op in that module has. + for agent in &plan.destroy { + crate::actions::destroy(coord, agent, false); + } if queued { - // The power ops emit their own; the first deploys do not. + // The power ops and destroy emit their own; the first deploys do not. coord.emit_rebuild_queue_snapshot(); } Ok(()) @@ -247,6 +256,7 @@ struct Plan { deploy: Vec, start: Vec, stop: Vec, + destroy: Vec, } /// Turn one declaration into that plan — pure, so what the loop does with a @@ -274,6 +284,7 @@ fn plan( Converge::Deploy => plan.deploy.push(agent.clone()), Converge::Start => plan.start.push(agent.clone()), Converge::Stop => plan.stop.push(agent.clone()), + Converge::Destroy => plan.destroy.push(agent.clone()), Converge::Nothing => {} } } @@ -288,6 +299,10 @@ enum Converge { Deploy, Start, Stop, + /// Declared destroyed, and this hive still has a container for it — run + /// the teardown once. Irreversible, so unlike `Start`/`Stop` there is no + /// intent to compare against: presence alone decides it. + Destroy, /// The hive already agrees with the declaration. Nothing, } @@ -305,9 +320,11 @@ fn decide(state: AgentState, present: bool, intent: Option) -> Converge Converge::Start } } - // Declared offline and not here: creating a container in order to - // leave it stopped is not what the declaration asks for. - AgentState::Offline if !present => Converge::Nothing, + // Declared offline/destroyed and not here: creating a container in + // order to leave it stopped, or to immediately re-destroy it, is not + // what either declaration asks for — absence plus a stopped-or-gone + // declaration is agreement, not a repair to queue. + AgentState::Offline | AgentState::Destroyed if !present => Converge::Nothing, AgentState::Offline => { if intent == Some(Wanted::Offline) { Converge::Nothing @@ -315,6 +332,10 @@ fn decide(state: AgentState, present: bool, intent: Option) -> Converge Converge::Stop } } + // Power intent plays no part here — a destroyed agent has no + // running/stopped distinction left to converge to, only present or + // not. + AgentState::Destroyed => Converge::Destroy, } } @@ -364,6 +385,7 @@ mod tests { deploy: vec![], start: vec!["adopted".to_owned()], stop: vec![], + destroy: vec![], } ); } @@ -432,6 +454,29 @@ mod tests { assert_eq!(decide(AgentState::Offline, false, None), Converge::Nothing); } + /// A present agent declared destroyed gets torn down — the whole point of + /// this state. Intent is irrelevant, unlike `Up`/`Offline`: even an + /// agent whose power intent is `Up` still gets destroyed, since a + /// terminal declaration outranks a steady-state one. + #[test] + fn a_present_agent_declared_destroyed_is_destroyed() { + assert_eq!(decide(AgentState::Destroyed, true, None), Converge::Destroy); + assert_eq!( + decide(AgentState::Destroyed, true, Some(Wanted::Up)), + Converge::Destroy + ); + } + + /// Already gone: destroying an absent agent would be acting on nothing. + /// Its control is the present case above, which must destroy. + #[test] + fn an_absent_agent_declared_destroyed_is_left_absent() { + assert_eq!( + decide(AgentState::Destroyed, false, None), + Converge::Nothing + ); + } + /// The watch's half of "absence is not a deletion order". `Put` is the /// control: without it this would pass on a function that refused /// everything, which would silently stop the fast path converging at all. @@ -456,7 +501,7 @@ mod tests { /// pass forever without measuring anything. #[test] fn every_state_this_build_knows_is_covered_above() { - for state in [AgentState::Up, AgentState::Offline] { + for state in [AgentState::Up, AgentState::Offline, AgentState::Destroyed] { let seen = [true, false].iter().any(|present| { decide(state, *present, None) != Converge::Nothing || decide(state, *present, Some(Wanted::Up)) != Converge::Nothing diff --git a/swarm-queue-client/src/wanted.rs b/swarm-queue-client/src/wanted.rs index 3592bd38..98140b1e 100644 --- a/swarm-queue-client/src/wanted.rs +++ b/swarm-queue-client/src/wanted.rs @@ -91,6 +91,11 @@ pub enum AgentState { Up, /// Exists on the hive and is not running. Offline, + /// Torn down entirely — the hive runs the destroy template once, then + /// this agent drops out of the declared set rather than staying as a + /// third steady state. Irreversible: there is no state that brings a + /// destroyed agent back short of a fresh deploy. + Destroyed, } impl AgentState { @@ -103,6 +108,7 @@ impl AgentState { match self { AgentState::Up => "up", AgentState::Offline => "offline", + AgentState::Destroyed => "destroyed", } } } @@ -211,10 +217,12 @@ mod tests { #[test] fn the_known_states_decode_and_round_trip() { - let doc = r#"{"agents":{"a":{"state":"up"},"b":{"state":"offline"}}}"#; + let doc = + r#"{"agents":{"a":{"state":"up"},"b":{"state":"offline"},"c":{"state":"destroyed"}}}"#; let decoded: HiveWanted = serde_json::from_str(doc).expect("decodes"); assert_eq!(decoded.agents["a"].state, AgentState::Up); assert_eq!(decoded.agents["b"].state, AgentState::Offline); + assert_eq!(decoded.agents["c"].state, AgentState::Destroyed); assert_eq!(serde_json::to_string(&decoded).expect("serialises"), doc); } @@ -253,10 +261,10 @@ mod tests { /// to compile here rather than quietly going untested. #[test] fn as_str_matches_the_serde_spelling() { - let every = [AgentState::Up, AgentState::Offline]; + let every = [AgentState::Up, AgentState::Offline, AgentState::Destroyed]; for state in every { match state { - AgentState::Up | AgentState::Offline => {} + AgentState::Up | AgentState::Offline | AgentState::Destroyed => {} } assert_eq!( serde_json::to_string(&state).expect("serialises"),