get container status
This commit is contained in:
parent
c961c2ae23
commit
5c5d0b354b
10 changed files with 223 additions and 61 deletions
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
60
src/route/stack.rs
Normal 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
22
src/stack/error.rs
Normal 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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 %}
|
|
@ -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 %}
|
15
templates/stack/get-one.html
Normal file
15
templates/stack/get-one.html
Normal 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 %}
|
Reference in a new issue