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;
pub type Result<T> = core::result::Result<T, AppError>;
#[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
}

View file

@ -10,6 +10,8 @@ use serde_json::json;
use super::error::{AppError, ErrorInfo};
pub type HandlerResponse = Result<askama_axum::Response, AppError>;
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<T: Template>(
html: T,
json: serde_json::Value,
) -> Result<axum::response::Response, AppError> {
pub fn reply<T: Template>(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<B>(
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() {
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 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,
}

View file

@ -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<String>,
}
async fn home(State(state): State<AppState>) -> Result<impl IntoResponse, AppError> {
let list = stack::list(&state.stack_dir)
async fn home(State(state): State<AppState>) -> 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<AppState> {
Router::new().route("/", get(home))
pub(crate) fn router() -> Router<AppState> {
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};
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 stacklist = vec![];
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() {
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())
}
}

View file

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

View file

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