WP-12: contract and failing tests for the Kubernetes overview
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / ui (push) Has been cancelled

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-02 22:29:36 +02:00
parent 684695e71a
commit 51ec216910
14 changed files with 922 additions and 0 deletions

View 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!()
}
}

View File

@ -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;

View File

@ -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(())
}
}

View 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"
]
);
}

View File

@ -1,4 +1,5 @@
mod auth_service_tests;
mod cluster_tests;
mod inventory_tests;
mod jobs_tests;
mod scheduler_tests;