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:
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,
|
||||
/// 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()?,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
)]
|
||||
|
||||
@ -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");
|
||||
|
||||
Reference in New Issue
Block a user