//! Application routes: inventory, lifecycle, and image updates.
//!
//! Every mutating handler resolves the `{host}` path segment against the live
//! Docker inventory first, so a job is only ever submitted for an application
//! that actually exists.
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use twice_core::{AppsResponse, Host, InfrastructureSpec, JobKind, JobView, UpdateImageRequest};
use crate::error::ApiError;
use crate::state::AppState;
/// `GET /apps` โ every `once`-managed application on this host.
pub(crate) async fn list(State(state): State<AppState>) -> Result<Json<AppsResponse>, ApiError> {
Ok(Json(AppsResponse {
apps: state.docker.list_apps().await?,
}))
}
/// `POST /apps/{host}/start`
pub(crate) async fn start(
State(state): State<AppState>,
Path(host): Path<String>,
) -> Result<(StatusCode, Json<JobView>), ApiError> {
let host = resolve(&state, &host).await?;
submit(&state, JobKind::Start { host }).await
}
/// `POST /apps/{host}/stop`
pub(crate) async fn stop(
State(state): State<AppState>,
Path(host): Path<String>,
) -> Result<(StatusCode, Json<JobView>), ApiError> {
let host = resolve(&state, &host).await?;
submit(&state, JobKind::Stop { host }).await
}
/// `POST /apps/{host}/update` โ roll an application onto a new image.
pub(crate) async fn update_image(
State(state): State<AppState>,
Path(host): Path<String>,
Json(body): Json<UpdateImageRequest>,
) -> Result<(StatusCode, Json<JobView>), ApiError> {
let host = resolve(&state, &host).await?;
let kind = JobKind::UpdateImage {
host,
image: body.image,
auto_update: body.auto_update,
};
submit(&state, kind).await
}
/// `POST /infrastructure/apply` โ converge one declared application.
///
/// A missing host is deployed; an existing host is rolled onto the declared
/// image. The same document therefore works for initial creation and updates.
pub(crate) async fn apply_infrastructure(
State(state): State<AppState>,
Json(spec): Json<InfrastructureSpec>,
) -> Result<(StatusCode, Json<JobView>), ApiError> {
let known = state.docker.knows_host(&spec.host).await?;
let kind = infrastructure_job(known, spec);
submit(&state, kind).await
}
fn infrastructure_job(known: bool, spec: InfrastructureSpec) -> JobKind {
let InfrastructureSpec {
host,
source,
version,
} = spec;
let image = source.into_image(&version);
if known {
JobKind::UpdateImage {
host,
image,
auto_update: None,
}
} else {
JobKind::Deploy { host, image }
}
}
/// Parses the path segment and confirms the application exists.
async fn resolve(state: &AppState, raw: &str) -> Result<Host, ApiError> {
let host = Host::parse(raw).map_err(|err| ApiError::BadHost(raw.to_owned(), err))?;
if state.docker.knows_host(&host).await? {
Ok(host)
} else {
Err(ApiError::UnknownApp(host))
}
}
async fn submit(state: &AppState, kind: JobKind) -> Result<(StatusCode, Json<JobView>), ApiError> {
let view = state.jobs.submit(kind).await?;
Ok((StatusCode::ACCEPTED, Json(view)))
}
#[cfg(test)]
mod tests {
use twice_core::{ImageSource, ImageVersion};
use super::*;
fn spec() -> InfrastructureSpec {
InfrastructureSpec {
host: Host::parse("writebook.example.com").unwrap(),
source: ImageSource::parse("ghcr.io/basecamp/writebook").unwrap(),
version: ImageVersion::parse("1.4.0").unwrap(),
}
}
#[test]
fn missing_application_is_deployed_from_the_pinned_image() {
match infrastructure_job(false, spec()) {
JobKind::Deploy { host, image } => {
assert_eq!(host.as_str(), "writebook.example.com");
assert_eq!(image.as_str(), "ghcr.io/basecamp/writebook:1.4.0");
}
other => panic!("expected deploy, got {other:?}"),
}
}
#[test]
fn existing_application_is_updated_from_the_same_document() {
match infrastructure_job(true, spec()) {
JobKind::UpdateImage {
image, auto_update, ..
} => {
assert_eq!(image.as_str(), "ghcr.io/basecamp/writebook:1.4.0");
assert_eq!(auto_update, None);
}
other => panic!("expected update, got {other:?}"),
}
}
}