diff --git a/nix/homelab/Cargo.lock b/nix/homelab/Cargo.lock index 7828a80..28aa97c 100644 --- a/nix/homelab/Cargo.lock +++ b/nix/homelab/Cargo.lock @@ -30,6 +30,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.21" @@ -289,6 +298,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.5.54" @@ -664,6 +686,7 @@ checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", + "futures-executor", "futures-io", "futures-sink", "futures-task", @@ -686,6 +709,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.31" @@ -840,11 +874,17 @@ name = "homelab-cli" version = "0.1.0" dependencies = [ "anyhow", + "askama", + "chrono", "clap", "dialoguer", + "futures", + "indicatif", "k8s-openapi", "kube", "schemars", + "serde_json", + "serde_yaml", "thiserror 2.0.18", "thiserror-ext", "tokio", @@ -979,6 +1019,30 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -1097,6 +1161,19 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "indicatif" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88" +dependencies = [ + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2129,15 +2206,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -2601,6 +2678,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -2785,6 +2868,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -3165,3 +3283,9 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zmij" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" diff --git a/nix/homelab/cli/Cargo.toml b/nix/homelab/cli/Cargo.toml index 4db6f6a..fcea2c8 100644 --- a/nix/homelab/cli/Cargo.toml +++ b/nix/homelab/cli/Cargo.toml @@ -5,11 +5,17 @@ edition = "2024" [dependencies] anyhow = "1.0.100" +askama = "0.15.1" +chrono = "0.4.43" clap = { version = "4.5.54", features = ["derive"] } dialoguer = "0.12.0" +futures = "0.3.31" +indicatif = "0.18.3" k8s-openapi = { version = "0.27.0", features = ["latest", "schemars", "v1_35"] } kube = { version = "3.0.0", features = ["runtime", "derive"] } schemars = "1.2.0" +serde_json = "1.0.149" +serde_yaml = "0.9.34" thiserror = "2.0.18" thiserror-ext = "0.3.0" tokio = { version = "1.49.0", features = ["macros", "rt"] } diff --git a/nix/homelab/cli/askama.toml b/nix/homelab/cli/askama.toml new file mode 100644 index 0000000..af4c69e --- /dev/null +++ b/nix/homelab/cli/askama.toml @@ -0,0 +1,3 @@ +[[escaper]] +path = "askama::filters::Text" +extensions = ["yaml"] diff --git a/nix/homelab/cli/src/commands/minecraft.rs b/nix/homelab/cli/src/commands/minecraft.rs index ccd736c..a0d62e2 100644 --- a/nix/homelab/cli/src/commands/minecraft.rs +++ b/nix/homelab/cli/src/commands/minecraft.rs @@ -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 = Api::namespaced(state.client.clone(), NAMESPACE); + jobs.create(&PostParams::default(), &job).await?; + + reporter.status("Waiting for pod to start..."); + + let pods: Api = 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 = 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, job_name: &str) -> Result { + 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 { + let template = BackupJobTemplate { world }; + let yaml = template.render()?; + Ok(serde_yaml::from_str(&yaml)?) +} + +async fn stream_pod_logs(pods: &Api, pod_name: &str, reporter: &Reporter) -> Result<()> { + let params = LogParams { + follow: true, + ..Default::default() + }; + + let stream = pods.log_stream(pod_name, ¶ms).await?; + let mut lines = stream.lines(); + + while let Some(line) = lines.try_next().await? { + reporter.log(&line); + } + + Ok(()) +} diff --git a/nix/homelab/cli/src/error.rs b/nix/homelab/cli/src/error.rs index c0237cb..6eaa6b0 100644 --- a/nix/homelab/cli/src/error.rs +++ b/nix/homelab/cli/src/error.rs @@ -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 = core::result::Result; diff --git a/nix/homelab/cli/src/main.rs b/nix/homelab/cli/src/main.rs index 7d7e774..0234cc1 100644 --- a/nix/homelab/cli/src/main.rs +++ b/nix/homelab/cli/src/main.rs @@ -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()?, }; diff --git a/nix/homelab/cli/src/reporter.rs b/nix/homelab/cli/src/reporter.rs new file mode 100644 index 0000000..c93122c --- /dev/null +++ b/nix/homelab/cli/src/reporter.rs @@ -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) { + 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() + } +} diff --git a/nix/homelab/cli/templates/backup-job.yaml b/nix/homelab/cli/templates/backup-job.yaml new file mode 100644 index 0000000..d8432c5 --- /dev/null +++ b/nix/homelab/cli/templates/backup-job.yaml @@ -0,0 +1,40 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: minecraft-{{ world }}-backup + namespace: minecraft + labels: + app: minecraft-backup + world: {{ world }} +spec: + ttlSecondsAfterFinished: 300 + backoffLimit: 0 + template: + metadata: + labels: + job-name: minecraft-{{ world }}-backup + spec: + restartPolicy: Never + containers: + - name: backup + image: busybox + command: + - "sh" + - "-c" + - | + tar -czvf /backups/minecraft-{{ world }}-manual.tar.gz -C /data . + volumeMounts: + - name: data + mountPath: /data + readOnly: true + - name: backups + mountPath: /backups + volumes: + - name: data + persistentVolumeClaim: + claimName: minecraft-{{ world }}-datadir + readOnly: true + - name: backups + nfs: + server: 192.168.27.2 + path: /backup/minecraft