From a87d1df9efec81d016d4fe7dea01557215f6368f Mon Sep 17 00:00:00 2001 From: Hamcha Date: Tue, 21 Nov 2023 01:36:42 +0100 Subject: [PATCH] wouldnt it be funny if I didn't commit for 24 hours straight --- Cargo.lock | 109 +++++++++++++++++++ Cargo.toml | 2 + src/http/error.rs | 8 ++ src/main.rs | 10 ++ src/node/container.rs | 55 +++++++++- src/node/mod.rs | 1 + src/node/stack.rs | 76 ++++++++++---- src/node/system.rs | 89 ++++++++++++++++ src/route/container.rs | 37 ++++++- src/route/mod.rs | 23 +++- src/route/stack.rs | 47 +++++++-- templates/base.html | 43 ++++++-- templates/container/get-one.html | 145 +++++++++++++++++++------- templates/home.html | 174 +++++++++++++++++++++++++++++-- templates/stack/get-one.html | 75 ++++++++----- 15 files changed, 782 insertions(+), 112 deletions(-) create mode 100644 src/node/system.rs diff --git a/Cargo.lock b/Cargo.lock index af3d530..528aa4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -377,6 +377,39 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" +[[package]] +name = "crossbeam-deque" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6fd6f855243022dcecf8702fef0c297d4338e226845fe067f6341ad9fa0cef" +dependencies = [ + "cfg-if", + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae211234986c545741a7dc064309f67ee1e5ad243d0e48335adc0484d960bcc7" +dependencies = [ + "autocfg", + "cfg-if", + "crossbeam-utils", + "memoffset", + "scopeguard", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294" +dependencies = [ + "cfg-if", +] + [[package]] name = "deranged" version = "0.3.9" @@ -393,6 +426,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "either" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" + [[package]] name = "equivalent" version = "1.0.1" @@ -717,6 +756,15 @@ version = "2.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" +[[package]] +name = "memoffset" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -769,6 +817,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "ntapi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -904,6 +961,26 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rayon" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c27db03db7734835b3f53954b534c91069375ce6ccaa2e065441e07d9b6cdb1" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ce3fb6ad83f861aac485e76e1985cd109d9a3713802152be56c3b1f0e0658ed" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -1084,12 +1161,14 @@ dependencies = [ "futures-util", "serde", "serde_json", + "sysinfo", "thiserror", "time", "tokio", "tokio-stream", "tracing", "tracing-subscriber", + "xshell", ] [[package]] @@ -1115,6 +1194,21 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "sysinfo" +version = "0.29.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a18d114d420ada3a891e6bc8e96a2023402203296a47cdd65083377dad18ba5" +dependencies = [ + "cfg-if", + "core-foundation-sys", + "libc", + "ntapi", + "once_cell", + "rayon", + "winapi", +] + [[package]] name = "thiserror" version = "1.0.50" @@ -1560,3 +1654,18 @@ name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "xshell" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2107fe03e558353b4c71ad7626d58ed82efaf56c54134228608893c77023ad" +dependencies = [ + "xshell-macros", +] + +[[package]] +name = "xshell-macros" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e2c411759b501fb9501aac2b1b2d287a6e93e5bdcf13c25306b23e1b716dd0e" diff --git a/Cargo.toml b/Cargo.toml index de383c8..882d207 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,9 +14,11 @@ dotenvy = "0.15" futures-util = "0.3" serde = "1" serde_json = "1" +sysinfo = "0.29.10" thiserror = "1" time = { version = "0.3", features = ["serde"] } tokio = { version = "1", features = ["full"] } tokio-stream = "0.1" tracing = "0.1" tracing-subscriber = "0.3" +xshell = "0.2" diff --git a/src/http/error.rs b/src/http/error.rs index 717aae1..aba8bc5 100644 --- a/src/http/error.rs +++ b/src/http/error.rs @@ -22,6 +22,9 @@ pub enum AppError { #[error("file error: {0}")] FileError(#[from] std::io::Error), + #[error("shell error: {0}")] + ShellError(#[from] xshell::Error), + #[error("unexpected internal error: {0}")] Internal(#[from] anyhow::Error), @@ -51,6 +54,11 @@ impl IntoResponse for AppError { code: "file-error".to_string(), message: err.to_string(), }, + AppError::ShellError(err) => ErrorInfo { + status: StatusCode::INTERNAL_SERVER_ERROR, + code: "shell-error".to_string(), + message: err.to_string(), + }, AppError::Template(err) => ErrorInfo { status: StatusCode::INTERNAL_SERVER_ERROR, code: "template-error".to_string(), diff --git a/src/main.rs b/src/main.rs index 58a3e13..04ab0dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,11 +21,20 @@ struct Args { /// Address:port to bind #[arg(short, long, default_value = "0.0.0.0:3000", env = "STAX_BIND")] bind: SocketAddr, + + /// Path to arion + #[arg( + long = "arion-bin", + env = "STAX_ARION_BIN", + default_value = "/run/current-system/sw/bin/arion" + )] + arion_binary: PathBuf, } #[derive(Clone)] pub struct AppState { pub stack_dir: PathBuf, + pub arion_bin: PathBuf, pub docker: Docker, } @@ -47,6 +56,7 @@ async fn main() -> Result<()> { let state = AppState { stack_dir: args.stack_dir, + arion_bin: args.arion_binary, docker, }; diff --git a/src/node/container.rs b/src/node/container.rs index a202c52..e34c964 100644 --- a/src/node/container.rs +++ b/src/node/container.rs @@ -1,6 +1,11 @@ +use std::collections::HashMap; + use crate::http::error::Result; use bollard::{ - container::InspectContainerOptions, + container::{ + InspectContainerOptions, KillContainerOptions, RemoveContainerOptions, + RestartContainerOptions, StartContainerOptions, StopContainerOptions, + }, service::{ContainerInspectResponse, ContainerSummary, MountPoint}, Docker, }; @@ -17,6 +22,15 @@ pub struct ContainerInfo { pub created_at: String, pub volumes: Option>, pub env: Option>, + pub labels: Option>, +} + +impl ContainerInfo { + pub fn stack(&self) -> Option { + self.labels + .clone() + .and_then(|lab| lab.get("com.docker.compose.project").cloned()) + } } impl From for ContainerInfo { @@ -33,6 +47,7 @@ impl From for ContainerInfo { image_id: value.image_id.unwrap_or_default(), created_at: created.time().to_string(), volumes: value.mounts, + labels: value.labels, env: None, } } @@ -53,6 +68,7 @@ impl From for ContainerInfo { image_id: value.image.unwrap_or_default(), created_at: value.created.unwrap_or_default(), volumes: value.mounts, + labels: config.labels, env: config.env, } } @@ -65,3 +81,40 @@ pub async fn get_info(docker: &Docker, name: &str) -> Result { Ok(info.into()) } + +pub async fn start(docker: &Docker, name: &str) -> Result<()> { + Ok(docker + .start_container(&name, None::>) + .await?) +} + +pub async fn restart(docker: &Docker, name: &str) -> Result<()> { + Ok(docker + .restart_container(&name, Some(RestartContainerOptions { t: 30 })) + .await?) +} + +pub async fn stop(docker: &Docker, name: &str) -> Result<()> { + Ok(docker + .stop_container(&name, Some(StopContainerOptions { t: 30 })) + .await?) +} + +pub async fn kill(docker: &Docker, name: &str) -> Result<()> { + Ok(docker + .kill_container(&name, None::>) + .await?) +} + +pub async fn remove(docker: &Docker, name: &str) -> Result<()> { + Ok(docker + .remove_container( + &name, + Some(RemoveContainerOptions { + v: false, + force: true, + link: false, + }), + ) + .await?) +} diff --git a/src/node/mod.rs b/src/node/mod.rs index 75eb685..21b27d8 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -1,3 +1,4 @@ pub mod container; pub mod error; pub mod stack; +pub mod system; diff --git a/src/node/stack.rs b/src/node/stack.rs index 4c96d66..d9edced 100644 --- a/src/node/stack.rs +++ b/src/node/stack.rs @@ -1,9 +1,10 @@ -use super::error::StackError; +use super::{container::ContainerInfo, error::StackError}; use crate::http::error::Result; use bollard::{container::ListContainersOptions, service::ContainerSummary, Docker}; use serde::Serialize; use std::{collections::HashMap, path::Path}; use tokio::fs::{read_dir, try_exists}; +use xshell::Shell; const COMPOSE_FILE: &str = "arion-compose.nix"; @@ -41,18 +42,26 @@ pub struct StackInfo { pub active: bool, } -pub async fn list(base_dir: &Path, docker: &Docker) -> Result> { - let containers = docker - .list_containers(Some(ListContainersOptions { - all: false, +#[derive(Serialize)] +pub struct NodeInfo { + pub stacks: Vec, + pub containers: Vec, +} + +pub async fn list(base_dir: &Path, docker: &Docker) -> Result { + let containers: Vec = docker + .list_containers(Some(ListContainersOptions:: { + all: true, limit: None, - filters: HashMap::from([("status".to_string(), vec!["running".to_string()])]), ..Default::default() })) - .await?; + .await? + .iter() + .map(|c| c.clone().into()) + .collect(); let mut dirs = read_dir(base_dir).await?; - let mut stacklist = vec![]; + let mut stacks = vec![]; while let Some(dir) = dirs.next_entry().await? { let meta = dir.metadata().await?; if !meta.is_dir() { @@ -61,17 +70,48 @@ pub async fn list(base_dir: &Path, docker: &Docker) -> Result> { if is_stack(&dir.path()).await? { 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 - .and_then(|lab| lab.get("com.docker.compose.project").cloned()) - .unwrap_or_default(); - name == project - }); - stacklist.push(StackInfo { name, active }) + let active = containers + .iter() + .any(|cont| cont.state == "running" && cont.stack() == Some(name.clone())); + stacks.push(StackInfo { name, active }) } } - Ok(stacklist) + Ok(NodeInfo { stacks, containers }) +} + +pub async fn command( + base_dir: &Path, + stack_name: &str, + arion_bin: &Path, + action: StackCommand, +) -> Result<()> { + let dir = base_dir.join(stack_name); + if !is_stack(&dir).await? { + return Err(StackError::NotFound.into()); + } + + let sh = Shell::new()?; + sh.change_dir(dir); + sh.cmd(arion_bin).args(action.command()).run()?; + + Ok(()) +} + +pub enum StackCommand { + Down, + Start, + Stop, + Restart, +} + +impl StackCommand { + fn command(&self) -> &[&str] { + match self { + StackCommand::Down => &["down"], + StackCommand::Start => &["up", "-d"], + StackCommand::Stop => &["stop"], + StackCommand::Restart => &["restart"], + } + } } diff --git a/src/node/system.rs b/src/node/system.rs new file mode 100644 index 0000000..ac0ebea --- /dev/null +++ b/src/node/system.rs @@ -0,0 +1,89 @@ +use serde::Serialize; +use sysinfo::{CpuExt, DiskExt, NetworkExt, NetworksExt, RefreshKind, System, SystemExt}; +use tokio::sync::OnceCell; + +#[derive(Serialize)] +pub struct NetworkInterface { + pub name: String, + pub received: u64, + pub transmitted: u64, +} + +#[derive(Serialize)] +pub struct CpuInfo { + pub name: String, + pub usage: f32, +} + +#[derive(Serialize)] +pub struct DiskInfo { + pub device_name: String, + pub mount_point: String, + pub total_space: u64, + pub available_space: u64, +} + +#[derive(Serialize)] +pub struct SystemInfo { + // Node info + pub name: String, + pub host_name: String, + pub os_version: String, + pub kernel_version: String, + + // RAM usage + pub total_memory: u64, + pub used_memory: u64, + pub total_swap: u64, + pub used_swap: u64, + + // Multi-elem resources + pub networks: Vec, + pub disks: Vec, + + // CPU + pub load_average: (f64, f64, f64), +} + +pub fn system_info() -> SystemInfo { + let refresh_kind: RefreshKind = RefreshKind::new().with_disks_list().with_memory(); + let mut sys = System::new_with_specifics(refresh_kind); + sys.refresh_specifics(refresh_kind); + + let networks = sys + .networks() + .iter() + .map(|(name, data)| NetworkInterface { + name: name.clone(), + received: data.received(), + transmitted: data.transmitted(), + }) + .collect(); + + let avg = sys.load_average(); + + let disks = sys + .disks() + .iter() + .map(|disk| DiskInfo { + device_name: disk.name().to_str().unwrap_or_default().to_string(), + mount_point: disk.mount_point().to_str().unwrap_or_default().to_string(), + total_space: disk.total_space(), + available_space: disk.available_space(), + }) + .collect(); + + SystemInfo { + name: sys.name().unwrap_or_default(), + host_name: sys.host_name().unwrap_or_default(), + os_version: sys.os_version().unwrap_or_default(), + kernel_version: sys.kernel_version().unwrap_or_default(), + total_memory: sys.total_memory(), + used_memory: sys.used_memory(), + total_swap: sys.total_swap(), + used_swap: sys.used_swap(), + networks, + disks, + load_average: (avg.one, avg.five, avg.fifteen), + } +} diff --git a/src/route/container.rs b/src/route/container.rs index ddf1bfe..ff13c5f 100644 --- a/src/route/container.rs +++ b/src/route/container.rs @@ -5,7 +5,8 @@ use crate::{ accept::ExtractAccept, response::{reply, HandlerResponse}, }, - node::container::{get_info, ContainerInfo}, + node::container::{get_info, kill, remove, restart, start, stop, ContainerInfo}, + route::AppError, AppState, }; use askama::Template; @@ -14,9 +15,9 @@ use axum::{ extract::{Path, State}, response::{ sse::{Event, KeepAlive}, - Sse, + Redirect, Sse, }, - routing::get, + routing::{get, post}, Router, }; use bollard::container::LogsOptions; @@ -108,8 +109,36 @@ async fn get_log_stream(container_name: String, state: AppState) -> impl IntoRes Sse::new(stream).keep_alive(KeepAlive::default()) } +macro_rules! container_command { + ($cmd: ident) => { + move |Path(cont_name): Path, State(state): State| async move { + $cmd(&state.docker, &cont_name).await?; + Ok(Redirect::to("./")) as Result + } + }; +} + pub(super) fn router() -> Router { Router::new() - .route("/:container", get(get_one)) + .route( + "/:container", + get(|Path(cont_name): Path| async move { + Redirect::permanent(format!("{}/", &cont_name).as_str()) + }), + ) + .route("/:container/", get(get_one)) .route("/:container/log", get(get_log)) + .route("/:container/start", post(container_command!(start))) + .route("/:container/stop", post(container_command!(stop))) + .route("/:container/restart", post(container_command!(restart))) + .route("/:container/kill", post(container_command!(kill))) + .route( + "/:container/remove", + post( + move |Path(cont_name): Path, State(state): State| async move { + remove(&state.docker, &cont_name).await?; + Ok(Redirect::to("/")) as Result + }, + ), + ) } diff --git a/src/route/mod.rs b/src/route/mod.rs index fb41ecd..5cf8e71 100644 --- a/src/route/mod.rs +++ b/src/route/mod.rs @@ -3,11 +3,14 @@ use crate::{ error::AppError, response::{reply, HandlerResponse}, }, - node::stack::{list, StackInfo}, + node::{ + stack::{list, NodeInfo}, + system::{system_info, SystemInfo}, + }, AppState, }; use askama::Template; -use axum::{extract::State, routing::get, Router}; +use axum::{extract::State, response::IntoResponse, routing::get, Json, Router}; use serde_json::json; mod container; @@ -16,19 +19,29 @@ mod stack; #[derive(Template)] #[template(path = "home.html")] struct HomeTemplate { - stacks: Vec, + info: NodeInfo, + system: SystemInfo, } async fn home(State(state): State) -> HandlerResponse { - let list = list(&state.stack_dir, &state.docker) + let info = list(&state.stack_dir, &state.docker) .await .map_err(AppError::from)?; - reply(json!({ "stacks": list }), HomeTemplate { stacks: list }) + let system = system_info(); + reply( + json!({ "info": info, "system": system }), + HomeTemplate { info, system }, + ) +} + +async fn get_sys_info() -> impl IntoResponse { + Json(system_info()) } pub(crate) fn router() -> Router { Router::new() .route("/", get(home)) + .route("/sysinfo", get(get_sys_info)) .nest("/stack", stack::router()) .nest("/container", container::router()) } diff --git a/src/route/stack.rs b/src/route/stack.rs index 39ac9fb..43db42e 100644 --- a/src/route/stack.rs +++ b/src/route/stack.rs @@ -1,16 +1,21 @@ use askama::Template; use axum::{ extract::{Path, State}, - routing::get, + response::Redirect, + routing::{get, post}, Router, }; +use futures_util::join; use serde_json::json; use crate::{ - http::response::{reply, HandlerResponse}, + http::{ + error::AppError, + response::{reply, HandlerResponse}, + }, node::{ container::ContainerInfo, - stack::{get_compose, get_containers}, + stack::{command, get_compose, get_containers, StackCommand}, }, AppState, }; @@ -24,8 +29,13 @@ struct GetOneTemplate { } 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?; + let (file_contents_res, containers_res) = join!( + get_compose(&state.stack_dir, &stack_name), + get_containers(&state.docker, &stack_name) + ); + + let file_contents = file_contents_res?; + let containers = containers_res?; reply( json!({ @@ -41,6 +51,29 @@ async fn get_one(Path(stack_name): Path, State(state): State) ) } -pub(super) fn router() -> Router { - Router::new().route("/:stack", get(get_one)) +macro_rules! stack_command { + ($cmd: expr) => { + move |Path(stack_name): Path, State(state): State| async move { + command(&state.stack_dir, &stack_name, &state.arion_bin, $cmd).await?; + Ok(Redirect::to("./")) as Result + } + }; +} + +pub(super) fn router() -> Router { + Router::new() + .route( + "/:stack", + get(|Path(stack_name): Path| async move { + Redirect::permanent(format!("{}/", &stack_name).as_str()) + }), + ) + .route("/:stack/", get(get_one)) + .route("/:stack/start", post(stack_command!(StackCommand::Start))) + .route( + "/:stack/restart", + post(stack_command!(StackCommand::Restart)), + ) + .route("/:stack/stop", post(stack_command!(StackCommand::Stop))) + .route("/:stack/down", post(stack_command!(StackCommand::Down))) } diff --git a/templates/base.html b/templates/base.html index 0c47de8..f71f025 100644 --- a/templates/base.html +++ b/templates/base.html @@ -10,8 +10,20 @@ diff --git a/templates/container/get-one.html b/templates/container/get-one.html index ace0dd1..1aa5e29 100644 --- a/templates/container/get-one.html +++ b/templates/container/get-one.html @@ -4,10 +4,22 @@ {% block content %}
-

Container {{container_name}}

+
+

Container {{container_name}} {% match info.stack() %}{% when + Some + with (stack) %}({{stack}}) + {% when None %}{% endmatch %}

+
+
+
+
+
+
+
+

Status

-
+
ID
{{ info.id }}
Status
@@ -18,6 +30,8 @@
{{ info.image_id }}
Created at
{{ info.created_at }}
+
+
{% match info.volumes %} {% when Some with (volumes) %}
Volumes
@@ -37,6 +51,15 @@ {% endfor %} {% when None %} {% endmatch %} + {% match info.labels %} + {% when Some with (labels) %} +
Labels
+ {% for label in labels %} +
{{ label.0 }} = {{ label.1 + }}
+ {% endfor %} + {% when None %} + {% endmatch %}
@@ -46,6 +69,10 @@