172 lines
5.5 KiB
Rust
172 lines
5.5 KiB
Rust
//! 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<Migrate>, ctx: Arc<ReconcileState>) -> 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<JsonResource> = 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<JobStatusResult, kube::Error> {
|
|
use k8s_openapi::api::batch::v1::Job;
|
|
let api: kube::Api<Job> = 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<String>,
|
|
completion_time: Option<String>,
|
|
}
|
|
|
|
async fn patch_migrate_status_from_job(
|
|
client: &kube::Client,
|
|
ns: &str,
|
|
name: &str,
|
|
job: &JobStatusResult,
|
|
) -> Result<(), kube::Error> {
|
|
let api: kube::Api<JsonResource> = 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<String, String>,
|
|
) -> 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<Value> = 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)
|
|
}
|