feat(homelab): add minecraft management interface

This commit is contained in:
2026-01-24 11:08:01 -08:00
parent 80ae32d799
commit 4d003329c7
8 changed files with 350 additions and 9 deletions

View File

@@ -1,7 +1,18 @@
use askama::Template;
use clap::{Args, Subcommand};
use kube::Api;
use futures::{AsyncBufReadExt, StreamExt, TryStreamExt};
use k8s_openapi::api::apps::v1::Deployment;
use k8s_openapi::api::batch::v1::Job;
use k8s_openapi::api::core::v1::Pod;
use kube::api::{Api, LogParams, Patch, PatchParams, PostParams};
use kube::runtime::{WatchStreamExt, watcher};
use serde_json::json;
use crate::{AppState, State};
use crate::State;
use crate::error::{ErrorKind, Result};
use crate::reporter::Reporter;
const NAMESPACE: &str = "minecraft";
#[derive(Debug, Args)]
pub struct MinecraftCommand {
@@ -11,6 +22,7 @@ pub struct MinecraftCommand {
#[derive(Debug, Subcommand)]
enum MinecraftSubcommand {
/// Backup a minecraft world
Backup {
/// the world to backup
#[arg(short, long)]
@@ -18,12 +30,115 @@ enum MinecraftSubcommand {
},
}
#[derive(Template)]
#[template(path = "backup-job.yaml")]
struct BackupJobTemplate<'a> {
world: &'a str,
}
impl MinecraftCommand {
pub async fn run(&self, app_state: State) {
pub async fn run(&self, state: State) -> Result<()> {
match &self.command {
MinecraftSubcommand::Backup { world } => backup_world(app_state, &world),
MinecraftSubcommand::Backup { world } => backup_world(state, world).await,
}
}
}
pub fn backup_world(app_state: State, world: &str) {}
pub async fn backup_world(state: State, world: &str) -> Result<()> {
let reporter = Reporter::new();
let job_name = format!("minecraft-{}-backup", world);
reporter.status(format!("Scaling deployment minecraft-{world}"));
scale_deployment(&state.client, NAMESPACE, &format!("minecraft-{world}"), 0).await?;
reporter.status("Creating backup job...");
let job = build_backup_job(world)?;
let jobs: Api<Job> = Api::namespaced(state.client.clone(), NAMESPACE);
jobs.create(&PostParams::default(), &job).await?;
reporter.status("Waiting for pod to start...");
let pods: Api<Pod> = Api::namespaced(state.client.clone(), NAMESPACE);
let pod_name = wait_for_job_pod(&pods, &job_name).await?;
reporter.status("Running backup...");
stream_pod_logs(&pods, &pod_name, &reporter).await?;
let job = jobs.get(&job_name).await?;
let status = job.status.as_ref();
let succeeded = status.and_then(|s| s.succeeded).unwrap_or(0);
let failed = status.and_then(|s| s.failed).unwrap_or(0);
reporter.status(format!("Scaling deployment minecraft-{world}, replicas: 1"));
scale_deployment(&state.client, NAMESPACE, &format!("minecraft-{world}"), 1).await?;
if succeeded > 0 {
reporter.success("Backup complete");
Ok(())
} else if failed > 0 {
reporter.fail("Backup job failed");
Err(ErrorKind::BackupFailed("Job failed".to_string()).into())
} else {
reporter.fail("Backup job status unknown");
Err(ErrorKind::BackupFailed("Unknown status".to_string()).into())
}
}
async fn scale_deployment(
client: &kube::Client,
namespace: &str,
name: &str,
replicas: i32,
) -> Result<()> {
let deployments: Api<Deployment> = Api::namespaced(client.clone(), namespace);
let patch = json!({ "spec": { "replicas": replicas } });
deployments
.patch(name, &PatchParams::default(), &Patch::Merge(&patch))
.await?;
Ok(())
}
async fn wait_for_job_pod(pods: &Api<Pod>, job_name: &str) -> Result<String> {
let label_selector = format!("job-name={}", job_name);
let config = watcher::Config::default().labels(&label_selector);
let mut stream = watcher(pods.clone(), config).applied_objects().boxed();
while let Some(pod) = stream.try_next().await? {
let name = pod.metadata.name.as_deref().unwrap_or_default();
let phase = pod
.status
.as_ref()
.and_then(|s| s.phase.as_deref())
.unwrap_or_default();
if phase == "Running" || phase == "Succeeded" || phase == "Failed" {
return Ok(name.to_string());
}
}
Err(ErrorKind::BackupFailed("Pod never started".to_string()).into())
}
fn build_backup_job(world: &str) -> Result<Job> {
let template = BackupJobTemplate { world };
let yaml = template.render()?;
Ok(serde_yaml::from_str(&yaml)?)
}
async fn stream_pod_logs(pods: &Api<Pod>, pod_name: &str, reporter: &Reporter) -> Result<()> {
let params = LogParams {
follow: true,
..Default::default()
};
let stream = pods.log_stream(pod_name, &params).await?;
let mut lines = stream.lines();
while let Some(line) = lines.try_next().await? {
reporter.log(&line);
}
Ok(())
}

View File

@@ -5,6 +5,16 @@ use thiserror::Error;
pub enum ErrorKind {
#[error("kube error: {0}")]
Kube(#[from] kube::Error),
#[error("watcher error: {0}")]
Watcher(#[from] kube::runtime::watcher::Error),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("backup failed: {0}")]
BackupFailed(String),
#[error("template error: {0}")]
Template(#[from] askama::Error),
#[error("error deserializing yaml: {0}")]
Yaml(#[from] serde_yaml::Error),
}
pub type Result<T> = core::result::Result<T, Error>;

View File

@@ -1,5 +1,6 @@
mod commands;
mod error;
mod reporter;
use std::{ops::Deref, sync::Arc};
@@ -48,7 +49,7 @@ async fn main() -> anyhow::Result<()> {
let app_state = State::new(AppState::new().await?);
match cli.command {
Some(Commands::Minecraft(cmd)) => cmd.run(app_state.clone()).await,
Some(Commands::Minecraft(cmd)) => cmd.run(app_state.clone()).await?,
_ => Cli::command().print_long_help()?,
};

View File

@@ -0,0 +1,42 @@
use indicatif::{ProgressBar, ProgressStyle};
pub struct Reporter {
spinner: ProgressBar,
}
impl Reporter {
pub fn new() -> Self {
let spinner = ProgressBar::new_spinner();
spinner.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.cyan} {msg}")
.unwrap(),
);
spinner.enable_steady_tick(std::time::Duration::from_millis(100));
Self { spinner }
}
pub fn status(&self, msg: impl Into<String>) {
self.spinner.set_message(msg.into());
}
pub fn log(&self, line: &str) {
self.spinner.suspend(|| {
println!("{}", line);
});
}
pub fn success(&self, msg: &str) {
self.spinner.finish_with_message(format!("{}", msg));
}
pub fn fail(&self, msg: &str) {
self.spinner.finish_with_message(format!("{}", msg));
}
}
impl Default for Reporter {
fn default() -> Self {
Self::new()
}
}