diff --git a/Cargo.lock b/Cargo.lock index dcfc85b..2dbb3a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1079,10 +1079,12 @@ dependencies = [ "bollard", "clap", "dotenvy", + "futures-util", "serde", "serde_json", "thiserror", "tokio", + "tokio-stream", "tracing", "tracing-subscriber", ] @@ -1214,6 +1216,17 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-stream" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.10" diff --git a/Cargo.toml b/Cargo.toml index c0c0ee4..9460c51 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,9 +11,11 @@ axum = "0.6" bollard = "0.15" clap = { version = "4", features = ["env", "derive"] } dotenvy = "0.15" +futures-util = "0.3" serde = "1" serde_json = "1" thiserror = "1" tokio = { version = "1", features = ["full"] } +tokio-stream = "0.1" tracing = "0.1" tracing-subscriber = "0.3" diff --git a/src/http/accept.rs b/src/http/accept.rs new file mode 100644 index 0000000..dd5779a --- /dev/null +++ b/src/http/accept.rs @@ -0,0 +1,27 @@ +use axum::{ + async_trait, + extract::FromRequestParts, + http::{ + header::{HeaderValue, ACCEPT}, + request::Parts, + StatusCode, + }, +}; + +pub struct ExtractAccept(pub HeaderValue); + +#[async_trait] +impl FromRequestParts for ExtractAccept +where + S: Send + Sync, +{ + type Rejection = (StatusCode, &'static str); + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + if let Some(user_agent) = parts.headers.get(ACCEPT) { + Ok(ExtractAccept(user_agent.clone())) + } else { + Err((StatusCode::BAD_REQUEST, "`User-Agent` header is missing")) + } + } +} diff --git a/src/http/mod.rs b/src/http/mod.rs index 52897d3..f6db83e 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -1,2 +1,3 @@ +pub mod accept; pub mod error; pub mod response; diff --git a/src/http/response.rs b/src/http/response.rs index 3cd210b..ac175df 100644 --- a/src/http/response.rs +++ b/src/http/response.rs @@ -1,3 +1,4 @@ +use super::error::{AppError, ErrorInfo}; use askama::Template; use askama_axum::IntoResponse; use axum::{ @@ -8,8 +9,6 @@ use axum::{ }; use serde_json::json; -use super::error::{AppError, ErrorInfo}; - pub type HandlerResponse = Result; struct Response { diff --git a/src/main.rs b/src/main.rs index e96f32f..58a3e13 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,8 +7,8 @@ use std::{net::SocketAddr, path::PathBuf}; use crate::http::response::response_interceptor; mod http; +mod node; mod route; -mod stack; /// GitOps+WebUI for arion-based stacks #[derive(Parser, Debug)] diff --git a/src/node/container.rs b/src/node/container.rs new file mode 100644 index 0000000..0dda524 --- /dev/null +++ b/src/node/container.rs @@ -0,0 +1,42 @@ +use crate::http::error::Result; +use bollard::{ + container::InspectContainerOptions, + service::{ContainerInspectResponse, ContainerSummary}, + Docker, +}; +use serde::Serialize; + +#[derive(Serialize)] +pub struct ContainerInfo { + pub name: String, + pub state: String, + pub image: String, +} + +impl From for ContainerInfo { + fn from(value: ContainerSummary) -> Self { + ContainerInfo { + name: value.names.unwrap()[0].trim_start_matches('/').to_string(), + state: value.state.unwrap(), + image: value.image.unwrap(), + } + } +} + +impl From for ContainerInfo { + fn from(value: ContainerInspectResponse) -> Self { + ContainerInfo { + name: value.name.unwrap(), + state: value.state.map(|s| s.status).flatten().unwrap().to_string(), + image: value.image.unwrap(), + } + } +} + +pub async fn get_info(docker: &Docker, name: &str) -> Result { + let info = docker + .inspect_container(name, Some(InspectContainerOptions { size: true })) + .await?; + + Ok(info.into()) +} diff --git a/src/stack/error.rs b/src/node/error.rs similarity index 99% rename from src/stack/error.rs rename to src/node/error.rs index 1fc8ff9..2e52cf8 100644 --- a/src/stack/error.rs +++ b/src/node/error.rs @@ -1,8 +1,7 @@ +use crate::http::error::AppError; use axum::http::StatusCode; use thiserror::Error; -use crate::http::error::AppError; - #[derive(Error, Debug)] pub enum StackError { #[error("stack not found")] diff --git a/src/node/mod.rs b/src/node/mod.rs new file mode 100644 index 0000000..75eb685 --- /dev/null +++ b/src/node/mod.rs @@ -0,0 +1,3 @@ +pub mod container; +pub mod error; +pub mod stack; diff --git a/src/stack/mod.rs b/src/node/stack.rs similarity index 57% rename from src/stack/mod.rs rename to src/node/stack.rs index 8fef23f..7c6b403 100644 --- a/src/stack/mod.rs +++ b/src/node/stack.rs @@ -1,13 +1,10 @@ +use super::error::StackError; +use crate::http::error::Result; use bollard::{container::ListContainersOptions, service::ContainerSummary, Docker}; +use serde::Serialize; use std::{collections::HashMap, path::PathBuf}; use tokio::fs::{read_dir, try_exists}; -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 { @@ -38,7 +35,22 @@ pub async fn get_compose(base_dir: &PathBuf, stack_name: &str) -> Result Ok(contents) } -pub async fn list(base_dir: &PathBuf) -> Result> { +#[derive(Serialize)] +pub struct StackInfo { + pub name: String, + pub active: bool, +} + +pub async fn list(base_dir: &PathBuf, docker: &Docker) -> Result> { + let containers = docker + .list_containers(Some(ListContainersOptions { + all: false, + limit: None, + filters: HashMap::from([("status".to_string(), vec!["running".to_string()])]), + ..Default::default() + })) + .await?; + let mut dirs = read_dir(base_dir).await?; let mut stacklist = vec![]; while let Some(dir) = dirs.next_entry().await? { @@ -47,7 +59,18 @@ pub async fn list(base_dir: &PathBuf) -> Result> { continue; } if is_stack(&dir.path()).await? { - stacklist.push(dir.file_name().to_string_lossy().to_string()) + let name = dir.file_name().to_string_lossy().to_string(); + // Check status by analyzing containers + let active = containers.iter().any(|cont| { + let project = cont + .clone() + .labels + .map(|lab| lab.get("com.docker.compose.project").cloned()) + .flatten() + .unwrap_or_default(); + name == project + }); + stacklist.push(StackInfo { name, active }) } } diff --git a/src/route/container.rs b/src/route/container.rs new file mode 100644 index 0000000..e60cc32 --- /dev/null +++ b/src/route/container.rs @@ -0,0 +1,115 @@ +use std::convert::Infallible; + +use crate::{ + http::{ + accept::ExtractAccept, + response::{reply, HandlerResponse}, + }, + node::container::{get_info, ContainerInfo}, + AppState, +}; +use askama::Template; +use askama_axum::IntoResponse; +use axum::{ + extract::{Path, State}, + response::{ + sse::{Event, KeepAlive}, + Sse, + }, + routing::get, + Router, +}; +use bollard::container::LogsOptions; +use futures_util::stream::StreamExt; +use serde_json::json; + +#[derive(Template)] +#[template(path = "container/get-one.html")] +struct GetOneTemplate { + container_name: String, + info: ContainerInfo, +} + +async fn get_one( + Path(container_name): Path, + State(state): State, +) -> HandlerResponse { + let info = get_info(&state.docker, &container_name).await?; + + reply( + json!({ + "name": container_name, + "info": info, + }), + GetOneTemplate { + container_name, + info, + }, + ) +} + +async fn get_log( + Path(container_name): Path, + State(state): State, + ExtractAccept(accept): ExtractAccept, +) -> impl IntoResponse { + if let Ok(accept_type) = accept.to_str() { + if accept_type == "text/event-stream" { + return get_log_stream(container_name, state).await.into_response(); + } + } + get_log_string(container_name, state).await.into_response() +} + +async fn get_log_string(container_name: String, state: AppState) -> impl IntoResponse { + state + .docker + .logs( + &container_name, + Some(LogsOptions:: { + follow: false, + stdout: true, + stderr: true, + ..Default::default() + }), + ) + .map(|ev| match ev { + Ok(output) => output.to_string(), + Err(error) => error.to_string(), + }) + .collect::>() + .await + .join("\n") +} + +async fn get_log_stream(container_name: String, state: AppState) -> impl IntoResponse { + let stream = state + .docker + .logs( + &container_name, + Some(LogsOptions:: { + follow: true, + stdout: true, + stderr: true, + ..Default::default() + }), + ) + .map(|msg| match msg { + Ok(output) => Ok::<_, Infallible>( + Event::default() + .json_data(json!({ "lines": output.to_string() })) + .unwrap(), + ), + Err(error) => Ok(Event::default() + .json_data(json!({ "error": error.to_string() })) + .unwrap()), + }); + + Sse::new(stream).keep_alive(KeepAlive::default()) +} + +pub(super) fn router() -> Router { + Router::new() + .route("/:container", get(get_one)) + .route("/:container/log", get(get_log)) +} diff --git a/src/route/mod.rs b/src/route/mod.rs index 7e7fe4a..fb41ecd 100644 --- a/src/route/mod.rs +++ b/src/route/mod.rs @@ -1,25 +1,26 @@ -use askama::Template; -use axum::{extract::State, routing::get, Router}; -use serde_json::json; - use crate::{ http::{ error::AppError, response::{reply, HandlerResponse}, }, + node::stack::{list, StackInfo}, AppState, }; +use askama::Template; +use axum::{extract::State, routing::get, Router}; +use serde_json::json; +mod container; mod stack; #[derive(Template)] #[template(path = "home.html")] struct HomeTemplate { - stacks: Vec, + stacks: Vec, } async fn home(State(state): State) -> HandlerResponse { - let list = crate::stack::list(&state.stack_dir) + let list = list(&state.stack_dir, &state.docker) .await .map_err(AppError::from)?; reply(json!({ "stacks": list }), HomeTemplate { stacks: list }) @@ -29,4 +30,5 @@ pub(crate) fn router() -> Router { Router::new() .route("/", get(home)) .nest("/stack", stack::router()) + .nest("/container", container::router()) } diff --git a/src/route/stack.rs b/src/route/stack.rs index 870acab..39ac9fb 100644 --- a/src/route/stack.rs +++ b/src/route/stack.rs @@ -4,12 +4,14 @@ use axum::{ routing::get, Router, }; -use bollard::service::ContainerSummary; use serde_json::json; use crate::{ http::response::{reply, HandlerResponse}, - stack::{get_compose, get_containers}, + node::{ + container::ContainerInfo, + stack::{get_compose, get_containers}, + }, AppState, }; @@ -21,22 +23,6 @@ struct GetOneTemplate { containers: Vec, } -struct ContainerInfo { - name: String, - state: String, - image: String, -} - -impl From for ContainerInfo { - fn from(value: ContainerSummary) -> Self { - ContainerInfo { - name: value.names.unwrap()[0].trim_start_matches('/').to_string(), - 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?; diff --git a/templates/container/get-one.html b/templates/container/get-one.html new file mode 100644 index 0000000..132ccfb --- /dev/null +++ b/templates/container/get-one.html @@ -0,0 +1,110 @@ +{% extends "base.html" %} + +{% block title %}Container {{container_name}}{% endblock %} + +{% block content %} +
+

Container {{container_name}}

+

Status

+
TODO
+

Logs

+ + +
+ + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/home.html b/templates/home.html index ea425de..c44447c 100644 --- a/templates/home.html +++ b/templates/home.html @@ -4,13 +4,33 @@ {% block content %}
+

All stacks

{% endblock %} \ No newline at end of file diff --git a/templates/stack/get-one.html b/templates/stack/get-one.html index 8a447f3..b8d909d 100644 --- a/templates/stack/get-one.html +++ b/templates/stack/get-one.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% block title %}Viewing {{stack_name}}{% endblock %} +{% block title %}Stack {{stack_name}}{% endblock %} {% block content %}