diff --git a/ROADMAP.md b/ROADMAP.md index 1ad664f..127a0ce 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -357,7 +357,7 @@ The server is reachable via `ssh softvisor` (as root). Findings from the inspect | WP-02 | M1 (shell) / M2 (rest) | done | shell 2026-09-02; encrypted settings, SMTP, job runner, scheduler 2026-09-02 | | WP-10 | M2 | done | 2026-09-02; snaps inventoried, no upstream check | | WP-11 | M2 | done | 2026-09-02; runs as root via systemd, sudoers scoping deferred to WP-41; log via polling | -| WP-12 | M2 | todo | | +| WP-12 | M2 | done | 2026-09-02; overview, restart, scale, set image; upstream tag check not done | | WP-20 | M3 | todo | | | WP-21 | M3 | todo | | | WP-30 | M4 | todo | | diff --git a/backend/crates/api/src/cluster.rs b/backend/crates/api/src/cluster.rs new file mode 100644 index 0000000..a1fdc49 --- /dev/null +++ b/backend/crates/api/src/cluster.rs @@ -0,0 +1,96 @@ +//! /api/cluster: Kubernetes overview and workload actions. +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use domain::cluster::{ClusterOverview, WorkloadKind, WorkloadRef}; +use domain::DomainError; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::error::ApiError; +use crate::extract::{AdminUser, AuthUser}; +use crate::AppState; + +pub fn router() -> Router { + Router::new() + .route("/overview", get(overview)) + .route("/workloads/{ns}/{kind}/{name}/restart", post(restart)) + .route("/workloads/{ns}/{kind}/{name}/scale", post(scale)) + .route("/workloads/{ns}/{kind}/{name}/image", post(set_image)) +} + +#[derive(Serialize, ToSchema)] +pub struct OverviewResponse { + #[serde(flatten)] + #[schema(value_type = Object)] + pub overview: ClusterOverview, + pub images: Vec, +} + +#[utoipa::path(get, path = "/api/cluster/overview", tag = "cluster", security(("bearer" = [])), responses((status = 200, body = OverviewResponse), (status = 502)))] +async fn overview( + State(state): State, + _: AuthUser, +) -> Result, ApiError> { + let overview = state.cluster.overview().await?; + Ok(Json(OverviewResponse { + images: overview.images(), + overview, + })) +} + +fn workload_ref((ns, kind, name): (String, String, String)) -> Result { + let kind = WorkloadKind::parse(&kind).ok_or(DomainError::NotFound)?; + Ok(WorkloadRef { + namespace: ns, + kind, + name, + }) +} + +#[utoipa::path(post, path = "/api/cluster/workloads/{ns}/{kind}/{name}/restart", tag = "cluster", security(("bearer" = [])), responses((status = 204), (status = 404)))] +async fn restart( + State(state): State, + _: AdminUser, + Path(p): Path<(String, String, String)>, +) -> Result { + state.cluster.restart(workload_ref(p)?).await?; + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Deserialize, ToSchema)] +pub struct ScaleRequest { + pub replicas: i32, +} + +#[utoipa::path(post, path = "/api/cluster/workloads/{ns}/{kind}/{name}/scale", tag = "cluster", security(("bearer" = [])), request_body = ScaleRequest, responses((status = 204), (status = 422)))] +async fn scale( + State(state): State, + _: AdminUser, + Path(p): Path<(String, String, String)>, + Json(req): Json, +) -> Result { + state.cluster.scale(workload_ref(p)?, req.replicas).await?; + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Deserialize, ToSchema)] +pub struct ImageRequest { + pub image: String, + pub container: Option, +} + +#[utoipa::path(post, path = "/api/cluster/workloads/{ns}/{kind}/{name}/image", tag = "cluster", security(("bearer" = [])), request_body = ImageRequest, responses((status = 204), (status = 404), (status = 422)))] +async fn set_image( + State(state): State, + _: AdminUser, + Path(p): Path<(String, String, String)>, + Json(req): Json, +) -> Result { + state + .cluster + .set_image(workload_ref(p)?, req.container, &req.image) + .await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/backend/crates/api/src/config.rs b/backend/crates/api/src/config.rs index e24dd63..0031495 100644 --- a/backend/crates/api/src/config.rs +++ b/backend/crates/api/src/config.rs @@ -9,6 +9,8 @@ pub struct Config { pub master_key: String, /// Use fake host adapters (dev machines without apt/kubectl). pub fake_host: bool, + /// Path to a kubeconfig; None infers. Defaults to the microk8s client config if present. + pub kubeconfig: Option, pub bind: SocketAddr, pub bootstrap_admin: Option<(String, String)>, pub cookie_secure: bool, @@ -32,6 +34,12 @@ impl Config { jwt_secret, master_key, fake_host: env("FAKE_HOST").is_some_and(|v| v == "true" || v == "1"), + kubeconfig: env("KUBECONFIG").or_else(|| { + let microk8s = "/var/snap/microk8s/current/credentials/client.config"; + std::path::Path::new(microk8s) + .exists() + .then(|| microk8s.to_string()) + }), bind: env("BIND") .unwrap_or_else(|| "127.0.0.1:8080".into()) .parse()?, diff --git a/backend/crates/api/src/lib.rs b/backend/crates/api/src/lib.rs index bbd46af..3b81645 100644 --- a/backend/crates/api/src/lib.rs +++ b/backend/crates/api/src/lib.rs @@ -1,5 +1,6 @@ //! HTTP API layer (axum). `build_app` is used by both the binary and the integration tests. pub mod auth; +pub mod cluster; pub mod config; pub mod error; pub mod extract; @@ -15,17 +16,18 @@ use std::sync::Arc; use application::scheduler::Scheduler; use application::{ - AuthService, InventoryService, JobRunner, PackageRefreshJob, PackageUpgradeJob, + AuthService, ClusterService, InventoryService, JobRunner, PackageRefreshJob, PackageUpgradeJob, SettingsService, UserService, }; use axum::{routing::get, Json, Router}; use domain::jobs::JobKind; use domain::ports::Mailer; -use domain::ports::{HostInspector, HostUpdater}; +use domain::ports::{ClusterGateway, HostInspector, HostUpdater}; use infrastructure::{ - AesGcmCipher, Argon2Hasher, DbPool, DebianInspector, DebianUpdater, FakeHostInspector, - FakeHostUpdater, JwtIssuer, LettreMailer, SqliteAuditLog, SqliteInventory, SqliteJobRuns, - SqliteRefreshTokens, SqliteSettings, SqliteUsers, SystemCommandRunner, + AesGcmCipher, Argon2Hasher, DbPool, DebianInspector, DebianUpdater, FakeClusterGateway, + FakeHostInspector, FakeHostUpdater, JwtIssuer, KubeGateway, LettreMailer, SqliteAuditLog, + SqliteInventory, SqliteJobRuns, SqliteRefreshTokens, SqliteSettings, SqliteUsers, + SystemCommandRunner, }; use tower_http::services::{ServeDir, ServeFile}; use tower_http::trace::TraceLayer; @@ -40,6 +42,7 @@ pub struct AppState { pub settings: Arc, pub jobs: Arc, pub inventory: Arc, + pub cluster: Arc, pub login_limiter: Arc, } @@ -47,16 +50,32 @@ impl AppState { /// Wire the services on top of a connected database. pub fn new(cfg: Config, pool: DbPool) -> anyhow::Result { let runner = Arc::new(SystemCommandRunner); - let (inspector, updater): (Arc, Arc) = if cfg.fake_host - { - (Arc::new(FakeHostInspector), Arc::new(FakeHostUpdater)) + let (inspector, updater, cluster): ( + Arc, + Arc, + Arc, + ) = if cfg.fake_host { + ( + Arc::new(FakeHostInspector), + Arc::new(FakeHostUpdater), + Arc::new(FakeClusterGateway), + ) } else { ( Arc::new(DebianInspector::new(runner.clone())), Arc::new(DebianUpdater::new(runner)), + Arc::new(KubeGateway::new(cfg.kubeconfig.clone())), ) }; - Self::with_adapters(cfg, pool, Arc::new(LettreMailer), inspector, updater, |r| r) + Self::with_adapters( + cfg, + pool, + Arc::new(LettreMailer), + inspector, + updater, + cluster, + |r| r, + ) } /// Wiring with replaceable adapters (used by tests and the fake-host mode). @@ -66,6 +85,7 @@ impl AppState { mailer: Arc, inspector: Arc, updater: Arc, + cluster: Arc, register_jobs: impl FnOnce(JobRunner) -> JobRunner, ) -> anyhow::Result { let users = Arc::new(SqliteUsers(pool.clone())); @@ -100,6 +120,7 @@ impl AppState { }), ); let jobs = Arc::new(register_jobs(runner)); + let cluster = Arc::new(ClusterService::new(cluster)); Ok(Self { cfg, auth: Arc::new(auth), @@ -107,6 +128,7 @@ impl AppState { settings, jobs, inventory, + cluster, login_limiter: Arc::new(rate_limit::RateLimiter::new( 10, std::time::Duration::from_secs(60), @@ -140,6 +162,7 @@ pub fn build_app(state: AppState) -> Router { .nest("/api/settings", settings::router()) .nest("/api/jobs", jobs::router()) .nest("/api/system", system::router()) + .nest("/api/cluster", cluster::router()) .fallback_service(spa) .layer(TraceLayer::new_for_http()) .with_state(state) diff --git a/backend/crates/api/src/openapi.rs b/backend/crates/api/src/openapi.rs index ffe7251..9e37212 100644 --- a/backend/crates/api/src/openapi.rs +++ b/backend/crates/api/src/openapi.rs @@ -27,6 +27,7 @@ impl Modify for BearerAuth { crate::settings::get_smtp, crate::settings::put_smtp, crate::settings::test_smtp, crate::settings::list_schedules, crate::settings::put_schedule, crate::jobs::list, crate::jobs::kinds, crate::jobs::get_one, crate::jobs::run, crate::system::inventory, crate::system::upgrade, + crate::cluster::overview, crate::cluster::restart, crate::cluster::scale, crate::cluster::set_image, ), modifiers(&BearerAuth) )] diff --git a/backend/crates/api/src/test_support.rs b/backend/crates/api/src/test_support.rs index 1c95b91..0340af8 100644 --- a/backend/crates/api/src/test_support.rs +++ b/backend/crates/api/src/test_support.rs @@ -16,6 +16,7 @@ pub fn test_config() -> Config { jwt_secret: "test-secret-test-secret-test-secret-1234".into(), master_key: "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f".into(), fake_host: true, + kubeconfig: None, bind: "127.0.0.1:0".parse().unwrap(), bootstrap_admin: None, cookie_secure: false, @@ -85,6 +86,7 @@ async fn build_test_app_with(cfg: Config) -> Router { Arc::new(RecordingMailer), Arc::new(infrastructure::FakeHostInspector), Arc::new(infrastructure::FakeHostUpdater), + Arc::new(infrastructure::FakeClusterGateway), register_test_jobs, ) .expect("state"); diff --git a/backend/crates/application/src/cluster_service.rs b/backend/crates/application/src/cluster_service.rs index 889ba74..bf7c10b 100644 --- a/backend/crates/application/src/cluster_service.rs +++ b/backend/crates/application/src/cluster_service.rs @@ -1,6 +1,8 @@ use std::sync::Arc; -use domain::cluster::{ClusterOverview, WorkloadRef}; +use domain::cluster::{ + validate_image, validate_k8s_name, ClusterOverview, WorkloadRef, MAX_REPLICAS, +}; use domain::ports::ClusterGateway; use domain::DomainError; @@ -10,28 +12,55 @@ pub struct ClusterService { impl ClusterService { pub fn new(gateway: Arc) -> Self { - let _ = &gateway; Self { gateway } } pub async fn overview(&self) -> Result { - todo!() + self.gateway.overview().await } - pub async fn restart(&self, _w: WorkloadRef) -> Result<(), DomainError> { - todo!() + pub async fn restart(&self, w: WorkloadRef) -> Result<(), DomainError> { + validate_ref(&w)?; + self.gateway.restart(&w).await } - pub async fn scale(&self, _w: WorkloadRef, _replicas: i32) -> Result<(), DomainError> { - todo!() + pub async fn scale(&self, w: WorkloadRef, replicas: i32) -> Result<(), DomainError> { + validate_ref(&w)?; + if !(0..=MAX_REPLICAS).contains(&replicas) { + return Err(DomainError::Validation(format!( + "replicas must be between 0 and {MAX_REPLICAS}" + ))); + } + self.gateway.scale(&w, replicas).await } + /// Change the image of `container` (default: the workload's first container). pub async fn set_image( &self, - _w: WorkloadRef, - _container: Option, - _image: &str, + w: WorkloadRef, + container: Option, + image: &str, ) -> Result<(), DomainError> { - todo!() + validate_ref(&w)?; + validate_image(image)?; + let container = match container { + Some(c) => c, + None => { + let overview = self.gateway.overview().await?; + overview + .workloads + .iter() + .find(|x| x.namespace == w.namespace && x.kind == w.kind && x.name == w.name) + .and_then(|x| x.containers.first()) + .map(|c| c.name.clone()) + .ok_or(DomainError::NotFound)? + } + }; + self.gateway.set_image(&w, &container, image).await } } + +fn validate_ref(w: &WorkloadRef) -> Result<(), DomainError> { + validate_k8s_name(&w.namespace)?; + validate_k8s_name(&w.name) +} diff --git a/backend/crates/infrastructure/src/host/command.rs b/backend/crates/infrastructure/src/host/command.rs index 6021ac4..f92ec35 100644 --- a/backend/crates/infrastructure/src/host/command.rs +++ b/backend/crates/infrastructure/src/host/command.rs @@ -43,11 +43,52 @@ impl CommandRunner for SystemCommandRunner { async fn run_streaming( &self, - _program: &str, - _args: &[&str], - _out: &dyn LineSink, + program: &str, + args: &[&str], + out: &dyn LineSink, ) -> Result { - todo!() + use tokio::io::{AsyncBufReadExt, BufReader}; + let mut child = tokio::process::Command::new(program) + .args(args) + .env("DEBIAN_FRONTEND", "noninteractive") + .env("LC_ALL", "C") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| DomainError::Unavailable(format!("{program}: {e}")))?; + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let mut readers = Vec::new(); + if let Some(stdout) = child.stdout.take() { + let tx = tx.clone(); + readers.push(tokio::spawn(async move { + let mut lines = BufReader::new(stdout).lines(); + while let Ok(Some(l)) = lines.next_line().await { + let _ = tx.send(l); + } + })); + } + if let Some(stderr) = child.stderr.take() { + let tx = tx.clone(); + readers.push(tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(l)) = lines.next_line().await { + let _ = tx.send(l); + } + })); + } + drop(tx); + while let Some(line) = rx.recv().await { + out.line(&line); + } + for r in readers { + let _ = r.await; + } + let status = child + .wait() + .await + .map_err(|e| DomainError::Unavailable(format!("{program}: {e}")))?; + Ok(status.success()) } async fn read_file(&self, path: &str) -> Result, DomainError> { diff --git a/backend/crates/infrastructure/src/k8s.rs b/backend/crates/infrastructure/src/k8s.rs new file mode 100644 index 0000000..577e2f7 --- /dev/null +++ b/backend/crates/infrastructure/src/k8s.rs @@ -0,0 +1,383 @@ +//! Kubernetes gateway on top of kube-rs, plus a fake for development. +use async_trait::async_trait; +use chrono::Utc; +use domain::cluster::{ + ClusterOverview, Container, NodeInfo, VolumeClaim, Workload, WorkloadKind, WorkloadRef, +}; +use domain::ports::ClusterGateway; +use domain::DomainError; +use k8s_openapi::api::apps::v1::{DaemonSet, Deployment, StatefulSet}; +use k8s_openapi::api::core::v1::{Namespace, Node, PersistentVolumeClaim, PodTemplateSpec}; +use kube::api::{Patch, PatchParams}; +use kube::config::{KubeConfigOptions, Kubeconfig}; +use kube::{Api, Client, Config}; +use serde_json::json; +use tokio::sync::OnceCell; + +pub struct KubeGateway { + kubeconfig: Option, + client: OnceCell, +} + +impl KubeGateway { + /// `kubeconfig`: path to a kubeconfig file; `None` infers (in-cluster or ~/.kube/config). + pub fn new(kubeconfig: Option) -> Self { + Self { + kubeconfig, + client: OnceCell::new(), + } + } + + async fn client(&self) -> Result { + let unavailable = |e: String| DomainError::Unavailable(format!("kubernetes: {e}")); + self.client + .get_or_try_init(|| async { + let config = match &self.kubeconfig { + Some(path) => { + let kc = + Kubeconfig::read_from(path).map_err(|e| unavailable(e.to_string()))?; + Config::from_custom_kubeconfig(kc, &KubeConfigOptions::default()) + .await + .map_err(|e| unavailable(e.to_string()))? + } + None => Config::infer() + .await + .map_err(|e| unavailable(e.to_string()))?, + }; + Client::try_from(config).map_err(|e| unavailable(e.to_string())) + }) + .await + .cloned() + } +} + +fn map_err(e: kube::Error) -> DomainError { + match e { + kube::Error::Api(ref r) if r.code == 404 => DomainError::NotFound, + kube::Error::Api(ref r) if r.code == 422 => DomainError::Validation(r.message.clone()), + e => DomainError::Unavailable(format!("kubernetes: {e}")), + } +} + +fn containers(t: &Option) -> Vec { + t.as_ref() + .and_then(|t| t.spec.as_ref()) + .map(|s| { + s.containers + .iter() + .map(|c| Container { + name: c.name.clone(), + image: c.image.clone().unwrap_or_default(), + }) + .collect() + }) + .unwrap_or_default() +} + +fn restart_patch() -> Patch { + Patch::Merge( + json!({"spec": {"template": {"metadata": {"annotations": {"kubectl.kubernetes.io/restartedAt": Utc::now().to_rfc3339()}}}}}), + ) +} + +fn image_patch(container: &str, image: &str) -> Patch { + Patch::Strategic( + json!({"spec": {"template": {"spec": {"containers": [{"name": container, "image": image}]}}}}), + ) +} + +macro_rules! patch_kind { + ($client:expr, $w:expr, $patch:expr) => {{ + let pp = PatchParams::apply("softvisor-monitoring").force(); + let pp = PatchParams { + field_manager: pp.field_manager, + ..PatchParams::default() + }; + match $w.kind { + WorkloadKind::Deployment => Api::::namespaced($client, &$w.namespace) + .patch(&$w.name, &pp, &$patch) + .await + .map(|_| ()), + WorkloadKind::StatefulSet => Api::::namespaced($client, &$w.namespace) + .patch(&$w.name, &pp, &$patch) + .await + .map(|_| ()), + WorkloadKind::DaemonSet => Api::::namespaced($client, &$w.namespace) + .patch(&$w.name, &pp, &$patch) + .await + .map(|_| ()), + } + .map_err(map_err) + }}; +} + +#[async_trait] +impl ClusterGateway for KubeGateway { + async fn overview(&self) -> Result { + let client = self.client().await?; + let lp = Default::default(); + let nodes = Api::::all(client.clone()) + .list(&lp) + .await + .map_err(map_err)?; + let namespaces = Api::::all(client.clone()) + .list(&lp) + .await + .map_err(map_err)?; + let deployments = Api::::all(client.clone()) + .list(&lp) + .await + .map_err(map_err)?; + let statefulsets = Api::::all(client.clone()) + .list(&lp) + .await + .map_err(map_err)?; + let daemonsets = Api::::all(client.clone()) + .list(&lp) + .await + .map_err(map_err)?; + let pvcs = Api::::all(client) + .list(&lp) + .await + .map_err(map_err)?; + + let mut workloads = Vec::new(); + for d in deployments { + workloads.push(Workload { + namespace: d.metadata.namespace.clone().unwrap_or_default(), + kind: WorkloadKind::Deployment, + name: d.metadata.name.clone().unwrap_or_default(), + ready: d + .status + .as_ref() + .and_then(|s| s.ready_replicas) + .unwrap_or(0), + desired: d.spec.as_ref().and_then(|s| s.replicas).unwrap_or(0), + containers: containers(&d.spec.as_ref().map(|s| s.template.clone())), + }); + } + for s in statefulsets { + workloads.push(Workload { + namespace: s.metadata.namespace.clone().unwrap_or_default(), + kind: WorkloadKind::StatefulSet, + name: s.metadata.name.clone().unwrap_or_default(), + ready: s + .status + .as_ref() + .and_then(|s| s.ready_replicas) + .unwrap_or(0), + desired: s.spec.as_ref().and_then(|s| s.replicas).unwrap_or(0), + containers: containers(&s.spec.as_ref().map(|s| s.template.clone())), + }); + } + for d in daemonsets { + workloads.push(Workload { + namespace: d.metadata.namespace.clone().unwrap_or_default(), + kind: WorkloadKind::DaemonSet, + name: d.metadata.name.clone().unwrap_or_default(), + ready: d.status.as_ref().map(|s| s.number_ready).unwrap_or(0), + desired: d + .status + .as_ref() + .map(|s| s.desired_number_scheduled) + .unwrap_or(0), + containers: containers(&d.spec.as_ref().map(|s| s.template.clone())), + }); + } + workloads.sort_by(|a, b| (&a.namespace, &a.name).cmp(&(&b.namespace, &b.name))); + + Ok(ClusterOverview { + nodes: nodes + .iter() + .map(|n| { + let info = n.status.as_ref().and_then(|s| s.node_info.as_ref()); + NodeInfo { + name: n.metadata.name.clone().unwrap_or_default(), + version: info.map(|i| i.kubelet_version.clone()).unwrap_or_default(), + ready: n + .status + .as_ref() + .and_then(|s| s.conditions.as_ref()) + .is_some_and(|c| { + c.iter().any(|c| c.type_ == "Ready" && c.status == "True") + }), + os_image: info.map(|i| i.os_image.clone()).unwrap_or_default(), + kernel: info.map(|i| i.kernel_version.clone()).unwrap_or_default(), + container_runtime: info + .map(|i| i.container_runtime_version.clone()) + .unwrap_or_default(), + } + }) + .collect(), + namespaces: namespaces + .iter() + .filter_map(|n| n.metadata.name.clone()) + .collect(), + workloads, + volume_claims: pvcs + .iter() + .map(|p| VolumeClaim { + namespace: p.metadata.namespace.clone().unwrap_or_default(), + name: p.metadata.name.clone().unwrap_or_default(), + capacity: p + .status + .as_ref() + .and_then(|s| s.capacity.as_ref()) + .and_then(|c| c.get("storage")) + .map(|q| q.0.clone()) + .unwrap_or_default(), + storage_class: p + .spec + .as_ref() + .and_then(|s| s.storage_class_name.clone()) + .unwrap_or_default(), + status: p + .status + .as_ref() + .and_then(|s| s.phase.clone()) + .unwrap_or_default(), + }) + .collect(), + fetched_at: Utc::now(), + }) + } + + async fn restart(&self, w: &WorkloadRef) -> Result<(), DomainError> { + let client = self.client().await?; + patch_kind!(client.clone(), w, restart_patch()) + } + + async fn scale(&self, w: &WorkloadRef, replicas: i32) -> Result<(), DomainError> { + if w.kind == WorkloadKind::DaemonSet { + return Err(DomainError::Validation( + "daemonsets cannot be scaled".into(), + )); + } + let client = self.client().await?; + patch_kind!( + client.clone(), + w, + Patch::::Merge(json!({"spec": {"replicas": replicas}})) + ) + } + + async fn set_image( + &self, + w: &WorkloadRef, + container: &str, + image: &str, + ) -> Result<(), DomainError> { + let client = self.client().await?; + patch_kind!(client.clone(), w, image_patch(container, image)) + } +} + +/// Sample cluster for development machines (FAKE_HOST=true). +pub struct FakeClusterGateway; + +#[async_trait] +impl ClusterGateway for FakeClusterGateway { + async fn overview(&self) -> Result { + let c = |name: &str, image: &str| { + vec![Container { + name: name.into(), + image: image.into(), + }] + }; + let w = |ns: &str, kind, name: &str, ready, desired, containers| Workload { + namespace: ns.into(), + kind, + name: name.into(), + ready, + desired, + containers, + }; + Ok(ClusterOverview { + nodes: vec![NodeInfo { + name: "fake-node".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(), + "cert-manager".into(), + "kube-system".into(), + ], + workloads: vec![ + w( + "cert-manager", + WorkloadKind::Deployment, + "cert-manager", + 1, + 1, + c( + "cert-manager", + "quay.io/jetstack/cert-manager-controller:v1.16.1", + ), + ), + w( + "gitea", + WorkloadKind::Deployment, + "gitea", + 1, + 1, + c("gitea", "gitea/gitea:1.22.3"), + ), + w( + "gitea", + WorkloadKind::StatefulSet, + "gitea-postgresql", + 1, + 1, + c("postgresql", "bitnami/postgresql:16.4.0"), + ), + w( + "gitea", + WorkloadKind::StatefulSet, + "gitea-valkey-primary", + 1, + 1, + c("valkey", "bitnami/valkey:8.0.1"), + ), + w( + "kube-system", + WorkloadKind::DaemonSet, + "calico-node", + 1, + 1, + c("calico-node", "docker.io/calico/node:v3.28.1"), + ), + ], + volume_claims: vec![ + VolumeClaim { + namespace: "gitea".into(), + name: "gitea-shared-storage".into(), + capacity: "10Gi".into(), + storage_class: "microk8s-hostpath".into(), + status: "Bound".into(), + }, + VolumeClaim { + namespace: "gitea".into(), + name: "data-gitea-postgresql-0".into(), + capacity: "10Gi".into(), + storage_class: "microk8s-hostpath".into(), + status: "Bound".into(), + }, + ], + fetched_at: Utc::now(), + }) + } + async fn restart(&self, _: &WorkloadRef) -> Result<(), DomainError> { + Ok(()) + } + async fn scale(&self, _: &WorkloadRef, _: i32) -> Result<(), DomainError> { + Ok(()) + } + async fn set_image(&self, _: &WorkloadRef, _: &str, _: &str) -> Result<(), DomainError> { + Ok(()) + } +} diff --git a/backend/crates/infrastructure/src/lib.rs b/backend/crates/infrastructure/src/lib.rs index 0d61a5c..fc8f0cb 100644 --- a/backend/crates/infrastructure/src/lib.rs +++ b/backend/crates/infrastructure/src/lib.rs @@ -2,6 +2,7 @@ pub mod cipher; pub mod db; pub mod host; +pub mod k8s; pub mod mail; pub mod password; pub mod sqlite; @@ -12,6 +13,7 @@ pub use db::{connect, DbPool}; pub use host::{ DebianInspector, DebianUpdater, FakeHostInspector, FakeHostUpdater, SystemCommandRunner, }; +pub use k8s::{FakeClusterGateway, KubeGateway}; pub use mail::LettreMailer; pub use password::Argon2Hasher; pub use sqlite::SqliteInventory; diff --git a/frontend/e2e/cluster.spec.ts b/frontend/e2e/cluster.spec.ts index 25d6588..ef0b7f2 100644 --- a/frontend/e2e/cluster.spec.ts +++ b/frontend/e2e/cluster.spec.ts @@ -10,7 +10,7 @@ test('cluster page shows node, workloads and lets an admin restart a workload', await page.getByRole('link', { name: 'Kubernetes' }).click() await expect(page.getByTestId('node-version')).toContainText('v1.32') - const row = page.getByRole('row', { name: /gitea-postgresql/ }) + const row = page.getByRole('row', { name: /^gitea gitea-postgresql statefulset/ }) await expect(row).toContainText('statefulset') page.once('dialog', (d) => d.accept()) await page diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index 91c1eaf..5916a59 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -7,6 +7,7 @@ const router = useRouter() const nav = [ { to: '/', label: 'Dashboard' }, { to: '/updates', label: 'Updates' }, + { to: '/cluster', label: 'Kubernetes' }, { to: '/vulnerabilities', label: 'Vulnerabilities' }, { to: '/backups', label: 'Backups' }, { to: '/jobs', label: 'Jobs' }, diff --git a/frontend/src/components/WorkloadTable.vue b/frontend/src/components/WorkloadTable.vue new file mode 100644 index 0000000..77e00ec --- /dev/null +++ b/frontend/src/components/WorkloadTable.vue @@ -0,0 +1,61 @@ + + + diff --git a/frontend/src/pages/ClusterPage.vue b/frontend/src/pages/ClusterPage.vue new file mode 100644 index 0000000..7407191 --- /dev/null +++ b/frontend/src/pages/ClusterPage.vue @@ -0,0 +1,132 @@ + + + diff --git a/frontend/src/router.ts b/frontend/src/router.ts index 735892e..7b264a5 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -9,6 +9,7 @@ import PlaceholderPage from './pages/PlaceholderPage.vue' import SettingsPage from './pages/SettingsPage.vue' import JobsPage from './pages/JobsPage.vue' import UpdatesPage from './pages/UpdatesPage.vue' +import ClusterPage from './pages/ClusterPage.vue' export const router = createRouter({ history: createWebHistory(), @@ -20,6 +21,7 @@ export const router = createRouter({ children: [ { path: '', name: 'dashboard', component: DashboardPage }, { path: 'updates', component: UpdatesPage }, + { path: 'cluster', component: ClusterPage }, { path: 'vulnerabilities', component: PlaceholderPage,