get container status

This commit is contained in:
Hamcha 2023-11-18 20:51:10 +01:00
parent c961c2ae23
commit 5c5d0b354b
10 changed files with 223 additions and 61 deletions

View file

@ -5,6 +5,8 @@ use axum::{
}; };
use thiserror::Error; use thiserror::Error;
pub type Result<T> = core::result::Result<T, AppError>;
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum AppError { pub enum AppError {
#[error("client error: <{code}> {message}")] #[error("client error: <{code}> {message}")]
@ -14,6 +16,12 @@ pub enum AppError {
message: &'static str, 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}")] #[error("unexpected internal error: {0}")]
Internal(#[from] anyhow::Error), Internal(#[from] anyhow::Error),
@ -25,31 +33,44 @@ pub enum AppError {
} }
pub(super) struct ErrorInfo { pub(super) struct ErrorInfo {
pub(super) status: StatusCode,
pub(super) code: String, pub(super) code: String,
pub(super) message: String, pub(super) message: String,
} }
impl IntoResponse for AppError { impl IntoResponse for AppError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let (status, info) = match self { let info = match self {
AppError::Internal(err) => ( AppError::Internal(err) => ErrorInfo {
StatusCode::INTERNAL_SERVER_ERROR, status: StatusCode::INTERNAL_SERVER_ERROR,
ErrorInfo {
code: "server-error".to_string(), code: "server-error".to_string(),
message: err.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 { AppError::Client {
status, status,
code, code,
message, message,
} => ( } => ErrorInfo {
status, status,
ErrorInfo {
code: code.to_string(), code: code.to_string(),
message: message.to_string(), message: message.to_string(),
}, },
),
AppError::JSONFormat(rejection) => { AppError::JSONFormat(rejection) => {
let status = match rejection { let status = match rejection {
JsonRejection::JsonDataError(_) => StatusCode::UNPROCESSABLE_ENTITY, JsonRejection::JsonDataError(_) => StatusCode::UNPROCESSABLE_ENTITY,
@ -57,24 +78,15 @@ impl IntoResponse for AppError {
JsonRejection::MissingJsonContentType(_) => StatusCode::UNSUPPORTED_MEDIA_TYPE, JsonRejection::MissingJsonContentType(_) => StatusCode::UNSUPPORTED_MEDIA_TYPE,
_ => StatusCode::INTERNAL_SERVER_ERROR, _ => StatusCode::INTERNAL_SERVER_ERROR,
}; };
(
status,
ErrorInfo { ErrorInfo {
status,
code: "invalid-body".to_string(), code: "invalid-body".to_string(),
message: rejection.body_text().clone(), message: rejection.body_text().clone(),
},
)
} }
AppError::Template(err) => ( }
StatusCode::INTERNAL_SERVER_ERROR,
ErrorInfo {
code: "template-error".to_string(),
message: err.to_string(),
},
),
}; };
let mut response = status.into_response(); let mut response = info.status.into_response();
response.extensions_mut().insert(info); response.extensions_mut().insert(info);
response response
} }

View file

@ -10,6 +10,8 @@ use serde_json::json;
use super::error::{AppError, ErrorInfo}; use super::error::{AppError, ErrorInfo};
pub type HandlerResponse = Result<askama_axum::Response, AppError>;
struct Response { struct Response {
html: String, html: String,
json: serde_json::Value, json: serde_json::Value,
@ -18,14 +20,11 @@ struct Response {
#[derive(Template)] #[derive(Template)]
#[template(path = "error.html")] #[template(path = "error.html")]
struct ErrorTemplate { struct ErrorTemplate {
code: String, status: String,
message: String, message: String,
} }
pub fn reply<T: Template>( pub fn reply<T: Template>(json: serde_json::Value, html: T) -> HandlerResponse {
html: T,
json: serde_json::Value,
) -> Result<axum::response::Response, AppError> {
let mut response = StatusCode::OK.into_response(); let mut response = StatusCode::OK.into_response();
response.extensions_mut().insert(Response { response.extensions_mut().insert(Response {
html: html.render()?, html: html.render()?,
@ -45,12 +44,26 @@ pub async fn response_interceptor<B>(
let mut response = next.run(request).await; let mut response = next.run(request).await;
if let Some(ErrorInfo { code, message }) = response.extensions_mut().remove::<ErrorInfo>() { if let Some(ErrorInfo {
status,
code,
message,
}) = response.extensions_mut().remove::<ErrorInfo>()
{
match accept_header.as_deref() { match accept_header.as_deref() {
Some(b"application/json") => { 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(),
} }
} }

View file

@ -2,7 +2,7 @@ use anyhow::Result;
use axum::middleware::from_fn; use axum::middleware::from_fn;
use bollard::Docker; use bollard::Docker;
use clap::Parser; use clap::Parser;
use std::net::SocketAddr; use std::{net::SocketAddr, path::PathBuf};
use crate::http::response::response_interceptor; use crate::http::response::response_interceptor;
@ -16,7 +16,7 @@ mod stack;
struct Args { struct Args {
/// Path to root of stacks /// Path to root of stacks
#[arg(short = 'd', long = "stack-dir", env = "STAX_DIR")] #[arg(short = 'd', long = "stack-dir", env = "STAX_DIR")]
stack_dir: String, stack_dir: PathBuf,
/// Address:port to bind /// Address:port to bind
#[arg(short, long, default_value = "0.0.0.0:3000", env = "STAX_BIND")] #[arg(short, long, default_value = "0.0.0.0:3000", env = "STAX_BIND")]
@ -25,7 +25,7 @@ struct Args {
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub stack_dir: String, pub stack_dir: PathBuf,
pub docker: Docker, pub docker: Docker,
} }

View file

@ -1,31 +1,32 @@
use askama::Template; use askama::Template;
use askama_axum::IntoResponse;
use axum::{extract::State, routing::get, Router}; use axum::{extract::State, routing::get, Router};
use serde_json::json; use serde_json::json;
use crate::{ use crate::{
http::{error::AppError, response::reply}, http::{
stack, AppState, error::AppError,
response::{reply, HandlerResponse},
},
AppState,
}; };
mod stack;
#[derive(Template)] #[derive(Template)]
#[template(path = "home.html")] #[template(path = "home.html")]
struct HomeTemplate { struct HomeTemplate {
stacks: Vec<String>, stacks: Vec<String>,
} }
async fn home(State(state): State<AppState>) -> Result<impl IntoResponse, AppError> { async fn home(State(state): State<AppState>) -> HandlerResponse {
let list = stack::list(&state.stack_dir) let list = crate::stack::list(&state.stack_dir)
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
reply( reply(json!({ "stacks": list }), HomeTemplate { stacks: list })
HomeTemplate {
stacks: list.clone(),
},
json!({ "stacks": list}),
)
} }
pub(super) fn router() -> Router<AppState> { pub(crate) fn router() -> Router<AppState> {
Router::new().route("/", get(home)) Router::new()
.route("/", get(home))
.nest("/stack", stack::router())
} }

60
src/route/stack.rs Normal file
View file

@ -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<ContainerInfo>,
}
struct ContainerInfo {
name: String,
state: String,
image: String,
}
impl From<ContainerSummary> 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<String>, State(state): State<AppState>) -> 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<AppState> {
Router::new().route("/:stack", get(get_one))
}

22
src/stack/error.rs Normal file
View file

@ -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<AppError> for StackError {
fn into(self) -> AppError {
match self {
StackError::NotFound => AppError::Client {
status: StatusCode::NOT_FOUND,
code: "stack-not-found",
message: "stack not found",
},
}
}
}

View file

@ -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}; use tokio::fs::{read_dir, try_exists};
pub async fn list(base_dir: &str) -> Result<Vec<String>> { 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<bool> {
Ok(try_exists(dir.join(COMPOSE_FILE)).await?)
}
pub async fn get_containers(docker: &Docker, stack_name: &str) -> Result<Vec<ContainerSummary>> {
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<String> {
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<Vec<String>> {
let mut dirs = read_dir(base_dir).await?; let mut dirs = read_dir(base_dir).await?;
let mut stacklist = vec![]; let mut stacklist = vec![];
while let Some(dir) = dirs.next_entry().await? { while let Some(dir) = dirs.next_entry().await? {
@ -9,7 +46,7 @@ pub async fn list(base_dir: &str) -> Result<Vec<String>> {
if !meta.is_dir() { if !meta.is_dir() {
continue; 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()) stacklist.push(dir.file_name().to_string_lossy().to_string())
} }
} }

View file

@ -4,7 +4,7 @@
{% block content %} {% block content %}
<main class="error-page"> <main class="error-page">
<h1>{{ code }}</h1> <h1>{{ status }}</h1>
<p>{{ message }}</p> <p>{{ message }}</p>
</main> </main>
{% endblock %} {% endblock %}

View file

@ -4,8 +4,10 @@
{% block content %} {% block content %}
<main> <main>
<ul>
{% for stack in stacks %} {% for stack in stacks %}
<li><a href="/stack/{{stack}}">{{stack}}</a></li> <li><a href="/stack/{{stack}}">{{stack}}</a></li>
{% endfor %} {% endfor %}
</ul>
</main> </main>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}Viewing {{stack_name}}{% endblock %}
{% block content %}
<main>
<h1>{{stack_name}}</h1>
<textarea>{{file_contents}}</textarea>
<ul>
{% for container in containers %}
<li>{{container.name}} ({{container.image}}) : {{ container.state }}</li>
{% endfor %}
</ul>
</main>
{% endblock %}