use std::collections::BTreeSet;
use aion_core::{ClusterEvent, PutOutcome};
use aion_proto::WireError;
use aion_store::{
DesiredState, NewWorkerDeployment, WorkerArtifactRef, WorkerDeployment,
WorkerDeploymentDeleteOutcome, WorkerDeploymentListing,
};
use axum::{
Json,
extract::{Path, State},
};
use serde::{Deserialize, Serialize};
use super::auth::HttpCaller;
use super::error::HttpWireError;
use crate::worker::supervisor::{Convergence, converge_and_report};
use crate::worker::{BinaryIdentityCaptureError, capture_binary_identity};
use crate::{ServerError, ServerState};
#[derive(Debug, Deserialize)]
pub(crate) struct PutWorkerDeploymentRequest {
artifact: WorkerArtifactRef,
namespaces: BTreeSet<String>,
task_queue: String,
node: Option<String>,
desired: DesiredState,
}
#[derive(Debug, Deserialize)]
pub(crate) struct SetDesiredStateRequest {
desired: DesiredState,
}
#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct PutWorkerDeploymentResponse {
outcome: PutOutcome,
deployment: WorkerDeployment,
}
pub(crate) async fn put_worker_deployment(
State(state): State<ServerState>,
HttpCaller(caller): HttpCaller,
Path(name): Path<String>,
Json(request): Json<PutWorkerDeploymentRequest>,
) -> Result<Json<PutWorkerDeploymentResponse>, HttpWireError> {
require_deploy_grant(&caller)?;
reserve_auto_prefix(&name)?;
let binary = capture_binary_identity().map_err(|error| capture_refusal(&error))?;
let requested = WorkerDeployment::new(
NewWorkerDeployment {
name,
artifact: request.artifact,
binary,
namespaces: request.namespaces,
task_queue: request.task_queue,
node: request.node,
desired: request.desired,
},
chrono::Utc::now(),
)
.map_err(|error| HttpWireError(WireError::invalid_input(error.to_string())))?;
let result = state
.worker_deployment_store()
.put_worker_deployment(requested)
.await
.map_err(store_error)?;
let outcome = result.outcome;
let record = result.deployment;
let event_name = record.name.clone();
let desired_state = record.desired;
let binary_version = record.binary.version.clone();
let binary_content_hash = record.binary.content_hash.clone();
drop(
state
.cluster_publisher()
.emit(|meta| ClusterEvent::WorkerDeploymentPut {
meta,
name: event_name,
outcome,
desired_state,
binary_version,
binary_content_hash,
}),
);
let mode = match outcome {
PutOutcome::Created => Convergence::Idempotent,
PutOutcome::Replaced => Convergence::Replacing,
};
drop(
converge_and_report(
state.worker_supervisor(),
&record.name,
mode,
"worker-deployment put",
)
.await,
);
Ok(Json(PutWorkerDeploymentResponse {
outcome,
deployment: record,
}))
}
pub(crate) async fn list_worker_deployments(
State(state): State<ServerState>,
HttpCaller(caller): HttpCaller,
) -> Result<Json<WorkerDeploymentListing>, HttpWireError> {
require_deploy_grant(&caller)?;
state
.worker_deployment_store()
.list_worker_deployments()
.await
.map(Json)
.map_err(store_error)
}
pub(crate) async fn get_worker_deployment(
State(state): State<ServerState>,
HttpCaller(caller): HttpCaller,
Path(name): Path<String>,
) -> Result<Json<WorkerDeployment>, HttpWireError> {
require_deploy_grant(&caller)?;
state
.worker_deployment_store()
.get_worker_deployment(&name)
.await
.map_err(store_error)?
.map(Json)
.ok_or_else(|| not_found(&name))
}
pub(crate) async fn set_worker_deployment_desired_state(
State(state): State<ServerState>,
HttpCaller(caller): HttpCaller,
Path(name): Path<String>,
Json(request): Json<SetDesiredStateRequest>,
) -> Result<Json<WorkerDeployment>, HttpWireError> {
require_deploy_grant(&caller)?;
let record = state
.worker_deployment_store()
.set_desired_state(&name, request.desired)
.await
.map_err(store_error)?
.ok_or_else(|| not_found(&name))?;
let event_name = record.name.clone();
let desired_state = record.desired;
drop(state.cluster_publisher().emit(|meta| {
ClusterEvent::WorkerDeploymentDesiredStateChanged {
meta,
name: event_name,
desired_state,
}
}));
drop(
converge_and_report(
state.worker_supervisor(),
&record.name,
Convergence::Idempotent,
"worker-deployment desired-state",
)
.await,
);
Ok(Json(record))
}
pub(crate) async fn delete_worker_deployment(
State(state): State<ServerState>,
HttpCaller(caller): HttpCaller,
Path(name): Path<String>,
) -> Result<Json<WorkerDeploymentDeleteOutcome>, HttpWireError> {
require_deploy_grant(&caller)?;
let outcome = state
.worker_deployment_store()
.delete_worker_deployment(&name)
.await
.map_err(store_error)?;
if !outcome.existed {
return Err(not_found(&name));
}
if let Err(error) = state.worker_supervisor().forget(&name).await {
tracing::error!(
operation = "worker-deployment delete",
worker = name.as_str(),
%error,
"the worker deployment record was deleted but its supervised process could not be \
proven stopped; find it with the process group in `GET /workers/managed` before the \
delete removed the row"
);
}
let event_name = name;
drop(
state
.cluster_publisher()
.emit(|meta| ClusterEvent::WorkerDeploymentDeleted {
meta,
name: event_name,
}),
);
Ok(Json(outcome))
}
fn reserve_auto_prefix(name: &str) -> Result<(), HttpWireError> {
if crate::worker::auto_provision::is_auto_name(name) {
return Err(HttpWireError(WireError::invalid_input(format!(
"worker deployment name `{name}` is refused: the `{prefix}` prefix is reserved for \
the records this server mints itself for documents that declare a `harness` \
section, and a deploy of such a document would replace anything written there. \
Choose a name without that prefix — an operator-authored record serving the same \
task queue already takes precedence over auto-provision.",
prefix = crate::worker::auto_provision::AUTO_PREFIX
))));
}
Ok(())
}
fn require_deploy_grant(caller: &crate::CallerIdentity) -> Result<(), HttpWireError> {
if caller.deploy_granted() {
Ok(())
} else {
Err(HttpWireError(
ServerError::namespace_denied(
"worker-deployment management requires the deployment-wide deploy grant",
)
.to_wire_error(),
))
}
}
fn capture_refusal(error: &BinaryIdentityCaptureError) -> HttpWireError {
HttpWireError(WireError::backend(format!(
"worker deployment refused because binary identity capture failed: {error}"
)))
}
fn store_error(error: aion_store::StoreError) -> HttpWireError {
HttpWireError(ServerError::from(error).to_wire_error())
}
fn not_found(name: &str) -> HttpWireError {
HttpWireError(WireError::not_found(format!(
"worker deployment `{name}` was not found"
)))
}
#[cfg(test)]
#[path = "worker_deployments_tests.rs"]
mod tests;