//! HTTP API for listing, creating and deleting the mutations of a live
//! MutantDeployment.
//!
//! Created mutations are standalone `Mutation` resources, never edits to the
//! MutantDeployment, which can stay owned by Argo CD (or any GitOps tool)
//! without the API and the sync fighting over it. Mutations declared inline
//! in the MutantDeployment are listed too, but are read-only here: they
//! change in git.
//!
//! ```text
//! GET /healthz
//! GET /api/v1/namespaces/{ns}/mutantdeployments
//! GET /api/v1/namespaces/{ns}/mutantdeployments/{name}/mutations
//! POST /api/v1/namespaces/{ns}/mutantdeployments/{name}/mutations
//! GET /api/v1/namespaces/{ns}/mutantdeployments/{name}/mutations/{label}
//! DELETE /api/v1/namespaces/{ns}/mutantdeployments/{name}/mutations/{label}
//! ```
use crate::crd::{
applied_status, resolve, MutantDeployment, Mutation, MutationDef, MutationSpec, MutationStatus,
Source, OWNER_LABEL, TARGET_LABEL,
};
use axum::{
extract::{Path, Request, State},
http::{header, StatusCode},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::get,
Json, Router,
};
use k8s_openapi::api::apps::v1::Deployment;
use kube::{
api::{Api, DeleteParams, ListParams, PostParams},
Client, Resource, ResourceExt,
};
use serde::Serialize;
use serde_json::json;
use std::{collections::BTreeMap, sync::Arc};
use tracing::{info, warn};
#[derive(Clone)]
pub struct AppState {
client: Client,
/// Bearer token required on every `/api` request; `None` disables auth.
token: Option<Arc<str>>,
}
impl AppState {
pub fn new(client: Client, token: Option<String>) -> Self {
Self { client, token: token.map(Into::into) }
}
}
pub fn router(state: AppState) -> Router {
let api = Router::new()
.route("/namespaces/{ns}/mutantdeployments", get(list_mutants))
.route(
"/namespaces/{ns}/mutantdeployments/{name}/mutations",
get(list_mutations).post(create_mutation),
)
.route(
"/namespaces/{ns}/mutantdeployments/{name}/mutations/{label}",
get(get_mutation).delete(delete_mutation),
)
.route_layer(middleware::from_fn_with_state(state.clone(), require_token));
Router::new()
.route("/healthz", get(|| async { "ok" }))
.nest("/api/v1", api)
.with_state(state)
}
async fn require_token(State(state): State<AppState>, req: Request, next: Next) -> Response {
let presented = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "));
if authorized(state.token.as_deref(), presented) {
next.run(req).await
} else {
ApiError::Unauthorized.into_response()
}
}
fn authorized(expected: Option<&str>, presented: Option<&str>) -> bool {
match (expected, presented) {
(None, _) => true,
(Some(want), Some(got)) => constant_time_eq(want.as_bytes(), got.as_bytes()),
(Some(_), None) => false,
}
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
#[derive(Debug)]
enum ApiError {
Unauthorized,
BadRequest(String),
NotFound(String),
Conflict(String),
Kube(kube::Error),
}
impl From<kube::Error> for ApiError {
fn from(err: kube::Error) -> Self {
match err {
kube::Error::Api(ref e) if e.code == 404 => ApiError::NotFound(e.message.clone()),
kube::Error::Api(ref e) if e.code == 409 => ApiError::Conflict(e.message.clone()),
kube::Error::Api(ref e) if e.code == 422 => ApiError::BadRequest(e.message.clone()),
other => ApiError::Kube(other),
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, msg) = match self {
ApiError::Unauthorized => (StatusCode::UNAUTHORIZED, "missing or invalid bearer token".into()),
ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
ApiError::NotFound(m) => (StatusCode::NOT_FOUND, m),
ApiError::Conflict(m) => (StatusCode::CONFLICT, m),
ApiError::Kube(e) => {
warn!("kubernetes request failed: {e}");
(StatusCode::BAD_GATEWAY, format!("kubernetes request failed: {e}"))
}
};
(status, Json(json!({ "error": msg }))).into_response()
}
}
type ApiResult<T> = Result<T, ApiError>;
/// A mutation as the API reports it: its definition, where it was declared,
/// and its live state.
#[derive(Serialize, Debug)]
struct MutationView {
#[serde(flatten)]
def: MutationDef,
source: Source,
status: MutationStatus,
}
#[derive(Serialize)]
struct MutantSummary {
name: String,
deployment: String,
mutations: Vec<String>,
}
/// Every mutation of `name`: the ones running, then Mutation resources that
/// are not (invalid, or a duplicate label), with why.
async fn views(client: &Client, ns: &str, name: &str) -> ApiResult<Vec<MutationView>> {
let mutant = Api::<MutantDeployment>::namespaced(client.clone(), ns).get(name).await?;
let resources = Api::<Mutation>::namespaced(client.clone(), ns)
.list(&ListParams::default())
.await?
.items;
// Status is read live from the Deployments rather than from Mutation
// status, so inline mutations, which have no status, get one too.
let deployments: BTreeMap<String, Deployment> = Api::<Deployment>::namespaced(client.clone(), ns)
.list(&ListParams::default().labels(&format!("{OWNER_LABEL}={name}")))
.await?
.into_iter()
.map(|d| (d.name_any(), d))
.collect();
let resolved = resolve(&mutant, &resources);
let mut out: Vec<_> = resolved
.active
.into_iter()
.map(|(def, source)| {
let dname = mutant.deployment_name(&def.label);
let status = applied_status(dname.clone(), def.instances, deployments.get(&dname));
MutationView { def, source, status }
})
.collect();
for (rname, state, message) in resolved.rejected {
if let Some(m) = resources.iter().find(|m| m.name_any() == rname) {
out.push(MutationView {
def: m.spec.def.clone(),
source: Source::Resource { name: rname },
status: MutationStatus { state, message: Some(message), ..Default::default() },
});
}
}
Ok(out)
}
async fn list_mutants(State(st): State<AppState>, Path(ns): Path<String>) -> ApiResult<Json<Vec<MutantSummary>>> {
let mutants = Api::<MutantDeployment>::namespaced(st.client.clone(), &ns)
.list(&ListParams::default())
.await?;
let resources = Api::<Mutation>::namespaced(st.client, &ns)
.list(&ListParams::default())
.await?
.items;
Ok(Json(
mutants
.into_iter()
.map(|md| MutantSummary {
mutations: resolve(&md, &resources).active.into_iter().map(|(d, _)| d.label).collect(),
deployment: md.base_name(),
name: md.name_any(),
})
.collect(),
))
}
async fn list_mutations(
State(st): State<AppState>,
Path((ns, name)): Path<(String, String)>,
) -> ApiResult<Json<Vec<MutationView>>> {
Ok(Json(views(&st.client, &ns, &name).await?))
}
async fn get_mutation(
State(st): State<AppState>,
Path((ns, name, label)): Path<(String, String, String)>,
) -> ApiResult<Json<MutationView>> {
// The running one: listed first, ahead of any rejected duplicate.
views(&st.client, &ns, &name)
.await?
.into_iter()
.find(|v| v.def.label == label)
.map(Json)
.ok_or_else(|| ApiError::NotFound(format!("mutation {label:?} not found")))
}
/// Creates a Mutation resource named `{name}-{label}`.
async fn create_mutation(
State(st): State<AppState>,
Path((ns, name)): Path<(String, String)>,
Json(def): Json<MutationDef>,
) -> ApiResult<(StatusCode, Json<Mutation>)> {
def.validate().map_err(ApiError::BadRequest)?;
let mutant = Api::<MutantDeployment>::namespaced(st.client.clone(), &ns).get(&name).await?;
// Refused here rather than created only to sit in Conflict.
if views(&st.client, &ns, &name).await?.iter().any(|v| v.def.label == def.label) {
return Err(ApiError::Conflict(format!("mutation {:?} already exists", def.label)));
}
let label = def.label.clone();
let mut mutation = Mutation::new(
&format!("{name}-{label}"),
MutationSpec { mutant_deployment: name.clone(), def },
);
mutation.metadata.labels = Some(BTreeMap::from([(TARGET_LABEL.to_string(), name.clone())]));
// Set up front so the Mutation is a dependent of its MutantDeployment
// from the start; the controller also adds it to hand-made ones.
mutation.metadata.owner_references = mutant.owner_ref(&()).map(|o| vec![o]);
let created = Api::<Mutation>::namespaced(st.client, &ns)
.create(&PostParams::default(), &mutation)
.await?;
info!(%ns, %name, %label, "mutation created");
Ok((StatusCode::CREATED, Json(created)))
}
/// Deletes every Mutation resource of `name` with this label: the running
/// one and any duplicate waiting behind it, which would otherwise take its
/// place. Inline mutations are refused.
async fn delete_mutation(
State(st): State<AppState>,
Path((ns, name, label)): Path<(String, String, String)>,
) -> ApiResult<StatusCode> {
let matching: Vec<_> = views(&st.client, &ns, &name)
.await?
.into_iter()
.filter(|v| v.def.label == label)
.collect();
if matching.is_empty() {
return Err(ApiError::NotFound(format!("mutation {label:?} not found")));
}
if matching.iter().any(|v| v.source == Source::Inline) {
return Err(ApiError::Conflict(format!(
"mutation {label:?} is declared inline in MutantDeployment {name}; remove it from its spec.mutations (in git) instead"
)));
}
let api = Api::<Mutation>::namespaced(st.client, &ns);
for v in matching {
if let Source::Resource { name: rname } = v.source {
match api.delete(&rname, &DeleteParams::default()).await {
Ok(_) | Err(kube::Error::Api(kube::core::ErrorResponse { code: 404, .. })) => {}
Err(e) => return Err(e.into()),
}
}
}
info!(%ns, %name, %label, "mutation deleted");
Ok(StatusCode::NO_CONTENT)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn auth() {
assert!(authorized(None, None));
assert!(authorized(None, Some("anything")));
assert!(authorized(Some("s3cret"), Some("s3cret")));
assert!(!authorized(Some("s3cret"), Some("s3cre")));
assert!(!authorized(Some("s3cret"), Some("s3creT")));
assert!(!authorized(Some("s3cret"), None));
}
}
neiam
/mutant-operator
An Experimental Operator
public · Issues · Pulls · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
10.8 KiB · text
History
c8d9978