1use std::collections::HashMap;
10use std::convert::Infallible;
11use std::sync::Arc;
12use std::time::Duration;
13
14use axum::extract::rejection::JsonRejection;
15use axum::extract::{DefaultBodyLimit, FromRequest, Path, Query, Request, State};
16use axum::http::{HeaderValue, StatusCode, header};
17use axum::middleware::{Next, from_fn_with_state};
18use axum::response::{IntoResponse, Response};
19use axum::routing::{get, post};
20use axum::{Extension, Json, Router};
21use base64::Engine;
22use base64::engine::general_purpose::STANDARD as B64;
23use cronoq::{TaskId, TaskRef, TaskStatus};
24use cronoq_proto::v1 as pb;
25use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer};
26use hyper_util::server::conn::auto;
27use hyper_util::service::TowerToHyperService;
28use serde::Deserialize;
29use serde::de::DeserializeOwned;
30use serde_json::{Value, json};
31use tokio::net::TcpListener;
32use tokio_util::sync::CancellationToken;
33use tower::ServiceExt;
34
35use crate::auth::{AuthError, Principal};
36use crate::config::Role;
37use crate::grpc;
38use crate::state::{AppState, Shared};
39
40#[derive(Clone, Debug, Default)]
42pub struct ConnInfo {
43 pub cert: Option<Principal>,
45}
46
47#[derive(Debug)]
49pub struct ApiError(pub StatusCode, pub String);
50
51impl IntoResponse for ApiError {
52 fn into_response(self) -> Response {
53 let code = self
54 .0
55 .canonical_reason()
56 .unwrap_or("error")
57 .to_ascii_lowercase()
58 .replace(' ', "_");
59 (self.0, Json(json!({ "error": self.1, "code": code }))).into_response()
60 }
61}
62
63impl From<tonic::Status> for ApiError {
64 fn from(s: tonic::Status) -> Self {
65 use tonic::Code::*;
66 let code = match s.code() {
67 InvalidArgument | OutOfRange => StatusCode::BAD_REQUEST,
68 NotFound => StatusCode::NOT_FOUND,
69 AlreadyExists | FailedPrecondition | Aborted => StatusCode::CONFLICT,
70 ResourceExhausted => StatusCode::TOO_MANY_REQUESTS,
71 Unauthenticated => StatusCode::UNAUTHORIZED,
72 PermissionDenied => StatusCode::FORBIDDEN,
73 DeadlineExceeded => StatusCode::GATEWAY_TIMEOUT,
74 Unavailable => StatusCode::SERVICE_UNAVAILABLE,
75 Unimplemented => StatusCode::NOT_IMPLEMENTED,
76 _ => StatusCode::INTERNAL_SERVER_ERROR,
77 };
78 ApiError(code, s.message().to_string())
79 }
80}
81
82impl From<AuthError> for ApiError {
83 fn from(e: AuthError) -> Self {
84 let mut r = ApiError::from(e.to_status());
85 if e != AuthError::Forbidden {
86 r.0 = StatusCode::UNAUTHORIZED;
87 }
88 r
89 }
90}
91
92fn bad(m: impl Into<String>) -> ApiError {
93 ApiError(StatusCode::BAD_REQUEST, m.into())
94}
95
96fn unavailable() -> ApiError {
97 ApiError(
98 StatusCode::SERVICE_UNAVAILABLE,
99 "not the leader (standby or shutting down)".into(),
100 )
101}
102
103pub struct ApiJson<T>(pub T);
105
106impl<T: DeserializeOwned, S: Send + Sync> FromRequest<S> for ApiJson<T> {
107 type Rejection = ApiError;
108
109 async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
110 match Json::<T>::from_request(req, state).await {
111 Ok(Json(v)) => Ok(ApiJson(v)),
112 Err(e) => {
113 let status = match &e {
114 JsonRejection::MissingJsonContentType(_) => StatusCode::UNSUPPORTED_MEDIA_TYPE,
115 JsonRejection::BytesRejection(b) => b.status(),
116 _ => StatusCode::BAD_REQUEST,
117 };
118 Err(ApiError(status, e.body_text()))
119 }
120 }
121 }
122}
123
124type St = Arc<Shared>;
125
126fn app(st: &Shared) -> Result<&Arc<AppState>, ApiError> {
127 st.app().ok_or_else(unavailable)
128}
129
130async fn gate(State((st, role)): State<(St, Role)>, mut req: Request, next: Next) -> Response {
133 if st.loopback_hosts_only && !loopback_host(&req) {
134 return ApiError(
135 StatusCode::FORBIDDEN,
136 "without auth the API only answers requests for a loopback host".into(),
137 )
138 .into_response();
139 }
140 let header = req
141 .headers()
142 .get(header::AUTHORIZATION)
143 .and_then(|v| v.to_str().ok());
144 let cert = req
145 .extensions()
146 .get::<ConnInfo>()
147 .and_then(|c| c.cert.clone());
148 let mut principal = match st.auth.authorize(header, cert.as_ref(), role) {
149 Ok(p) => p,
150 Err(e) => {
151 let mut r = ApiError::from(e).into_response();
152 if r.status() == StatusCode::UNAUTHORIZED {
153 r.headers_mut()
154 .insert(header::WWW_AUTHENTICATE, HeaderValue::from_static("Bearer"));
155 }
156 return r;
157 }
158 };
159 if st.app().is_none() {
160 return unavailable().into_response();
161 }
162 let waits = req.uri().path().ends_with("/result") || req.uri().path() == "/v1/tasks";
164 let limit = st.limits.request_timeout()
165 + if waits {
166 st.limits.max_wait()
167 } else {
168 Duration::ZERO
169 };
170 principal.forwarded = req.headers().contains_key(crate::auth::FORWARDED_HEADER);
171 req.extensions_mut().insert(principal);
172 match tokio::time::timeout(limit, next.run(req)).await {
173 Ok(r) => r,
174 Err(_) => ApiError(StatusCode::GATEWAY_TIMEOUT, "request timed out".into()).into_response(),
175 }
176}
177
178pub(crate) fn loopback_host<B>(req: &::http::Request<B>) -> bool {
181 let host = req
182 .headers()
183 .get(header::HOST)
184 .and_then(|v| v.to_str().ok())
185 .or_else(|| req.uri().authority().map(|a| a.as_str()));
186 let Some(host) = host else { return false };
187 let name = match host.strip_prefix('[') {
188 Some(v6) => match v6.split_once(']') {
189 Some((ip, _)) => ip,
190 None => return false,
191 },
192 None => host.rsplit_once(':').map_or(host, |(h, _)| h),
193 };
194 let name = name.trim_end_matches('.').to_ascii_lowercase();
195 name == "localhost"
196 || name.ends_with(".localhost")
197 || name
198 .parse::<std::net::IpAddr>()
199 .is_ok_and(|ip| ip.is_loopback())
200}
201
202fn gated(router: Router<St>, st: &St, role: Role) -> Router<St> {
203 router.route_layer(from_fn_with_state((st.clone(), role), gate))
204}
205
206pub fn router(st: St) -> Router {
208 let api = Router::new()
209 .route("/v1/tasks", post(submit).get(list_tasks))
210 .route("/v1/tasks:batch", post(submit_batch))
211 .route("/v1/tasks/{id}", get(get_by_id).delete(cancel_by_id))
212 .route("/v1/tasks/{id}/result", get(result_by_id))
213 .route(
214 "/v1/tasks/by-key/{key}",
215 get(get_by_key).delete(cancel_by_key),
216 )
217 .route("/v1/tasks/by-key/{key}/result", get(result_by_key));
218 let metrics = Router::new()
219 .route("/v1/snapshot", get(snapshot))
220 .route("/v1/workers", get(list_workers))
221 .route("/v1/recurring", get(list_recurring))
222 .route("/metrics", get(metrics));
223 let body_limit = st.limits.max_http_body_bytes;
224 gated(api, &st, Role::Client)
225 .merge(gated(metrics, &st, Role::Metrics))
226 .merge(health_routes())
227 .fallback(|| async { ApiError(StatusCode::NOT_FOUND, "no such route".into()) })
228 .method_not_allowed_fallback(|| async {
229 ApiError(StatusCode::METHOD_NOT_ALLOWED, "method not allowed".into())
230 })
231 .layer(DefaultBodyLimit::max(body_limit))
232 .with_state(st)
233}
234
235pub fn metrics_router(st: St) -> Router {
237 Router::new()
238 .route("/metrics", get(metrics))
239 .merge(health_routes())
240 .with_state(st)
241}
242
243fn health_routes() -> Router<St> {
244 Router::new()
245 .route("/healthz", get(|| async { "ok" }))
246 .route(
247 "/readyz",
248 get(|State(st): State<St>| async move {
249 if st.app().is_some() {
250 (StatusCode::OK, "ready")
251 } else {
252 (StatusCode::SERVICE_UNAVAILABLE, "standby")
253 }
254 }),
255 )
256}
257
258pub async fn serve(
261 listener: TcpListener,
262 router: Router,
263 tls: Option<tokio_rustls::TlsAcceptor>,
264 st: St,
265 stop: CancellationToken,
266 drain: Duration,
267) {
268 let mut builder = auto::Builder::new(TokioExecutor::new());
269 builder
270 .http1()
271 .timer(TokioTimer::new())
272 .header_read_timeout(Duration::from_secs(10));
273 builder
274 .http2()
275 .timer(TokioTimer::new())
276 .max_concurrent_streams(st.limits.max_concurrent_streams)
277 .keep_alive_interval(Some(st.limits.keepalive_interval()))
278 .keep_alive_timeout(st.limits.keepalive_timeout());
279 let graceful = hyper_util::server::graceful::GracefulShutdown::new();
280 let conns = Arc::new(tokio::sync::Semaphore::new(
281 st.limits.max_concurrent_requests.max(1),
282 ));
283 loop {
284 let (tcp, peer) = tokio::select! {
285 r = listener.accept() => match r {
286 Ok(c) => c,
287 Err(e) => {
288 tracing::debug!(error = %e, "accept failed");
289 tokio::time::sleep(Duration::from_millis(50)).await;
290 continue;
291 }
292 },
293 _ = stop.cancelled() => break,
294 };
295 let Ok(permit) = conns.clone().try_acquire_owned() else {
296 tracing::warn!(%peer, "too many HTTP connections; refusing");
297 continue;
298 };
299 let _ = tcp.set_nodelay(true);
300 let tcp = crate::standby::CloseOnStandby::new(tcp, st.clone());
301 let (router, tls, st, builder) = (router.clone(), tls.clone(), st.clone(), builder.clone());
302 let watcher = graceful.watcher();
303 tokio::spawn(async move {
304 let _permit = permit;
305 match tls {
306 None => {
307 let svc = service(router, ConnInfo::default());
308 let conn = builder
309 .serve_connection_with_upgrades(TokioIo::new(tcp), svc)
310 .into_owned();
311 let _ = watcher.watch(conn).await;
312 }
313 Some(acceptor) => {
314 let stream =
315 match tokio::time::timeout(Duration::from_secs(10), acceptor.accept(tcp))
316 .await
317 {
318 Ok(Ok(s)) => s,
319 Ok(Err(e)) => {
320 tracing::debug!(%peer, error = %e, "TLS handshake failed");
321 return;
322 }
323 Err(_) => return,
324 };
325 let cert = stream
326 .get_ref()
327 .1
328 .peer_certificates()
329 .and_then(|c| c.first())
330 .and_then(|c| st.auth.principal_for_cert(c.as_ref()));
331 let svc = service(router, ConnInfo { cert });
332 let conn = builder
333 .serve_connection_with_upgrades(TokioIo::new(stream), svc)
334 .into_owned();
335 let _ = watcher.watch(conn).await;
336 }
337 }
338 });
339 }
340 drop(listener);
341 if tokio::time::timeout(drain, graceful.shutdown())
342 .await
343 .is_err()
344 {
345 tracing::debug!("HTTP connections did not drain in time");
346 }
347}
348
349fn service(
350 router: Router,
351 info: ConnInfo,
352) -> TowerToHyperService<
353 impl tower::Service<
354 hyper::Request<hyper::body::Incoming>,
355 Response = Response,
356 Error = Infallible,
357 Future = impl Send + 'static,
358 > + Clone
359 + Send
360 + 'static,
361> {
362 TowerToHyperService::new(router.map_request(
363 move |mut r: hyper::Request<hyper::body::Incoming>| {
364 r.extensions_mut().insert(info.clone());
365 r.map(axum::body::Body::new)
366 },
367 ))
368}
369
370#[derive(Deserialize, Default)]
375#[serde(deny_unknown_fields)]
376pub struct TaskJson {
377 payload: Option<Value>,
379 payload_b64: Option<String>,
381 content_type: Option<String>,
382 kind: Option<String>,
383 pool: Option<String>,
384 priority: Option<String>,
385 key: Option<String>,
386 deadline_in_s: Option<f64>,
387 deadline_unix_ns: Option<i64>,
388 start_in_s: Option<f64>,
389 start_unix_ns: Option<i64>,
390 tolerance_s: Option<f64>,
391 on_miss: Option<String>,
392 duration_hint_s: Option<f64>,
393 timeout_s: Option<f64>,
394 retry: Option<RetryJson>,
395 #[serde(default)]
397 after: Vec<Value>,
398 #[serde(default)]
399 deps: Vec<DepJson>,
400 #[serde(default)]
401 resources: HashMap<String, u32>,
402 #[serde(default)]
403 tags: HashMap<String, String>,
404 #[serde(default)]
406 labels: HashMap<String, String>,
407 tenant: Option<String>,
409 #[serde(default)]
411 wait: bool,
412 wait_timeout_s: Option<f64>,
414}
415
416#[derive(Deserialize)]
417#[serde(deny_unknown_fields)]
418struct RetryJson {
419 max_attempts: u32,
420 #[serde(default)]
421 initial_backoff_s: f64,
422 #[serde(default)]
423 multiplier: f64,
424 #[serde(default)]
425 max_backoff_s: f64,
426 #[serde(default = "yes")]
427 jitter: bool,
428}
429
430fn yes() -> bool {
431 true
432}
433
434#[derive(Deserialize)]
435#[serde(deny_unknown_fields)]
436struct DepJson {
437 id: Option<Value>,
439 key: Option<String>,
440 #[serde(default)]
441 cond: Option<String>,
442}
443
444fn json_id(v: &Value) -> Result<u64, ApiError> {
445 match v {
446 Value::Number(n) => n.as_u64().ok_or_else(|| bad("invalid id")),
447 Value::String(s) => s.parse().map_err(|_| bad(format!("invalid id '{s}'"))),
448 _ => Err(bad("id must be a string or number")),
449 }
450}
451
452fn id_ref(id: u64) -> pb::TaskRef {
453 pb::TaskRef {
454 target: Some(pb::task_ref::Target::Id(id)),
455 }
456}
457
458fn key_ref(k: String) -> pb::TaskRef {
459 pb::TaskRef {
460 target: Some(pb::task_ref::Target::Key(k)),
461 }
462}
463
464impl TaskJson {
465 fn into_proto(self) -> Result<(pb::TaskSpec, bool, Option<f64>), ApiError> {
466 let (payload, default_ct) = match (self.payload, self.payload_b64) {
467 (Some(_), Some(_)) => return Err(bad("use either payload or payload_b64")),
468 (Some(v), None) => (
469 serde_json::to_vec(&v).map_err(|e| bad(e.to_string()))?,
470 "application/json",
471 ),
472 (None, Some(b)) => (
473 B64.decode(b)
474 .map_err(|e| bad(format!("payload_b64: {e}")))?,
475 "application/octet-stream",
476 ),
477 (None, None) => (Vec::new(), ""),
478 };
479 let priority = match self.priority.as_deref() {
480 None | Some("normal") => pb::Priority::Normal,
481 Some("critical") => pb::Priority::Critical,
482 Some("high") => pb::Priority::High,
483 Some("low") => pb::Priority::Low,
484 Some(p) => {
485 return Err(bad(format!(
486 "unknown priority '{p}' (critical, high, normal, low)"
487 )));
488 }
489 };
490 let on_miss = match self.on_miss.as_deref() {
491 None | Some("run_late") => pb::MissPolicy::RunLate,
492 Some("skip") => pb::MissPolicy::Skip,
493 Some("fail") => pb::MissPolicy::Fail,
494 Some(p) => return Err(bad(format!("unknown on_miss '{p}' (run_late, skip, fail)"))),
495 };
496 let mut deps = Vec::new();
497 for a in &self.after {
498 let target = match a {
499 Value::String(k) => key_ref(k.clone()),
500 Value::Number(_) => id_ref(json_id(a)?),
501 Value::Object(o) => match o.get("id") {
502 Some(id) if o.len() == 1 => id_ref(json_id(id)?),
503 _ => return Err(bad("`after` objects must be {\"id\": \"...\"}")),
504 },
505 _ => return Err(bad("`after` entries must be keys (strings) or ids")),
506 };
507 deps.push(pb::Dep {
508 target: Some(target),
509 cond: pb::EdgeCond::OnSuccess as i32,
510 });
511 }
512 for d in self.deps {
513 let target = match (d.id, d.key) {
514 (Some(id), None) => id_ref(json_id(&id)?),
515 (None, Some(k)) => key_ref(k),
516 _ => return Err(bad("dependency needs exactly one of id or key")),
517 };
518 let cond = match d.cond.as_deref() {
519 None | Some("on_success") => pb::EdgeCond::OnSuccess,
520 Some("on_complete") => pb::EdgeCond::OnComplete,
521 Some("on_failure") => pb::EdgeCond::OnFailure,
522 Some(c) => {
523 return Err(bad(format!(
524 "unknown cond '{c}' (on_success, on_complete, on_failure)"
525 )));
526 }
527 };
528 deps.push(pb::Dep {
529 target: Some(target),
530 cond: cond as i32,
531 });
532 }
533 let spec = pb::TaskSpec {
534 payload,
535 content_type: self.content_type.unwrap_or_else(|| default_ct.into()),
536 kind: self.kind.unwrap_or_default(),
537 pool: self.pool.unwrap_or_default(),
538 priority: priority as i32,
539 key: self.key.unwrap_or_default(),
540 deadline_in_s: self.deadline_in_s,
541 deadline_unix_ns: self.deadline_unix_ns,
542 start_in_s: self.start_in_s,
543 start_unix_ns: self.start_unix_ns,
544 tolerance_s: self.tolerance_s,
545 on_miss: on_miss as i32,
546 duration_hint_s: self.duration_hint_s,
547 timeout_s: self.timeout_s,
548 retry: self.retry.map(|r| pb::RetryPolicy {
549 max_attempts: r.max_attempts,
550 initial_backoff_s: r.initial_backoff_s,
551 multiplier: r.multiplier,
552 max_backoff_s: r.max_backoff_s,
553 jitter: r.jitter,
554 }),
555 deps,
556 resources: self.resources,
557 tags: self.tags,
558 labels: self.labels,
559 tenant: self.tenant.unwrap_or_default(),
560 };
561 Ok((spec, self.wait, self.wait_timeout_s))
562 }
563}
564
565pub fn status_name(s: i32) -> &'static str {
567 match pb::TaskStatus::try_from(s).unwrap_or(pb::TaskStatus::Unspecified) {
568 pb::TaskStatus::Unspecified => "unknown",
569 pb::TaskStatus::Blocked => "blocked",
570 pb::TaskStatus::Scheduled => "scheduled",
571 pb::TaskStatus::Ready => "ready",
572 pb::TaskStatus::Running => "running",
573 pb::TaskStatus::Retrying => "retrying",
574 pb::TaskStatus::Succeeded => "succeeded",
575 pb::TaskStatus::Failed => "failed",
576 pb::TaskStatus::DeadLettered => "dead_lettered",
577 pb::TaskStatus::Cancelled => "cancelled",
578 pb::TaskStatus::Skipped => "skipped",
579 pb::TaskStatus::Missed => "missed",
580 pb::TaskStatus::Shed => "shed",
581 }
582}
583
584fn result_json(r: &pb::TaskResult) -> Value {
585 let mut v = json!({
586 "id": r.id.to_string(),
587 "status": status_name(r.status),
588 "output_available": r.output_available,
589 });
590 if !r.message.is_empty() {
591 v["message"] = r.message.clone().into();
592 }
593 if let Some(out) = &r.output {
594 v["output_b64"] = B64.encode(out).into();
595 if let Ok(j) = serde_json::from_slice::<Value>(out) {
596 v["output"] = j;
597 }
598 }
599 v
600}
601
602fn info_json(i: &pb::TaskInfo) -> Value {
603 let mut v = json!({
604 "id": i.id.to_string(),
605 "status": status_name(i.status),
606 "pool": i.pool,
607 "attempt": i.attempt,
608 "output_available": i.output_available,
609 });
610 if !i.key.is_empty() {
611 v["key"] = i.key.clone().into();
612 }
613 if !i.kind.is_empty() {
614 v["kind"] = i.kind.clone().into();
615 }
616 if i.max_attempts > 0 {
617 v["max_attempts"] = i.max_attempts.into();
618 }
619 for (name, t) in [
620 ("created_unix_ns", i.created_unix_ns),
621 ("started_unix_ns", i.started_unix_ns),
622 ("finished_unix_ns", i.finished_unix_ns),
623 ] {
624 if let Some(t) = t {
625 v[name] = t.to_string().into();
626 }
627 }
628 if !i.message.is_empty() {
629 v["message"] = i.message.clone().into();
630 }
631 if !i.tenant.is_empty() {
632 v["tenant"] = i.tenant.clone().into();
633 }
634 if let Some(p) = priority_name(i.priority) {
635 v["priority"] = p.into();
636 }
637 v
638}
639
640fn priority_name(p: i32) -> Option<&'static str> {
642 match pb::Priority::try_from(p).ok()? {
643 pb::Priority::Unspecified => None,
644 pb::Priority::Critical => Some("critical"),
645 pb::Priority::High => Some("high"),
646 pb::Priority::Normal => Some("normal"),
647 pb::Priority::Low => Some("low"),
648 }
649}
650
651fn overlap_name(o: i32) -> &'static str {
652 match pb::Overlap::try_from(o).unwrap_or(pb::Overlap::Unspecified) {
653 pb::Overlap::Unspecified | pb::Overlap::Skip => "skip",
654 pb::Overlap::Allow => "allow",
655 pb::Overlap::Queue => "queue",
656 pb::Overlap::QueueOnSuccess => "queue_on_success",
657 }
658}
659
660fn parse_id(s: &str) -> Result<TaskRef, ApiError> {
661 s.parse::<u64>()
662 .map(|id| TaskRef::Id(TaskId(id)))
663 .map_err(|_| {
664 bad(format!(
665 "invalid task id '{s}' (use /v1/tasks/by-key/{{key}} for keys)"
666 ))
667 })
668}
669
670fn key(app: &AppState, k: String) -> Result<TaskRef, ApiError> {
671 Ok(crate::convert::task_ref(Some(key_ref(k)), &app.limits)?)
672}
673
674async fn submit(
678 State(st): State<St>,
679 Extension(p): Extension<Principal>,
680 ApiJson(body): ApiJson<TaskJson>,
681) -> Result<Json<Value>, ApiError> {
682 let app = app(&st)?;
683 let (spec, wait, wait_timeout) = body.into_proto()?;
684 let wait = if wait {
685 Some(grpc::wait_bound(wait_timeout, &app.limits)?)
686 } else {
687 None
688 };
689 let r = crate::route::submit(app, &p, spec, wait, &st.stop).await?;
690 let mut v = json!({ "id": r.id.to_string(), "duplicate": r.duplicate });
691 if let Some(res) = &r.result {
692 v["result"] = result_json(res);
693 }
694 Ok(Json(v))
695}
696
697#[derive(Deserialize)]
698#[serde(deny_unknown_fields)]
699struct BatchJson {
700 tasks: Vec<TaskJson>,
701}
702
703async fn submit_batch(
704 State(st): State<St>,
705 Extension(p): Extension<Principal>,
706 ApiJson(body): ApiJson<BatchJson>,
707) -> Result<Json<Value>, ApiError> {
708 let app = app(&st)?;
709 let max = app.limits.max_tasks_per_batch;
710 if body.tasks.len() > max {
711 return Err(bad(format!("at most {max} tasks per batch")));
712 }
713 let specs = body
714 .tasks
715 .into_iter()
716 .enumerate()
717 .map(|(i, t)| {
718 let (spec, wait, _) = t.into_proto()?;
719 if wait {
720 return Err(bad(format!(
721 "tasks[{i}]: `wait` is not supported in batches; wait on each id"
722 )));
723 }
724 Ok(spec)
725 })
726 .collect::<Result<Vec<_>, ApiError>>()?;
727 let r = crate::route::submit_batch(app, &p, specs).await?;
728 Ok(Json(json!({
729 "tasks": r.tasks.iter().map(|t| json!({"id": t.id.to_string(), "duplicate": t.duplicate})).collect::<Vec<_>>()
730 })))
731}
732
733async fn get_task(st: &Shared, p: &Principal, target: TaskRef) -> Result<Json<Value>, ApiError> {
734 let app = app(st)?;
735 let (info, missing) = crate::route::get(app, p, target).await?;
736 let mut v = info_json(&info);
737 note_missing(&mut v, &missing);
738 Ok(Json(v))
739}
740
741async fn get_by_id(
742 State(st): State<St>,
743 Extension(p): Extension<Principal>,
744 Path(id): Path<String>,
745) -> Result<Json<Value>, ApiError> {
746 get_task(&st, &p, parse_id(&id)?).await
747}
748
749async fn get_by_key(
750 State(st): State<St>,
751 Extension(p): Extension<Principal>,
752 Path(k): Path<String>,
753) -> Result<Json<Value>, ApiError> {
754 let target = key(app(&st)?, k)?;
755 get_task(&st, &p, target).await
756}
757
758async fn cancel_task(st: &Shared, p: &Principal, target: TaskRef) -> Result<Json<Value>, ApiError> {
759 let app = app(st)?;
760 Ok(Json(
761 json!({ "cancelled": crate::route::cancel(app, p, target).await? }),
762 ))
763}
764
765async fn cancel_by_id(
766 State(st): State<St>,
767 Extension(p): Extension<Principal>,
768 Path(id): Path<String>,
769) -> Result<Json<Value>, ApiError> {
770 cancel_task(&st, &p, parse_id(&id)?).await
771}
772
773async fn cancel_by_key(
774 State(st): State<St>,
775 Extension(p): Extension<Principal>,
776 Path(k): Path<String>,
777) -> Result<Json<Value>, ApiError> {
778 let target = key(app(&st)?, k)?;
779 cancel_task(&st, &p, target).await
780}
781
782#[derive(Deserialize)]
783#[serde(deny_unknown_fields)]
784struct WaitQuery {
785 timeout_s: Option<f64>,
786}
787
788async fn wait_task(
789 st: &Shared,
790 p: &Principal,
791 target: TaskRef,
792 q: WaitQuery,
793) -> Result<Json<Value>, ApiError> {
794 let app = app(st)?;
795 let timeout = grpc::wait_bound(q.timeout_s, &app.limits)?;
796 let (result, missing) = crate::route::wait(app, p, target, timeout, &st.stop).await?;
797 let mut v = result_json(&result);
798 note_missing(&mut v, &missing);
799 Ok(Json(v))
800}
801
802fn query(
803 q: Result<Query<WaitQuery>, axum::extract::rejection::QueryRejection>,
804) -> Result<WaitQuery, ApiError> {
805 q.map(|Query(q)| q).map_err(|e| bad(e.body_text()))
806}
807
808async fn result_by_id(
809 State(st): State<St>,
810 Extension(p): Extension<Principal>,
811 Path(id): Path<String>,
812 q: Result<Query<WaitQuery>, axum::extract::rejection::QueryRejection>,
813) -> Result<Json<Value>, ApiError> {
814 wait_task(&st, &p, parse_id(&id)?, query(q)?).await
815}
816
817async fn result_by_key(
818 State(st): State<St>,
819 Extension(p): Extension<Principal>,
820 Path(k): Path<String>,
821 q: Result<Query<WaitQuery>, axum::extract::rejection::QueryRejection>,
822) -> Result<Json<Value>, ApiError> {
823 let target = key(app(&st)?, k)?;
824 wait_task(&st, &p, target, query(q)?).await
825}
826
827#[derive(Deserialize)]
828#[serde(deny_unknown_fields)]
829struct ListQuery {
830 pool: Option<String>,
832 status: Option<String>,
834 after: Option<String>,
835 limit: Option<u32>,
836}
837
838fn csv(s: Option<&str>) -> impl Iterator<Item = &str> {
839 s.unwrap_or_default()
840 .split(',')
841 .map(str::trim)
842 .filter(|p| !p.is_empty())
843}
844
845async fn list_tasks(
846 State(st): State<St>,
847 Extension(p): Extension<Principal>,
848 q: Result<Query<ListQuery>, axum::extract::rejection::QueryRejection>,
849) -> Result<Json<Value>, ApiError> {
850 let Query(q) = q.map_err(|e| bad(e.body_text()))?;
851 let mut statuses = Vec::new();
852 for name in csv(q.status.as_deref()) {
853 let s =
854 TaskStatus::from_name(name).ok_or_else(|| bad(format!("unknown status '{name}'")))?;
855 statuses.push(crate::convert::status(s) as i32);
856 }
857 let after = match q.after.as_deref() {
858 None | Some("") => 0,
859 Some(a) => a
860 .parse::<u64>()
861 .map_err(|_| bad(format!("invalid task id '{a}' in after")))?,
862 };
863 let req = pb::ListTasksRequest {
864 pools: csv(q.pool.as_deref()).map(str::to_string).collect(),
865 statuses,
866 after,
867 limit: q.limit.unwrap_or(0),
868 };
869 let r = crate::route::list_tasks(app(&st)?, &p, req).await?;
870 let mut v = json!({ "tasks": r.tasks.iter().map(info_json).collect::<Vec<_>>() });
871 note_missing(&mut v, &r.unavailable_shards);
872 if r.next_after > 0 {
873 v["next_after"] = r.next_after.to_string().into();
874 }
875 Ok(Json(v))
876}
877
878async fn list_workers(
879 State(st): State<St>,
880 Extension(p): Extension<Principal>,
881) -> Result<Json<Value>, ApiError> {
882 let r = crate::route::list_workers(app(&st)?, &p).await?;
883 let workers: Vec<Value> = r
884 .workers
885 .iter()
886 .map(|w| {
887 json!({
888 "id": w.id.to_string(), "name": w.name, "pool": w.pool, "labels": w.labels,
889 "slots": w.slots, "busy": w.busy,
890 "connected_unix_ns": w.connected_unix_ns.to_string(),
891 })
892 })
893 .collect();
894 let mut v = json!({ "workers": workers });
895 note_missing(&mut v, &r.unavailable_shards);
896 Ok(Json(v))
897}
898
899async fn list_recurring(
900 State(st): State<St>,
901 Extension(p): Extension<Principal>,
902) -> Result<Json<Value>, ApiError> {
903 let r = crate::route::list_recurring(app(&st)?, &p).await?;
904 let schedules: Vec<Value> = r
905 .schedules
906 .iter()
907 .map(|s| {
908 let mut v = json!({
909 "name": s.name, "cron": s.cron, "pool": s.pool, "kind": s.kind,
910 "overlap": overlap_name(s.overlap),
911 });
912 if !s.tz.is_empty() {
913 v["tz"] = s.tz.clone().into();
914 }
915 if let Some(t) = s.next_unix_ns {
916 v["next_unix_ns"] = t.to_string().into();
917 }
918 if let Some(id) = s.last_task_id {
919 v["last_task_id"] = id.to_string().into();
920 }
921 v
922 })
923 .collect();
924 let mut v = json!({ "schedules": schedules });
925 note_missing(&mut v, &r.unavailable_shards);
926 Ok(Json(v))
927}
928
929fn note_missing(v: &mut Value, shards: &[String]) {
931 if !shards.is_empty() {
932 v["unavailable_shards"] = shards.into();
933 }
934}
935
936async fn snapshot(
937 State(st): State<St>,
938 Extension(p): Extension<Principal>,
939) -> Result<Json<Value>, ApiError> {
940 let s = crate::route::snapshot(app(&st)?, &p).await?;
941 let pools: Vec<Value> = s
942 .pools
943 .iter()
944 .map(|p| {
945 json!({
946 "name": p.name, "capacity": p.capacity, "busy": p.busy, "held": p.held,
947 "desired": p.desired, "pending": p.pending_by_class.iter().sum::<u64>(),
948 "pending_by_class": p.pending_by_class, "reservations": p.reservations,
949 "dispatched": p.dispatched, "succeeded": p.succeeded, "failed": p.failed,
950 "priority_adherence": p.priority_adherence, "fixed_start_hit_rate": p.fixed_start_hit_rate,
951 "utilization": p.utilization, "schedule_efficiency": p.schedule_efficiency,
952 "wait_p50_s": p.wait_p50_s, "wait_p90_s": p.wait_p90_s, "wait_p99_s": p.wait_p99_s,
953 "cost": p.cost, "workers_connected": p.workers_connected,
954 "groups": p.groups.iter().map(|g| json!({
955 "labels": g.labels, "capacity": g.capacity, "busy": g.busy,
956 })).collect::<Vec<_>>(),
957 })
958 })
959 .collect();
960 let t = s.totals.unwrap_or_default();
961 Ok(Json(json!({
962 "pools": pools, "live_tasks": s.live_tasks, "running": s.running, "pending": s.pending,
963 "workers_connected": s.workers_connected, "unavailable_shards": s.unavailable_shards,
964 "totals": {
965 "submitted": t.submitted, "dispatched": t.dispatched, "succeeded": t.succeeded,
966 "failed": t.failed, "dead_lettered": t.dead_lettered, "cancelled": t.cancelled,
967 "skipped": t.skipped, "missed": t.missed, "shed": t.shed, "retries": t.retries,
968 "timeouts": t.timeouts, "lease_expiries": t.lease_expiries,
969 "stale_completions": t.stale_completions,
970 },
971 })))
972}
973
974async fn metrics(State(st): State<St>) -> Result<Response, ApiError> {
975 let s = grpc::snapshot(app(&st)?).await?;
976 Ok((
977 [(header::CONTENT_TYPE, crate::metrics::CONTENT_TYPE)],
978 crate::metrics::render(&s),
979 )
980 .into_response())
981}