WP-12: Kubernetes overview and workload actions
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:
@ -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-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-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-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-20 | M3 | todo | |
|
||||||
| WP-21 | M3 | todo | |
|
| WP-21 | M3 | todo | |
|
||||||
| WP-30 | M4 | todo | |
|
| WP-30 | M4 | todo | |
|
||||||
|
|||||||
96
backend/crates/api/src/cluster.rs
Normal file
96
backend/crates/api/src/cluster.rs
Normal 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)
|
||||||
|
}
|
||||||
@ -9,6 +9,8 @@ pub struct Config {
|
|||||||
pub master_key: String,
|
pub master_key: String,
|
||||||
/// Use fake host adapters (dev machines without apt/kubectl).
|
/// Use fake host adapters (dev machines without apt/kubectl).
|
||||||
pub fake_host: bool,
|
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 bind: SocketAddr,
|
||||||
pub bootstrap_admin: Option<(String, String)>,
|
pub bootstrap_admin: Option<(String, String)>,
|
||||||
pub cookie_secure: bool,
|
pub cookie_secure: bool,
|
||||||
@ -32,6 +34,12 @@ impl Config {
|
|||||||
jwt_secret,
|
jwt_secret,
|
||||||
master_key,
|
master_key,
|
||||||
fake_host: env("FAKE_HOST").is_some_and(|v| v == "true" || v == "1"),
|
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")
|
bind: env("BIND")
|
||||||
.unwrap_or_else(|| "127.0.0.1:8080".into())
|
.unwrap_or_else(|| "127.0.0.1:8080".into())
|
||||||
.parse()?,
|
.parse()?,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
//! HTTP API layer (axum). `build_app` is used by both the binary and the integration tests.
|
//! HTTP API layer (axum). `build_app` is used by both the binary and the integration tests.
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod cluster;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod extract;
|
pub mod extract;
|
||||||
@ -15,17 +16,18 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use application::scheduler::Scheduler;
|
use application::scheduler::Scheduler;
|
||||||
use application::{
|
use application::{
|
||||||
AuthService, InventoryService, JobRunner, PackageRefreshJob, PackageUpgradeJob,
|
AuthService, ClusterService, InventoryService, JobRunner, PackageRefreshJob, PackageUpgradeJob,
|
||||||
SettingsService, UserService,
|
SettingsService, UserService,
|
||||||
};
|
};
|
||||||
use axum::{routing::get, Json, Router};
|
use axum::{routing::get, Json, Router};
|
||||||
use domain::jobs::JobKind;
|
use domain::jobs::JobKind;
|
||||||
use domain::ports::Mailer;
|
use domain::ports::Mailer;
|
||||||
use domain::ports::{HostInspector, HostUpdater};
|
use domain::ports::{ClusterGateway, HostInspector, HostUpdater};
|
||||||
use infrastructure::{
|
use infrastructure::{
|
||||||
AesGcmCipher, Argon2Hasher, DbPool, DebianInspector, DebianUpdater, FakeHostInspector,
|
AesGcmCipher, Argon2Hasher, DbPool, DebianInspector, DebianUpdater, FakeClusterGateway,
|
||||||
FakeHostUpdater, JwtIssuer, LettreMailer, SqliteAuditLog, SqliteInventory, SqliteJobRuns,
|
FakeHostInspector, FakeHostUpdater, JwtIssuer, KubeGateway, LettreMailer, SqliteAuditLog,
|
||||||
SqliteRefreshTokens, SqliteSettings, SqliteUsers, SystemCommandRunner,
|
SqliteInventory, SqliteJobRuns, SqliteRefreshTokens, SqliteSettings, SqliteUsers,
|
||||||
|
SystemCommandRunner,
|
||||||
};
|
};
|
||||||
use tower_http::services::{ServeDir, ServeFile};
|
use tower_http::services::{ServeDir, ServeFile};
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
@ -40,6 +42,7 @@ pub struct AppState {
|
|||||||
pub settings: Arc<SettingsService>,
|
pub settings: Arc<SettingsService>,
|
||||||
pub jobs: Arc<JobRunner>,
|
pub jobs: Arc<JobRunner>,
|
||||||
pub inventory: Arc<InventoryService>,
|
pub inventory: Arc<InventoryService>,
|
||||||
|
pub cluster: Arc<ClusterService>,
|
||||||
pub login_limiter: Arc<rate_limit::RateLimiter>,
|
pub login_limiter: Arc<rate_limit::RateLimiter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,16 +50,32 @@ impl AppState {
|
|||||||
/// Wire the services on top of a connected database.
|
/// Wire the services on top of a connected database.
|
||||||
pub fn new(cfg: Config, pool: DbPool) -> anyhow::Result<Self> {
|
pub fn new(cfg: Config, pool: DbPool) -> anyhow::Result<Self> {
|
||||||
let runner = Arc::new(SystemCommandRunner);
|
let runner = Arc::new(SystemCommandRunner);
|
||||||
let (inspector, updater): (Arc<dyn HostInspector>, Arc<dyn HostUpdater>) = if cfg.fake_host
|
let (inspector, updater, cluster): (
|
||||||
{
|
Arc<dyn HostInspector>,
|
||||||
(Arc::new(FakeHostInspector), Arc::new(FakeHostUpdater))
|
Arc<dyn HostUpdater>,
|
||||||
|
Arc<dyn ClusterGateway>,
|
||||||
|
) = if cfg.fake_host {
|
||||||
|
(
|
||||||
|
Arc::new(FakeHostInspector),
|
||||||
|
Arc::new(FakeHostUpdater),
|
||||||
|
Arc::new(FakeClusterGateway),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
(
|
(
|
||||||
Arc::new(DebianInspector::new(runner.clone())),
|
Arc::new(DebianInspector::new(runner.clone())),
|
||||||
Arc::new(DebianUpdater::new(runner)),
|
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).
|
/// Wiring with replaceable adapters (used by tests and the fake-host mode).
|
||||||
@ -66,6 +85,7 @@ impl AppState {
|
|||||||
mailer: Arc<dyn Mailer>,
|
mailer: Arc<dyn Mailer>,
|
||||||
inspector: Arc<dyn HostInspector>,
|
inspector: Arc<dyn HostInspector>,
|
||||||
updater: Arc<dyn HostUpdater>,
|
updater: Arc<dyn HostUpdater>,
|
||||||
|
cluster: Arc<dyn ClusterGateway>,
|
||||||
register_jobs: impl FnOnce(JobRunner) -> JobRunner,
|
register_jobs: impl FnOnce(JobRunner) -> JobRunner,
|
||||||
) -> anyhow::Result<Self> {
|
) -> anyhow::Result<Self> {
|
||||||
let users = Arc::new(SqliteUsers(pool.clone()));
|
let users = Arc::new(SqliteUsers(pool.clone()));
|
||||||
@ -100,6 +120,7 @@ impl AppState {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
let jobs = Arc::new(register_jobs(runner));
|
let jobs = Arc::new(register_jobs(runner));
|
||||||
|
let cluster = Arc::new(ClusterService::new(cluster));
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
cfg,
|
cfg,
|
||||||
auth: Arc::new(auth),
|
auth: Arc::new(auth),
|
||||||
@ -107,6 +128,7 @@ impl AppState {
|
|||||||
settings,
|
settings,
|
||||||
jobs,
|
jobs,
|
||||||
inventory,
|
inventory,
|
||||||
|
cluster,
|
||||||
login_limiter: Arc::new(rate_limit::RateLimiter::new(
|
login_limiter: Arc::new(rate_limit::RateLimiter::new(
|
||||||
10,
|
10,
|
||||||
std::time::Duration::from_secs(60),
|
std::time::Duration::from_secs(60),
|
||||||
@ -140,6 +162,7 @@ pub fn build_app(state: AppState) -> Router {
|
|||||||
.nest("/api/settings", settings::router())
|
.nest("/api/settings", settings::router())
|
||||||
.nest("/api/jobs", jobs::router())
|
.nest("/api/jobs", jobs::router())
|
||||||
.nest("/api/system", system::router())
|
.nest("/api/system", system::router())
|
||||||
|
.nest("/api/cluster", cluster::router())
|
||||||
.fallback_service(spa)
|
.fallback_service(spa)
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
|
|||||||
@ -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::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::jobs::list, crate::jobs::kinds, crate::jobs::get_one, crate::jobs::run,
|
||||||
crate::system::inventory, crate::system::upgrade,
|
crate::system::inventory, crate::system::upgrade,
|
||||||
|
crate::cluster::overview, crate::cluster::restart, crate::cluster::scale, crate::cluster::set_image,
|
||||||
),
|
),
|
||||||
modifiers(&BearerAuth)
|
modifiers(&BearerAuth)
|
||||||
)]
|
)]
|
||||||
|
|||||||
@ -16,6 +16,7 @@ pub fn test_config() -> Config {
|
|||||||
jwt_secret: "test-secret-test-secret-test-secret-1234".into(),
|
jwt_secret: "test-secret-test-secret-test-secret-1234".into(),
|
||||||
master_key: "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f".into(),
|
master_key: "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f".into(),
|
||||||
fake_host: true,
|
fake_host: true,
|
||||||
|
kubeconfig: None,
|
||||||
bind: "127.0.0.1:0".parse().unwrap(),
|
bind: "127.0.0.1:0".parse().unwrap(),
|
||||||
bootstrap_admin: None,
|
bootstrap_admin: None,
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
@ -85,6 +86,7 @@ async fn build_test_app_with(cfg: Config) -> Router {
|
|||||||
Arc::new(RecordingMailer),
|
Arc::new(RecordingMailer),
|
||||||
Arc::new(infrastructure::FakeHostInspector),
|
Arc::new(infrastructure::FakeHostInspector),
|
||||||
Arc::new(infrastructure::FakeHostUpdater),
|
Arc::new(infrastructure::FakeHostUpdater),
|
||||||
|
Arc::new(infrastructure::FakeClusterGateway),
|
||||||
register_test_jobs,
|
register_test_jobs,
|
||||||
)
|
)
|
||||||
.expect("state");
|
.expect("state");
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
use std::sync::Arc;
|
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::ports::ClusterGateway;
|
||||||
use domain::DomainError;
|
use domain::DomainError;
|
||||||
|
|
||||||
@ -10,28 +12,55 @@ pub struct ClusterService {
|
|||||||
|
|
||||||
impl ClusterService {
|
impl ClusterService {
|
||||||
pub fn new(gateway: Arc<dyn ClusterGateway>) -> Self {
|
pub fn new(gateway: Arc<dyn ClusterGateway>) -> Self {
|
||||||
let _ = &gateway;
|
|
||||||
Self { gateway }
|
Self { gateway }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn overview(&self) -> Result<ClusterOverview, DomainError> {
|
pub async fn overview(&self) -> Result<ClusterOverview, DomainError> {
|
||||||
todo!()
|
self.gateway.overview().await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn restart(&self, _w: WorkloadRef) -> Result<(), DomainError> {
|
pub async fn restart(&self, w: WorkloadRef) -> Result<(), DomainError> {
|
||||||
todo!()
|
validate_ref(&w)?;
|
||||||
|
self.gateway.restart(&w).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn scale(&self, _w: WorkloadRef, _replicas: i32) -> Result<(), DomainError> {
|
pub async fn scale(&self, w: WorkloadRef, replicas: i32) -> Result<(), DomainError> {
|
||||||
todo!()
|
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(
|
pub async fn set_image(
|
||||||
&self,
|
&self,
|
||||||
_w: WorkloadRef,
|
w: WorkloadRef,
|
||||||
_container: Option<String>,
|
container: Option<String>,
|
||||||
_image: &str,
|
image: &str,
|
||||||
) -> Result<(), DomainError> {
|
) -> 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)
|
||||||
|
}
|
||||||
|
|||||||
@ -43,11 +43,52 @@ impl CommandRunner for SystemCommandRunner {
|
|||||||
|
|
||||||
async fn run_streaming(
|
async fn run_streaming(
|
||||||
&self,
|
&self,
|
||||||
_program: &str,
|
program: &str,
|
||||||
_args: &[&str],
|
args: &[&str],
|
||||||
_out: &dyn LineSink,
|
out: &dyn LineSink,
|
||||||
) -> Result<bool, DomainError> {
|
) -> 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> {
|
async fn read_file(&self, path: &str) -> Result<Option<String>, DomainError> {
|
||||||
|
|||||||
383
backend/crates/infrastructure/src/k8s.rs
Normal file
383
backend/crates/infrastructure/src/k8s.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@
|
|||||||
pub mod cipher;
|
pub mod cipher;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod host;
|
pub mod host;
|
||||||
|
pub mod k8s;
|
||||||
pub mod mail;
|
pub mod mail;
|
||||||
pub mod password;
|
pub mod password;
|
||||||
pub mod sqlite;
|
pub mod sqlite;
|
||||||
@ -12,6 +13,7 @@ pub use db::{connect, DbPool};
|
|||||||
pub use host::{
|
pub use host::{
|
||||||
DebianInspector, DebianUpdater, FakeHostInspector, FakeHostUpdater, SystemCommandRunner,
|
DebianInspector, DebianUpdater, FakeHostInspector, FakeHostUpdater, SystemCommandRunner,
|
||||||
};
|
};
|
||||||
|
pub use k8s::{FakeClusterGateway, KubeGateway};
|
||||||
pub use mail::LettreMailer;
|
pub use mail::LettreMailer;
|
||||||
pub use password::Argon2Hasher;
|
pub use password::Argon2Hasher;
|
||||||
pub use sqlite::SqliteInventory;
|
pub use sqlite::SqliteInventory;
|
||||||
|
|||||||
@ -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 page.getByRole('link', { name: 'Kubernetes' }).click()
|
||||||
|
|
||||||
await expect(page.getByTestId('node-version')).toContainText('v1.32')
|
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')
|
await expect(row).toContainText('statefulset')
|
||||||
page.once('dialog', (d) => d.accept())
|
page.once('dialog', (d) => d.accept())
|
||||||
await page
|
await page
|
||||||
|
|||||||
@ -7,6 +7,7 @@ const router = useRouter()
|
|||||||
const nav = [
|
const nav = [
|
||||||
{ to: '/', label: 'Dashboard' },
|
{ to: '/', label: 'Dashboard' },
|
||||||
{ to: '/updates', label: 'Updates' },
|
{ to: '/updates', label: 'Updates' },
|
||||||
|
{ to: '/cluster', label: 'Kubernetes' },
|
||||||
{ to: '/vulnerabilities', label: 'Vulnerabilities' },
|
{ to: '/vulnerabilities', label: 'Vulnerabilities' },
|
||||||
{ to: '/backups', label: 'Backups' },
|
{ to: '/backups', label: 'Backups' },
|
||||||
{ to: '/jobs', label: 'Jobs' },
|
{ to: '/jobs', label: 'Jobs' },
|
||||||
|
|||||||
61
frontend/src/components/WorkloadTable.vue
Normal file
61
frontend/src/components/WorkloadTable.vue
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Workload } from '../api/types'
|
||||||
|
|
||||||
|
defineProps<{ workloads: Workload[]; canAct: boolean }>()
|
||||||
|
defineEmits<{ restart: [w: Workload]; scale: [w: Workload]; image: [w: Workload] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<table class="w-full text-left text-sm">
|
||||||
|
<thead class="border-b border-gray-200 text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2">Namespace</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Kind</th>
|
||||||
|
<th>Ready</th>
|
||||||
|
<th>Images</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="w in workloads"
|
||||||
|
:key="`${w.namespace}/${w.kind}/${w.name}`"
|
||||||
|
class="border-b border-gray-100"
|
||||||
|
:class="w.ready < w.desired ? 'bg-red-50' : ''"
|
||||||
|
>
|
||||||
|
<td class="py-1.5">{{ w.namespace }}</td>
|
||||||
|
<td class="font-mono">{{ w.name }}</td>
|
||||||
|
<td>{{ w.kind }}</td>
|
||||||
|
<td :class="w.ready < w.desired ? 'font-medium text-red-700' : ''">
|
||||||
|
{{ w.ready }}/{{ w.desired }}
|
||||||
|
</td>
|
||||||
|
<td class="font-mono text-xs text-gray-600">
|
||||||
|
<div v-for="c in w.containers" :key="c.name">{{ c.image }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="space-x-3 whitespace-nowrap text-right">
|
||||||
|
<template v-if="canAct">
|
||||||
|
<button
|
||||||
|
name="restart"
|
||||||
|
class="text-blue-600 hover:underline"
|
||||||
|
@click="$emit('restart', w)"
|
||||||
|
>
|
||||||
|
Restart
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="w.kind !== 'daemonset'"
|
||||||
|
name="scale"
|
||||||
|
class="text-blue-600 hover:underline"
|
||||||
|
@click="$emit('scale', w)"
|
||||||
|
>
|
||||||
|
Scale
|
||||||
|
</button>
|
||||||
|
<button name="image" class="text-blue-600 hover:underline" @click="$emit('image', w)">
|
||||||
|
Set image
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
132
frontend/src/pages/ClusterPage.vue
Normal file
132
frontend/src/pages/ClusterPage.vue
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { api, ApiError } from '../api/client'
|
||||||
|
import type { ClusterOverview, Workload } from '../api/types'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { useToastStore } from '../stores/toast'
|
||||||
|
import WorkloadTable from '../components/WorkloadTable.vue'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const toast = useToastStore()
|
||||||
|
const overview = ref<ClusterOverview | null>(null)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
overview.value = await api.get<ClusterOverview>('/api/cluster/overview')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof ApiError ? e.message : 'Request failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(load)
|
||||||
|
|
||||||
|
const path = (w: Workload) => `/api/cluster/workloads/${w.namespace}/${w.kind}/${w.name}`
|
||||||
|
|
||||||
|
async function act(label: string, fn: () => Promise<unknown>) {
|
||||||
|
try {
|
||||||
|
await fn()
|
||||||
|
toast.success(label)
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : 'Request failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restart(w: Workload) {
|
||||||
|
if (!confirm(`Rolling restart of ${w.kind} ${w.namespace}/${w.name}?`)) return
|
||||||
|
act('Restart requested', () => api.post(path(w) + '/restart', {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function scale(w: Workload) {
|
||||||
|
const v = prompt(`Replicas for ${w.namespace}/${w.name}`, String(w.desired))
|
||||||
|
if (v === null) return
|
||||||
|
act(`Scaled to ${v}`, () => api.post(path(w) + '/scale', { replicas: Number(v) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function setImage(w: Workload) {
|
||||||
|
const current = w.containers[0]?.image ?? ''
|
||||||
|
const image = prompt(`New image for ${w.namespace}/${w.name} (${w.containers[0]?.name})`, current)
|
||||||
|
if (!image || image === current) return
|
||||||
|
act('Image updated, rollout started', () => api.post(path(w) + '/image', { image }))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-semibold">Kubernetes</h1>
|
||||||
|
<button class="rounded-md border border-gray-300 px-4 py-2 text-sm" @click="load">
|
||||||
|
Reload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" role="alert" class="mt-6 rounded-md bg-red-50 p-4 text-sm text-red-800">
|
||||||
|
Cluster unreachable: {{ error }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-if="overview">
|
||||||
|
<div class="mt-6 grid gap-4 md:grid-cols-3">
|
||||||
|
<div
|
||||||
|
v-for="n in overview.nodes"
|
||||||
|
:key="n.name"
|
||||||
|
class="rounded-lg border border-gray-200 bg-white p-4"
|
||||||
|
>
|
||||||
|
<div class="text-xs uppercase text-gray-500">Node</div>
|
||||||
|
<div class="mt-1 text-lg font-medium">{{ n.name }}</div>
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
|
<span data-testid="node-version">{{ n.version }}</span> · {{ n.container_runtime }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600">{{ n.os_image }} · {{ n.kernel }}</div>
|
||||||
|
<span
|
||||||
|
class="mt-2 inline-block rounded-full px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="n.ready ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'"
|
||||||
|
>
|
||||||
|
{{ n.ready ? 'Ready' : 'NotReady' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
||||||
|
<div class="text-xs uppercase text-gray-500">Workloads</div>
|
||||||
|
<div class="mt-1 text-2xl font-semibold">{{ overview.workloads.length }}</div>
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
|
{{ overview.images.length }} distinct images · {{ overview.namespaces.length }} namespaces
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="mt-8 text-lg font-medium">Workloads</h2>
|
||||||
|
<WorkloadTable
|
||||||
|
class="mt-3"
|
||||||
|
:workloads="overview.workloads"
|
||||||
|
:can-act="auth.isAdmin"
|
||||||
|
@restart="restart"
|
||||||
|
@scale="scale"
|
||||||
|
@image="setImage"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h2 class="mt-8 text-lg font-medium">Persistent volume claims</h2>
|
||||||
|
<table class="mt-3 w-full text-left text-sm">
|
||||||
|
<thead class="border-b border-gray-200 text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2">Namespace</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Capacity</th>
|
||||||
|
<th>Storage class</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="p in overview.volume_claims"
|
||||||
|
:key="`${p.namespace}/${p.name}`"
|
||||||
|
class="border-b border-gray-100"
|
||||||
|
>
|
||||||
|
<td class="py-1.5">{{ p.namespace }}</td>
|
||||||
|
<td class="font-mono">{{ p.name }}</td>
|
||||||
|
<td>{{ p.capacity }}</td>
|
||||||
|
<td>{{ p.storage_class }}</td>
|
||||||
|
<td>{{ p.status }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
@ -9,6 +9,7 @@ import PlaceholderPage from './pages/PlaceholderPage.vue'
|
|||||||
import SettingsPage from './pages/SettingsPage.vue'
|
import SettingsPage from './pages/SettingsPage.vue'
|
||||||
import JobsPage from './pages/JobsPage.vue'
|
import JobsPage from './pages/JobsPage.vue'
|
||||||
import UpdatesPage from './pages/UpdatesPage.vue'
|
import UpdatesPage from './pages/UpdatesPage.vue'
|
||||||
|
import ClusterPage from './pages/ClusterPage.vue'
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@ -20,6 +21,7 @@ export const router = createRouter({
|
|||||||
children: [
|
children: [
|
||||||
{ path: '', name: 'dashboard', component: DashboardPage },
|
{ path: '', name: 'dashboard', component: DashboardPage },
|
||||||
{ path: 'updates', component: UpdatesPage },
|
{ path: 'updates', component: UpdatesPage },
|
||||||
|
{ path: 'cluster', component: ClusterPage },
|
||||||
{
|
{
|
||||||
path: 'vulnerabilities',
|
path: 'vulnerabilities',
|
||||||
component: PlaceholderPage,
|
component: PlaceholderPage,
|
||||||
|
|||||||
Reference in New Issue
Block a user