diff --git a/Cargo.lock b/Cargo.lock index 528aa4a..745c1af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -377,6 +377,12 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" +[[package]] +name = "countme" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" + [[package]] name = "crossbeam-deque" version = "0.8.3" @@ -990,12 +996,40 @@ dependencies = [ "bitflags", ] +[[package]] +name = "rnix" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb35cedbeb70e0ccabef2a31bcff0aebd114f19566086300b8f42c725fc2cb5f" +dependencies = [ + "rowan", +] + +[[package]] +name = "rowan" +version = "0.15.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "906057e449592587bf6724f00155bf82a6752c868d78a8fb3aa41f4e6357cfe8" +dependencies = [ + "countme", + "hashbrown 0.12.3", + "memoffset", + "rustc-hash", + "text-size", +] + [[package]] name = "rustc-demangle" version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustversion" version = "1.0.14" @@ -1159,6 +1193,7 @@ dependencies = [ "clap", "dotenvy", "futures-util", + "rnix", "serde", "serde_json", "sysinfo", @@ -1209,6 +1244,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "text-size" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" + [[package]] name = "thiserror" version = "1.0.50" diff --git a/Cargo.toml b/Cargo.toml index 882d207..4259c9f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ bollard = { version = "0.15", features = ["time"] } clap = { version = "4", features = ["env", "derive"] } dotenvy = "0.15" futures-util = "0.3" +rnix = "0.11.0" serde = "1" serde_json = "1" sysinfo = "0.29.10" diff --git a/src/main.rs b/src/main.rs index 04ab0dd..545179a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,12 @@ +use crate::http::response::response_interceptor; use anyhow::Result; use axum::middleware::from_fn; use bollard::Docker; use clap::Parser; use std::{net::SocketAddr, path::PathBuf}; -use crate::http::response::response_interceptor; - mod http; +mod nix; mod node; mod route; diff --git a/src/nix/mod.rs b/src/nix/mod.rs new file mode 100644 index 0000000..ea86848 --- /dev/null +++ b/src/nix/mod.rs @@ -0,0 +1 @@ +pub mod parse; diff --git a/src/nix/parse.rs b/src/nix/parse.rs new file mode 100644 index 0000000..c7fe72c --- /dev/null +++ b/src/nix/parse.rs @@ -0,0 +1,91 @@ +use anyhow::{anyhow, Result}; +use rnix::ast::Entry; +use rnix::{ast::Expr, ast::HasEntry}; +use std::collections::HashMap; + +pub type FlattenedSet = HashMap; + +pub fn flatten_set(expr: Expr) -> Result { + let set = match expr { + Expr::AttrSet(set) => set, + _ => return Err(anyhow!("not a set")), + }; + + let mut hashmap = HashMap::new(); + + for entry in set.entries() { + match entry { + Entry::AttrpathValue(val) => { + let path = val + .attrpath() + .ok_or_else(|| anyhow!("could not read attr"))?; + let attr = path + .attrs() + .map(|ast| ast.to_string()) + .collect::>() + .join("\0"); + match val.value().map(flatten_set).and_then(|res| res.ok()) { + Some(nested_map) => { + for (entry, value) in nested_map { + hashmap.insert(format!("{}\0{}", attr, entry), value); + } + } + None => { + hashmap + .insert(attr, val.value().map(|v| v.to_string()).unwrap_or_default()); + } + } + } + _ => {} + } + } + + Ok(hashmap) +} + +#[cfg(test)] +mod tests { + use super::*; + use rnix::Root; + + #[test] + fn test_flatten_set_simple() { + let test_obj = r#"{ + foo = { + bar = "baz"; + }; + }"#; + let ast = Root::parse(test_obj).ok().expect("invalid nix source"); + let expr = ast.expr().expect("invalid nix root"); + let flattened = flatten_set(expr).unwrap(); + + assert_eq!(flattened.get("foo\0bar").unwrap(), r#""baz""#); + } + + #[test] + fn test_flatten_set_multiple_levels() { + let test_obj = r#"{ + foo = { + bar = { + baz = "qux"; + }; + bar.qux = "quux"; + }; + foo.boo = { + bar = { + bax = "qxx"; + }; + }; + foo.fii.fuu = 123; + }"#; + + let ast = Root::parse(test_obj).ok().expect("invalid nix source"); + let expr = ast.expr().expect("invalid nix root"); + let flattened = flatten_set(expr).unwrap(); + + assert_eq!(flattened.get("foo\0bar\0baz").unwrap(), r#""qux""#); + assert_eq!(flattened.get("foo\0bar\0qux").unwrap(), r#""quux""#); + assert_eq!(flattened.get("foo\0boo\0bar\0bax").unwrap(), r#""qxx""#); + assert_eq!(flattened.get("foo\0fii\0fuu").unwrap(), r#"123"#); + } +} diff --git a/src/node/container.rs b/src/node/container.rs index e34c964..c657b7c 100644 --- a/src/node/container.rs +++ b/src/node/container.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use crate::http::error::Result; use bollard::{ container::{ @@ -10,6 +8,7 @@ use bollard::{ Docker, }; use serde::Serialize; +use std::collections::HashMap; use time::OffsetDateTime; #[derive(Serialize)] @@ -31,6 +30,10 @@ impl ContainerInfo { .clone() .and_then(|lab| lab.get("com.docker.compose.project").cloned()) } + + pub fn running(&self) -> bool { + self.state == "running" + } } impl From for ContainerInfo { diff --git a/src/node/mod.rs b/src/node/mod.rs index 21b27d8..36ac3e3 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -1,4 +1,5 @@ pub mod container; pub mod error; +pub mod nix; pub mod stack; pub mod system; diff --git a/src/node/nix.rs b/src/node/nix.rs new file mode 100644 index 0000000..43bcf9b --- /dev/null +++ b/src/node/nix.rs @@ -0,0 +1,26 @@ +use crate::nix::parse::flatten_set; +use anyhow::{anyhow, Result}; +use std::collections::HashSet; + +pub struct StackComposeInfo { + pub services: Vec, +} + +pub fn parse_arion_compose(file: &str) -> Result { + let ast = rnix::Root::parse(file).ok()?; + let expr = ast.expr().ok_or_else(|| anyhow!("invalid nix root"))?; + let flattened = flatten_set(expr)?; + + let services = flattened + .iter() + .filter_map(|(key, _)| { + key.strip_prefix("services\0") + .and_then(|key| key.split_once('\0')) + .and_then(|k| Some(k.0.to_string())) + }) + .collect::>() + .into_iter() + .collect(); + + Ok(StackComposeInfo { services }) +} diff --git a/src/node/stack.rs b/src/node/stack.rs index d9edced..d2c11f1 100644 --- a/src/node/stack.rs +++ b/src/node/stack.rs @@ -1,4 +1,8 @@ -use super::{container::ContainerInfo, error::StackError}; +use super::{ + container::ContainerInfo, + error::StackError, + nix::{parse_arion_compose, StackComposeInfo}, +}; use crate::http::error::Result; use bollard::{container::ListContainersOptions, service::ContainerSummary, Docker}; use serde::Serialize; @@ -36,10 +40,25 @@ pub async fn get_compose(base_dir: &Path, stack_name: &str) -> Result { Ok(contents) } +#[derive(Serialize)] +pub struct ServiceInfo { + name: String, + container: Option, + running: bool, +} + #[derive(Serialize)] pub struct StackInfo { pub name: String, pub active: bool, + pub services: Vec, +} + +impl StackInfo { + pub fn stats(&self) -> (usize, usize) { + let running = self.services.iter().filter(|s| s.running).count(); + (running, self.services.len() - running) + } } #[derive(Serialize)] @@ -48,6 +67,27 @@ pub struct NodeInfo { pub containers: Vec, } +fn get_service(containers: &Vec, stack_name: &str, service: &str) -> ServiceInfo { + let container = containers.iter().find(|cont| { + let labels = cont.labels.clone().unwrap_or_default(); + labels.get("com.docker.compose.project") == Some(&stack_name.to_string()) + && labels.get("com.docker.compose.service") == Some(&service.to_string()) + }); + + match container { + Some(info) => ServiceInfo { + name: service.to_string(), + container: Some(info.name.clone()), + running: info.running(), + }, + _ => ServiceInfo { + name: service.to_string(), + container: None, + running: false, + }, + } +} + pub async fn list(base_dir: &Path, docker: &Docker) -> Result { let containers: Vec = docker .list_containers(Some(ListContainersOptions:: { @@ -73,7 +113,18 @@ pub async fn list(base_dir: &Path, docker: &Docker) -> Result { let active = containers .iter() .any(|cont| cont.state == "running" && cont.stack() == Some(name.clone())); - stacks.push(StackInfo { name, active }) + let compose_file = get_compose(base_dir, &name).await?; + let info = parse_arion_compose(&compose_file)?; + let services = info + .services + .iter() + .map(|service| get_service(&containers, &name, service)) + .collect(); + stacks.push(StackInfo { + name, + active, + services, + }) } } diff --git a/src/route/container.rs b/src/route/container.rs index ff13c5f..78e9629 100644 --- a/src/route/container.rs +++ b/src/route/container.rs @@ -1,5 +1,3 @@ -use std::convert::Infallible; - use crate::{ http::{ accept::ExtractAccept, @@ -23,6 +21,7 @@ use axum::{ use bollard::container::LogsOptions; use futures_util::stream::StreamExt; use serde_json::json; +use std::convert::Infallible; #[derive(Template)] #[template(path = "container/get-one.html")] diff --git a/src/route/stack.rs b/src/route/stack.rs index 43db42e..e6e8018 100644 --- a/src/route/stack.rs +++ b/src/route/stack.rs @@ -1,13 +1,3 @@ -use askama::Template; -use axum::{ - extract::{Path, State}, - response::Redirect, - routing::{get, post}, - Router, -}; -use futures_util::join; -use serde_json::json; - use crate::{ http::{ error::AppError, @@ -19,6 +9,15 @@ use crate::{ }, AppState, }; +use askama::Template; +use axum::{ + extract::{Path, State}, + response::Redirect, + routing::{get, post}, + Router, +}; +use futures_util::join; +use serde_json::json; #[derive(Template)] #[template(path = "stack/get-one.html")] diff --git a/templates/home.html b/templates/home.html index b6e3bf0..3236bee 100644 --- a/templates/home.html +++ b/templates/home.html @@ -76,8 +76,15 @@

Stacks

    {% for stack in info.stacks %} -
  • {{stack.name}} +
  • + {{stack.name}} + {% let (running, stopped) = stack.stats() %} + {% if running > 0 %} + {{running}} + {% endif %} + {% if stopped > 0 %} + {{stopped}} + {% endif %}
  • {% endfor %}
@@ -180,6 +187,21 @@ } } + .count { + &::before { + content: "◉"; + margin-right: 0.5ch; + } + + &.running::before { + color: #46A758; + } + + &.stopped::before { + color: #E5484D; + } + } + @media (min-width: 1000px) { main { display: grid;