//! Controller for the `Migrate` CRD — creates a one-shot Job on reconcile. //! //! The Job is re-created on every reconcile (idempotent). Once the Job //! succeeds, the Migrate status is patched to "Completed". use crate::context::ReconcileState; use crate::controller::helpers::{child_meta, env_var_to_json, merge_env, owner_ref, std_labels}; use crate::crd::{JsonResource, K8sObjectMeta, Migrate, MigrateSpec}; use chrono::Utc; use serde_json::{Value, json}; use std::sync::Arc; use tracing::info; pub async fn reconcile(mig: Arc, ctx: Arc) -> Result<(), kube::Error> { let ns = mig.metadata.namespace.as_deref().unwrap_or("default"); let name = mig.metadata.name.as_deref().unwrap_or(""); let spec = &mig.spec; let client = &ctx.client; let or = owner_ref(&mig.metadata, &mig.api_version, &mig.kind); let labels = std_labels(); let job_meta = child_meta(name, ns, &or, labels.clone()); let job = build_job(spec, job_meta, &labels); // Use JsonResource for Job create/replace (spec part) let jobs_api: kube::Api = kube::Api::namespaced(client.clone(), ns); match jobs_api.get(name).await { Ok(_) => { info!(name, ns, "replacing migrate job"); let _ = jobs_api .replace(name, &kube::api::PostParams::default(), &job) .await?; } Err(kube::Error::Api(e)) if e.code == 404 => { info!(name, ns, "creating migrate job"); let _ = jobs_api.create(&kube::api::PostParams::default(), &job).await?; } Err(e) => return Err(e), } // Query real Job status via k8s-openapi (reads status subresource) let job_status = query_job_status(client, ns, name).await?; patch_migrate_status_from_job(client, ns, name, &job_status).await?; Ok(()) } /// Query actual Job status and derive Migrate phase + timestamps. async fn query_job_status( client: &kube::Client, ns: &str, name: &str, ) -> Result { use k8s_openapi::api::batch::v1::Job; let api: kube::Api = kube::Api::namespaced(client.clone(), ns); match api.get(name).await { Ok(job) => { 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); let active = status.and_then(|s| s.active).unwrap_or(0); let phase = if succeeded > 0 { "Completed" } else if failed > 0 { "Failed" } else if active > 0 { "Running" } else { "Pending" }; let start_time = status.and_then(|s| s.start_time.as_ref()).map(|t| t.to_string()); let completion_time = status.and_then(|s| s.completion_time.as_ref()).map(|t| t.to_string()); Ok(JobStatusResult { phase, start_time, completion_time }) } Err(kube::Error::Api(e)) if e.code == 404 => { Ok(JobStatusResult { phase: "Pending".to_string(), start_time: None, completion_time: None }) } Err(e) => Err(e), } } struct JobStatusResult { phase: String, start_time: Option, completion_time: Option, } async fn patch_migrate_status_from_job( client: &kube::Client, ns: &str, name: &str, job: &JobStatusResult, ) -> Result<(), kube::Error> { let api: kube::Api = kube::Api::namespaced(client.clone(), ns); let mut status_obj = json!({ "phase": job.phase }); if let Some(ref st) = job.start_time { status_obj["startTime"] = json!(st); } if let Some(ref ct) = job.completion_time { status_obj["completionTime"] = json!(ct); } let patch = json!({ "status": status_obj }); let _ = api .patch_status( name, &kube::api::PatchParams::default(), &kube::api::Patch::Merge(&patch), ) .await?; Ok(()) } fn build_job( spec: &MigrateSpec, meta: K8sObjectMeta, labels: &std::collections::BTreeMap, ) -> JsonResource { let image = if spec.image.is_empty() { "myapp/migrate:latest".to_string() } else { spec.image.clone() }; let env = merge_env(&[], &spec.env); let env_vars: Vec = env.iter().map(env_var_to_json).collect(); let cmd_parts: Vec<&str> = spec.command.split_whitespace().collect(); let cmd: Vec<&str> = if cmd_parts.is_empty() { vec!["up"] } else { cmd_parts }; let now = Utc::now().to_rfc3339(); let mut meta_with_anno = meta.clone(); meta_with_anno.annotations = Some(std::collections::BTreeMap::from([( "code.dev/last-migrate".to_string(), now, )])); let body = json!({ "metadata": meta_with_anno, "spec": { "backoffLimit": spec.backoff_limit, "ttlSecondsAfterFinished": 300, "template": { "metadata": { "labels": labels.clone() }, "spec": { "restartPolicy": "Never", "containers": [{ "name": "migrate", "image": image, "command": ["/app/migrate"], "args": cmd, "env": env_vars, "imagePullPolicy": "IfNotPresent" }] } } } }); JsonResource::new(meta, body) }