diff --git a/book/src/admin.md b/book/src/admin.md index bb58d0d2..c6c20cc5 100644 --- a/book/src/admin.md +++ b/book/src/admin.md @@ -269,6 +269,36 @@ back at the blocks section. > landing. It's admin-only input — the intentional escape hatch — so > only paste HTML you trust. +### Schedules + +Cron-scheduled, run-to-completion jobs (Admin-only) — nightly ETL, +report generation, cache warm-ups. A schedule picks one of your +containerized apps and runs **that app's image** with the same +environment, volumes, resource limits and registry credentials a normal +replica gets, optionally overriding the command (one argv element per +line; leave it empty to run the app's own `container-cmd`, or the +image's baked `CMD`). External apps can't be scheduled — there is +nothing to run. + +Semantics worth knowing: + +- **No run on creation.** A new schedule waits for its next cron + occurrence (times are UTC). +- **Downtime collapses.** If the server was down across several + occurrences, the schedule fires **once** on the next tick — ETL + semantics, not a message queue. +- **Leader-only in HA.** With several active-active instances, only the + scaler leader fires schedules, and a database claim backstops a split + brain so an occurrence never double-fires. +- **Timeout.** Each run is capped — 1 hour by default, or the + per-schedule *Timeout (minutes)* when set. A run over the cap is + killed and recorded as an error. +- **History.** The *Latest runs* table shows each run's status (`ok` / + `failed` = non-zero exit / `error` = couldn't run), exit code, + duration and an expandable log tail. +- **Alerts.** A failed run raises a `job-failed` alert through the + webhook configured in the System tab (see below). + ### Groups Groups (`/admin/groups`, admin-only) gate which apps a user sees. They're **derived**, not a separate table: a group exists as long as a user belongs diff --git a/crates/ruscker-admin/assets/i18n/en/landing.ftl b/crates/ruscker-admin/assets/i18n/en/landing.ftl index 77ec38d0..fc8b7b3d 100644 --- a/crates/ruscker-admin/assets/i18n/en/landing.ftl +++ b/crates/ruscker-admin/assets/i18n/en/landing.ftl @@ -936,3 +936,37 @@ admin-disk-reclaim = Reclaim space admin-disk-reclaim-hint = Prune dangling images + build cache (host-safe — never a tagged image or any container). admin-disk-reclaim-confirm = Reclaim space? Prunes dangling images and the build cache (no tagged image or container is removed). admin-disk-flash-reclaimed = Space reclaimed (dangling images + build cache). + +# Schedules — cron jobs (#986 slice C) +admin-nav-schedules = Schedules +admin-schedules-title = Schedules +admin-schedules-subtitle = Run an app's image to completion on a cron schedule (ETL, reports). +admin-schedules-create = New schedule +admin-schedules-spec = App +admin-schedules-cron = Cron +admin-schedules-cron-help = Standard 5-field cron, UTC. Examples: "0 3 * * *" = every day at 03:00; "*/15 * * * *" = every 15 minutes. +admin-schedules-cmd = Command +admin-schedules-cmd-help = One line per argument (argv). Empty = the app's own command (its container-cmd, else the image's CMD). +admin-schedules-timeout = Timeout (minutes) +admin-schedules-timeout-help = Wall-clock cap for one run. Empty = 1 hour. +admin-schedules-next-run = Next run +admin-schedules-last-run = Last run +admin-schedules-enabled = Enabled +admin-schedules-disabled = Disabled +admin-schedules-toggle = Enable/disable +admin-schedules-delete = Delete +admin-schedules-confirm-delete = Delete this schedule? Its run history goes with it. +admin-schedules-empty = No schedules yet — create one above. +admin-schedules-runs-title = Latest runs +admin-schedules-runs-empty = No runs yet. +admin-schedules-runs-started = Started +admin-schedules-runs-status = Status +admin-schedules-runs-exit = Exit code +admin-schedules-runs-duration = Duration +admin-schedules-log = Log +admin-schedules-flash-created = Schedule created. It fires at the next cron occurrence (no run on creation). +admin-schedules-flash-deleted = Schedule deleted. +admin-schedules-flash-toggled = Schedule updated. +admin-schedules-flash-bad-cron = Invalid cron expression — use the 5-field form, e.g. "0 3 * * *". +admin-schedules-flash-bad-spec = Unknown app, or the app has no container image to run. +admin-schedules-flash-error = The operation failed — check the server logs. diff --git a/crates/ruscker-admin/assets/i18n/es/landing.ftl b/crates/ruscker-admin/assets/i18n/es/landing.ftl index 3f6b4d1e..2c3e6fbf 100644 --- a/crates/ruscker-admin/assets/i18n/es/landing.ftl +++ b/crates/ruscker-admin/assets/i18n/es/landing.ftl @@ -936,3 +936,37 @@ admin-disk-reclaim = Recuperar espacio admin-disk-reclaim-hint = Limpia imágenes dangling + caché de compilación (seguro — nunca una imagen con tag ni un contenedor). admin-disk-reclaim-confirm = ¿Recuperar espacio? Limpia imágenes dangling y la caché de compilación (no se elimina ninguna imagen con tag ni contenedor). admin-disk-flash-reclaimed = Espacio recuperado (imágenes dangling + caché de compilación). + +# Programaciones — cron jobs (#986 parte C) +admin-nav-schedules = Programaciones +admin-schedules-title = Programaciones +admin-schedules-subtitle = Ejecuta la imagen de una app hasta terminar según un horario cron (ETL, informes). +admin-schedules-create = Nueva programación +admin-schedules-spec = App +admin-schedules-cron = Cron +admin-schedules-cron-help = Cron estándar de 5 campos, en UTC. Ejemplos: "0 3 * * *" = cada día a las 03:00; "*/15 * * * *" = cada 15 minutos. +admin-schedules-cmd = Comando +admin-schedules-cmd-help = Un argumento por línea (argv). Vacío = el comando de la propia app (su container-cmd, si no el CMD de la imagen). +admin-schedules-timeout = Timeout (minutos) +admin-schedules-timeout-help = Límite de duración de una ejecución. Vacío = 1 hora. +admin-schedules-next-run = Próxima ejecución +admin-schedules-last-run = Última ejecución +admin-schedules-enabled = Activa +admin-schedules-disabled = Inactiva +admin-schedules-toggle = Activar/desactivar +admin-schedules-delete = Eliminar +admin-schedules-confirm-delete = ¿Eliminar esta programación? Su historial de ejecuciones se va con ella. +admin-schedules-empty = Aún no hay programaciones — crea una arriba. +admin-schedules-runs-title = Últimas ejecuciones +admin-schedules-runs-empty = Aún no hay ejecuciones. +admin-schedules-runs-started = Inicio +admin-schedules-runs-status = Estado +admin-schedules-runs-exit = Código de salida +admin-schedules-runs-duration = Duración +admin-schedules-log = Log +admin-schedules-flash-created = Programación creada. Se dispara en la próxima ocurrencia del cron (no se ejecuta al crearla). +admin-schedules-flash-deleted = Programación eliminada. +admin-schedules-flash-toggled = Programación actualizada. +admin-schedules-flash-bad-cron = Expresión cron no válida — usa la forma de 5 campos, p. ej. "0 3 * * *". +admin-schedules-flash-bad-spec = App desconocida, o la app no tiene imagen de contenedor que ejecutar. +admin-schedules-flash-error = La operación falló — revisa los logs del servidor. diff --git a/crates/ruscker-admin/assets/i18n/fr/landing.ftl b/crates/ruscker-admin/assets/i18n/fr/landing.ftl index 61bf80b9..ba506b57 100644 --- a/crates/ruscker-admin/assets/i18n/fr/landing.ftl +++ b/crates/ruscker-admin/assets/i18n/fr/landing.ftl @@ -936,3 +936,37 @@ admin-disk-reclaim = Récupérer de l'espace admin-disk-reclaim-hint = Purge les images dangling + le cache de build (sûr — jamais une image taguée ni un conteneur). admin-disk-reclaim-confirm = Récupérer de l'espace ? Purge les images dangling et le cache de build (aucune image taguée ni conteneur supprimé). admin-disk-flash-reclaimed = Espace récupéré (images dangling + cache de build). + +# Planifications — cron jobs (#986 tranche C) +admin-nav-schedules = Planifications +admin-schedules-title = Planifications +admin-schedules-subtitle = Exécute l'image d'une app jusqu'au bout selon un horaire cron (ETL, rapports). +admin-schedules-create = Nouvelle planification +admin-schedules-spec = App +admin-schedules-cron = Cron +admin-schedules-cron-help = Cron standard à 5 champs, en UTC. Exemples : "0 3 * * *" = chaque jour à 03:00 ; "*/15 * * * *" = toutes les 15 minutes. +admin-schedules-cmd = Commande +admin-schedules-cmd-help = Un argument par ligne (argv). Vide = la commande de l'app elle-même (son container-cmd, sinon le CMD de l'image). +admin-schedules-timeout = Timeout (minutes) +admin-schedules-timeout-help = Durée maximale d'une exécution. Vide = 1 heure. +admin-schedules-next-run = Prochaine exécution +admin-schedules-last-run = Dernière exécution +admin-schedules-enabled = Active +admin-schedules-disabled = Inactive +admin-schedules-toggle = Activer/désactiver +admin-schedules-delete = Supprimer +admin-schedules-confirm-delete = Supprimer cette planification ? Son historique d'exécutions part avec elle. +admin-schedules-empty = Aucune planification pour l'instant — créez-en une ci-dessus. +admin-schedules-runs-title = Dernières exécutions +admin-schedules-runs-empty = Aucune exécution pour l'instant. +admin-schedules-runs-started = Début +admin-schedules-runs-status = Statut +admin-schedules-runs-exit = Code de sortie +admin-schedules-runs-duration = Durée +admin-schedules-log = Log +admin-schedules-flash-created = Planification créée. Elle se déclenche à la prochaine occurrence du cron (pas d'exécution à la création). +admin-schedules-flash-deleted = Planification supprimée. +admin-schedules-flash-toggled = Planification mise à jour. +admin-schedules-flash-bad-cron = Expression cron invalide — utilisez la forme à 5 champs, p. ex. "0 3 * * *". +admin-schedules-flash-bad-spec = App inconnue, ou l'app n'a pas d'image de conteneur à exécuter. +admin-schedules-flash-error = L'opération a échoué — consultez les logs du serveur. diff --git a/crates/ruscker-admin/assets/i18n/pt/landing.ftl b/crates/ruscker-admin/assets/i18n/pt/landing.ftl index 6a81b561..8f7883fe 100644 --- a/crates/ruscker-admin/assets/i18n/pt/landing.ftl +++ b/crates/ruscker-admin/assets/i18n/pt/landing.ftl @@ -940,3 +940,37 @@ admin-disk-reclaim = Recuperar espaço admin-disk-reclaim-hint = Limpa imagens dangling + cache de build (seguro — nunca remove imagem nomeada nem container). admin-disk-reclaim-confirm = Recuperar espaço? Limpa imagens dangling e o cache de build (nenhuma imagem nomeada ou container é removido). admin-disk-flash-reclaimed = Espaço recuperado (imagens dangling + cache de build). + +# Agendamentos — cron jobs (#986 fatia C) +admin-nav-schedules = Agendamentos +admin-schedules-title = Agendamentos +admin-schedules-subtitle = Executa a imagem de um app até o fim em um horário cron (ETL, relatórios). +admin-schedules-create = Novo agendamento +admin-schedules-spec = App +admin-schedules-cron = Cron +admin-schedules-cron-help = Cron padrão de 5 campos, em UTC. Exemplos: "0 3 * * *" = todo dia às 03:00; "*/15 * * * *" = a cada 15 minutos. +admin-schedules-cmd = Comando +admin-schedules-cmd-help = Um argumento por linha (argv). Vazio = o comando do próprio app (o container-cmd, senão o CMD da imagem). +admin-schedules-timeout = Timeout (minutos) +admin-schedules-timeout-help = Limite de duração de uma execução. Vazio = 1 hora. +admin-schedules-next-run = Próxima execução +admin-schedules-last-run = Última execução +admin-schedules-enabled = Ativo +admin-schedules-disabled = Inativo +admin-schedules-toggle = Ativar/desativar +admin-schedules-delete = Excluir +admin-schedules-confirm-delete = Excluir este agendamento? O histórico de execuções vai junto. +admin-schedules-empty = Nenhum agendamento ainda — crie um acima. +admin-schedules-runs-title = Últimas execuções +admin-schedules-runs-empty = Nenhuma execução ainda. +admin-schedules-runs-started = Início +admin-schedules-runs-status = Status +admin-schedules-runs-exit = Código de saída +admin-schedules-runs-duration = Duração +admin-schedules-log = Log +admin-schedules-flash-created = Agendamento criado. Dispara na próxima ocorrência do cron (não executa ao criar). +admin-schedules-flash-deleted = Agendamento excluído. +admin-schedules-flash-toggled = Agendamento atualizado. +admin-schedules-flash-bad-cron = Expressão cron inválida — use a forma de 5 campos, ex.: "0 3 * * *". +admin-schedules-flash-bad-spec = App desconhecido, ou o app não tem imagem de container para executar. +admin-schedules-flash-error = A operação falhou — verifique os logs do servidor. diff --git a/crates/ruscker-admin/src/auth.rs b/crates/ruscker-admin/src/auth.rs index 318ae7dc..50661e1b 100644 --- a/crates/ruscker-admin/src/auth.rs +++ b/crates/ruscker-admin/src/auth.rs @@ -69,7 +69,8 @@ impl Role { // authenticated-end-user role (group-based card visibility, // #155), so it reaches NO admin section. "dashboard" | "specs" | "images" => *self >= Role::Editor, - // Everything else is admin-only. + // Everything else — credentials, landing, users, groups, + // logs, disk, audit, system, schedules — is admin-only. _ => *self == Role::Admin, } } @@ -752,7 +753,7 @@ mod tests { assert_eq!(Role::Editor.home(), "/admin/specs"); assert_eq!(Role::Admin.home(), "/admin/specs"); // Admin-only sections. - for sec in ["credentials", "landing", "blocks", "audit"] { + for sec in ["credentials", "landing", "blocks", "audit", "schedules"] { assert!(!Role::Viewer.can_access_section(sec)); assert!(!Role::Editor.can_access_section(sec)); assert!(Role::Admin.can_access_section(sec)); diff --git a/crates/ruscker-admin/src/db/schedules.rs b/crates/ruscker-admin/src/db/schedules.rs index 6a4b0ddc..2d4ca70b 100644 --- a/crates/ruscker-admin/src/db/schedules.rs +++ b/crates/ruscker-admin/src/db/schedules.rs @@ -32,6 +32,22 @@ impl Schedule { } } +/// One row of run history, joined with its schedule so the admin UI +/// (#986 slice C) can show which SPEC ran without a second query. A +/// run whose schedule was deleted disappears with it (`ON DELETE +/// CASCADE` on `schedule_runs.schedule_id`). +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct RunRow { + pub schedule_id: i64, + pub spec_id: String, + pub started_at: DateTime, + /// `ok` | `failed` | `error` (see [`RunStatus`]). + pub status: String, + pub exit_code: Option, + pub log_tail: Option, + pub duration_ms: Option, +} + /// Outcome bucket for a run row. `Error` = the job could not run at /// all; `Failed` = ran and exited non-zero. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -69,6 +85,41 @@ pub async fn list_all(db: &ConfigDb) -> Result> { Ok(rows) } +/// The most recent runs across ALL schedules, newest first — the +/// "Latest runs" table on `/admin/schedules`. +pub async fn recent_runs(db: &ConfigDb, limit: i64) -> Result> { + // The JOIN resolves spec_id; the index on (schedule_id, started_at) + // doesn't help a global ORDER BY, but the table is small and the + // LIMIT keeps the scan bounded in practice. + let rows = match db { + ConfigDb::Sqlite(pool) => sqlx::query_as::<_, RunRow>( + "SELECT r.schedule_id, s.spec_id, r.started_at, r.status, r.exit_code, + r.log_tail, r.duration_ms + FROM schedule_runs r + JOIN schedules s ON s.id = r.schedule_id + ORDER BY r.started_at DESC + LIMIT ?", + ) + .bind(limit) + .fetch_all(pool) + .await + .context("recent schedule runs (sqlite)")?, + ConfigDb::Postgres(pool) => sqlx::query_as::<_, RunRow>( + "SELECT r.schedule_id, s.spec_id, r.started_at, r.status, r.exit_code, + r.log_tail, r.duration_ms + FROM schedule_runs r + JOIN schedules s ON s.id = r.schedule_id + ORDER BY r.started_at DESC + LIMIT $1", + ) + .bind(limit) + .fetch_all(pool) + .await + .context("recent schedule runs (postgres)")?, + }; + Ok(rows) +} + pub async fn insert( db: &ConfigDb, spec_id: &str, @@ -312,6 +363,37 @@ mod tests { assert!(list_all(&db).await.unwrap().is_empty()); } + /// #986 slice C: `recent_runs` joins the spec back in, orders + /// newest-first, and honours the limit. + #[tokio::test] + async fn recent_runs_joins_spec_and_orders_newest_first() { + let db = mem_db().await; + insert(&db, "etl-a", "0 3 * * *", None, None, None).await.unwrap(); + insert(&db, "etl-b", "0 4 * * *", None, None, None).await.unwrap(); + let all = list_all(&db).await.unwrap(); + let (a, b) = (all[0].id, all[1].id); + assert_eq!(all[0].spec_id, "etl-a"); + + let t0: DateTime = "2026-07-13T03:00:00Z".parse().unwrap(); + let t1: DateTime = "2026-07-13T04:00:00Z".parse().unwrap(); + record_run(&db, a, t0, RunStatus::Ok, Some(0), "done", Some(1500)).await.unwrap(); + record_run(&db, b, t1, RunStatus::Failed, Some(2), "boom", Some(80)).await.unwrap(); + + let runs = recent_runs(&db, 20).await.unwrap(); + assert_eq!(runs.len(), 2); + // Newest first, each carrying its schedule's spec_id. + assert_eq!(runs[0].spec_id, "etl-b"); + assert_eq!(runs[0].status, "failed"); + assert_eq!(runs[0].exit_code, Some(2)); + assert_eq!(runs[0].log_tail.as_deref(), Some("boom")); + assert_eq!(runs[0].duration_ms, Some(80)); + assert_eq!(runs[1].spec_id, "etl-a"); + assert_eq!(runs[1].status, "ok"); + + // The limit bounds the page. + assert_eq!(recent_runs(&db, 1).await.unwrap().len(), 1); + } + // Dual-dialect check (the `IS NOT DISTINCT FROM` claim + BOOLEAN // columns). Gated on `postgres-it`. #[cfg(feature = "postgres-it")] @@ -331,6 +413,10 @@ mod tests { assert!(mark_fired(&db, s.id, None, now).await.unwrap()); assert!(!mark_fired(&db, s.id, None, now).await.unwrap()); record_run(&db, s.id, now, RunStatus::Ok, Some(0), "", Some(10)).await.unwrap(); + // The $n-placeholder JOIN query parses under real Postgres too. + let runs = recent_runs(&db, 5).await.unwrap(); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].spec_id, "etl-app"); delete(&db, s.id, None).await.unwrap(); } } diff --git a/crates/ruscker-admin/src/jobs.rs b/crates/ruscker-admin/src/jobs.rs index 7abfe75a..db2034a9 100644 --- a/crates/ruscker-admin/src/jobs.rs +++ b/crates/ruscker-admin/src/jobs.rs @@ -86,6 +86,15 @@ async fn job_request( if let Some(c) = creds { req = req.with_creds(c); } + // Per-schedule wall-clock cap (#986 slice C). Zero/negative values + // (nothing can produce them through the UI, but the column is plain + // i64) fall back to the backend's default rather than a 0s timeout + // that would kill every job instantly. + if let Some(t) = schedule.timeout_secs { + if t > 0 { + req = req.with_job_timeout(t as u64); + } + } Ok(req) } @@ -158,8 +167,8 @@ async fn tick(state: &AppState) { }; // Detached: a long ETL must not block the next tick. The run - // itself is bounded by run_job's cap (per-schedule timeout is - // slice C, the column already exists). + // itself is bounded by run_job's cap (the schedule's own + // timeout when set, else the backend default). let state = state.clone(); let backend = backend.clone(); tokio::spawn(async move { diff --git a/crates/ruscker-admin/src/routes/admin.rs b/crates/ruscker-admin/src/routes/admin.rs index 57bea9e3..b4420757 100644 --- a/crates/ruscker-admin/src/routes/admin.rs +++ b/crates/ruscker-admin/src/routes/admin.rs @@ -31,6 +31,7 @@ pub mod groups; pub mod images; pub mod landing; pub mod logs; +pub mod schedules_ui; pub mod spec_form; pub mod specs; pub mod system; @@ -63,6 +64,7 @@ pub fn routes() -> Router { .merge(audit::routes()) .merge(groups::routes()) .merge(logs::routes()) + .merge(schedules_ui::routes()) .merge(users::routes()) .merge(system::routes()) } @@ -739,6 +741,8 @@ fn section_for_admin_path(path: &str) -> &'static str { "audit" } else if path.starts_with("/admin/logs") { "logs" + } else if path.starts_with("/admin/schedules") { + "schedules" } else { // /admin root and anything unrecognised → dashboard (every // role can reach it). diff --git a/crates/ruscker-admin/src/routes/admin/schedules_ui.rs b/crates/ruscker-admin/src/routes/admin/schedules_ui.rs new file mode 100644 index 00000000..a12d48b5 --- /dev/null +++ b/crates/ruscker-admin/src/routes/admin/schedules_ui.rs @@ -0,0 +1,418 @@ +//! Admin > Schedules — cron-triggered run-to-completion jobs (#986 +//! slice C). Admin-only. +//! +//! The storage and the scheduler shipped in slices A/B +//! ([`crate::db::schedules`] + [`crate::jobs`]); this page is the CRUD +//! surface over them: create a schedule for a containerized spec, +//! enable/disable/delete it, and read the recent run history (status, +//! exit code, duration, log tail). Validation is server-side — the +//! cron expression must parse under the same `croner` grammar the +//! scheduler evaluates, and the spec must exist in the effective +//! catalog WITH a `container-image` (an External spec has nothing to +//! run). + +use askama::Template; +use axum::{ + extract::{Form, Path, Query, State}, + response::{IntoResponse, Redirect, Response}, + routing::{get, post}, + Router, +}; +use chrono::{DateTime, Utc}; +use serde::Deserialize; + +use crate::auth::{RequireAdmin, Role}; +use crate::db::schedules::{RunRow, Schedule}; +use crate::i18n::{Locale, Locales}; +use crate::theme::Theme; +use crate::AppState; + +pub fn routes() -> Router { + Router::new() + .route("/admin/schedules", get(index).post(create)) + .route("/admin/schedules/{id}/toggle", post(toggle)) + .route("/admin/schedules/{id}/delete", post(delete)) +} + +/// One schedule row, pre-formatted for the template. +struct ScheduleRow { + id: i64, + spec_id: String, + cron: String, + /// First line of the argv override, or an em dash for "the spec's + /// own command". + cmd_summary: String, + /// `YYYY-MM-DD HH:MM UTC`, or an em dash when the schedule is + /// disabled or its cron no longer parses. + next_run: String, + /// `YYYY-MM-DD HH:MM UTC`, or an em dash before the first fire. + last_run: String, + enabled: bool, +} + +/// One run-history row, pre-formatted for the template. +struct RunView { + spec_id: String, + started: String, + /// `ok` | `failed` | `error` — drives the badge colour. + status: String, + exit_code: String, + duration: String, + log_tail: String, +} + +#[derive(Template)] +#[template(path = "admin/schedules.html")] +struct SchedulesPage<'a> { + locale: Locale, + theme: Theme, + locales: &'a Locales, + locales_all: &'static [Locale], + base: std::sync::Arc, + nav_section: &'static str, + role: Role, + /// Containerized specs from the effective catalog — `(id, display + /// name)` — for the create form's select. External specs are + /// excluded: there is nothing to run. + specs: Vec<(String, String)>, + schedules: Vec, + runs: Vec, + flash: Option<&'static str>, + flash_error: bool, +} + +impl SchedulesPage<'_> { + fn t(&self, key: &str) -> String { + self.locales.t(self.locale, key, None) + } +} + +/// `2026-07-13T03:00:12Z` → `2026-07-13 03:00 UTC` — minute precision +/// is all a cron UI needs. +fn fmt_utc(dt: &DateTime) -> String { + dt.format("%Y-%m-%d %H:%M UTC").to_string() +} + +/// The schedule's next occurrence, from the same anchor the scheduler +/// uses ([`crate::jobs`]): the last fire marker, or creation for a +/// schedule that never fired (no fire-on-create). `None` when the +/// stored cron no longer parses. +fn next_run(schedule: &Schedule) -> Option> { + let anchor = schedule.last_run_at.unwrap_or(schedule.created_at); + let parsed: croner::Cron = schedule.cron.parse().ok()?; + parsed.find_next_occurrence(&anchor, false).ok() +} + +fn schedule_row(s: Schedule) -> ScheduleRow { + let next = if s.enabled { + next_run(&s).map(|dt| fmt_utc(&dt)) + } else { + None // a disabled schedule has no next fire + }; + ScheduleRow { + cmd_summary: s + .cmd_override() + .and_then(|argv| argv.first().cloned()) + .unwrap_or_else(|| "—".to_string()), + next_run: next.unwrap_or_else(|| "—".to_string()), + last_run: s.last_run_at.map(|dt| fmt_utc(&dt)).unwrap_or_else(|| "—".to_string()), + id: s.id, + spec_id: s.spec_id, + cron: s.cron, + enabled: s.enabled, + } +} + +/// `1234` ms → `1.2 s`; sub-second stays in ms. Missing → em dash. +fn fmt_duration(ms: Option) -> String { + match ms { + None => "—".to_string(), + Some(ms) if ms < 1000 => format!("{ms} ms"), + Some(ms) => format!("{:.1} s", ms as f64 / 1000.0), + } +} + +fn run_view(r: RunRow) -> RunView { + RunView { + spec_id: r.spec_id, + started: fmt_utc(&r.started_at), + status: r.status, + exit_code: r.exit_code.map(|c| c.to_string()).unwrap_or_else(|| "—".to_string()), + duration: fmt_duration(r.duration_ms), + log_tail: r.log_tail.unwrap_or_default(), + } +} + +#[derive(Debug, Deserialize)] +struct FlashQuery { + flash: Option, +} + +async fn index( + admin: RequireAdmin, + State(state): State, + loc: Locale, + theme: Theme, + Query(q): Query, +) -> Response { + // Whitelist the flash codes — the query string is user input. + let (flash, flash_error) = match q.flash.as_deref() { + Some("created") => (Some("admin-schedules-flash-created"), false), + Some("deleted") => (Some("admin-schedules-flash-deleted"), false), + Some("toggled") => (Some("admin-schedules-flash-toggled"), false), + Some("bad-cron") => (Some("admin-schedules-flash-bad-cron"), true), + Some("bad-spec") => (Some("admin-schedules-flash-bad-spec"), true), + Some("error") => (Some("admin-schedules-flash-error"), true), + _ => (None, false), + }; + + // Only containerized specs can be scheduled — an External spec is a + // link, there is nothing to run. + let specs: Vec<(String, String)> = crate::catalog::effective_specs_cached(&state) + .await + .iter() + .filter(|s| s.container_image.is_some()) + .map(|s| { + ( + s.id.clone(), + s.display_name.clone().unwrap_or_else(|| s.id.clone()), + ) + }) + .collect(); + + let (schedules, runs) = match state.db.as_ref() { + Some(db) => { + let schedules = crate::db::schedules::list_all(db).await.unwrap_or_else(|e| { + tracing::warn!(error = ?e, "schedules: list failed"); + Vec::new() + }); + let runs = crate::db::schedules::recent_runs(db, 20).await.unwrap_or_else(|e| { + tracing::warn!(error = ?e, "schedules: recent runs failed"); + Vec::new() + }); + (schedules, runs) + } + None => (Vec::new(), Vec::new()), + }; + + super::render(&SchedulesPage { + locale: loc, + theme, + locales: &state.locales, + locales_all: &Locale::ALL, + base: state.base_path.clone(), + nav_section: "schedules", + role: admin.role, + specs, + schedules: schedules.into_iter().map(schedule_row).collect(), + runs: runs.into_iter().map(run_view).collect(), + flash, + flash_error, + }) +} + +#[derive(Debug, Deserialize)] +struct CreateForm { + spec_id: String, + cron: String, + /// Multiline: one argv element per line. Empty ⇒ the spec's own + /// command (its `container-cmd`, else the image's baked CMD). + #[serde(default)] + cmd: String, + /// Minutes; empty ⇒ the backend's default cap (1 h). + #[serde(default)] + timeout_mins: String, +} + +/// Textarea lines → the argv override: trimmed, empties dropped, +/// serialized as a JSON array for `schedules.cmd_json`. All-empty ⇒ +/// `None` (run the spec's own command). +fn cmd_json_from_lines(cmd: &str) -> Option { + let argv: Vec<&str> = cmd.lines().map(str::trim).filter(|l| !l.is_empty()).collect(); + if argv.is_empty() { + None + } else { + // Serializing a Vec<&str> can't fail. + Some(serde_json::to_string(&argv).expect("serialize argv")) + } +} + +/// Empty ⇒ `Ok(None)` (backend default); a positive integer number of +/// minutes ⇒ seconds; anything else ⇒ `Err` (rejected as bad input). +fn timeout_secs_from_mins(timeout_mins: &str) -> Result, ()> { + let t = timeout_mins.trim(); + if t.is_empty() { + return Ok(None); + } + match t.parse::() { + // Cap at something absurd-but-safe so `* 60` can't overflow i64. + Ok(mins) if mins > 0 && mins <= 60 * 24 * 365 => Ok(Some((mins * 60) as i64)), + _ => Err(()), + } +} + +async fn create( + admin: RequireAdmin, + State(state): State, + Form(form): Form, +) -> Response { + let Some(db) = state.db.as_ref() else { + return redirect("error"); + }; + + // The spec must exist in the effective catalog and be containerized + // (External has nothing to run) — same gate the scheduler applies + // per tick, enforced here so a schedule is never born broken. + let spec_ok = crate::catalog::effective_specs_cached(&state) + .await + .iter() + .any(|s| s.id == form.spec_id && s.container_image.is_some()); + if !spec_ok { + return redirect("bad-spec"); + } + + // The exact grammar the scheduler evaluates (croner, minute-level). + let cron = form.cron.trim(); + if cron.is_empty() || cron.parse::().is_err() { + return redirect("bad-cron"); + } + + let cmd_json = cmd_json_from_lines(&form.cmd); + let Ok(timeout_secs) = timeout_secs_from_mins(&form.timeout_mins) else { + return redirect("error"); + }; + + match crate::db::schedules::insert( + db, + &form.spec_id, + cron, + cmd_json.as_deref(), + timeout_secs, + Some(admin.actor()), + ) + .await + { + Ok(()) => redirect("created"), + Err(e) => { + tracing::warn!(error = ?e, spec = %form.spec_id, "schedules: create failed"); + redirect("error") + } + } +} + +async fn toggle( + admin: RequireAdmin, + State(state): State, + Path(id): Path, +) -> Response { + let Some(db) = state.db.as_ref() else { + return redirect("error"); + }; + // No fetch-by-id in the store (the table is small); find the current + // enabled bit in the full listing. + let current = match crate::db::schedules::list_all(db).await { + Ok(all) => all.into_iter().find(|s| s.id == id), + Err(e) => { + tracing::warn!(error = ?e, id, "schedules: list for toggle failed"); + return redirect("error"); + } + }; + let Some(schedule) = current else { + return redirect("error"); + }; + match crate::db::schedules::set_enabled(db, id, !schedule.enabled, Some(admin.actor())).await { + Ok(()) => redirect("toggled"), + Err(e) => { + tracing::warn!(error = ?e, id, "schedules: toggle failed"); + redirect("error") + } + } +} + +async fn delete( + admin: RequireAdmin, + State(state): State, + Path(id): Path, +) -> Response { + let Some(db) = state.db.as_ref() else { + return redirect("error"); + }; + match crate::db::schedules::delete(db, id, Some(admin.actor())).await { + Ok(()) => redirect("deleted"), + Err(e) => { + tracing::warn!(error = ?e, id, "schedules: delete failed"); + redirect("error") + } + } +} + +/// Post/redirect/get back to the page with a one-word flash code. The +/// base-path response rewriter re-prefixes the `Location` header. +fn redirect(flash: &str) -> Response { + Redirect::to(&format!("/admin/schedules?flash={flash}")).into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cmd_lines_become_a_json_argv_or_none() { + assert_eq!(cmd_json_from_lines(""), None); + assert_eq!(cmd_json_from_lines(" \n\n "), None); + assert_eq!( + cmd_json_from_lines("Rscript\n etl.R \n\n--full"), + Some(r#"["Rscript","etl.R","--full"]"#.to_string()) + ); + } + + #[test] + fn timeout_minutes_parse_to_seconds_or_default() { + assert_eq!(timeout_secs_from_mins(""), Ok(None)); + assert_eq!(timeout_secs_from_mins(" "), Ok(None)); + assert_eq!(timeout_secs_from_mins("90"), Ok(Some(5400))); + assert_eq!(timeout_secs_from_mins("0"), Err(())); + assert_eq!(timeout_secs_from_mins("-5"), Err(())); + assert_eq!(timeout_secs_from_mins("abc"), Err(())); + } + + fn sched(cron: &str, enabled: bool, last: Option<&str>) -> Schedule { + Schedule { + id: 7, + spec_id: "etl".into(), + cron: cron.into(), + cmd_json: Some(r#"["Rscript","etl.R"]"#.into()), + enabled, + timeout_secs: None, + last_run_at: last.map(|s| s.parse().unwrap()), + created_at: "2026-07-01T10:00:00Z".parse().unwrap(), + updated_at: "2026-07-01T10:00:00Z".parse().unwrap(), + } + } + + #[test] + fn schedule_row_formats_next_and_last_run() { + // Fired 2026-07-12 03:00 → next daily 03:00 is the 13th. + let row = schedule_row(sched("0 3 * * *", true, Some("2026-07-12T03:00:10Z"))); + assert_eq!(row.next_run, "2026-07-13 03:00 UTC"); + assert_eq!(row.last_run, "2026-07-12 03:00 UTC"); + assert_eq!(row.cmd_summary, "Rscript"); + + // Disabled → no next fire; never fired → em-dash last run. + let row = schedule_row(sched("0 3 * * *", false, None)); + assert_eq!(row.next_run, "—"); + assert_eq!(row.last_run, "—"); + + // A stored cron that no longer parses can't show a next run. + let row = schedule_row(sched("not a cron", true, None)); + assert_eq!(row.next_run, "—"); + } + + #[test] + fn durations_format_human_readable() { + assert_eq!(fmt_duration(None), "—"); + assert_eq!(fmt_duration(Some(80)), "80 ms"); + assert_eq!(fmt_duration(Some(1234)), "1.2 s"); + assert_eq!(fmt_duration(Some(65000)), "65.0 s"); + } +} diff --git a/crates/ruscker-admin/templates/admin/_layout.html b/crates/ruscker-admin/templates/admin/_layout.html index 8e071070..5051b021 100644 --- a/crates/ruscker-admin/templates/admin/_layout.html +++ b/crates/ruscker-admin/templates/admin/_layout.html @@ -82,6 +82,12 @@ {{ self.t("admin-nav-landing") }} {% endif %} + {% if role.can_access_section("schedules") %} + + + {{ self.t("admin-nav-schedules") }} + + {% endif %} {% if role.can_access_section("users") %} diff --git a/crates/ruscker-admin/templates/admin/schedules.html b/crates/ruscker-admin/templates/admin/schedules.html new file mode 100644 index 00000000..3d1ee1ee --- /dev/null +++ b/crates/ruscker-admin/templates/admin/schedules.html @@ -0,0 +1,177 @@ +{% extends "admin/_layout.html" %} + +{% block title %}{{ self.t("admin-schedules-title") }} · Ruscker{% endblock %} + +{% block content %} +
+
+

{{ self.t("admin-schedules-title") }}

+

{{ self.t("admin-schedules-subtitle") }}

+
+
+ +{# Flash split (#746) — persistent role="alert" errors, toastable + role="status" successes; no colour-only state. #} +{% if let Some(f) = flash %} + {% if flash_error %} + + {% else %} +
+ + {{ self.t(f) }} +
+ {% endif %} +{% endif %} + +{# ── Create a schedule ─────────────────────────────────────────── #} +
+
+ + {{ self.t("admin-schedules-create") }} +
+
+
+ + + + + + + +
+ +
+ +
+
+
+ +{# ── Schedules table ───────────────────────────────────────────── #} +
+ + + + + + + + + + + + + + {% for s in schedules %} + + + + + + + + + + {% endfor %} + {% if schedules.is_empty() %} + + {% endif %} + +
{{ self.t("admin-schedules-spec") }}{{ self.t("admin-schedules-cron") }}{{ self.t("admin-schedules-cmd") }}{{ self.t("admin-schedules-next-run") }}{{ self.t("admin-schedules-last-run") }}{{ self.t("admin-schedules-enabled") }}
{{ s.spec_id }}{{ s.cron }}{{ s.cmd_summary }}{{ s.next_run }}{{ s.last_run }} + {% if s.enabled %} + {{ self.t("admin-schedules-enabled") }} + {% else %} + {{ self.t("admin-schedules-disabled") }} + {% endif %} + +
+ +
+
+ +
+
{{ self.t("admin-schedules-empty") }}
+
+ +{# ── Latest runs ───────────────────────────────────────────────── #} +
+
+ + {{ self.t("admin-schedules-runs-title") }} +
+ + + + + + + + + + + + + {% for r in runs %} + + + + + + + + + {% endfor %} + {% if runs.is_empty() %} + + {% endif %} + +
{{ self.t("admin-schedules-spec") }}{{ self.t("admin-schedules-runs-started") }}{{ self.t("admin-schedules-runs-status") }}{{ self.t("admin-schedules-runs-exit") }}{{ self.t("admin-schedules-runs-duration") }}{{ self.t("admin-schedules-log") }}
{{ r.spec_id }}{{ r.started }} + {% if r.status == "ok" %} + {{ r.status }} + {% else %} + {# failed (non-zero exit) and error (couldn't run) are both red. #} + {{ r.status }} + {% endif %} + {{ r.exit_code }}{{ r.duration }} + {% if !r.log_tail.is_empty() %} +
+ {{ self.t("admin-schedules-log") }} +
{{ r.log_tail }}
+
+ {% else %} + + {% endif %} +
{{ self.t("admin-schedules-runs-empty") }}
+
+{% endblock %} diff --git a/crates/ruscker-admin/tests/schedules_ui.rs b/crates/ruscker-admin/tests/schedules_ui.rs new file mode 100644 index 00000000..c58ff9cc --- /dev/null +++ b/crates/ruscker-admin/tests/schedules_ui.rs @@ -0,0 +1,279 @@ +//! Integration tests for the Schedules admin page (#986 slice C): +//! create (with server-side cron/spec validation), toggle, delete, and +//! the admin-only gate. Harness copied from `proxy_access.rs` — a full +//! `router()` over a temp-file SQLite, sessions minted directly in the +//! shared store (the `rbac.rs` trick). + +use axum::body::Body; +use axum::http::{header, Request, StatusCode}; +use ruscker_admin::auth::{AdminAuth, Role, COOKIE_NAME}; +use ruscker_admin::db::ConfigDb; +use ruscker_admin::i18n::Locales; +use ruscker_admin::{router, AppState}; +use ruscker_config::Config; +use std::sync::Arc; +use tower::ServiceExt; + +const CONFIG: &str = r#" +proxy: + title: Ruscker Test + port: 8088 + specs: + - id: etl-app + display-name: ETL App + container-image: demo/etl + # No container-image ⇒ auto-classified External (nothing to run). + - id: external-app + display-name: External App +"#; + +async fn open_db() -> ConfigDb { + use std::sync::atomic::{AtomicU64, Ordering}; + static N: AtomicU64 = AtomicU64::new(0); + let path = std::env::temp_dir().join(format!( + "ruscker-schedules-ui-{}-{}.db", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )); + let _ = std::fs::remove_file(&path); + ConfigDb::Sqlite(ruscker_admin::db::open(&path).await.unwrap()) +} + +async fn app_state(db: ConfigDb) -> AppState { + std::env::set_var("DOCKER_REGISTRY_PASSWORD", "test"); + let config = Config::from_yaml(CONFIG).expect("parse config"); + let locales = Locales::load().expect("load locales"); + AppState { + config: Arc::new(config), + base_path: Arc::from(""), + locales: Arc::new(locales), + admin_auth: AdminAuth::with_token("test-token"), + admin_sessions: Arc::new(ruscker_admin::auth::InMemoryAdminSessionStore::default()), + log_buffer: None, + login_limiter: Arc::new(ruscker_admin::auth::LoginRateLimiter::default_policy()), + api_limiter: Arc::new(ruscker_admin::ratelimit::ApiRateLimiter::new()), + db: Some(db), + images_dir: None, + master_key: Default::default(), + backend: None, + replicas: Arc::new(tokio::sync::RwLock::new(Default::default())), + cookie_key: ruscker_proxy::sticky::CookieKey::random(), + spawn_locks: Arc::new(dashmap::DashMap::new()), + sessions: Arc::new(ruscker_admin::sessions::InMemorySessionStore::new()), + logout_index: Arc::new(dashmap::DashMap::new()), + leader: Arc::new(ruscker_admin::leader::AlwaysLeader), + metrics: ruscker_admin::metrics_cache::MetricsCache::new(), + draining: Arc::new(std::sync::atomic::AtomicBool::new(false)), + spec_cache: std::sync::Arc::new(dashmap::DashMap::new()), + catalog_cache: std::sync::Arc::new(tokio::sync::RwLock::new(None)), + access_counter: Arc::new(ruscker_admin::access_counter::AccessCounter::default()), + alerts: ruscker_admin::alerts::AlertSink::default(), + } +} + +/// Mint an admin session straight into the shared store and return the +/// cookie header value. +async fn admin_cookie(state: &AppState) -> String { + let sid = state + .admin_sessions + .create(Role::Admin, Some("root".into())) + .await; + format!("{COOKIE_NAME}={sid}") +} + +async fn get(state: AppState, path: &str, cookie: &str) -> (StatusCode, String) { + let app = router(state); + let resp = app + .oneshot( + Request::builder() + .method("GET") + .uri(path) + .header(header::COOKIE, cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = resp.status(); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + (status, String::from_utf8_lossy(&bytes).into_owned()) +} + +/// POST a form; returns (status, Location header). +async fn post(state: AppState, uri: &str, body: &str, cookie: &str) -> (StatusCode, String) { + let app = router(state); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri(uri) + .header(header::COOKIE, cookie) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + let status = resp.status(); + let loc = resp + .headers() + .get(header::LOCATION) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + (status, loc) +} + +#[tokio::test] +async fn create_valid_schedule_persists_and_renders() { + let state = app_state(open_db().await).await; + let cookie = admin_cookie(&state).await; + + let (status, loc) = post( + state.clone(), + "/admin/schedules", + "spec_id=etl-app&cron=0+3+*+*+*&cmd=Rscript%0Aetl.R&timeout_mins=90", + &cookie, + ) + .await; + assert_eq!(status, StatusCode::SEE_OTHER); + assert_eq!(loc, "/admin/schedules?flash=created"); + + // Persisted with the parsed argv + timeout in seconds. + let db = state.db.clone().unwrap(); + let all = ruscker_admin::db::schedules::list_all(&db).await.unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].spec_id, "etl-app"); + assert_eq!(all[0].cron, "0 3 * * *"); + assert_eq!( + all[0].cmd_override(), + Some(vec!["Rscript".to_string(), "etl.R".to_string()]) + ); + assert_eq!(all[0].timeout_secs, Some(90 * 60)); + assert!(all[0].enabled); + + // And the page shows it (spec id + the enabled badge's toggle form). + let (status, html) = get(state, "/admin/schedules?flash=created", &cookie).await; + assert_eq!(status, StatusCode::OK); + assert!(html.contains("etl-app"), "schedule row renders"); + assert!(html.contains("0 3 * * *"), "cron renders"); + assert!(html.contains("/toggle"), "toggle action renders"); + // External specs never appear in the create form's app select. + assert!(!html.contains(r#"value="external-app""#), "External is not schedulable"); +} + +#[tokio::test] +async fn invalid_cron_is_rejected_and_not_persisted() { + let state = app_state(open_db().await).await; + let cookie = admin_cookie(&state).await; + + let (status, loc) = post( + state.clone(), + "/admin/schedules", + "spec_id=etl-app&cron=not+a+cron&cmd=&timeout_mins=", + &cookie, + ) + .await; + assert_eq!(status, StatusCode::SEE_OTHER); + assert_eq!(loc, "/admin/schedules?flash=bad-cron"); + + let db = state.db.clone().unwrap(); + assert!(ruscker_admin::db::schedules::list_all(&db).await.unwrap().is_empty()); +} + +#[tokio::test] +async fn unknown_or_external_spec_is_rejected() { + let state = app_state(open_db().await).await; + let cookie = admin_cookie(&state).await; + + // Not in the catalog at all. + let (_, loc) = post( + state.clone(), + "/admin/schedules", + "spec_id=no-such-app&cron=0+3+*+*+*&cmd=&timeout_mins=", + &cookie, + ) + .await; + assert_eq!(loc, "/admin/schedules?flash=bad-spec"); + + // In the catalog but External (no container-image → nothing to run). + let (_, loc) = post( + state.clone(), + "/admin/schedules", + "spec_id=external-app&cron=0+3+*+*+*&cmd=&timeout_mins=", + &cookie, + ) + .await; + assert_eq!(loc, "/admin/schedules?flash=bad-spec"); + + let db = state.db.clone().unwrap(); + assert!(ruscker_admin::db::schedules::list_all(&db).await.unwrap().is_empty()); +} + +#[tokio::test] +async fn toggle_and_delete_round_trip() { + let state = app_state(open_db().await).await; + let cookie = admin_cookie(&state).await; + let db = state.db.clone().unwrap(); + + ruscker_admin::db::schedules::insert(&db, "etl-app", "0 3 * * *", None, None, Some("root")) + .await + .unwrap(); + let id = ruscker_admin::db::schedules::list_all(&db).await.unwrap()[0].id; + + // Toggle: enabled → disabled. + let (status, loc) = post( + state.clone(), + &format!("/admin/schedules/{id}/toggle"), + "", + &cookie, + ) + .await; + assert_eq!(status, StatusCode::SEE_OTHER); + assert_eq!(loc, "/admin/schedules?flash=toggled"); + assert!(!ruscker_admin::db::schedules::list_all(&db).await.unwrap()[0].enabled); + + // Toggle again: back to enabled. + let (_, loc) = post( + state.clone(), + &format!("/admin/schedules/{id}/toggle"), + "", + &cookie, + ) + .await; + assert_eq!(loc, "/admin/schedules?flash=toggled"); + assert!(ruscker_admin::db::schedules::list_all(&db).await.unwrap()[0].enabled); + + // Delete removes the row. + let (_, loc) = post( + state.clone(), + &format!("/admin/schedules/{id}/delete"), + "", + &cookie, + ) + .await; + assert_eq!(loc, "/admin/schedules?flash=deleted"); + assert!(ruscker_admin::db::schedules::list_all(&db).await.unwrap().is_empty()); +} + +#[tokio::test] +async fn schedules_are_admin_only() { + let state = app_state(open_db().await).await; + let editor = state + .admin_sessions + .create(Role::Editor, Some("ed".into())) + .await; + let cookie = format!("{COOKIE_NAME}={editor}"); + + let (status, _) = get(state.clone(), "/admin/schedules", &cookie).await; + assert_eq!(status, StatusCode::FORBIDDEN, "Editor cannot view schedules"); + + let (status, _) = post( + state, + "/admin/schedules", + "spec_id=etl-app&cron=0+3+*+*+*&cmd=&timeout_mins=", + &cookie, + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "Editor cannot create schedules"); +} diff --git a/crates/ruscker-core/src/lib.rs b/crates/ruscker-core/src/lib.rs index 68229897..ab7b18cc 100644 --- a/crates/ruscker-core/src/lib.rs +++ b/crates/ruscker-core/src/lib.rs @@ -163,6 +163,12 @@ pub struct SpawnRequest { /// the container's labels; its own `ruscker.*` labels win on a key /// collision. Empty ⇒ only the internal labels. pub labels: Vec<(String, String)>, + + /// Wall-clock cap, in seconds, for a run-to-completion job (#986 + /// slice C). Only [`ContainerBackend::run_job`] consumes it — a + /// regular replica spawn ignores the field entirely. `None` ⇒ the + /// backend's default cap (1 h on the local Docker backend). + pub job_timeout_secs: Option, } impl SpawnRequest { @@ -181,6 +187,7 @@ impl SpawnRequest { cmd: None, network: None, labels: Vec::new(), + job_timeout_secs: None, } } @@ -232,6 +239,13 @@ impl SpawnRequest { self.labels = labels; self } + + /// Cap a run-to-completion job at `secs` seconds (see + /// [`SpawnRequest::job_timeout_secs`]). No-op for replica spawns. + pub fn with_job_timeout(mut self, secs: u64) -> Self { + self.job_timeout_secs = Some(secs); + self + } } /// Abstract container backend. The default implementation is Docker @@ -776,6 +790,15 @@ mod registry_tests { assert!(bare.cmd.is_none()); } + // #986 slice C: the per-schedule job timeout rides on the request. + // Default is None (backend default cap); the builder sets it. + #[test] + fn spawn_request_carries_job_timeout() { + assert!(SpawnRequest::new("a", "img").job_timeout_secs.is_none()); + let req = SpawnRequest::new("etl", "img").with_job_timeout(600); + assert_eq!(req.job_timeout_secs, Some(600)); + } + #[test] fn inc_and_dec_round_trip() { let mut reg = ReplicaRegistry::new(); diff --git a/crates/ruscker-docker/src/lib.rs b/crates/ruscker-docker/src/lib.rs index 07e0b64a..bae4508c 100644 --- a/crates/ruscker-docker/src/lib.rs +++ b/crates/ruscker-docker/src/lib.rs @@ -1064,10 +1064,15 @@ impl ContainerBackend for LocalDockerBackend { } // Wait for the exit, bounded: a hung job must not pin the - // scheduler forever. The cap is generous — ETL runs are long — - // and slice B makes it per-schedule. + // scheduler forever. The default cap is generous — ETL runs are + // long — and a schedule can override it per-request via + // `SpawnRequest::job_timeout_secs` (#986 slice C). const JOB_TIMEOUT: Duration = Duration::from_secs(60 * 60); - let waited = tokio::time::timeout(JOB_TIMEOUT, async { + let cap = req + .job_timeout_secs + .map(Duration::from_secs) + .unwrap_or(JOB_TIMEOUT); + let waited = tokio::time::timeout(cap, async { self.docker .wait_container(&container_id, None::) .next() @@ -1078,7 +1083,7 @@ impl ContainerBackend for LocalDockerBackend { Err(_) => { cleanup(self.docker.clone(), container_id).await; return Err(CoreError::Backend(format!( - "job for `{}` still running after {JOB_TIMEOUT:?}; killed and removed", + "job for `{}` still running after {cap:?}; killed and removed", req.spec_id ))); }