WP-12: contract and failing tests for the Kubernetes overview
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
105
backend/crates/api/tests/cluster.rs
Normal file
105
backend/crates/api/tests/cluster.rs
Normal file
@ -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
|
||||
);
|
||||
}
|
||||
37
backend/crates/application/src/cluster_service.rs
Normal file
37
backend/crates/application/src/cluster_service.rs
Normal file
@ -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<dyn ClusterGateway>,
|
||||
}
|
||||
|
||||
impl ClusterService {
|
||||
pub fn new(gateway: Arc<dyn ClusterGateway>) -> Self {
|
||||
let _ = &gateway;
|
||||
Self { gateway }
|
||||
}
|
||||
|
||||
pub async fn overview(&self) -> Result<ClusterOverview, DomainError> {
|
||||
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<String>,
|
||||
_image: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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<Vec<String>>,
|
||||
}
|
||||
|
||||
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<ClusterOverview, DomainError> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
91
backend/crates/application/src/tests/cluster_tests.rs
Normal file
91
backend/crates/application/src/tests/cluster_tests.rs
Normal file
@ -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<MemCluster>, 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"
|
||||
]
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
mod auth_service_tests;
|
||||
mod cluster_tests;
|
||||
mod inventory_tests;
|
||||
mod jobs_tests;
|
||||
mod scheduler_tests;
|
||||
|
||||
110
backend/crates/domain/src/cluster.rs
Normal file
110
backend/crates/domain/src/cluster.rs
Normal file
@ -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> {
|
||||
[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<Container>,
|
||||
}
|
||||
|
||||
#[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<NodeInfo>,
|
||||
pub namespaces: Vec<String>,
|
||||
pub workloads: Vec<Workload>,
|
||||
pub volume_claims: Vec<VolumeClaim>,
|
||||
pub fetched_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl ClusterOverview {
|
||||
/// Distinct images across all workloads (input for vulnerability scans).
|
||||
pub fn images(&self) -> Vec<String> {
|
||||
let mut v: Vec<String> = 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}")))
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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<ClusterOverview, DomainError>;
|
||||
/// 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>;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user