diff --git a/src/http/error.rs b/src/http/error.rs index 6267324..ebb5d33 100644 --- a/src/http/error.rs +++ b/src/http/error.rs @@ -5,6 +5,8 @@ use axum::{ }; use thiserror::Error; +pub type Result = core::result::Result; + #[derive(Error, Debug)] pub enum AppError { #[error("client error: <{code}> {message}")] @@ -14,6 +16,12 @@ pub enum AppError { message: &'static str, }, + #[error("docker error: {0}")] + DockerError(#[from] bollard::errors::Error), + + #[error("file error: {0}")] + FileError(#[from] std::io::Error), + #[error("unexpected internal error: {0}")] Internal(#[from] anyhow::Error), @@ -25,31 +33,44 @@ pub enum AppError { } pub(super) struct ErrorInfo { + pub(super) status: StatusCode, pub(super) code: String, pub(super) message: String, } impl IntoResponse for AppError { fn into_response(self) -> Response { - let (status, info) = match self { - AppError::Internal(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - ErrorInfo { - code: "server-error".to_string(), - message: err.to_string(), - }, - ), + let info = match self { + AppError::Internal(err) => ErrorInfo { + status: StatusCode::INTERNAL_SERVER_ERROR, + code: "server-error".to_string(), + message: err.to_string(), + }, + AppError::FileError(err) => ErrorInfo { + status: StatusCode::INTERNAL_SERVER_ERROR, + code: "file-error".to_string(), + message: err.to_string(), + }, + AppError::DockerError(err) => ErrorInfo { + status: StatusCode::INTERNAL_SERVER_ERROR, + code: "docker-error".to_string(), + message: err.to_string(), + }, + AppError::Template(err) => ErrorInfo { + status: StatusCode::INTERNAL_SERVER_ERROR, + code: "template-error".to_string(), + message: err.to_string(), + }, AppError::Client { status, code, message, - } => ( + } => ErrorInfo { status, - ErrorInfo { - code: code.to_string(), - message: message.to_string(), - }, - ), + code: code.to_string(), + message: message.to_string(), + }, + AppError::JSONFormat(rejection) => { let status = match rejection { JsonRejection::JsonDataError(_) => StatusCode::UNPROCESSABLE_ENTITY, @@ -57,24 +78,15 @@ impl IntoResponse for AppError { JsonRejection::MissingJsonContentType(_) => StatusCode::UNSUPPORTED_MEDIA_TYPE, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - ( - status, - ErrorInfo { - code: "invalid-body".to_string(), - message: rejection.body_text().clone(), - }, - ) - } - AppError::Template(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, ErrorInfo { - code: "template-error".to_string(), - message: err.to_string(), - }, - ), + status, + code: "invalid-body".to_string(), + message: rejection.body_text().clone(), + } + } }; - let mut response = status.into_response(); + let mut response = info.status.into_response(); response.extensions_mut().insert(info); response } diff --git a/src/http/response.rs b/src/http/response.rs index 7cf4777..3cd210b 100644 --- a/src/http/response.rs +++ b/src/http/response.rs @@ -10,6 +10,8 @@ use serde_json::json; use super::error::{AppError, ErrorInfo}; +pub type HandlerResponse = Result; + struct Response { html: String, json: serde_json::Value, @@ -18,14 +20,11 @@ struct Response { #[derive(Template)] #[template(path = "error.html")] struct ErrorTemplate { - code: String, + status: String, message: String, } -pub fn reply( - html: T, - json: serde_json::Value, -) -> Result { +pub fn reply(json: serde_json::Value, html: T) -> HandlerResponse { let mut response = StatusCode::OK.into_response(); response.extensions_mut().insert(Response { html: html.render()?, @@ -45,12 +44,26 @@ pub async fn response_interceptor( let mut response = next.run(request).await; - if let Some(ErrorInfo { code, message }) = response.extensions_mut().remove::() { + if let Some(ErrorInfo { + status, + code, + message, + }) = response.extensions_mut().remove::() + { match accept_header.as_deref() { Some(b"application/json") => { - return Json(json!({"code": code, "message": message})).into_response() + return (status, Json(json!({"code": code, "message": message}))).into_response() + } + _ => { + return ( + status, + ErrorTemplate { + status: status.to_string(), + message, + }, + ) + .into_response() } - _ => return ErrorTemplate { code, message }.into_response(), } } diff --git a/src/main.rs b/src/main.rs index bafb411..e96f32f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ use anyhow::Result; use axum::middleware::from_fn; use bollard::Docker; use clap::Parser; -use std::net::SocketAddr; +use std::{net::SocketAddr, path::PathBuf}; use crate::http::response::response_interceptor; @@ -16,7 +16,7 @@ mod stack; struct Args { /// Path to root of stacks #[arg(short = 'd', long = "stack-dir", env = "STAX_DIR")] - stack_dir: String, + stack_dir: PathBuf, /// Address:port to bind #[arg(short, long, default_value = "0.0.0.0:3000", env = "STAX_BIND")] @@ -25,7 +25,7 @@ struct Args { #[derive(Clone)] pub struct AppState { - pub stack_dir: String, + pub stack_dir: PathBuf, pub docker: Docker, } diff --git a/src/route/mod.rs b/src/route/mod.rs index f169d35..7e7fe4a 100644 --- a/src/route/mod.rs +++ b/src/route/mod.rs @@ -1,31 +1,32 @@ use askama::Template; -use askama_axum::IntoResponse; use axum::{extract::State, routing::get, Router}; use serde_json::json; use crate::{ - http::{error::AppError, response::reply}, - stack, AppState, + http::{ + error::AppError, + response::{reply, HandlerResponse}, + }, + AppState, }; +mod stack; + #[derive(Template)] #[template(path = "home.html")] struct HomeTemplate { stacks: Vec, } -async fn home(State(state): State) -> Result { - let list = stack::list(&state.stack_dir) +async fn home(State(state): State) -> HandlerResponse { + let list = crate::stack::list(&state.stack_dir) .await .map_err(AppError::from)?; - reply( - HomeTemplate { - stacks: list.clone(), - }, - json!({ "stacks": list}), - ) + reply(json!({ "stacks": list }), HomeTemplate { stacks: list }) } -pub(super) fn router() -> Router { - Router::new().route("/", get(home)) +pub(crate) fn router() -> Router { + Router::new() + .route("/", get(home)) + .nest("/stack", stack::router()) } diff --git a/src/route/stack.rs b/src/route/stack.rs new file mode 100644 index 0000000..320c70e --- /dev/null +++ b/src/route/stack.rs @@ -0,0 +1,60 @@ +use askama::Template; +use axum::{ + extract::{Path, State}, + routing::get, + Router, +}; +use bollard::service::ContainerSummary; +use serde_json::json; + +use crate::{ + http::response::{reply, HandlerResponse}, + stack::{get_compose, get_containers}, + AppState, +}; + +#[derive(Template)] +#[template(path = "stack/get-one.html")] +struct GetOneTemplate { + stack_name: String, + file_contents: String, + containers: Vec, +} + +struct ContainerInfo { + name: String, + state: String, + image: String, +} + +impl From for ContainerInfo { + fn from(value: ContainerSummary) -> Self { + ContainerInfo { + name: value.names.unwrap().join(","), + state: value.state.unwrap(), + image: value.image.unwrap(), + } + } +} + +async fn get_one(Path(stack_name): Path, State(state): State) -> HandlerResponse { + let file_contents = get_compose(&state.stack_dir, &stack_name).await?; + let containers = get_containers(&state.docker, &stack_name).await?; + + reply( + json!({ + "name": stack_name, + "file": file_contents, + "containers": containers, + }), + GetOneTemplate { + stack_name, + file_contents, + containers: containers.iter().map(|c| c.clone().into()).collect(), + }, + ) +} + +pub(super) fn router() -> Router { + Router::new().route("/:stack", get(get_one)) +} diff --git a/src/stack/error.rs b/src/stack/error.rs new file mode 100644 index 0000000..1fc8ff9 --- /dev/null +++ b/src/stack/error.rs @@ -0,0 +1,22 @@ +use axum::http::StatusCode; +use thiserror::Error; + +use crate::http::error::AppError; + +#[derive(Error, Debug)] +pub enum StackError { + #[error("stack not found")] + NotFound, +} + +impl Into for StackError { + fn into(self) -> AppError { + match self { + StackError::NotFound => AppError::Client { + status: StatusCode::NOT_FOUND, + code: "stack-not-found", + message: "stack not found", + }, + } + } +} diff --git a/src/stack/mod.rs b/src/stack/mod.rs index 3d39005..8fef23f 100644 --- a/src/stack/mod.rs +++ b/src/stack/mod.rs @@ -1,7 +1,44 @@ -use anyhow::Result; +use bollard::{container::ListContainersOptions, service::ContainerSummary, Docker}; +use std::{collections::HashMap, path::PathBuf}; use tokio::fs::{read_dir, try_exists}; -pub async fn list(base_dir: &str) -> Result> { +use crate::http::error::Result; + +use self::error::StackError; + +pub mod error; + +const COMPOSE_FILE: &str = "arion-compose.nix"; + +async fn is_stack(dir: &PathBuf) -> Result { + Ok(try_exists(dir.join(COMPOSE_FILE)).await?) +} + +pub async fn get_containers(docker: &Docker, stack_name: &str) -> Result> { + Ok(docker + .list_containers(Some(ListContainersOptions { + all: true, + limit: None, + size: true, + filters: HashMap::from([( + "label".to_string(), + vec![format!("com.docker.compose.project={}", stack_name)], + )]), + })) + .await?) +} + +pub async fn get_compose(base_dir: &PathBuf, stack_name: &str) -> Result { + let dir = base_dir.join(stack_name); + if !is_stack(&dir).await? { + return Err(StackError::NotFound.into()); + } + + let contents = tokio::fs::read_to_string(dir.join(COMPOSE_FILE)).await?; + Ok(contents) +} + +pub async fn list(base_dir: &PathBuf) -> Result> { let mut dirs = read_dir(base_dir).await?; let mut stacklist = vec![]; while let Some(dir) = dirs.next_entry().await? { @@ -9,7 +46,7 @@ pub async fn list(base_dir: &str) -> Result> { if !meta.is_dir() { continue; } - if try_exists(dir.path().join("arion-compose.nix")).await? { + if is_stack(&dir.path()).await? { stacklist.push(dir.file_name().to_string_lossy().to_string()) } } diff --git a/templates/error.html b/templates/error.html index 01b7045..0bcb041 100644 --- a/templates/error.html +++ b/templates/error.html @@ -4,7 +4,7 @@ {% block content %}
-

{{ code }}

+

{{ status }}

{{ message }}

-{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/templates/home.html b/templates/home.html index c5d7cb7..8f70389 100644 --- a/templates/home.html +++ b/templates/home.html @@ -4,8 +4,10 @@ {% block content %}
- {% for stack in stacks %} -
  • {{stack}}
  • - {% endfor %} +
      + {% for stack in stacks %} +
    • {{stack}}
    • + {% endfor %} +
    {% endblock %} \ No newline at end of file diff --git a/templates/stack/get-one.html b/templates/stack/get-one.html new file mode 100644 index 0000000..0d7f55c --- /dev/null +++ b/templates/stack/get-one.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block title %}Viewing {{stack_name}}{% endblock %} + +{% block content %} +
    +

    {{stack_name}}

    + +
      + {% for container in containers %} +
    • {{container.name}} ({{container.image}}) : {{ container.state }}
    • + {% endfor %} +
    +
    +{% endblock %} \ No newline at end of file