feat(homelab)!: migrate to svelte kit, add more server stats endpoints

This commit is contained in:
2026-01-12 00:09:22 -08:00
parent 1f43dff2c5
commit 73b6cc4f1d
29 changed files with 721 additions and 107 deletions

View File

@@ -0,0 +1,36 @@
use actix_web::{HttpResponse, web};
use serde::{Deserialize, Serialize};
use crate::AppState;
use crate::error::Result;
use crate::kubernetes;
#[derive(Deserialize)]
pub struct RestoreRequest {
pub backup_file: String,
}
#[derive(Serialize)]
pub struct RestoreResponse {
pub server: String,
pub job_name: String,
pub backup_file: String,
pub status: String,
}
/// POST /api/minecraft/{server}/restore
pub async fn create_restore(
app_state: web::Data<AppState>,
path: web::Path<ServerPath>,
body: web::Json<RestoreRequest>,
) -> Result<HttpResponse> {
let job_name =
kubernetes::create_restore_job(&app_state.kube, &path.server, &body.backup_file).await?;
Ok(HttpResponse::Created().json(RestoreResponse {
server: path.server.clone(),
job_name,
backup_file: body.backup_file.clone(),
status: "created".to_string(),
}))
}

View File

@@ -1 +1,5 @@
pub mod server_stats;
pub mod server_uptime;
pub mod world_size;
pub(self) const MINECRAFT_NAMESPACE: &str = "minecraft";

View File

