use std::{sync::Arc, time::Duration};
use futures::StreamExt;
use k8s_openapi::api::batch::v1::CronJob;
use k8s_openapi::api::core::v1::ServiceAccount;
use kube::{
Api, Client, Resource, ResourceExt,
api::{DeleteParams, Patch, PatchParams, Preconditions},
runtime::{
controller::{Action, Controller},
watcher,
},
};
use serde_json::json;
use crate::{
cron_dow::normalize_cron_dow,
fanout::{self},
routine::{
Routine, RoutinePayload, RoutineSchedule, RoutineSpec, RoutineStatus, RoutineSuspend,
},
};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("kube api: {0}")]
Kube(#[from] kube::Error),
#[error("routine has no namespace")]
NoNamespace,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RoutineAction {
Apply,
Invalid(String),
Noop,
}
#[must_use]
pub fn plan(routine: &Routine) -> RoutineAction {
if routine.meta().deletion_timestamp.is_some() {
return RoutineAction::Noop;
}
match validate_spec(&routine.spec) {
Err(reason) => RoutineAction::Invalid(reason),
Ok(()) => RoutineAction::Apply,
}
}
fn validate_schedule(schedule: &RoutineSchedule) -> Result<(), String> {
match schedule {
RoutineSchedule::Cron {
expression,
timezone,
} => {
if normalize_cron_dow(expression)
.parse::<saffron::Cron>()
.is_err()
{
return Err(format!(
"schedule.expression `{expression}` is not a valid cron expression"
));
}
if let Some(tz) = timezone
&& tz.parse::<chrono_tz::Tz>().is_err()
{
return Err(format!(
"schedule.timezone `{tz}` is not a known IANA zone name"
));
}
Ok(())
}
RoutineSchedule::Once { at } => {
if at.parse::<chrono::DateTime<chrono::Utc>>().is_err() {
return Err(format!("schedule.at `{at}` is not a valid RFC3339 instant"));
}
Ok(())
}
}
}
fn validate_suspend(suspend: Option<&RoutineSuspend>) -> Result<(), String> {
let Some(suspend) = suspend else {
return Ok(());
};
if suspend.paused_by.trim().is_empty() {
return Err("suspend.pausedBy must not be empty".to_owned());
}
if suspend
.paused_at
.parse::<chrono::DateTime<chrono::Utc>>()
.is_err()
{
return Err(format!(
"suspend.pausedAt `{}` is not a valid RFC3339 instant",
suspend.paused_at
));
}
Ok(())
}
fn validate_payload(payload: &RoutinePayload) -> Result<(), String> {
if payload.prompt.trim().is_empty() {
return Err("payload.prompt must not be empty".to_owned());
}
Ok(())
}
pub fn validate_spec(spec: &RoutineSpec) -> Result<(), String> {
validate_schedule(&spec.schedule)?;
validate_suspend(spec.suspend.as_ref())?;
validate_payload(&spec.payload)?;
Ok(())
}
fn trigger_service_account_name(routine: &Routine) -> String {
format!("{}-trigger", routine.name_any())
}
pub struct Context {
pub client: Client,
}
#[tracing::instrument(skip_all, fields(routine = %routine.name_any()))]
pub async fn reconcile(routine: Arc<Routine>, ctx: Arc<Context>) -> Result<Action, Error> {
let ns = routine.namespace().ok_or(Error::NoNamespace)?;
match plan(&routine) {
RoutineAction::Noop => Ok(Action::await_change()),
RoutineAction::Invalid(reason) => {
sync_status(&ctx.client, &ns, &routine, false, "Degraded", Some(&reason)).await?;
Ok(Action::await_change())
}
RoutineAction::Apply => {
let service_accounts: Api<ServiceAccount> = Api::namespaced(ctx.client.clone(), &ns);
let service_account_name = trigger_service_account_name(&routine);
if let Some(service_account) = service_accounts.get_opt(&service_account_name).await? {
if let Some(params) = legacy_delete_params(&routine, &service_account) {
ignore_not_found(
service_accounts
.delete(&service_account_name, ¶ms)
.await,
)?;
} else {
tracing::warn!(
resource = %service_account_name,
"refusing to delete a legacy ServiceAccount not owned by this Routine incarnation"
);
}
}
let cronjobs: Api<CronJob> = Api::namespaced(ctx.client.clone(), &ns);
let cronjob_name = routine.name_any();
if let Some(cronjob) = cronjobs.get_opt(&cronjob_name).await? {
if let Some(params) = legacy_delete_params(&routine, &cronjob) {
ignore_not_found(cronjobs.delete(&cronjob_name, ¶ms).await)?;
} else {
tracing::warn!(
resource = %cronjob_name,
"refusing to delete a legacy CronJob not owned by this Routine incarnation"
);
}
}
sync_status(&ctx.client, &ns, &routine, true, "Ready", None).await?;
Ok(Action::await_change())
}
}
}
fn legacy_delete_params<K>(routine: &Routine, child: &K) -> Option<DeleteParams>
where
K: ResourceExt,
{
let routine_uid = routine.uid()?;
let owned = child
.meta()
.owner_references
.iter()
.flatten()
.any(|reference| {
reference.controller == Some(true)
&& reference.kind == "Routine"
&& reference.uid == routine_uid
});
if !owned {
return None;
}
let child_uid = child.uid()?;
let child_resource_version = child.resource_version()?;
Some(DeleteParams {
preconditions: Some(Preconditions {
uid: Some(child_uid),
resource_version: Some(child_resource_version),
}),
..DeleteParams::default()
})
}
fn ignore_not_found<T>(res: Result<T, kube::Error>) -> Result<(), Error> {
match res {
Ok(_) => Ok(()),
Err(kube::Error::Api(e)) if e.code == 404 => Ok(()),
Err(e) => Err(Error::Kube(e)),
}
}
async fn sync_status(
client: &Client,
ns: &str,
routine: &Routine,
ready: bool,
phase: &str,
message: Option<&str>,
) -> Result<(), Error> {
let default = RoutineStatus::default();
let current = routine.status.as_ref().unwrap_or(&default);
let unchanged = current.ready == ready
&& current.phase.as_deref() == Some(phase)
&& current.message.as_deref() == message;
if unchanged {
return Ok(());
}
let routines: Api<Routine> = Api::namespaced(client.clone(), ns);
let status = json!({ "status": {
"ready": ready,
"phase": phase,
"message": message,
} });
routines
.patch_status(
&routine.name_any(),
&PatchParams::default(),
&Patch::Merge(&status),
)
.await?;
tracing::info!(%phase, ready, "synced routine status");
Ok(())
}
#[must_use]
pub fn error_policy(_routine: Arc<Routine>, err: &Error, _ctx: Arc<Context>) -> Action {
tracing::warn!(error = %err, "routine reconcile failed; requeuing");
Action::requeue(Duration::from_secs(10))
}
pub async fn run_routine(
client: Client,
watch_client: Client,
namespace: &str,
) -> Result<(), Error> {
let routines: Api<Routine> = Api::namespaced(watch_client, namespace);
fanout::await_watchable(&routines, namespace, "Routine").await;
let ctx = Arc::new(Context { client });
Controller::new(routines, watcher::Config::default())
.run(reconcile, error_policy, ctx)
.for_each(|res| async move {
if let Err(e) = res {
tracing::warn!(error = %e, "routine reconcile stream item errored");
}
})
.await;
Ok(())
}
#[cfg(test)]
mod tests;