use super::*;
#[derive(Serialize)]
struct CreateDeploymentResponse {
id: String,
missing: Vec<String>,
}
#[derive(Debug, Default, Deserialize)]
pub(super) struct DeployMetaQuery {
source: Option<String>,
branch: Option<String>,
author: Option<String>,
message: Option<String>,
tag: Option<String>,
tags: Option<String>,
}
impl From<DeployMetaQuery> for DeployMetaInput {
fn from(q: DeployMetaQuery) -> Self {
Self {
source: q.source,
branch: q.branch,
author: q.author,
message: q.message,
tag: q.tag,
tags: q
.tags
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default(),
}
}
}
pub(super) async fn create_deployment(
State(deploy): State<DeployStore>,
Path(_site): Path<String>,
Query(meta): Query<DeployMetaQuery>,
Json(manifest): Json<Manifest>,
) -> Response {
let result = async {
let id = deploy.put_manifest_with(&manifest, meta.into()).await?;
let missing = deploy.missing_blobs(&manifest).await?;
Ok::<_, DeployError>((id, missing))
}
.await;
match result {
Ok((id, missing)) => {
srvmetrics::server_metrics().record_deployment();
(
StatusCode::OK,
Json(CreateDeploymentResponse { id, missing }),
)
.into_response()
}
Err(err) => deploy_error_response(err),
}
}
pub(super) async fn put_blob(
State(deploy): State<DeployStore>,
Extension(guard): Extension<Arc<UploadGuard>>,
Path(hash): Path<String>,
headers: HeaderMap,
body: Body,
) -> Response {
let content_length = headers
.get(header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok());
if guard.content_length_rejected(content_length) {
return (
StatusCode::PAYLOAD_TOO_LARGE,
"blob exceeds the upload limit\n",
)
.into_response();
}
let Some(_permit) = guard.try_acquire() else {
return (
StatusCode::SERVICE_UNAVAILABLE,
"too many concurrent uploads; retry shortly\n",
)
.into_response();
};
let stream = body
.into_data_stream()
.map(|chunk| chunk.map_err(|err| StorageError::backend(err.to_string())))
.boxed();
let stream = guard.limit_body(stream);
match deploy.put_blob(&hash, stream).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(err) => deploy_error_response(err),
}
}
pub(super) async fn activate_deployment(
State(deploy): State<DeployStore>,
Extension(handlers): Extension<Arc<HandlerRuntime>>,
Extension(project): axum::extract::Extension<ProjectContext>,
Path((site, id)): Path<(String, String)>,
) -> Response {
if let Some(resp) = reject_invalid_name("site", &site) {
return resp;
}
match deploy.get_manifest(&id).await {
Ok(Some(manifest)) => {
let site_config = match deploy.get_site_config(project.as_ref(), &site).await {
Ok(config) => config,
Err(err) => return deploy_error_response(err),
};
if let Err(reason) = handlers
.precheck_activation(&deploy, &manifest, site_config.as_ref())
.await
{
tracing::warn!(site, id, reason, "activation refused by handler pre-check");
return (StatusCode::UNPROCESSABLE_ENTITY, format!("{reason}\n")).into_response();
}
}
Ok(None) => {}
Err(err) => return deploy_error_response(err),
}
match deploy.activate(project.as_ref(), &site, &id).await {
Ok(()) => {
srvmetrics::server_metrics().record_activation();
StatusCode::NO_CONTENT.into_response()
}
Err(err) => deploy_error_response(err),
}
}
#[derive(Serialize)]
struct CurrentResponse {
site: String,
deployment: Option<String>,
}
pub(super) async fn current_deployment(
State(deploy): State<DeployStore>,
Extension(project): axum::extract::Extension<ProjectContext>,
Path(site): Path<String>,
) -> Response {
match deploy.current_id(project.as_ref(), &site).await {
Ok(deployment) => Json(CurrentResponse { site, deployment }).into_response(),
Err(err) => deploy_error_response(err),
}
}
pub(super) async fn list_deployments(
State(deploy): State<DeployStore>,
Extension(project): axum::extract::Extension<ProjectContext>,
Path(site): Path<String>,
) -> Response {
match deploy.deployments(project.as_ref(), &site).await {
Ok(list) => Json(list).into_response(),
Err(err) => deploy_error_response(err),
}
}
pub(super) async fn list_sites(
State(deploy): State<DeployStore>,
Extension(project): axum::extract::Extension<ProjectContext>,
) -> Response {
match deploy.all_sites(project.as_ref()).await {
Ok(sites) => Json(sites).into_response(),
Err(err) => deploy_error_response(err),
}
}
pub(super) async fn get_site_config(
State(deploy): State<DeployStore>,
Extension(project): axum::extract::Extension<ProjectContext>,
Path(site): Path<String>,
) -> Response {
match deploy.get_site_config(project.as_ref(), &site).await {
Ok(config) => Json(config.unwrap_or_default()).into_response(),
Err(err) => deploy_error_response(err),
}
}
pub(super) async fn delete_site(
State(deploy): State<DeployStore>,
Extension(project): axum::extract::Extension<ProjectContext>,
Path(site): Path<String>,
) -> Response {
match deploy.delete_site(project.as_ref(), &site).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(err) => deploy_error_response(err),
}
}
fn canon_domain_entry(host: &str) -> String {
boatramp_core::host::Host::new(host).domain_entry()
}
pub(super) async fn put_site_config(
State(deploy): State<DeployStore>,
Extension(project): axum::extract::Extension<ProjectContext>,
Path(site): Path<String>,
Json(config): Json<SiteConfig>,
) -> Response {
if let Some(resp) = reject_invalid_name("site", &site) {
return resp;
}
let current = match deploy.get_site_config(project.as_ref(), &site).await {
Ok(c) => c.unwrap_or_default(),
Err(err) => return deploy_error_response(err),
};
let existing: std::collections::BTreeSet<String> = current
.domains
.exact_hosts()
.map(canon_domain_entry)
.chain(
current
.domains
.wildcards
.iter()
.map(|w| canon_domain_entry(w)),
)
.collect();
let added: Vec<String> = config
.domains
.exact_hosts()
.map(canon_domain_entry)
.chain(
config
.domains
.wildcards
.iter()
.map(|w| canon_domain_entry(w)),
)
.filter(|host| !existing.contains(host))
.collect();
for host in added {
let verification = match deploy
.get_domain_verification(
project.as_ref(),
&boatramp_core::site::SiteName::new(site.as_str()),
&host,
)
.await
{
Ok(v) => v,
Err(err) => return deploy_error_response(err),
};
if !verification.as_ref().is_some_and(|v| v.verified) {
return (
StatusCode::FORBIDDEN,
format!(
"{host} is not verified for {site}; run \
`boatramp domain add {host} --site {site}` first\n"
),
)
.into_response();
}
if host.starts_with("*.")
&& verification.as_ref().map(|v| v.method)
!= Some(boatramp_core::domain_verify::VerificationMethod::Dns)
{
return (
StatusCode::FORBIDDEN,
format!("wildcard {host} must be verified via DNS (an HTTP token proves only the base host)\n"),
)
.into_response();
}
}
match deploy
.set_site_config(project.as_ref(), &site, &config)
.await
{
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(err) => deploy_error_response(err),
}
}
pub(super) async fn get_daemon_config(
State(deploy): State<DeployStore>,
Extension(daemon): Extension<Arc<DaemonRuntime>>,
) -> Response {
match deploy.get_daemon_config().await {
Ok(cfg) => Json(serde_json::json!({
"generation": daemon.generation(),
"config": cfg.unwrap_or_default(),
}))
.into_response(),
Err(err) => deploy_error_response(err),
}
}
pub(super) async fn put_daemon_config(
State(deploy): State<DeployStore>,
Extension(daemon): Extension<Arc<DaemonRuntime>>,
Json(cfg): Json<boatramp_core::daemon_config::DaemonConfig>,
) -> Response {
if let Err(err) = cfg.validate(daemon.baseline()) {
return (
StatusCode::BAD_REQUEST,
format!("invalid daemon config: {err}\n"),
)
.into_response();
}
match deploy.set_daemon_config(&cfg).await {
Ok(generation) => {
if let Err(err) = daemon.reload(&deploy).await {
return deploy_error_response(err);
}
Json(serde_json::json!({ "generation": generation })).into_response()
}
Err(err) => deploy_error_response(err),
}
}
pub(super) async fn rollback_daemon_config(
State(deploy): State<DeployStore>,
Extension(daemon): Extension<Arc<DaemonRuntime>>,
) -> Response {
match deploy.rollback_daemon_config().await {
Ok(Some(generation)) => {
if let Err(err) = daemon.reload(&deploy).await {
return deploy_error_response(err);
}
Json(serde_json::json!({ "generation": generation })).into_response()
}
Ok(None) => (
StatusCode::CONFLICT,
"no prior daemon config to roll back to\n",
)
.into_response(),
Err(err) => deploy_error_response(err),
}
}
pub(super) async fn list_compute(
State(deploy): State<DeployStore>,
Extension(project): axum::extract::Extension<ProjectContext>,
) -> Response {
match deploy.list_compute_workloads(project.as_ref()).await {
Ok(mut workloads) => {
workloads.sort_by(|a, b| a.name.cmp(&b.name));
Json(workloads).into_response()
}
Err(err) => deploy_error_response(err),
}
}
pub(super) async fn get_compute(
State(deploy): State<DeployStore>,
Extension(project): axum::extract::Extension<ProjectContext>,
Path(name): Path<String>,
) -> Response {
match deploy.get_compute_workload(project.as_ref(), &name).await {
Ok(Some(workload)) => Json(workload).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "no such workload\n").into_response(),
Err(err) => deploy_error_response(err),
}
}
#[derive(Deserialize)]
pub(super) struct PutComputeRequest {
spec: boatramp_core::compute::ComputeSpec,
#[serde(default = "one")]
replicas: u32,
#[serde(default)]
placement: boatramp_core::compute::PlacementConstraints,
}
fn one() -> u32 {
1
}
#[derive(Serialize)]
struct PutComputeResponse {
spec: String,
}
pub(super) async fn put_compute(
State(deploy): State<DeployStore>,
Extension(daemon): Extension<Arc<DaemonRuntime>>,
Extension(project): axum::extract::Extension<ProjectContext>,
Path(name): Path<String>,
Json(mut request): Json<PutComputeRequest>,
) -> Response {
if let Some(resp) = reject_invalid_name("compute", &name) {
return resp;
}
if matches!(
request.spec.root,
boatramp_core::compute::RootSource::Rootfs(_)
) && request.spec.kernel.is_empty()
{
match daemon.effective().default_kernel.as_ref() {
Some(k) => request.spec.kernel = k.source.clone(),
None => {
return (
StatusCode::BAD_REQUEST,
"micro-VM workload has no kernel and no default kernel is configured; set \
one with `boatramp config set compute.default_kernel …`\n",
)
.into_response()
}
}
}
let spec_hash = match deploy.put_compute_spec(&request.spec).await {
Ok(hash) => hash,
Err(err) => return deploy_error_response(err),
};
let workload = boatramp_core::compute::ComputeWorkload {
version: boatramp_core::SCHEMA_VERSION,
name,
active: spec_hash.clone(),
replicas: request.replicas,
placement: request.placement,
};
match deploy
.set_compute_workload(project.as_ref(), &workload)
.await
{
Ok(()) => (
StatusCode::CREATED,
Json(PutComputeResponse { spec: spec_hash }),
)
.into_response(),
Err(err) => deploy_error_response(err),
}
}
pub(super) async fn delete_compute(
State(deploy): State<DeployStore>,
Extension(project): axum::extract::Extension<ProjectContext>,
Path(name): Path<String>,
) -> Response {
match deploy
.delete_compute_workload(project.as_ref(), &name)
.await
{
Ok(true) => StatusCode::NO_CONTENT.into_response(),
Ok(false) => (StatusCode::NOT_FOUND, "no such workload\n").into_response(),
Err(err) => deploy_error_response(err),
}
}
#[cfg(feature = "oidc")]
#[derive(Serialize)]
struct ExchangeResponse {
token: String,
expires_in: u64,
}
#[cfg(feature = "oidc")]
pub(super) async fn auth_exchange(
Extension(issuer): Extension<Issuer>,
Extension(oidc): Extension<OidcState>,
headers: HeaderMap,
) -> Response {
let (Some(signer), Some(verifier)) = (issuer.0, oidc.0) else {
return (
StatusCode::NOT_IMPLEMENTED,
"OIDC exchange is not configured on this node\n",
)
.into_response();
};
let Some(jwt) = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
else {
return (StatusCode::UNAUTHORIZED, "missing bearer JWT\n").into_response();
};
let Some(claims) = verifier.verify(jwt) else {
return (StatusCode::UNAUTHORIZED, "invalid OIDC token\n").into_response();
};
let roles: Vec<GrantedRole> = claims.iter().map(|s| GrantedRole::parse(s)).collect();
if roles.is_empty() {
return (
StatusCode::FORBIDDEN,
"OIDC token carries no boatramp roles\n",
)
.into_response();
}
let claims = Claims {
roles,
kind: cose::KIND_ROLE.to_string(),
ttl_secs: Some(EXCHANGE_TTL_SECS),
now_unix: now_unix(),
};
match cose::mint(&claims, &*signer).await {
Ok(token) => Json(ExchangeResponse {
token,
expires_in: EXCHANGE_TTL_SECS,
})
.into_response(),
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
}
}
pub(super) async fn get_deployment(
State(deploy): State<DeployStore>,
Path((_site, id)): Path<(String, String)>,
) -> Response {
match deploy.get_manifest(&id).await {
Ok(Some(manifest)) => Json(manifest).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "deployment not found\n").into_response(),
Err(err) => deploy_error_response(err),
}
}
#[derive(Deserialize)]
pub(super) struct SetAliasRequest {
id: String,
}
pub(super) async fn set_alias(
State(deploy): State<DeployStore>,
Extension(project): axum::extract::Extension<ProjectContext>,
Path((site, name)): Path<(String, String)>,
Json(request): Json<SetAliasRequest>,
) -> Response {
match deploy
.set_alias(project.as_ref(), &site, &name, &request.id)
.await
{
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(err) => deploy_error_response(err),
}
}
pub(super) async fn list_aliases(
State(deploy): State<DeployStore>,
Extension(project): axum::extract::Extension<ProjectContext>,
Path(site): Path<String>,
) -> Response {
match deploy.list_aliases(project.as_ref(), &site).await {
Ok(map) => Json(map).into_response(),
Err(err) => deploy_error_response(err),
}
}
pub(super) async fn remove_alias(
State(deploy): State<DeployStore>,
Extension(project): axum::extract::Extension<ProjectContext>,
Path((site, name)): Path<(String, String)>,
) -> Response {
match deploy.remove_alias(project.as_ref(), &site, &name).await {
Ok(true) => StatusCode::NO_CONTENT.into_response(),
Ok(false) => (StatusCode::NOT_FOUND, "no such alias\n").into_response(),
Err(err) => deploy_error_response(err),
}
}
#[derive(Debug, Default, Deserialize)]
pub(super) struct PruneQuery {
grace: Option<u64>,
keep_last: Option<usize>,
keep_age: Option<u64>,
}
impl PruneQuery {
fn options(&self) -> GcOptions {
GcOptions {
grace_secs: self.grace.unwrap_or(3600),
keep_last: self.keep_last,
keep_age_secs: self.keep_age,
}
}
}
pub(super) async fn prune_report(
State(deploy): State<DeployStore>,
Query(q): Query<PruneQuery>,
) -> Response {
prune_response(deploy.collect_garbage_with(false, q.options()).await)
}
pub(super) async fn prune_delete(
State(deploy): State<DeployStore>,
Query(q): Query<PruneQuery>,
) -> Response {
prune_response(deploy.collect_garbage_with(true, q.options()).await)
}
fn prune_response(result: Result<GcReport, DeployError>) -> Response {
match result {
Ok(report) => Json(report).into_response(),
Err(err) => deploy_error_response(err),
}
}
pub(super) async fn scrub_blobs(State(deploy): State<DeployStore>) -> Response {
match deploy.scrub_blobs().await {
Ok(report) => Json(report).into_response(),
Err(err) => deploy_error_response(err),
}
}
pub(super) async fn cert_status(State(deploy): State<DeployStore>) -> Response {
match deploy.cert_status().await {
Ok(status) => Json(status).into_response(),
Err(err) => deploy_error_response(err),
}
}
pub(super) async fn invalidate_cache(
State(deploy): State<DeployStore>,
Json(body): Json<InvalidateRequest>,
) -> Response {
if body.keys.is_empty() {
deploy.invalidate_cache();
} else {
deploy.invalidate_cache_keys(&body.keys);
}
StatusCode::NO_CONTENT.into_response()
}
#[derive(Deserialize)]
pub(super) struct InvalidateRequest {
#[serde(default)]
keys: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deploy_meta_query_parses_tag_and_tags_json() {
let q = DeployMetaQuery {
source: Some("abc".into()),
branch: None,
author: None,
message: None,
tag: Some("v1.2.3".into()),
tags: Some(r#"{"env":"prod","ticket":"ABC-123"}"#.into()),
};
let input: DeployMetaInput = q.into();
assert_eq!(input.tag.as_deref(), Some("v1.2.3"));
assert_eq!(input.tags.get("env").map(String::as_str), Some("prod"));
assert_eq!(
input.tags.get("ticket").map(String::as_str),
Some("ABC-123")
);
}
#[test]
fn deploy_meta_query_malformed_tags_drop_to_empty() {
let q = DeployMetaQuery {
tags: Some("not json".into()),
..Default::default()
};
let input: DeployMetaInput = q.into();
assert!(input.tags.is_empty());
}
}