@@ -1,14 +1,19 @@
use actix_web::{HttpResponse, web};
use serde::Serialize;
use crate::{AppState, error::Result, rcon::parse_online_list};
use crate::{
AppState,
endpoints::server_uptime::{PodUptime, get_pod_uptime},
error::Result,
rcon::parse_online_list,
};
#[derive(Serialize)]
pub struct ServerStats {
struct ServerStats {
pub status: String,
pub players_online: u16,
pub max_players: u16,
pub uptime: Option<String>,
pub uptime: PodUptime,
pub world_size: Option<String>,
}
@@ -17,12 +22,13 @@ pub async fn get_server_stats(app_state: web::Data<AppState>) -> Result<HttpResp
let (_, (players_online, max_players)) =
parse_online_list(&list_response).map_err(|e| crate::error::Error::Parse(e.to_string()))?;
let uptime = get_pod_uptime(&app_state.kube, "main").await?;
let stats = ServerStats {
status: "Online".to_string(),
players_online,
max_players,
uptime: None,
uptime,
world_size: None,
};

View File

@@ -0,0 +1,56 @@
use actix_web::{HttpResponse, web};
use k8s_openapi::api::core::v1::Pod;
use kube::{Api, Client, api::ListParams};
use crate::{
AppState,
endpoints::MINECRAFT_NAMESPACE,
error::{Error, Result},
};
#[derive(serde::Serialize)]
pub(super) struct PodUptime {
pub seconds: i64,
pub started_at: String,
}
pub async fn get_uptime(
app_state: web::Data<AppState>,
path: web::Path<String>,
) -> Result<HttpResponse> {
let server = path.into_inner();
let uptime = get_pod_uptime(&app_state.kube, &server).await?;
Ok(HttpResponse::Ok().json(uptime))
}
pub(super) async fn find_minecraft_pod(client: &Client, server_name: &str) -> Result<Pod> {
let pods: Api<Pod> = Api::namespaced(client.clone(), MINECRAFT_NAMESPACE);
let label_selector = format!("app=minecraft-{server_name}");
let lp = ListParams::default().labels(&label_selector);
let pod_list = pods.list(&lp).await?;
pod_list
.items
.into_iter()
.next()
.ok_or_else(|| Error::PodNotFound(format!("minecraft-{server_name}")))
}
pub(super) async fn get_pod_uptime(client: &Client, server_name: &str) -> Result<PodUptime> {
let pod = find_minecraft_pod(client, server_name).await?;
let start_time = pod
.status
.and_then(|s| s.start_time)
.ok_or_else(|| Error::PodExec("pod has no start time".into()))?;
let now = chrono::Utc::now();
let duration = now.signed_duration_since(&start_time.0);
Ok(PodUptime {
seconds: duration.num_seconds(),
started_at: start_time.0.to_rfc3339(),
})
}

View File

@@ -0,0 +1,56 @@
use actix_web::{HttpResponse, web};
use k8s_openapi::api::core::v1::Pod;
use kube::{Api, Client};
use serde::Serialize;
use crate::{
AppState,
endpoints::{MINECRAFT_NAMESPACE, server_uptime::find_minecraft_pod},
error::{Error, Result},
};
#[derive(Serialize)]
pub struct WorldSizeResponse {
pub server: String,
pub size: String,
}
pub async fn get_world_size(
app_state: web::Data<AppState>,
path: web::Path<String>,
) -> Result<HttpResponse> {
let server = path.into_inner();
let size = get_size_inner(&app_state.kube, &server).await?;
Ok(HttpResponse::Ok().json(WorldSizeResponse {
server: server.clone(),
size,
}))
}
pub async fn get_size_inner(client: &Client, server_name: &str) -> Result<String> {
let pod = find_minecraft_pod(client, server_name).await?;
let pod_name = pod
.metadata
.name
.ok_or_else(|| Error::PodNotFound("no pod name".into()))?;
let output = exec_in_pod(client, &pod_name, vec!["du", "-sh", "/data"]).await?;
let size = output
.split_whitespace()
.next()
.unwrap_or("unknown")
.to_string();
Ok(size)
}
async fn exec_in_pod(client: &Client, pod_name: &str, command: &str) {
let pods: Api<Pod> = Api::namespaced(client.clone(), MINECRAFT_NAMESPACE);
const params = AttachParams {
stdin: true,
..Default::default()
};
}

View File

@@ -9,12 +9,20 @@ pub enum Error {
Rcon(#[from] rcon::Error),
#[error("parse error: {0}")]
Parse(String),
#[error("kubernetes error: {0}")]
Kube(#[from] kube::Error),
#[error("pod not found: {0}")]
PodNotFound(String),
#[error("pod exec error: {0}")]
PodExec(String),
}
impl ResponseError for Error {
fn status_code(&self) -> StatusCode {
match self {
Self::Rcon(_) => StatusCode::SERVICE_UNAVAILABLE,
Self::PodNotFound(_) => StatusCode::NOT_FOUND,
Self::Kube(_) | Self::PodExec(_) => StatusCode::BAD_GATEWAY,
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}

View File

@@ -0,0 +1,219 @@
use futures::TryStreamExt;
use k8s_openapi::api::batch::v1::Job;
use k8s_openapi::api::core::v1::Pod;
use kube::Client;
use kube::api::{Api, AttachParams, ListParams, PostParams};
use crate::error::{Error, Result};
const NFS_SERVER: &str = "192.168.27.2";
const NFS_BACKUP_PATH: &str = "/backup/minecraft";
/// Find the Minecraft server pod by server name (e.g., "main", "creative")
/// Execute a command in a pod and return stdout
pub async fn exec_in_pod(client: &Client, pod_name: &str, command: Vec<&str>) -> Result<String> {
let pods: Api<Pod> = Api::namespaced(client.clone(), MINECRAFT_NAMESPACE);
let ap = AttachParams {
stdout: true,
stderr: true,
..Default::default()
};
let mut attached = pods.exec(pod_name, command, &ap).await?;
let mut stdout_str = String::new();
if let Some(stdout) = attached.stdout() {
let bytes: Vec<u8> = tokio_util::io::ReaderStream::new(stdout)
.try_collect::<Vec<_>>()
.await
.map_err(|e| Error::PodExec(e.to_string()))?
.into_iter()
.flatten()
.collect();
stdout_str = String::from_utf8_lossy(&bytes).to_string();
}
// Wait for exec to finish
attached
.join()
.await
.map_err(|e| Error::PodExec(e.to_string()))?;
Ok(stdout_str.trim().to_string())
}
/// Get the world size by running du -sh /data in the pod
/// Get pod uptime by calculating time since pod started
/// Create a restore job for a Minecraft server
pub async fn create_restore_job(
client: &Client,
server_name: &str,
backup_file: &str,
) -> Result<String> {
let jobs: Api<Job> = Api::namespaced(client.clone(), MINECRAFT_NAMESPACE);
let job = build_restore_job(server_name, backup_file);
let job_name = job
.metadata
.name
.clone()
.unwrap_or_else(|| "unknown".into());
jobs.create(&PostParams::default(), &job).await?;
Ok(job_name)
}
fn build_restore_job(server_name: &str, backup_file: &str) -> Job {
use k8s_openapi::api::core::v1::{
Container, EnvVar, NFSVolumeSource, PersistentVolumeClaimVolumeSource, PodSecurityContext,
PodSpec, PodTemplateSpec, Volume, VolumeMount,
};
let job_name = format!(
"minecraft-restore-{server_name}-{}",
chrono::Utc::now().timestamp()
);
let restore_script = r#"
set -e
echo "=========================================="
echo "Minecraft World Restore Job"
echo "=========================================="
echo ""
SERVER_NAME="${SERVER_NAME}"
BACKUP_FILE="${BACKUP_FILE:-latest.tgz}"
BACKUP_PATH="/backups/${BACKUP_FILE}"
DATA_DIR="/data"
echo "Configuration:"
echo " Server: ${SERVER_NAME}"
echo " Backup file: ${BACKUP_FILE}"
echo " Backup path: ${BACKUP_PATH}"
echo " Data directory: ${DATA_DIR}"
echo ""
if [ ! -f "${BACKUP_PATH}" ]; then
echo "ERROR: Backup file not found: ${BACKUP_PATH}"
echo ""
echo "Available backups:"
ls -lh /backups/ | grep "minecraft-${SERVER_NAME}" || echo " (none found)"
exit 1
fi
echo "Backup file found"
echo " Size: $(du -hL ${BACKUP_PATH} | cut -f1)"
echo ""
if [ -d "${DATA_DIR}/world" ]; then
echo "WARNING: Existing world data found!"
echo "Removing existing world data..."
rm -rf "${DATA_DIR}/world" "${DATA_DIR}/world_nether" "${DATA_DIR}/world_the_end"
echo "Old world data removed"
fi
echo ""
echo "Extracting backup..."
tar -xzf "${BACKUP_PATH}" -C "${DATA_DIR}/"
echo ""
echo "=========================================="
echo "Restore Complete!"
echo "=========================================="
"#;
Job {
metadata: kube::core::ObjectMeta {
name: Some(job_name),
namespace: Some(MINECRAFT_NAMESPACE.to_string()),
labels: Some(
[("app".to_string(), "minecraft-restore".to_string())]
.into_iter()
.collect(),
),
..Default::default()
},
spec: Some(k8s_openapi::api::batch::v1::JobSpec {
backoff_limit: Some(0),
ttl_seconds_after_finished: Some(3600),
template: PodTemplateSpec {
metadata: Some(kube::core::ObjectMeta {
labels: Some(
[("app".to_string(), "minecraft-restore".to_string())]
.into_iter()
.collect(),
),
..Default::default()
}),
spec: Some(PodSpec {
restart_policy: Some("Never".to_string()),
security_context: Some(PodSecurityContext {
fs_group: Some(2000),
run_as_user: Some(1000),
run_as_group: Some(3000),
..Default::default()
}),
containers: vec![Container {
name: "restore".to_string(),
image: Some("busybox:latest".to_string()),
command: Some(vec!["sh".to_string(), "-c".to_string()]),
args: Some(vec![restore_script.to_string()]),
env: Some(vec![
EnvVar {
name: "SERVER_NAME".to_string(),
value: Some(server_name.to_string()),
..Default::default()
},
EnvVar {
name: "BACKUP_FILE".to_string(),
value: Some(backup_file.to_string()),
..Default::default()
},
]),
volume_mounts: Some(vec![
VolumeMount {
name: "data".to_string(),
mount_path: "/data".to_string(),
..Default::default()
},
VolumeMount {
name: "backups".to_string(),
mount_path: "/backups".to_string(),
read_only: Some(true),
..Default::default()
},
]),
..Default::default()
}],
volumes: Some(vec![
Volume {
name: "data".to_string(),
persistent_volume_claim: Some(PersistentVolumeClaimVolumeSource {
claim_name: format!("minecraft-{server_name}-datadir"),
..Default::default()
}),
..Default::default()
},
Volume {
name: "backups".to_string(),
nfs: Some(NFSVolumeSource {
server: NFS_SERVER.to_string(),
path: NFS_BACKUP_PATH.to_string(),
..Default::default()
}),
..Default::default()
},
]),
..Default::default()
}),
},
..Default::default()
}),
..Default::default()
}
}

View File

@@ -5,11 +5,13 @@ mod rcon;
use std::env;
use actix_web::{App, HttpServer, web};
use kube::Client;
use crate::rcon::RconClient;
struct AppState {
pub struct AppState {
rcon: RconClient,
kube: Client,
}
struct Env {
@@ -36,8 +38,16 @@ fn load_env() -> Env {
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let env = load_env();
// Initialize Kubernetes client
// Uses in-cluster config when running in k3s, falls back to ~/.kube/config locally
let kube_client = Client::try_default()
.await
.expect("failed to create Kubernetes client");
let app_state = web::Data::new(AppState {
rcon: RconClient::new(env.rcon_password),
kube: kube_client,
});
HttpServer::new(move || {
@@ -48,10 +58,24 @@ async fn main() -> std::io::Result<()> {
web::get()
.to(async || concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))),
)
.service(web::scope("/api").route(
"/minecraft-server-stats",
web::get().to(endpoints::server_stats::get_server_stats),
))
.service(
web::scope("/api")
.route(
"/minecraft-server-stats",
web::get().to(endpoints::server_stats::get_server_stats),
)
// .route(
// "/minecraft/{server}/world-size",
// web::get().to(endpoints::kubernetes::get_world_size),
// )
.route(
"/minecraft/{server}/uptime",
web::get().to(endpoints::server_uptime::get_uptime),
), // .route(
// "/minecraft/{server}/restore",
// web::post().to(endpoints::kubernetes::create_restore),
// ),
)
})
.bind(("127.0.0.1", 8080))?
.run()