log refused agent-permission changes on the controller api
This commit is contained in:
parent
96405621d7
commit
3d142feb6a
2 changed files with 126 additions and 11 deletions
|
|
@ -308,7 +308,8 @@ writes. Set them with `swarmctl agent permissions get|set|clear <agent>`
|
||||||
(`permissions` in `POST /api/agents`). A missing key or an unset field means
|
(`permissions` in `POST /api/agents`). A missing key or an unset field means
|
||||||
the default (`Permission::DEFAULT`): `messaging`, `meta`, `inbox`,
|
the default (`Permission::DEFAULT`): `messaging`, `meta`, `inbox`,
|
||||||
`execution`, for every agent whatever its name. `"permissions": []` grants
|
`execution`, for every agent whatever its name. `"permissions": []` grants
|
||||||
none.
|
none. swarm-controller logs every refused `PUT` or `DELETE` on that route at `warn` as
|
||||||
|
`agent permissions: request refused`, with the fields `op`, `agent`, `status` and `detail`.
|
||||||
|
|
||||||
**How a change applies** —
|
**How a change applies** —
|
||||||
- The agent's harness reads its own key every 10 seconds and builds every
|
- The agent's harness reads its own key every 10 seconds and builds every
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
|
use axum::extract::rejection::JsonRejection;
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use backon::{ConstantBuilder, Retryable as _};
|
use backon::{ConstantBuilder, Retryable as _};
|
||||||
|
|
@ -70,6 +71,19 @@ fn agent_name(name: &str) -> Result<String, (StatusCode, String)> {
|
||||||
.map_err(|reason| (StatusCode::BAD_REQUEST, reason.to_owned()))
|
.map_err(|reason| (StatusCode::BAD_REQUEST, reason.to_owned()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Log a refused `op` on `agent` at `warn` and pass the problem on. The
|
||||||
|
/// handlers have no caller identity; `detail` is the response's.
|
||||||
|
fn logged(op: &'static str, agent: &str, problem: ProblemDetails) -> ProblemDetails {
|
||||||
|
tracing::warn!(
|
||||||
|
op,
|
||||||
|
agent,
|
||||||
|
status = problem.status.map(|s| s.as_u16()),
|
||||||
|
detail = problem.detail.as_deref(),
|
||||||
|
"agent permissions: request refused"
|
||||||
|
);
|
||||||
|
problem
|
||||||
|
}
|
||||||
|
|
||||||
fn queue_problem(e: &anyhow::Error) -> ProblemDetails {
|
fn queue_problem(e: &anyhow::Error) -> ProblemDetails {
|
||||||
tracing::warn!(error = %format!("{e:#}"), "agent permissions: queue failure");
|
tracing::warn!(error = %format!("{e:#}"), "agent permissions: queue failure");
|
||||||
error_problem(wanted_error_status(e), &format!("{e:#}"))
|
error_problem(wanted_error_status(e), &format!("{e:#}"))
|
||||||
|
|
@ -136,16 +150,20 @@ pub(crate) async fn get_agent_permissions(
|
||||||
pub(crate) async fn put_agent_permissions(
|
pub(crate) async fn put_agent_permissions(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(name): Path<String>,
|
Path(name): Path<String>,
|
||||||
Json(req): Json<Permissions>,
|
req: Result<Json<Permissions>, JsonRejection>,
|
||||||
) -> Result<Json<Permissions>, ProblemDetails> {
|
) -> Result<Json<Permissions>, ProblemDetails> {
|
||||||
let agent = agent_name(&name).map_err(refused)?;
|
let refuse = |p| logged("put", &name, p);
|
||||||
let value = into_stored(req).map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?;
|
let Json(req) =
|
||||||
let client = queue(&state).map_err(refused)?;
|
req.map_err(|rejection| refuse(error_problem(rejection.status(), &rejection.body_text())))?;
|
||||||
if !live(&state).await?.contains(&agent) {
|
let agent = agent_name(&name).map_err(|r| refuse(refused(r)))?;
|
||||||
return Err(error_problem(
|
let value =
|
||||||
|
into_stored(req).map_err(|why| refuse(error_problem(StatusCode::BAD_REQUEST, &why)))?;
|
||||||
|
let client = queue(&state).map_err(|r| refuse(refused(r)))?;
|
||||||
|
if !live(&state).await.map_err(refuse)?.contains(&agent) {
|
||||||
|
return Err(refuse(error_problem(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
&format!("no hive's wanted state declares an agent {agent:?} that is not destroyed"),
|
&format!("no hive's wanted state declares an agent {agent:?} that is not destroyed"),
|
||||||
));
|
)));
|
||||||
}
|
}
|
||||||
let store = open_or_create(&client).await?;
|
let store = open_or_create(&client).await?;
|
||||||
for _ in 0..MAX_ATTEMPTS {
|
for _ in 0..MAX_ATTEMPTS {
|
||||||
|
|
@ -184,8 +202,9 @@ pub(crate) async fn delete_agent_permissions(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(name): Path<String>,
|
Path(name): Path<String>,
|
||||||
) -> Result<StatusCode, ProblemDetails> {
|
) -> Result<StatusCode, ProblemDetails> {
|
||||||
let agent = agent_name(&name).map_err(refused)?;
|
let refuse = |p| logged("delete", &name, p);
|
||||||
let client = queue(&state).map_err(refused)?;
|
let agent = agent_name(&name).map_err(|r| refuse(refused(r)))?;
|
||||||
|
let client = queue(&state).map_err(|r| refuse(refused(r)))?;
|
||||||
let store = open_or_create(&client).await?;
|
let store = open_or_create(&client).await?;
|
||||||
store
|
store
|
||||||
.delete(&agent)
|
.delete(&agent)
|
||||||
|
|
@ -543,7 +562,7 @@ mod tests {
|
||||||
put_agent_permissions(
|
put_agent_permissions(
|
||||||
State(state.clone()),
|
State(state.clone()),
|
||||||
Path(agent.to_owned()),
|
Path(agent.to_owned()),
|
||||||
axum::Json(value),
|
Ok(axum::Json(value)),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
@ -605,6 +624,101 @@ mod tests {
|
||||||
assert_eq!(published(&client, "iris").await, None);
|
assert_eq!(published(&client, "iris").await, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Fields = std::collections::BTreeMap<String, String>;
|
||||||
|
|
||||||
|
/// Every event, by field name. Installed once as the global subscriber:
|
||||||
|
/// a per-thread one loses to a callsite another test already cached as
|
||||||
|
/// disabled.
|
||||||
|
struct Events(std::sync::Mutex<Vec<Fields>>);
|
||||||
|
|
||||||
|
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for &'static Events {
|
||||||
|
fn on_event(
|
||||||
|
&self,
|
||||||
|
event: &tracing::Event<'_>,
|
||||||
|
_: tracing_subscriber::layer::Context<'_, S>,
|
||||||
|
) {
|
||||||
|
let mut fields = Fields::new();
|
||||||
|
event.record(
|
||||||
|
&mut |field: &tracing::field::Field, value: &dyn std::fmt::Debug| {
|
||||||
|
fields.insert(field.name().to_owned(), format!("{value:?}"));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
self.0.lock().expect("unpoisoned").push(fields);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn events() -> &'static Events {
|
||||||
|
use tracing_subscriber::layer::SubscriberExt as _;
|
||||||
|
static EVENTS: Events = Events(std::sync::Mutex::new(Vec::new()));
|
||||||
|
static INSTALL: std::sync::Once = std::sync::Once::new();
|
||||||
|
INSTALL.call_once(|| {
|
||||||
|
tracing::subscriber::set_global_default(tracing_subscriber::registry().with(&EVENTS))
|
||||||
|
.expect("no other global subscriber in this test binary");
|
||||||
|
});
|
||||||
|
&EVENTS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The refusals logged for `agent`.
|
||||||
|
fn refusals(agent: &str) -> Vec<Fields> {
|
||||||
|
let want = format!("{agent:?}");
|
||||||
|
events()
|
||||||
|
.0
|
||||||
|
.lock()
|
||||||
|
.expect("unpoisoned")
|
||||||
|
.iter()
|
||||||
|
.filter(|f| {
|
||||||
|
f.get("message")
|
||||||
|
.is_some_and(|m| m.contains("request refused"))
|
||||||
|
&& f.get("agent") == Some(&want)
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every refused PUT or DELETE logs one `warn` naming the operation, the
|
||||||
|
/// agent, the status and the response's detail.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_refused_change_is_logged_with_the_agent_and_status() {
|
||||||
|
use tower::ServiceExt as _;
|
||||||
|
let nats = TestNats::start();
|
||||||
|
let client = nats.client().await;
|
||||||
|
let state = crate::agent_limits::tests::declared(&client).await;
|
||||||
|
let app = crate::startup::build_app(state.clone());
|
||||||
|
let _ = events();
|
||||||
|
|
||||||
|
// Control: the lookup finds nothing for an agent nobody refused.
|
||||||
|
assert_eq!(refusals("ghost"), []);
|
||||||
|
|
||||||
|
let _ = put(&state, "ghost", granted())
|
||||||
|
.await
|
||||||
|
.expect_err("undeclared");
|
||||||
|
let got = refusals("ghost");
|
||||||
|
let [one] = got.as_slice() else {
|
||||||
|
panic!("one event, got {got:?}");
|
||||||
|
};
|
||||||
|
assert_eq!(one["op"], "\"put\"");
|
||||||
|
assert_eq!(one["status"], "400");
|
||||||
|
assert!(one["detail"].contains("declares an agent"), "{one:?}");
|
||||||
|
|
||||||
|
let malformed = axum::http::Request::put("/api/agents/ghost2/permissions")
|
||||||
|
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(axum::body::Body::from(r#"{"permisions":[]}"#))
|
||||||
|
.expect("request");
|
||||||
|
let status = app.oneshot(malformed).await.expect("answers").status();
|
||||||
|
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
let got = refusals("ghost2");
|
||||||
|
assert_eq!(got.len(), 1, "{got:?}");
|
||||||
|
assert_eq!(got[0]["status"], "422");
|
||||||
|
|
||||||
|
let _ = delete_agent_permissions(State(state), Path("Bad_Ghost".to_owned()))
|
||||||
|
.await
|
||||||
|
.expect_err("not an identifier");
|
||||||
|
let got = refusals("Bad_Ghost");
|
||||||
|
assert_eq!(got.len(), 1, "{got:?}");
|
||||||
|
assert_eq!(got[0]["op"], "\"delete\"");
|
||||||
|
assert_eq!(got[0]["status"], "400");
|
||||||
|
}
|
||||||
|
|
||||||
async fn put_raw(app: &axum::Router, body: &'static str) -> StatusCode {
|
async fn put_raw(app: &axum::Router, body: &'static str) -> StatusCode {
|
||||||
use tower::ServiceExt as _;
|
use tower::ServiceExt as _;
|
||||||
app.clone()
|
app.clone()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue