WP-12: Kubernetes overview and workload actions
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / ui (push) Has been cancelled

kube-rs gateway (nodes, namespaces, deployments/statefulsets/daemonsets with
images, PVCs; rollout restart, scale, set image via patches) using the
microk8s kubeconfig by default, fake gateway for dev, /api/cluster routes,
Kubernetes page with node cards, workload table with admin actions and PVC
list. Also implements the streaming command runner that WP-11 relied on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-02 22:33:57 +02:00
parent 51ec216910
commit 8f36a54da0
15 changed files with 807 additions and 26 deletions

View File

@ -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<AppState> {
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<String>,
}
#[utoipa::path(get, path = "/api/cluster/overview", tag = "cluster", security(("bearer" = [])), responses((status = 200, body = OverviewResponse), (status = 502)))]
async fn overview(
State(state): State<AppState>,
_: AuthUser,
) -> Result<Json<OverviewResponse>, ApiError> {
let overview = state.cluster.overview().await?;
Ok(Json(OverviewResponse {
images: overview.images(),
overview,
}))
}
fn workload_ref((ns, kind, name): (String, String, String)) -> Result<WorkloadRef, DomainError> {
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<AppState>,
_: AdminUser,
Path(p): Path<(String, String, String)>,
) -> Result<StatusCode, ApiError> {
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<AppState>,
_: AdminUser,
Path(p): Path<(String, String, String)>,
Json(req): Json<ScaleRequest>,
) -> Result<StatusCode, ApiError> {
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<String>,
}
#[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<AppState>,
_: AdminUser,
Path(p): Path<(String, String, String)>,
Json(req): Json<ImageRequest>,
) -> Result<StatusCode, ApiError> {
state
.cluster
.set_image(workload_ref(p)?, req.container, &req.image)
.await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@ -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<String>,
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()?,

View File

@ -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<SettingsService>,
pub jobs: Arc<JobRunner>,
pub inventory: Arc<InventoryService>,
pub cluster: Arc<ClusterService>,
pub login_limiter: Arc<rate_limit::RateLimiter>,
}
@ -47,16 +50,32 @@ impl AppState {
/// Wire the services on top of a connected database.
pub fn new(cfg: Config, pool: DbPool) -> anyhow::Result<Self> {
let runner = Arc::new(SystemCommandRunner);
let (inspector, updater): (Arc<dyn HostInspector>, Arc<dyn HostUpdater>) = if cfg.fake_host
{
(Arc::new(FakeHostInspector), Arc::new(FakeHostUpdater))
let (inspector, updater, cluster): (
Arc<dyn HostInspector>,
Arc<dyn HostUpdater>,
Arc<dyn ClusterGateway>,
) = 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<dyn Mailer>,
inspector: Arc<dyn HostInspector>,
updater: Arc<dyn HostUpdater>,
cluster: Arc<dyn ClusterGateway>,
register_jobs: impl FnOnce(JobRunner) -> JobRunner,
) -> anyhow::Result<Self> {
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)

View File

@ -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)
)]

View File

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

View File

@ -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<dyn ClusterGateway>) -> Self {
let _ = &gateway;
Self { gateway }
}
pub async fn overview(&self) -> Result<ClusterOverview, DomainError> {
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<String>,
_image: &str,
w: WorkloadRef,
container: Option<String>,
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)
}

View File

@ -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<bool, DomainError> {
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::<String>();
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<Option<String>, DomainError> {

View File

@ -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<String>,
client: OnceCell<Client>,
}
impl KubeGateway {
/// `kubeconfig`: path to a kubeconfig file; `None` infers (in-cluster or ~/.kube/config).
pub fn new(kubeconfig: Option<String>) -> Self {
Self {
kubeconfig,
client: OnceCell::new(),
}
}
async fn client(&self) -> Result<Client, DomainError> {
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<PodTemplateSpec>) -> Vec<Container> {
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<serde_json::Value> {
Patch::Merge(
json!({"spec": {"template": {"metadata": {"annotations": {"kubectl.kubernetes.io/restartedAt": Utc::now().to_rfc3339()}}}}}),
)
}
fn image_patch(container: &str, image: &str) -> Patch<serde_json::Value> {
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::<Deployment>::namespaced($client, &$w.namespace)
.patch(&$w.name, &pp, &$patch)
.await
.map(|_| ()),
WorkloadKind::StatefulSet => Api::<StatefulSet>::namespaced($client, &$w.namespace)
.patch(&$w.name, &pp, &$patch)
.await
.map(|_| ()),
WorkloadKind::DaemonSet => Api::<DaemonSet>::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<ClusterOverview, DomainError> {
let client = self.client().await?;
let lp = Default::default();
let nodes = Api::<Node>::all(client.clone())
.list(&lp)
.await
.map_err(map_err)?;
let namespaces = Api::<Namespace>::all(client.clone())
.list(&lp)
.await
.map_err(map_err)?;
let deployments = Api::<Deployment>::all(client.clone())
.list(&lp)
.await
.map_err(map_err)?;
let statefulsets = Api::<StatefulSet>::all(client.clone())
.list(&lp)
.await
.map_err(map_err)?;
let daemonsets = Api::<DaemonSet>::all(client.clone())
.list(&lp)
.await
.map_err(map_err)?;
let pvcs = Api::<PersistentVolumeClaim>::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::<serde_json::Value>::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<ClusterOverview, DomainError> {
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(())
}
}

View File

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