use axum::{
Json,
extract::{Path, State},
};
use serde::Serialize;
use super::auth::HttpCaller;
use super::error::HttpWireError;
use crate::ServerState;
use crate::api::handlers::managed_workers::wire_error;
use crate::namespace::CallerIdentity;
use crate::worker::supervisor::{ManagedWorkerReport, ManagedWorkerStatus};
#[derive(Debug, Serialize)]
pub(crate) struct ManagedWorkerActionResponse {
pub(crate) worker: ManagedWorkerStatus,
}
pub(crate) async fn list_managed_workers(
State(state): State<ServerState>,
HttpCaller(caller): HttpCaller,
) -> Result<Json<ManagedWorkerReport>, HttpWireError> {
deploy_gate(&caller)?;
state
.worker_supervisor()
.report()
.await
.map(Json)
.map_err(|error| HttpWireError(wire_error(&error)))
}
pub(crate) async fn start_managed_worker(
State(state): State<ServerState>,
HttpCaller(caller): HttpCaller,
Path(name): Path<String>,
) -> Result<Json<ManagedWorkerActionResponse>, HttpWireError> {
deploy_gate(&caller)?;
state
.worker_supervisor()
.start(&name)
.await
.map(|worker| Json(ManagedWorkerActionResponse { worker }))
.map_err(|error| HttpWireError(wire_error(&error)))
}
pub(crate) async fn stop_managed_worker(
State(state): State<ServerState>,
HttpCaller(caller): HttpCaller,
Path(name): Path<String>,
) -> Result<Json<ManagedWorkerActionResponse>, HttpWireError> {
deploy_gate(&caller)?;
state
.worker_supervisor()
.stop(&name)
.await
.map(|worker| Json(ManagedWorkerActionResponse { worker }))
.map_err(|error| HttpWireError(wire_error(&error)))
}
pub(crate) async fn restart_managed_worker(
State(state): State<ServerState>,
HttpCaller(caller): HttpCaller,
Path(name): Path<String>,
) -> Result<Json<ManagedWorkerActionResponse>, HttpWireError> {
deploy_gate(&caller)?;
state
.worker_supervisor()
.restart(&name)
.await
.map(|worker| Json(ManagedWorkerActionResponse { worker }))
.map_err(|error| HttpWireError(wire_error(&error)))
}
fn deploy_gate(caller: &CallerIdentity) -> Result<(), HttpWireError> {
if caller.deploy_granted() {
Ok(())
} else {
Err(HttpWireError(aion_proto::WireError::deploy_denied(
"managed-worker administration requires the deployment-wide deploy grant",
)))
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use std::num::NonZeroU32;
use std::path::PathBuf;
use std::time::Duration;
use aion_store::{
DeployedBinaryIdentity, DesiredState, NewWorkerDeployment, WorkerArtifactRef,
WorkerDeployment,
};
use axum::{
body::Body,
extract::{Path, State},
http::Request,
};
use tower::ServiceExt;
use super::super::auth::HttpCaller;
use super::super::router::workflow_router;
use super::super::test_support::{runtime_config, server_state, shared_engine};
use super::{restart_managed_worker, start_managed_worker, stop_managed_worker};
use crate::worker::supervisor::{ManagedExecutable, SupervisionPolicy};
use crate::{
CallerIdentity, NamespaceResolver, StaticScheduleNamespaces, StaticWorkflowNamespaces,
config::NamespaceMode,
};
type TestResult = Result<(), Box<dyn std::error::Error>>;
#[tokio::test]
async fn the_status_route_reports_an_uncommissioned_server_with_its_remedy() -> TestResult {
let state = admin_state(false).await?;
let response = workflow_router(state.clone())
.oneshot(
Request::builder()
.method("GET")
.uri("/workers/managed")
.body(Body::empty())?,
)
.await?;
assert_eq!(response.status(), axum::http::StatusCode::OK);
let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024).await?;
let body: serde_json::Value = serde_json::from_slice(&bytes)?;
assert_eq!(body["commissioned"], serde_json::Value::Bool(false));
let remedy = body["remedy"].as_str().ok_or("a remedy must be present")?;
assert!(remedy.contains("[worker_supervision]"), "{remedy}");
assert_eq!(
body["workers"].as_array().map(Vec::len),
Some(0),
"no deployment was created, so no worker may be reported"
);
Ok(())
}
#[tokio::test]
async fn the_lifecycle_routes_are_dark_when_deploy_is_disabled() -> TestResult {
let state = admin_state(false).await?;
assert!(
state
.worker_supervisor()
.commission(policy()?, ManagedExecutable::Path(PathBuf::from("/bin/sh")))
);
state
.worker_deployment_store()
.put_worker_deployment(deployment("dark", DesiredState::Stopped)?)
.await
.map(drop)?;
let router = workflow_router(state.clone());
for verb in ["start", "stop", "restart"] {
let response = router
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/workers/managed/dark/{verb}"))
.body(Body::empty())?,
)
.await?;
assert_eq!(
response.status(),
axum::http::StatusCode::NOT_FOUND,
"{verb} answered with the deploy surface disabled"
);
}
let record = state
.worker_deployment_store()
.get_worker_deployment("dark")
.await?
.ok_or("the durable record must still exist")?;
assert_eq!(
record.desired,
DesiredState::Stopped,
"a dark route still changed durable intent"
);
let report = state.worker_supervisor().report().await?;
let dark = report
.workers
.iter()
.find(|worker| worker.name == "dark")
.ok_or("the status read must keep answering with deploy off")?;
assert!(dark.pid.is_none(), "a dark route still spawned a process");
Ok(())
}
#[tokio::test]
async fn the_lifecycle_routes_start_restart_and_stop_a_managed_worker() -> TestResult {
let state = admin_state(true).await?;
assert!(
state
.worker_supervisor()
.commission(policy()?, ManagedExecutable::Path(PathBuf::from("/bin/sh")))
);
state
.worker_deployment_store()
.put_worker_deployment(deployment("lifecycle", DesiredState::Stopped)?)
.await
.map(drop)?;
let router = workflow_router(state.clone());
let started = post_json(&router, "/workers/managed/lifecycle/start").await;
let restarted = post_json(&router, "/workers/managed/lifecycle/restart").await;
let stopped = post_json(&router, "/workers/managed/lifecycle/stop").await;
let guard = state.worker_supervisor().stop("lifecycle").await;
let (started, restarted, stopped) = (started?, restarted?, stopped?);
drop(guard?);
assert_eq!(started.0, axum::http::StatusCode::OK, "{}", started.1);
assert_eq!(started.1["worker"]["name"], "lifecycle");
assert_eq!(started.1["worker"]["desired"], "running");
assert_eq!(started.1["worker"]["task_queue"], "shell");
assert_eq!(restarted.0, axum::http::StatusCode::OK, "{}", restarted.1);
assert_eq!(restarted.1["worker"]["desired"], "running");
assert_eq!(stopped.0, axum::http::StatusCode::OK, "{}", stopped.1);
assert_eq!(stopped.1["worker"]["desired"], "stopped");
assert_eq!(stopped.1["worker"]["state"], "stopped");
let record = state
.worker_deployment_store()
.get_worker_deployment("lifecycle")
.await?
.ok_or("the durable record must survive the lifecycle")?;
assert_eq!(
record.desired,
DesiredState::Stopped,
"the stop must have recorded the operator intent durably"
);
Ok(())
}
#[tokio::test]
async fn an_unknown_deployment_name_is_not_found_on_every_verb() -> TestResult {
let state = admin_state(true).await?;
assert!(
state
.worker_supervisor()
.commission(policy()?, ManagedExecutable::Path(PathBuf::from("/bin/sh")))
);
let router = workflow_router(state.clone());
for verb in ["start", "stop", "restart"] {
let (status, body) =
post_json(&router, &format!("/workers/managed/absent/{verb}")).await?;
assert_eq!(
status,
axum::http::StatusCode::NOT_FOUND,
"{verb} answered {status} for an unknown deployment"
);
assert_eq!(body["code"], "not_found", "{verb}: {body}");
}
Ok(())
}
#[tokio::test]
async fn an_uncommissioned_server_refuses_start_and_restart_with_the_remedy() -> TestResult {
let state = admin_state(true).await?;
state
.worker_deployment_store()
.put_worker_deployment(deployment("unsupervised", DesiredState::Running)?)
.await
.map(drop)?;
let router = workflow_router(state.clone());
for verb in ["start", "restart"] {
let (status, body) =
post_json(&router, &format!("/workers/managed/unsupervised/{verb}")).await?;
assert_eq!(
status,
axum::http::StatusCode::CONFLICT,
"{verb} must refuse on an uncommissioned server: {body}"
);
assert_eq!(body["code"], "invalid_state", "{verb}: {body}");
let message = body["message"].as_str().ok_or("refusal had no message")?;
assert!(
message.contains("[worker_supervision]"),
"{verb}: {message}"
);
}
let (status, body) = post_json(&router, "/workers/managed/unsupervised/stop").await?;
assert_eq!(status, axum::http::StatusCode::OK, "{body}");
assert_eq!(body["worker"]["desired"], "stopped");
let record = state
.worker_deployment_store()
.get_worker_deployment("unsupervised")
.await?
.ok_or("the durable record must survive the stop")?;
assert_eq!(record.desired, DesiredState::Stopped);
Ok(())
}
#[tokio::test]
async fn a_caller_without_the_deploy_grant_is_denied_before_any_effect() -> TestResult {
let state = admin_state(true).await?;
assert!(
state
.worker_supervisor()
.commission(policy()?, ManagedExecutable::Path(PathBuf::from("/bin/sh")))
);
state
.worker_deployment_store()
.put_worker_deployment(deployment("guarded", DesiredState::Stopped)?)
.await
.map(drop)?;
let denied = CallerIdentity::operator("reader").with_deploy(false);
let start = start_managed_worker(
State(state.clone()),
HttpCaller(denied.clone()),
Path("guarded".to_owned()),
)
.await;
let stop = stop_managed_worker(
State(state.clone()),
HttpCaller(denied.clone()),
Path("guarded".to_owned()),
)
.await;
let restart = restart_managed_worker(
State(state.clone()),
HttpCaller(denied),
Path("guarded".to_owned()),
)
.await;
for (verb, result) in [("start", start), ("stop", stop), ("restart", restart)] {
let refusal = result
.err()
.ok_or_else(|| format!("{verb} served an ungranted caller"))?;
assert_eq!(
refusal.0.code,
aion_proto::WireErrorCode::DeployDenied,
"{verb}: {}",
refusal.0.message
);
}
let record = state
.worker_deployment_store()
.get_worker_deployment("guarded")
.await?
.ok_or("the durable record must still exist")?;
assert_eq!(
record.desired,
DesiredState::Stopped,
"a denied verb still changed durable intent"
);
let report = state.worker_supervisor().report().await?;
let guarded = report
.workers
.iter()
.find(|worker| worker.name == "guarded")
.ok_or("the deployment must be reported")?;
assert!(
guarded.pid.is_none(),
"a denied start still spawned a process"
);
Ok(())
}
#[tokio::test]
async fn a_deploy_denied_lifecycle_call_is_a_403_at_the_transport() -> TestResult {
let state = auth_on_state().await?;
let router = workflow_router(state.clone());
for (verb, past_the_gate) in [
("start", axum::http::StatusCode::CONFLICT),
("stop", axum::http::StatusCode::NOT_FOUND),
("restart", axum::http::StatusCode::CONFLICT),
] {
let uri = format!("/workers/managed/absent/{verb}");
let denied = router
.clone()
.oneshot(lifecycle_request(&uri, false)?)
.await?;
assert_eq!(
denied.status(),
axum::http::StatusCode::FORBIDDEN,
"{verb} served a caller with no deploy grant"
);
let bytes = axum::body::to_bytes(denied.into_body(), 64 * 1024).await?;
let body: serde_json::Value = serde_json::from_slice(&bytes)?;
assert_eq!(body["code"], "deploy_denied", "{verb}: {body}");
let granted = router
.clone()
.oneshot(lifecycle_request(&uri, true)?)
.await?;
assert_eq!(
granted.status(),
past_the_gate,
"{verb} must pass the gate for a granted caller and land on \
its own downstream refusal"
);
}
Ok(())
}
fn lifecycle_request(
uri: &str,
granted: bool,
) -> Result<Request<Body>, Box<dyn std::error::Error>> {
#[cfg(feature = "auth")]
let token = if granted {
crate::auth::test_support::mint_token_with_deploy("alice", "tenant-a", true)?
} else {
crate::auth::test_support::mint_token("alice", "tenant-a")?
};
#[cfg(not(feature = "auth"))]
let token = super::super::test_support::TOKEN.to_owned();
let builder = Request::builder()
.method("POST")
.uri(uri)
.header("authorization", format!("Bearer {token}"))
.header("x-aion-subject", "alice")
.header("x-aion-namespaces", "tenant-a");
#[cfg(not(feature = "auth"))]
let builder = if granted {
builder.header("x-aion-deploy", "true")
} else {
builder
};
Ok(builder.body(Body::empty())?)
}
async fn post_json(
router: &axum::Router,
uri: &str,
) -> Result<(axum::http::StatusCode, serde_json::Value), Box<dyn std::error::Error>> {
let response = router
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(uri)
.body(Body::empty())?,
)
.await?;
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024).await?;
Ok((status, serde_json::from_slice(&bytes)?))
}
fn policy() -> Result<SupervisionPolicy, &'static str> {
Ok(SupervisionPolicy {
restart_backoff_initial: Duration::from_millis(20),
restart_backoff_max: Duration::from_millis(20),
restart_backoff_multiplier: NonZeroU32::new(1).ok_or("multiplier")?,
restart_window: Duration::from_secs(600),
max_restarts_per_window: NonZeroU32::new(5).ok_or("budget")?,
stop_grace: Duration::from_secs(2),
})
}
fn deployment(
name: &str,
desired: DesiredState,
) -> Result<WorkerDeployment, Box<dyn std::error::Error>> {
Ok(WorkerDeployment::new(
NewWorkerDeployment {
name: name.to_owned(),
artifact: WorkerArtifactRef::Builtin {
verb: vec!["-c".to_owned(), "sleep 300".to_owned()],
},
binary: DeployedBinaryIdentity {
version: "test".to_owned(),
commit: "test".to_owned(),
dirty: "false".to_owned(),
content_hash: "deploy-time-hash".to_owned(),
},
namespaces: BTreeSet::from(["default".to_owned()]),
task_queue: "shell".to_owned(),
node: None,
desired,
},
chrono::Utc::now(),
)?)
}
async fn admin_state(
deploy_enabled: bool,
) -> Result<crate::test_support::StateUnderTest, Box<dyn std::error::Error>> {
let mut runtime = runtime_config();
runtime.auth.enabled = false;
runtime.deploy.enabled = deploy_enabled;
engine_state(runtime).await
}
async fn auth_on_state()
-> Result<crate::test_support::StateUnderTest, Box<dyn std::error::Error>> {
let mut runtime = runtime_config();
runtime.deploy.enabled = true;
engine_state(runtime).await
}
async fn engine_state(
runtime: crate::config::RuntimeConfig,
) -> Result<crate::test_support::StateUnderTest, Box<dyn std::error::Error>> {
let (engine, store, visibility) = shared_engine().await?;
std::hint::black_box((store, visibility));
let resolver = NamespaceResolver::from_parts(
NamespaceMode::SharedEngine,
Some(engine.handle()),
std::sync::Arc::new(StaticWorkflowNamespaces::default()),
std::sync::Arc::new(StaticScheduleNamespaces::default()),
);
server_state(engine, resolver, runtime).await
}
}