diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 2d4ecc6..dfa559c 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -326,6 +326,16 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -559,6 +569,20 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.34" @@ -621,6 +645,7 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-sink", @@ -700,6 +725,30 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + [[package]] name = "heck" version = "0.5.0" @@ -819,6 +868,56 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-http-proxy" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd1d471ea2f65ba45eddb1d6ab7d58ac2671d2d4ac14da5c6516ae1d97e1327a" +dependencies = [ + "bytes", + "futures-util", + "headers", + "http", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "log", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", ] [[package]] @@ -828,12 +927,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "libc", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -988,6 +1092,8 @@ dependencies = [ "chrono", "domain", "jsonwebtoken", + "k8s-openapi", + "kube", "lettre", "serde", "serde_json", @@ -1022,6 +1128,19 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonpath-rust" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c00ae348f9f8fd2d09f82a98ca381c60df9e0820d8d79fce43e649b4dc3128b" +dependencies = [ + "pest", + "pest_derive", + "regex", + "serde_json", + "thiserror", +] + [[package]] name = "jsonwebtoken" version = "9.3.1" @@ -1037,6 +1156,83 @@ dependencies = [ "simple_asn1", ] +[[package]] +name = "k8s-openapi" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c75b990324f09bef15e791606b7b7a296d02fc88a344f6eba9390970a870ad5" +dependencies = [ + "base64 0.22.1", + "chrono", + "serde", + "serde-value", + "serde_json", +] + +[[package]] +name = "kube" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a4eb20010536b48abe97fec37d23d43069bcbe9686adcf9932202327bc5ca6e" +dependencies = [ + "k8s-openapi", + "kube-client", + "kube-core", +] + +[[package]] +name = "kube-client" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fc2ed952042df20d15ac2fe9614d0ec14b6118eab89633985d4b36e688dccf1" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "either", + "futures", + "home", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-http-proxy", + "hyper-rustls", + "hyper-timeout", + "hyper-util", + "jsonpath-rust", + "k8s-openapi", + "kube-core", + "pem", + "rustls", + "secrecy", + "serde", + "serde_json", + "serde_yaml", + "thiserror", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tracing", +] + +[[package]] +name = "kube-core" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff0d0793db58e70ca6d689489183816cb3aa481673e7433dc618cf7e8007c675" +dependencies = [ + "chrono", + "form_urlencoded", + "http", + "k8s-openapi", + "serde", + "serde-value", + "serde_json", + "thiserror", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1279,6 +1475,21 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "parking" version = "2.2.1" @@ -1344,6 +1555,48 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" +dependencies = [ + "pest", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1575,6 +1828,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -1607,12 +1872,53 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.229" @@ -1623,6 +1929,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -1679,6 +1995,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.10.7" @@ -2192,6 +2521,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -2207,6 +2537,7 @@ dependencies = [ "pin-project-lite", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -2218,6 +2549,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ + "base64 0.22.1", "bitflags", "bytes", "futures-core", @@ -2312,12 +2644,24 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicase" version = "2.9.0" @@ -2361,6 +2705,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -2440,6 +2790,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/backend/crates/api/tests/cluster.rs b/backend/crates/api/tests/cluster.rs new file mode 100644 index 0000000..2aa973f --- /dev/null +++ b/backend/crates/api/tests/cluster.rs @@ -0,0 +1,105 @@ +//! WP-12: /api/cluster +mod common; + +use axum::http::StatusCode; +use common::{get, post, test_app_with_admin}; +use serde_json::json; + +const ADMIN: &str = "admin@example.com"; +const PW: &str = "admin-password-123"; + +#[tokio::test] +async fn overview_and_admin_actions() { + let app = test_app_with_admin().await; + let token = common::login(&app, ADMIN, PW).await.access; + + let res = get(&app, "/api/cluster/overview", Some(&token)).await; + assert_eq!(res.status, StatusCode::OK, "{}", res.json); + assert_eq!(res.json["nodes"][0]["version"], "v1.32.13"); + let w = res.json["workloads"].as_array().unwrap(); + assert!(w + .iter() + .any(|w| w["name"] == "gitea" && w["kind"] == "deployment")); + assert!(res.json["images"].as_array().unwrap().len() >= 2); + + let base = "/api/cluster/workloads/gitea/deployment/gitea"; + assert_eq!( + post(&app, &format!("{base}/restart"), json!({}), Some(&token)) + .await + .status, + StatusCode::NO_CONTENT + ); + assert_eq!( + post( + &app, + &format!("{base}/scale"), + json!({"replicas": 2}), + Some(&token) + ) + .await + .status, + StatusCode::NO_CONTENT + ); + assert_eq!( + post( + &app, + &format!("{base}/scale"), + json!({"replicas": 500}), + Some(&token) + ) + .await + .status, + StatusCode::UNPROCESSABLE_ENTITY + ); + assert_eq!( + post( + &app, + &format!("{base}/image"), + json!({"image": "gitea/gitea:1.23.0"}), + Some(&token) + ) + .await + .status, + StatusCode::NO_CONTENT + ); + assert_eq!( + post( + &app, + "/api/cluster/workloads/gitea/cronjob/x/restart", + json!({}), + Some(&token) + ) + .await + .status, + StatusCode::NOT_FOUND + ); +} + +#[tokio::test] +async fn actions_are_admin_only_and_overview_needs_auth() { + let app = test_app_with_admin().await; + let admin = common::login(&app, ADMIN, PW).await.access; + post(&app, "/api/users", json!({"email": "u@x.de", "display_name": "U", "password": "user-password-123", "role": "user"}), Some(&admin)).await; + let user = common::login(&app, "u@x.de", "user-password-123") + .await + .access; + assert_eq!( + get(&app, "/api/cluster/overview", Some(&user)).await.status, + StatusCode::OK + ); + assert_eq!( + post( + &app, + "/api/cluster/workloads/gitea/deployment/gitea/restart", + json!({}), + Some(&user) + ) + .await + .status, + StatusCode::FORBIDDEN + ); + assert_eq!( + get(&app, "/api/cluster/overview", None).await.status, + StatusCode::UNAUTHORIZED + ); +} diff --git a/backend/crates/application/src/cluster_service.rs b/backend/crates/application/src/cluster_service.rs new file mode 100644 index 0000000..889ba74 --- /dev/null +++ b/backend/crates/application/src/cluster_service.rs @@ -0,0 +1,37 @@ +use std::sync::Arc; + +use domain::cluster::{ClusterOverview, WorkloadRef}; +use domain::ports::ClusterGateway; +use domain::DomainError; + +pub struct ClusterService { + gateway: Arc, +} + +impl ClusterService { + pub fn new(gateway: Arc) -> Self { + let _ = &gateway; + Self { gateway } + } + + pub async fn overview(&self) -> Result { + todo!() + } + + pub async fn restart(&self, _w: WorkloadRef) -> Result<(), DomainError> { + todo!() + } + + pub async fn scale(&self, _w: WorkloadRef, _replicas: i32) -> Result<(), DomainError> { + todo!() + } + + pub async fn set_image( + &self, + _w: WorkloadRef, + _container: Option, + _image: &str, + ) -> Result<(), DomainError> { + todo!() + } +} diff --git a/backend/crates/application/src/lib.rs b/backend/crates/application/src/lib.rs index bc6596f..396e102 100644 --- a/backend/crates/application/src/lib.rs +++ b/backend/crates/application/src/lib.rs @@ -1,5 +1,6 @@ //! Application layer: use cases orchestrating the domain through its ports. pub mod auth_service; +pub mod cluster_service; pub mod inventory_service; pub mod jobs; pub mod scheduler; @@ -8,6 +9,7 @@ pub mod upgrade_service; pub mod user_service; pub use auth_service::AuthService; +pub use cluster_service::ClusterService; pub use inventory_service::{InventoryService, PackageRefreshJob}; pub use jobs::{JobHandler, JobLog, JobRunner}; pub use settings_service::SettingsService; diff --git a/backend/crates/application/src/test_fakes.rs b/backend/crates/application/src/test_fakes.rs index 6e2a37b..3353285 100644 --- a/backend/crates/application/src/test_fakes.rs +++ b/backend/crates/application/src/test_fakes.rs @@ -399,3 +399,98 @@ impl HostUpdater for FakeUpdater { Ok(()) } } + +use domain::cluster::{ + ClusterOverview, Container, NodeInfo, VolumeClaim, Workload, WorkloadKind, WorkloadRef, +}; +use domain::ports::ClusterGateway; + +#[derive(Default)] +pub struct MemCluster { + pub actions: Mutex>, +} + +pub fn sample_overview() -> ClusterOverview { + ClusterOverview { + nodes: vec![NodeInfo { + name: "node1".into(), + version: "v1.32.13".into(), + ready: true, + os_image: "Debian GNU/Linux 12 (bookworm)".into(), + kernel: "6.1.0-42-amd64".into(), + container_runtime: "containerd://1.6.36".into(), + }], + namespaces: vec!["default".into(), "gitea".into()], + workloads: vec![ + Workload { + namespace: "gitea".into(), + kind: WorkloadKind::Deployment, + name: "gitea".into(), + ready: 1, + desired: 1, + containers: vec![Container { + name: "gitea".into(), + image: "gitea/gitea:1.22.3".into(), + }], + }, + Workload { + namespace: "gitea".into(), + kind: WorkloadKind::StatefulSet, + name: "gitea-postgresql".into(), + ready: 1, + desired: 1, + containers: vec![Container { + name: "postgresql".into(), + image: "bitnami/postgresql:16.4.0".into(), + }], + }, + ], + volume_claims: vec![VolumeClaim { + namespace: "gitea".into(), + name: "gitea-shared-storage".into(), + capacity: "10Gi".into(), + storage_class: "microk8s-hostpath".into(), + status: "Bound".into(), + }], + fetched_at: Utc::now(), + } +} + +#[async_trait] +impl ClusterGateway for MemCluster { + async fn overview(&self) -> Result { + Ok(sample_overview()) + } + async fn restart(&self, w: &WorkloadRef) -> Result<(), DomainError> { + self.actions.lock().unwrap().push(format!( + "restart {}/{}/{}", + w.namespace, + w.kind.as_str(), + w.name + )); + Ok(()) + } + async fn scale(&self, w: &WorkloadRef, replicas: i32) -> Result<(), DomainError> { + self.actions.lock().unwrap().push(format!( + "scale {}/{}/{} {replicas}", + w.namespace, + w.kind.as_str(), + w.name + )); + Ok(()) + } + async fn set_image( + &self, + w: &WorkloadRef, + container: &str, + image: &str, + ) -> Result<(), DomainError> { + self.actions.lock().unwrap().push(format!( + "image {}/{}/{} {container}={image}", + w.namespace, + w.kind.as_str(), + w.name + )); + Ok(()) + } +} diff --git a/backend/crates/application/src/tests/cluster_tests.rs b/backend/crates/application/src/tests/cluster_tests.rs new file mode 100644 index 0000000..4d0de48 --- /dev/null +++ b/backend/crates/application/src/tests/cluster_tests.rs @@ -0,0 +1,91 @@ +use std::sync::Arc; + +use domain::cluster::{WorkloadKind, WorkloadRef}; +use domain::DomainError; + +use crate::test_fakes::MemCluster; +use crate::ClusterService; + +fn gitea() -> WorkloadRef { + WorkloadRef { + namespace: "gitea".into(), + kind: WorkloadKind::Deployment, + name: "gitea".into(), + } +} + +fn svc() -> (Arc, ClusterService) { + let gw = Arc::new(MemCluster::default()); + (gw.clone(), ClusterService::new(gw)) +} + +#[tokio::test] +async fn overview_lists_workloads_and_distinct_images() { + let (_, s) = svc(); + let o = s.overview().await.unwrap(); + assert_eq!(o.workloads.len(), 2); + assert_eq!( + o.images(), + vec!["bitnami/postgresql:16.4.0", "gitea/gitea:1.22.3"] + ); +} + +#[tokio::test] +async fn restart_and_scale_are_forwarded_with_validation() { + let (gw, s) = svc(); + s.restart(gitea()).await.unwrap(); + s.scale(gitea(), 2).await.unwrap(); + assert!(matches!( + s.scale(gitea(), -1).await.unwrap_err(), + DomainError::Validation(_) + )); + assert!(matches!( + s.scale(gitea(), 999).await.unwrap_err(), + DomainError::Validation(_) + )); + let bad = WorkloadRef { + namespace: "Bad Name".into(), + ..gitea() + }; + assert!(matches!( + s.restart(bad).await.unwrap_err(), + DomainError::Validation(_) + )); + assert_eq!( + *gw.actions.lock().unwrap(), + vec![ + "restart gitea/deployment/gitea", + "scale gitea/deployment/gitea 2" + ] + ); +} + +#[tokio::test] +async fn set_image_defaults_to_the_first_container_and_validates_image() { + let (gw, s) = svc(); + s.set_image(gitea(), None, "gitea/gitea:1.23.0") + .await + .unwrap(); + s.set_image(gitea(), Some("sidecar".into()), "x/y:1") + .await + .unwrap(); + assert!(matches!( + s.set_image(gitea(), None, "has space").await.unwrap_err(), + DomainError::Validation(_) + )); + let unknown = WorkloadRef { + name: "nope".into(), + ..gitea() + }; + assert_eq!( + s.set_image(unknown, None, "x/y:1").await.unwrap_err(), + DomainError::NotFound + ); + assert_eq!( + *gw.actions.lock().unwrap(), + vec![ + "image gitea/deployment/gitea gitea=gitea/gitea:1.23.0", + "image gitea/deployment/gitea sidecar=x/y:1" + ] + ); +} diff --git a/backend/crates/application/src/tests/mod.rs b/backend/crates/application/src/tests/mod.rs index b58b29f..22c5f8b 100644 --- a/backend/crates/application/src/tests/mod.rs +++ b/backend/crates/application/src/tests/mod.rs @@ -1,4 +1,5 @@ mod auth_service_tests; +mod cluster_tests; mod inventory_tests; mod jobs_tests; mod scheduler_tests; diff --git a/backend/crates/domain/src/cluster.rs b/backend/crates/domain/src/cluster.rs new file mode 100644 index 0000000..376bd3c --- /dev/null +++ b/backend/crates/domain/src/cluster.rs @@ -0,0 +1,110 @@ +//! Kubernetes cluster overview and workload actions. +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum WorkloadKind { + Deployment, + StatefulSet, + DaemonSet, +} + +impl WorkloadKind { + pub fn as_str(self) -> &'static str { + match self { + WorkloadKind::Deployment => "deployment", + WorkloadKind::StatefulSet => "statefulset", + WorkloadKind::DaemonSet => "daemonset", + } + } + pub fn parse(s: &str) -> Option { + [Self::Deployment, Self::StatefulSet, Self::DaemonSet] + .into_iter() + .find(|k| k.as_str() == s) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Container { + pub name: String, + pub image: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Workload { + pub namespace: String, + pub kind: WorkloadKind, + pub name: String, + pub ready: i32, + pub desired: i32, + pub containers: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NodeInfo { + pub name: String, + pub version: String, + pub ready: bool, + pub os_image: String, + pub kernel: String, + pub container_runtime: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct VolumeClaim { + pub namespace: String, + pub name: String, + pub capacity: String, + pub storage_class: String, + pub status: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClusterOverview { + pub nodes: Vec, + pub namespaces: Vec, + pub workloads: Vec, + pub volume_claims: Vec, + pub fetched_at: DateTime, +} + +impl ClusterOverview { + /// Distinct images across all workloads (input for vulnerability scans). + pub fn images(&self) -> Vec { + let mut v: Vec = self + .workloads + .iter() + .flat_map(|w| w.containers.iter().map(|c| c.image.clone())) + .collect(); + v.sort(); + v.dedup(); + v + } +} + +/// Reference to a workload for actions. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WorkloadRef { + pub namespace: String, + pub kind: WorkloadKind, + pub name: String, +} + +pub const MAX_REPLICAS: i32 = 20; + +pub fn validate_k8s_name(name: &str) -> Result<(), crate::DomainError> { + let ok = !name.is_empty() + && name.len() <= 253 + && name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '.')); + ok.then_some(()) + .ok_or_else(|| crate::DomainError::Validation(format!("invalid kubernetes name: {name}"))) +} + +pub fn validate_image(image: &str) -> Result<(), crate::DomainError> { + let ok = !image.is_empty() && image.len() <= 512 && !image.chars().any(char::is_whitespace); + ok.then_some(()) + .ok_or_else(|| crate::DomainError::Validation(format!("invalid image reference: {image}"))) +} diff --git a/backend/crates/domain/src/lib.rs b/backend/crates/domain/src/lib.rs index bacc6a8..f641596 100644 --- a/backend/crates/domain/src/lib.rs +++ b/backend/crates/domain/src/lib.rs @@ -1,6 +1,7 @@ //! Domain layer: entities, value objects, errors and the ports (traits) the application //! layer depends on. No I/O here. pub mod auth; +pub mod cluster; pub mod error; pub mod host; pub mod jobs; diff --git a/backend/crates/domain/src/ports.rs b/backend/crates/domain/src/ports.rs index 34aa331..d66b5fb 100644 --- a/backend/crates/domain/src/ports.rs +++ b/backend/crates/domain/src/ports.rs @@ -3,6 +3,7 @@ use async_trait::async_trait; use uuid::Uuid; use crate::auth::{AccessClaims, AuthEvent, RefreshToken}; +use crate::cluster::{ClusterOverview, WorkloadRef}; use crate::host::{Inventory, OsInfo, Package}; use crate::jobs::{JobKind, JobRun, JobStatus}; use crate::settings::SmtpSettings; @@ -101,3 +102,18 @@ pub trait LineSink: Send + Sync { pub trait HostUpdater: Send + Sync { async fn upgrade(&self, packages: &[String], out: &dyn LineSink) -> Result<(), DomainError>; } + +#[async_trait] +pub trait ClusterGateway: Send + Sync { + async fn overview(&self) -> Result; + /// Rolling restart (like `kubectl rollout restart`). + async fn restart(&self, workload: &WorkloadRef) -> Result<(), DomainError>; + async fn scale(&self, workload: &WorkloadRef, replicas: i32) -> Result<(), DomainError>; + /// Set the image of one container (= application update). + async fn set_image( + &self, + workload: &WorkloadRef, + container: &str, + image: &str, + ) -> Result<(), DomainError>; +} diff --git a/backend/crates/infrastructure/Cargo.toml b/backend/crates/infrastructure/Cargo.toml index cc72474..434b503 100644 --- a/backend/crates/infrastructure/Cargo.toml +++ b/backend/crates/infrastructure/Cargo.toml @@ -19,6 +19,8 @@ serde_json.workspace = true tokio.workspace = true sqlx.workspace = true uuid.workspace = true +kube = { version = "0.99", default-features = false, features = ["client", "rustls-tls"] } +k8s-openapi = { version = "0.24", features = ["v1_32"] } [dev-dependencies] tokio.workspace = true diff --git a/frontend/e2e/cluster.spec.ts b/frontend/e2e/cluster.spec.ts new file mode 100644 index 0000000..25d6588 --- /dev/null +++ b/frontend/e2e/cluster.spec.ts @@ -0,0 +1,21 @@ +import { test, expect } from '@playwright/test' + +test('cluster page shows node, workloads and lets an admin restart a workload', async ({ + page, +}) => { + await page.goto('/login') + await page.getByLabel('Email').fill('admin@example.com') + await page.getByLabel('Password').fill('admin-password-123') + await page.getByRole('button', { name: 'Sign in' }).click() + await page.getByRole('link', { name: 'Kubernetes' }).click() + + await expect(page.getByTestId('node-version')).toContainText('v1.32') + const row = page.getByRole('row', { name: /gitea-postgresql/ }) + await expect(row).toContainText('statefulset') + page.once('dialog', (d) => d.accept()) + await page + .getByRole('row', { name: /^gitea deployment/ }) + .getByRole('button', { name: 'Restart' }) + .click() + await expect(page.getByRole('status')).toContainText('Restart requested') +}) diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 624186d..d03bd84 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -91,3 +91,40 @@ export interface InventoryResponse { packages: Package[] summary: { total: number; upgradable: number; security: number } } + +export type WorkloadKind = 'deployment' | 'statefulset' | 'daemonset' + +export interface Workload { + namespace: string + kind: WorkloadKind + name: string + ready: number + desired: number + containers: { name: string; image: string }[] +} + +export interface NodeInfo { + name: string + version: string + ready: boolean + os_image: string + kernel: string + container_runtime: string +} + +export interface VolumeClaim { + namespace: string + name: string + capacity: string + storage_class: string + status: string +} + +export interface ClusterOverview { + nodes: NodeInfo[] + namespaces: string[] + workloads: Workload[] + volume_claims: VolumeClaim[] + images: string[] + fetched_at: string +} diff --git a/frontend/src/components/WorkloadTable.test.ts b/frontend/src/components/WorkloadTable.test.ts new file mode 100644 index 0000000..5fb3103 --- /dev/null +++ b/frontend/src/components/WorkloadTable.test.ts @@ -0,0 +1,45 @@ +import { mount } from '@vue/test-utils' +import WorkloadTable from './WorkloadTable.vue' +import type { Workload } from '../api/types' + +const workloads: Workload[] = [ + { + namespace: 'gitea', + kind: 'deployment', + name: 'gitea', + ready: 1, + desired: 1, + containers: [{ name: 'gitea', image: 'gitea/gitea:1.22.3' }], + }, + { + namespace: 'gitea', + kind: 'statefulset', + name: 'gitea-postgresql', + ready: 0, + desired: 1, + containers: [{ name: 'postgresql', image: 'bitnami/postgresql:16.4.0' }], + }, +] + +describe('WorkloadTable', () => { + it('renders workloads with readiness and images', () => { + const w = mount(WorkloadTable, { props: { workloads, canAct: false } }) + const rows = w.findAll('tbody tr') + expect(rows).toHaveLength(2) + expect(rows[0].text()).toContain('gitea/gitea:1.22.3') + expect(rows[0].text()).toContain('1/1') + expect(rows[1].text()).toContain('0/1') + expect(rows[1].classes().join(' ')).toContain('red') + expect(w.findAll('button[name=restart]')).toHaveLength(0) + }) + + it('emits actions for admins', async () => { + const w = mount(WorkloadTable, { props: { workloads, canAct: true } }) + await w.findAll('button[name=restart]')[0].trigger('click') + expect(w.emitted('restart')![0][0]).toMatchObject({ name: 'gitea', kind: 'deployment' }) + await w.findAll('button[name=scale]')[1].trigger('click') + expect(w.emitted('scale')![0][0]).toMatchObject({ name: 'gitea-postgresql' }) + await w.findAll('button[name=image]')[0].trigger('click') + expect(w.emitted('image')![0][0]).toMatchObject({ name: 'gitea' }) + }) +})