swarm-controller: refuse a state transition off destroyed
This commit is contained in:
parent
4fae3f13cb
commit
79937a1934
2 changed files with 86 additions and 1 deletions
|
|
@ -794,7 +794,7 @@ fn declaration_target(
|
||||||
request_body = SetAgentStateRequest,
|
request_body = SetAgentStateRequest,
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "the declaration as now published", body = Vec<AgentDeclaration>),
|
(status = 200, description = "the declaration as now published", body = Vec<AgentDeclaration>),
|
||||||
(status = 400, description = "a name is not an identifier, the hive is not in this swarm, or the state is unknown (problem+json)", body = String),
|
(status = 400, description = "a name is not an identifier, the hive is not in this swarm, the state is unknown, or the agent is already declared destroyed (terminal — problem+json)", body = String),
|
||||||
(status = 503, description = "no swarm queue is wired up (problem+json)", body = String),
|
(status = 503, description = "no swarm queue is wired up (problem+json)", body = String),
|
||||||
(status = 500, description = "the declaration could not be published (problem+json)", body = String),
|
(status = 500, description = "the declaration could not be published (problem+json)", body = String),
|
||||||
),
|
),
|
||||||
|
|
@ -812,6 +812,12 @@ async fn set_agent_state(
|
||||||
.into_string();
|
.into_string();
|
||||||
|
|
||||||
let declaration = writer.set(&hive, &agent, req.state).await.map_err(|e| {
|
let declaration = writer.set(&hive, &agent, req.state).await.map_err(|e| {
|
||||||
|
// A `TerminalStateError` is the caller's mistake (asking to move an
|
||||||
|
// agent off a state its own doc comment says is terminal), not a
|
||||||
|
// server fault — everything else here is the pre-existing catch-all.
|
||||||
|
if let Some(terminal) = e.downcast_ref::<wanted::TerminalStateError>() {
|
||||||
|
return error_problem(axum::http::StatusCode::BAD_REQUEST, &terminal.to_string());
|
||||||
|
}
|
||||||
tracing::warn!(hive = %hive, agent = %agent, error = %format!("{e:#}"), "declaring agent state failed");
|
tracing::warn!(hive = %hive, agent = %agent, error = %format!("{e:#}"), "declaring agent state failed");
|
||||||
error_problem(
|
error_problem(
|
||||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,41 @@
|
||||||
//! declaration to reconcile against, because the current value can be read
|
//! declaration to reconcile against, because the current value can be read
|
||||||
//! back from the queue whenever it is needed.
|
//! back from the queue whenever it is needed.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use async_nats::jetstream::kv::{CreateErrorKind, UpdateErrorKind};
|
use async_nats::jetstream::kv::{CreateErrorKind, UpdateErrorKind};
|
||||||
use swarm_queue_client::wanted::{AgentState, AgentWanted, HiveWanted};
|
use swarm_queue_client::wanted::{AgentState, AgentWanted, HiveWanted};
|
||||||
|
|
||||||
|
/// The one failure out of [`apply`] that is the caller's mistake, not a
|
||||||
|
/// server fault — worth a distinct type so `set_agent_state` can tell it
|
||||||
|
/// apart from every other error this module raises (an undecodable
|
||||||
|
/// declaration, a lost write) and answer 400 instead of 500.
|
||||||
|
///
|
||||||
|
/// Carried through as a plain `anyhow::Error` like every other error here
|
||||||
|
/// (this crate has no typed-error convention to join), and recovered at the
|
||||||
|
/// HTTP boundary with `downcast_ref` — the shape every other "kind of error
|
||||||
|
/// that came from underneath" check in this module already uses
|
||||||
|
/// ([`Wrote`]'s `UpdateErrorKind`/`CreateErrorKind` matching), just inspecting
|
||||||
|
/// an `anyhow::Error` instead of a client library's own error enum.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct TerminalStateError {
|
||||||
|
agent: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for TerminalStateError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{} is already declared destroyed — terminal, per AgentState::Destroyed's own doc \
|
||||||
|
comment: no transition brings it back short of a fresh deploy",
|
||||||
|
self.agent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for TerminalStateError {}
|
||||||
|
|
||||||
/// The three outcomes of one write attempt, which the two KV verbs report
|
/// The three outcomes of one write attempt, which the two KV verbs report
|
||||||
/// through separate error types.
|
/// through separate error types.
|
||||||
enum Wrote {
|
enum Wrote {
|
||||||
|
|
@ -50,6 +81,19 @@ fn apply(current: Option<&[u8]>, agent: &str, state: AgentState) -> Result<(Hive
|
||||||
.context("the hive's current declaration is not decodable")?,
|
.context("the hive's current declaration is not decodable")?,
|
||||||
None => HiveWanted::default(),
|
None => HiveWanted::default(),
|
||||||
};
|
};
|
||||||
|
// Redeclaring the same terminal state is a no-op, not a transition — a
|
||||||
|
// client that already sees the agent as destroyed and asks again should
|
||||||
|
// not be punished for it. Anything else moving off `Destroyed` is the
|
||||||
|
// one transition this module exists to refuse.
|
||||||
|
if let Some(existing) = declaration.agents.get(agent)
|
||||||
|
&& existing.state == AgentState::Destroyed
|
||||||
|
&& state != AgentState::Destroyed
|
||||||
|
{
|
||||||
|
return Err(TerminalStateError {
|
||||||
|
agent: agent.to_owned(),
|
||||||
|
}
|
||||||
|
.into());
|
||||||
|
}
|
||||||
declaration
|
declaration
|
||||||
.agents
|
.agents
|
||||||
.insert(agent.to_owned(), AgentWanted { state });
|
.insert(agent.to_owned(), AgentWanted { state });
|
||||||
|
|
@ -218,4 +262,39 @@ mod tests {
|
||||||
assert_eq!(again.agents["atlas"].state, AgentState::Offline);
|
assert_eq!(again.agents["atlas"].state, AgentState::Offline);
|
||||||
assert_eq!(again.agents["iris"].state, AgentState::Up);
|
assert_eq!(again.agents["iris"].state, AgentState::Up);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The bug this module exists to fix: nothing used to stop a caller from
|
||||||
|
/// declaring a destroyed agent back to `Up`/`Offline`, silently
|
||||||
|
/// un-terminaling a state whose own doc comment says that is impossible.
|
||||||
|
#[test]
|
||||||
|
fn moving_a_destroyed_agent_to_any_other_state_is_refused() {
|
||||||
|
let current = br#"{"agents":{"atlas":{"state":"destroyed"}}}"#;
|
||||||
|
for state in [AgentState::Up, AgentState::Offline] {
|
||||||
|
let err = apply(Some(current), "atlas", state).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.downcast_ref::<super::TerminalStateError>().is_some(),
|
||||||
|
"expected a TerminalStateError for {state:?}, got: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Redeclaring `Destroyed` while already `Destroyed` is a no-op, not a
|
||||||
|
/// transition — a client that already sees the terminal state and asks
|
||||||
|
/// again should get success back, not an error for stating a fact.
|
||||||
|
#[test]
|
||||||
|
fn redeclaring_destroyed_as_destroyed_is_not_a_transition() {
|
||||||
|
let current = br#"{"agents":{"atlas":{"state":"destroyed"}}}"#;
|
||||||
|
let (declaration, _) = apply(Some(current), "atlas", AgentState::Destroyed).unwrap();
|
||||||
|
assert_eq!(declaration.agents["atlas"].state, AgentState::Destroyed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The refusal is per-agent — a destroyed agent on the same hive as a
|
||||||
|
/// live one must not block declaring the live one.
|
||||||
|
#[test]
|
||||||
|
fn a_destroyed_agent_does_not_block_declaring_a_different_one() {
|
||||||
|
let current = br#"{"agents":{"atlas":{"state":"destroyed"},"iris":{"state":"up"}}}"#;
|
||||||
|
let (declaration, _) = apply(Some(current), "iris", AgentState::Offline).unwrap();
|
||||||
|
assert_eq!(declaration.agents["atlas"].state, AgentState::Destroyed);
|
||||||
|
assert_eq!(declaration.agents["iris"].state, AgentState::Offline);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue