/* * Author: Ruben Fiszel * Copyright: Windmill Labs, Inc 2022 * This file and its contents are licensed under the AGPLv3 License. * Please see the included NOTICE for copyright information and * LICENSE-AGPL for a copy of the license. */ use anyhow::Context; use axum::{ extract::{FromRequest, Json, Path, Query}, response::{IntoResponse, Response}, routing::{get, post}, Extension, Router, }; use base64::Engine; use hmac::Mac; use hyper::{HeaderMap, Request, StatusCode}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use sql_builder::{prelude::*, quote, SqlBuilder}; use sqlx::{query_scalar, types::Uuid, FromRow, Postgres, Transaction}; use urlencoding::encode; use windmill_audit::{audit_log, ActionKind}; use windmill_common::{ error::{self, to_anyhow, Error}, flow_status::{Approval, FlowStatus, FlowStatusModule}, flows::FlowValue, oauth2::HmacSha256, scripts::{ScriptHash, ScriptLang}, users::username_to_permissioned_as, utils::{not_found_if_none, now_from_db, paginate, require_admin, Pagination, StripPath}, }; use windmill_queue::{get_queued_job, push, JobKind, JobPayload, QueuedJob, RawCode}; use crate::{ db::{UserDB, DB}, users::{require_owner_of_path, Authed}, variables::get_workspace_key, BASE_URL, }; pub fn workspaced_service() -> Router { Router::new() .route("/run/f/*script_path", post(run_flow_by_path)) .route("/run/p/*script_path", post(run_job_by_path)) .route( "/run_wait_result/p/*script_path", post(run_wait_result_job_by_path), ) .route( "/run_wait_result/h/:hash", post(run_wait_result_job_by_hash), ) .route( "/run_wait_result/f/*script_path", post(run_wait_result_flow_by_path), ) .route("/run/h/:hash", post(run_job_by_hash)) .route("/run/preview", post(run_preview_job)) .route("/run/preview_flow", post(run_preview_flow_job)) .route("/list", get(list_jobs)) .route("/queue/list", get(list_queue_jobs)) .route("/queue/cancel/:id", post(cancel_job_api)) .route("/completed/list", get(list_completed_jobs)) .route("/completed/get/:id", get(get_completed_job)) .route("/completed/get_result/:id", get(get_completed_job_result)) .route("/completed/delete/:id", post(delete_completed_job)) .route("/flow/resume/:id", post(resume_suspended_flow_as_owner)) .route( "/job_signature/:job_id/:resume_id", get(create_job_signature), ) .route("/resume_urls/:job_id/:resume_id", get(get_resume_urls)) .route("/result_by_id/:job_id/:node_id", get(get_result_by_id)) } pub fn global_service() -> Router { Router::new() .route( "/resume/:job_id/:resume_id/:secret", get(resume_suspended_job), ) .route( "/resume/:job_id/:resume_id/:secret", post(resume_suspended_job), ) .route( "/cancel/:job_id/:resume_id/:secret", get(cancel_suspended_job), ) .route( "/cancel/:job_id/:resume_id/:secret", post(cancel_suspended_job), ) .route( "/get_flow/:job_id/:resume_id/:secret", get(get_suspended_job_flow), ) .route("/get/:id", get(get_job)) .route("/getupdate/:id", get(get_job_update)) } async fn get_result_by_id( Extension(db): Extension, Query(ResultByIdQuery { skip_direct }): Query, Path((w_id, flow_id, node_id)): Path<(String, String, String)>, ) -> windmill_common::error::JsonResult { let res = windmill_queue::get_result_by_id(db, skip_direct, w_id, flow_id, node_id).await?; Ok(Json(res)) } async fn cancel_job_api( authed: Authed, Extension(user_db): Extension, Path((w_id, id)): Path<(String, Uuid)>, Json(CancelJob { reason }): Json, ) -> error::Result { let tx = user_db.begin(&authed).await?; let (mut tx, job_option) = windmill_queue::cancel_job(&authed.username, reason, id, &w_id, tx).await?; if let Some(id) = job_option { audit_log( &mut tx, &authed.username, "jobs.cancel", ActionKind::Delete, &w_id, Some(&id.to_string()), None, ) .await?; tx.commit().await?; Ok(id.to_string()) } else { let (job_o, tx) = get_job_by_id(tx, &w_id, id).await?; tx.commit().await?; let err = match job_o { Some(Job::CompletedJob(_)) => { return Ok(format!("queued job id {} is already completed", id)) } _ => error::Error::NotFound(format!("queued job id {} does not exist", id)), }; Err(err) } } pub async fn get_path_for_hash<'c>( db: &mut Transaction<'c, Postgres>, w_id: &str, hash: i64, ) -> error::Result { let path = sqlx::query_scalar!( "select path from script where hash = $1 AND workspace_id = $2", hash, w_id ) .fetch_one(db) .await .map_err(|e| { Error::InternalErr(format!( "querying getting path for hash {hash} in {w_id}: {e}" )) })?; Ok(path) } async fn get_job( Extension(db): Extension, Path((w_id, id)): Path<(String, Uuid)>, ) -> error::JsonResult { let tx = db.begin().await?; let (job_o, tx) = get_job_by_id(tx, &w_id, id).await?; let job = not_found_if_none(job_o, "Job", id.to_string())?; tx.commit().await?; Ok(Json(job)) } #[derive(Deserialize)] pub struct ResultByIdQuery { pub skip_direct: bool, } pub async fn get_job_by_id<'c>( mut tx: Transaction<'c, Postgres>, w_id: &str, id: Uuid, ) -> error::Result<(Option, Transaction<'c, Postgres>)> { let cjob_option = sqlx::query_as::<_, CompletedJob>( "SELECT * FROM completed_job WHERE id = $1 AND workspace_id = $2", ) .bind(id) .bind(w_id) .fetch_optional(&mut tx) .await?; let job_option = match cjob_option { Some(job) => Some(Job::CompletedJob(job)), None => get_queued_job(id, w_id, &mut tx).await?.map(Job::QueuedJob), }; if job_option.is_some() { Ok((job_option, tx)) } else { // check if a job had been moved in-between queries let cjob_option = sqlx::query_as::<_, CompletedJob>( "SELECT * FROM completed_job WHERE id = $1 AND workspace_id = $2", ) .bind(id) .bind(w_id) .fetch_optional(&mut tx) .await?; Ok((cjob_option.map(Job::CompletedJob), tx)) } } #[derive(Debug, sqlx::FromRow, Serialize)] pub struct CompletedJob { pub workspace_id: String, pub id: Uuid, #[serde(skip_serializing_if = "Option::is_none")] pub parent_job: Option, pub created_by: String, pub created_at: chrono::DateTime, pub started_at: chrono::DateTime, pub duration_ms: i32, pub success: bool, #[serde(skip_serializing_if = "Option::is_none")] pub script_hash: Option, #[serde(skip_serializing_if = "Option::is_none")] pub script_path: Option, pub args: Option, #[serde(skip_serializing_if = "Option::is_none")] pub result: Option, #[serde(skip_serializing_if = "Option::is_none")] pub logs: Option, pub deleted: bool, #[serde(skip_serializing_if = "Option::is_none")] pub raw_code: Option, pub canceled: bool, #[serde(skip_serializing_if = "Option::is_none")] pub canceled_by: Option, #[serde(skip_serializing_if = "Option::is_none")] pub canceled_reason: Option, pub job_kind: JobKind, #[serde(skip_serializing_if = "Option::is_none")] pub schedule_path: Option, pub permissioned_as: String, #[serde(skip_serializing_if = "Option::is_none")] pub flow_status: Option, #[serde(skip_serializing_if = "Option::is_none")] pub raw_flow: Option, pub is_flow_step: bool, #[serde(skip_serializing_if = "Option::is_none")] pub language: Option, pub is_skipped: bool, pub email: String, pub visible_to_owner: bool, #[serde(skip_serializing_if = "Option::is_none")] pub mem_peak: Option, } #[derive(Deserialize, Clone)] pub struct RunJobQuery { scheduled_for: Option>, scheduled_in_secs: Option, parent_job: Option, include_header: Option, invisible_to_owner: Option, queue_limit: Option, } lazy_static::lazy_static! { static ref INCLUDE_HEADERS: Vec = std::env::var("INCLUDE_HEADERS") .ok().map(|x| x .split(',') .map(|s| s.to_string()) .collect()).unwrap_or_default(); } impl RunJobQuery { async fn get_scheduled_for<'c>( &self, db: &mut Transaction<'c, Postgres>, ) -> error::Result>> { if let Some(scheduled_for) = self.scheduled_for { Ok(Some(scheduled_for)) } else if let Some(scheduled_in_secs) = self.scheduled_in_secs { let now = now_from_db(db).await?; Ok(Some(now + chrono::Duration::seconds(scheduled_in_secs))) } else { Ok(None) } } fn add_include_headers( &self, headers: HeaderMap, mut args: serde_json::Map, ) -> serde_json::Map { let whitelist = self .include_header .as_ref() .map(|s| s.split(",").map(|s| s.to_string()).collect::>()) .unwrap_or_default(); whitelist .iter() .chain(INCLUDE_HEADERS.iter()) .for_each(|h| { if let Some(v) = headers.get(h) { args.insert( h.to_string().to_lowercase().replace('-', "_"), serde_json::Value::String(v.to_str().unwrap().to_string()), ); } }); args } } #[derive(Deserialize)] pub struct ListQueueQuery { pub script_path_start: Option, pub script_path_exact: Option, pub script_hash: Option, pub created_by: Option, pub created_before: Option>, pub created_after: Option>, pub running: Option, pub parent_job: Option, pub order_desc: Option, pub job_kinds: Option, pub suspended: Option, } fn list_queue_jobs_query(w_id: &str, lq: &ListQueueQuery, fields: &[&str]) -> SqlBuilder { let mut sqlb = SqlBuilder::select_from("queue") .fields(fields) .order_by("created_at", lq.order_desc.unwrap_or(true)) .limit(1000) .and_where_eq("workspace_id", "?".bind(&w_id)) .clone(); if let Some(ps) = &lq.script_path_start { sqlb.and_where_like_left("script_path", "?".bind(ps)); } if let Some(p) = &lq.script_path_exact { sqlb.and_where_eq("script_path", "?".bind(p)); } if let Some(h) = &lq.script_hash { sqlb.and_where_eq("script_hash", "?".bind(h)); } if let Some(cb) = &lq.created_by { sqlb.and_where_eq("created_by", "?".bind(cb)); } if let Some(r) = &lq.running { sqlb.and_where_eq("running", &r); } if let Some(pj) = &lq.parent_job { sqlb.and_where_eq("parent_job", "?".bind(pj)); } if let Some(dt) = &lq.created_before { sqlb.and_where_lt("created_at", format!("to_timestamp({})", dt.timestamp())); } if let Some(dt) = &lq.created_after { sqlb.and_where_gt("created_at", format!("to_timestamp({})", dt.timestamp())); } if let Some(s) = &lq.suspended { if *s { sqlb.and_where_gt("suspend", 0); } else { sqlb.and_where_eq("suspend", 0); } } if let Some(jk) = &lq.job_kinds { sqlb.and_where_in( "job_kind", &jk.split(',').into_iter().map(quote).collect::>(), ); } sqlb } #[derive(Serialize, FromRow)] struct ListableQueuedJob { pub id: Uuid, pub created_by: String, pub created_at: chrono::DateTime, pub started_at: Option>, pub scheduled_for: chrono::DateTime, pub script_hash: Option, pub script_path: Option, pub args: Option, pub job_kind: JobKind, pub schedule_path: Option, pub is_flow_step: bool, pub language: Option, pub email: String, pub suspend: Option, } async fn list_queue_jobs( Extension(db): Extension, Path(w_id): Path, Query(lq): Query, ) -> error::JsonResult> { let sql = list_queue_jobs_query( &w_id, &lq, &[ "id", "created_by", "created_at", "started_at", "scheduled_for", "script_hash", "script_path", "args", "job_kind", "schedule_path", "permissioned_as", "is_flow_step", "language", "same_worker", "email", "suspend", ], ) .sql()?; let jobs = sqlx::query_as::<_, ListableQueuedJob>(&sql) .fetch_all(&db) .await?; Ok(Json(jobs)) } async fn list_jobs( authed: Authed, Extension(user_db): Extension, Path(w_id): Path, Query(pagination): Query, Query(lq): Query, ) -> error::JsonResult> { // TODO: todo!("rewrite this to just run list_queue_jobs and list_completed_jobs separately and return as one"); let (per_page, offset) = paginate(pagination); let lqc = lq.clone(); let sqlq = list_queue_jobs_query( &w_id, &ListQueueQuery { script_path_start: lq.script_path_start, script_path_exact: lq.script_path_exact, script_hash: lq.script_hash, created_by: lq.created_by, created_before: lq.created_before, created_after: lq.created_after, running: None, parent_job: lq.parent_job, order_desc: Some(true), job_kinds: lq.job_kinds, suspended: lq.suspended, }, &[ "'QueuedJob' as typ", "id", "workspace_id", "parent_job", "created_by", "created_at", "started_at", "scheduled_for", "running", "script_hash", "script_path", "CASE WHEN pg_column_size(args) > 1000 THEN '\"too large args\"'::jsonb ELSE args END", "null as duration_ms", "null as success", "false as deleted", "canceled", "canceled_by", "job_kind", "schedule_path", "permissioned_as", "is_flow_step", "language", "false as is_skipped", "email", "visible_to_owner", "suspend", "mem_peak", ], ); let sqlc = list_completed_jobs_query( &w_id, per_page + offset, 0, &ListCompletedQuery { order_desc: Some(true), ..lqc }, &[ "'CompletedJob' as typ", "id", "workspace_id", "parent_job", "created_by", "created_at", "started_at", "null as scheduled_for", "null as running", "script_hash", "script_path", "CASE WHEN pg_column_size(args) > 1000 THEN '\"too large args\"'::jsonb ELSE args END", "duration_ms", "success", "deleted", "canceled", "canceled_by", "job_kind", "schedule_path", "permissioned_as", "is_flow_step", "language", "is_skipped", "email", "visible_to_owner", "null as suspend", "mem_peak", ], ); let sql = format!( "{} UNION ALL {} ORDER BY created_at DESC LIMIT {} OFFSET {};", &sqlq.subquery()?, &sqlc.subquery()?, per_page, offset ); let mut tx = user_db.begin(&authed).await?; let jobs: Vec = sqlx::query_as(&sql).fetch_all(&mut tx).await?; tx.commit().await?; Ok(Json(jobs.into_iter().map(From::from).collect())) } pub async fn resume_suspended_flow_as_owner( authed: Authed, Extension(db): Extension, Path((w_id, flow_id)): Path<(String, Uuid)>, QueryOrBody(value): QueryOrBody, ) -> error::Result { let value = value.unwrap_or(serde_json::Value::Null); let mut tx = db.begin().await?; let (flow, job_id) = get_suspended_flow_info(flow_id, &mut tx).await?; if !authed.is_admin { require_owner_of_path( &w_id, &authed.username, &authed.groups, &flow.script_path.clone().unwrap_or_else(|| String::new()), &db, ) .await?; } insert_resume_job(0, job_id, &flow, value, Some(authed.username), &mut tx).await?; resume_immediately_if_relevant(flow, job_id, &mut tx).await?; tx.commit().await?; Ok(StatusCode::CREATED) } pub async fn resume_suspended_job( /* unauthed */ Extension(db): Extension, Path((w_id, job_id, resume_id, secret)): Path<(String, Uuid, u32, String)>, Query(approver): Query, QueryOrBody(value): QueryOrBody, ) -> error::Result { let value = value.unwrap_or(serde_json::Value::Null); let mut tx = db.begin().await?; let key = get_workspace_key(&w_id, &mut tx).await?; let mut mac = HmacSha256::new_from_slice(key.as_bytes()).map_err(to_anyhow)?; mac.update(job_id.as_bytes()); mac.update(resume_id.to_be_bytes().as_ref()); if let Some(approver) = approver.approver.clone() { mac.update(approver.as_bytes()); } mac.verify_slice(hex::decode(secret)?.as_ref()) .map_err(|_| anyhow::anyhow!("Invalid signature"))?; let flow = get_suspended_prent_flow_info(job_id, &mut tx).await?; let exists = sqlx::query_scalar!( r#" SELECT EXISTS (SELECT 1 FROM resume_job WHERE id = $1) "#, Uuid::from_u128(job_id.as_u128() ^ resume_id as u128), ) .fetch_one(&mut tx) .await? .unwrap_or(false); if exists { return Err(anyhow::anyhow!("resume request already sent").into()); } insert_resume_job(resume_id, job_id, &flow, value, approver.approver, &mut tx).await?; resume_immediately_if_relevant(flow, job_id, &mut tx).await?; tx.commit().await?; Ok(StatusCode::CREATED) } /* If the flow is currently waiting to be resumed (`FlowStatusModule::WaitingForEvents`) * the suspend column must be set to the number of resume messages waited on. * * The flow's queue row is locked in this transaction because to avoid race conditions around * the suspend column. * That is, a job needs one event but it hasn't arrived, a worker counts zero events before * entering WaitingForEvents. Then this message arrives but the job isn't in WaitingForEvents * yet so the suspend counter isn't updated. Then the job enters WaitingForEvents expecting * one event to arrive based on the count that is no longer correct. */ async fn resume_immediately_if_relevant<'c>( flow: FlowInfo, job_id: Uuid, tx: &mut Transaction<'c, Postgres>, ) -> error::Result<()> { Ok( if let Some(suspend) = (0 < flow.suspend).then(|| flow.suspend - 1) { let status = serde_json::from_value::(flow.flow_status.context("no flow status")?) .context("deserialize flow status")?; if matches!(status.current_step(), Some(FlowStatusModule::WaitingForEvents { job, .. }) if job == &job_id) { sqlx::query!( "UPDATE queue SET suspend = $1 WHERE id = $2", suspend, flow.id, ) .execute(tx) .await?; } }, ) } async fn insert_resume_job<'c>( resume_id: u32, job_id: Uuid, flow: &FlowInfo, value: serde_json::Value, approver: Option, tx: &mut Transaction<'c, Postgres>, ) -> error::Result<()> { sqlx::query!( r#" INSERT INTO resume_job (id, resume_id, job, flow, value, approver) VALUES ($1, $2, $3, $4, $5, $6) "#, Uuid::from_u128(job_id.as_u128() ^ resume_id as u128), resume_id as i32, job_id, flow.id, value, approver ) .execute(tx) .await?; Ok(()) } #[derive(sqlx::FromRow)] struct FlowInfo { id: Uuid, flow_status: Option, suspend: i32, script_path: Option, } async fn get_suspended_prent_flow_info<'c>( job_id: Uuid, tx: &mut Transaction<'c, Postgres>, ) -> error::Result { let flow = sqlx::query_as!( FlowInfo, r#" SELECT id, flow_status, suspend, script_path FROM queue WHERE id = ( SELECT parent_job FROM queue WHERE id = $1 UNION ALL SELECT parent_job FROM completed_job WHERE id = $1) FOR UPDATE "#, job_id, ) .fetch_optional(tx) .await? .ok_or_else(|| anyhow::anyhow!("parent flow job not found"))?; Ok(flow) } async fn get_suspended_flow_info<'c>( job_id: Uuid, tx: &mut Transaction<'c, Postgres>, ) -> error::Result<(FlowInfo, Uuid)> { let flow = sqlx::query_as!( FlowInfo, r#" SELECT id, flow_status, suspend, script_path FROM queue WHERE id = $1 "#, job_id, ) .fetch_optional(tx) .await? .ok_or_else(|| anyhow::anyhow!("parent flow job not found"))?; let job_id = flow .flow_status .as_ref() .and_then(|v| serde_json::from_value::(v.clone()).ok()) .and_then(|s| match s.modules.get(s.step as usize) { Some(FlowStatusModule::WaitingForEvents { job, .. }) => Some(job.to_owned()), _ => None, }); if let Some(job_id) = job_id { Ok((flow, job_id)) } else { Err(anyhow::anyhow!("the flow is not in a suspended state anymore").into()) } } pub async fn cancel_suspended_job( /* unauthed */ Extension(db): Extension, Path((w_id, job, resume_id, secret)): Path<(String, Uuid, u32, String)>, Query(approver): Query, ) -> error::Result { let mut tx = db.begin().await?; let key = get_workspace_key(&w_id, &mut tx).await?; let mut mac = HmacSha256::new_from_slice(key.as_bytes()).map_err(to_anyhow)?; mac.update(job.as_bytes()); mac.update(resume_id.to_be_bytes().as_ref()); if let Some(approver) = approver.approver.clone() { mac.update(approver.as_bytes()); } mac.verify_slice(hex::decode(secret)?.as_ref()) .map_err(|_| anyhow::anyhow!("Invalid signature"))?; let whom = approver.approver.unwrap_or_else(|| "unknown".to_string()); let parent_flow = get_root_job(db, &w_id, job).await?; let (mut tx, job) = windmill_queue::cancel_job( &whom, Some("approval request disapproved".to_string()), parent_flow, &w_id, tx, ) .await?; if job.is_some() { audit_log( &mut tx, &whom, "jobs.disapproval", ActionKind::Delete, &w_id, Some(&parent_flow.to_string()), None, ) .await?; } tx.commit().await?; Ok("Flow of job cancelled".to_string()) } pub async fn get_root_job(db: DB, w_id: &str, job: Uuid) -> error::Result { let mut tx = db.begin().await?; let mut job_id = job; loop { let (job, ntx) = get_job_by_id(tx, w_id, job_id).await?; tx = ntx; let p_job = job.and_then(|x| match x { Job::QueuedJob(job) => job.parent_job, Job::CompletedJob(job) => job.parent_job, }); if let Some(p_job) = p_job { job_id = p_job; } else { return Ok(job_id); } } } #[derive(Serialize)] pub struct SuspendedJobFlow { pub job: Job, pub approvers: Vec, } #[derive(Deserialize)] pub struct QueryApprover { pub approver: Option, } pub async fn get_suspended_job_flow( /* unauthed */ Extension(db): Extension, Path((w_id, job, resume_id, secret)): Path<(String, Uuid, u32, String)>, Query(approver): Query, ) -> error::JsonResult { let mut tx = db.begin().await?; let key = get_workspace_key(&w_id, &mut tx).await?; let mut mac = HmacSha256::new_from_slice(key.as_bytes()).map_err(to_anyhow)?; mac.update(job.as_bytes()); mac.update(resume_id.to_be_bytes().as_ref()); if let Some(approver) = approver.approver { mac.update(approver.as_bytes()); } mac.verify_slice(hex::decode(secret)?.as_ref()) .map_err(|_| anyhow::anyhow!("Invalid signature"))?; let flow_id = sqlx::query_scalar!( r#" SELECT parent_job FROM queue WHERE id = $1 AND workspace_id = $2 UNION ALL SELECT parent_job FROM completed_job WHERE id = $1 AND workspace_id = $2 "#, job, w_id ) .fetch_optional(&mut tx) .await? .flatten() .ok_or_else(|| anyhow::anyhow!("parent flow job not found"))?; let (flow_o, mut tx) = get_job_by_id(tx, &w_id, flow_id).await?; let flow = not_found_if_none(flow_o, "Parent Flow", job.to_string())?; let flow_status = flow .flow_status() .ok_or_else(|| anyhow::anyhow!("unable to deserialize the flow"))?; let flow_module_status = flow_status .modules .iter() .find(|p| p.job() == Some(job)) .ok_or_else(|| anyhow::anyhow!("unable to find the module"))?; let approvers_from_status = match flow_module_status { FlowStatusModule::Success { approvers, .. } => approvers.to_owned(), _ => vec![], }; let approvers = if approvers_from_status.is_empty() { sqlx::query!( r#" SELECT resume_id, approver FROM resume_job WHERE job = $1 "#, job, ) .fetch_all(&mut tx) .await? .into_iter() .map(|x| Approval { resume_id: x.resume_id as u16, approver: x.approver.unwrap_or_else(|| "anonymous".to_string()), }) .collect() } else { approvers_from_status }; Ok(Json(SuspendedJobFlow { job: flow, approvers })) } pub async fn create_job_signature( authed: Authed, Extension(user_db): Extension, Path((w_id, job_id, resume_id)): Path<(String, Uuid, u32)>, Query(approver): Query, ) -> error::Result { let key = get_workspace_key(&w_id, &mut user_db.begin(&authed).await?).await?; create_signature(key, job_id, resume_id, approver.approver) } fn create_signature( key: String, job_id: Uuid, resume_id: u32, approver: Option, ) -> Result { let mut mac = HmacSha256::new_from_slice(key.as_bytes()).map_err(to_anyhow)?; mac.update(job_id.as_bytes()); mac.update(resume_id.to_be_bytes().as_ref()); if let Some(approver) = approver { mac.update(approver.as_bytes()); } Ok(hex::encode(mac.finalize().into_bytes())) } #[allow(non_snake_case)] #[derive(Serialize)] pub struct ResumeUrls { approvalPage: String, cancel: String, resume: String, } fn build_resume_url( op: &str, w_id: &str, job_id: &Uuid, resume_id: &u32, signature: &str, approver: &str, base_url: &str, ) -> String { format!("{base_url}/api/w/{w_id}/jobs_u/{op}/{job_id}/{resume_id}/{signature}{approver}") } pub async fn get_resume_urls( authed: Authed, Extension(user_db): Extension, Path((w_id, job_id, resume_id)): Path<(String, Uuid, u32)>, Query(approver): Query, ) -> error::JsonResult { let key = get_workspace_key(&w_id, &mut user_db.begin(&authed).await?).await?; let signature = create_signature(key, job_id, resume_id, approver.approver.clone())?; let approver = approver .approver .as_ref() .map(|x| format!("?approver={}", encode(x))) .unwrap_or_else(String::new); let base_url = BASE_URL.as_str(); let res = ResumeUrls { approvalPage: format!( "{base_url}/approve/{w_id}/{job_id}/{resume_id}/{signature}{approver}" ), cancel: build_resume_url( "cancel", &w_id, &job_id, &resume_id, &signature, &approver, &base_url, ), resume: build_resume_url( "resume", &w_id, &job_id, &resume_id, &signature, &approver, &base_url, ), }; Ok(Json(res)) } #[derive(Serialize, Debug)] #[serde(tag = "type")] pub enum Job { QueuedJob(QueuedJob), CompletedJob(CompletedJob), } impl Job { pub fn raw_flow(&self) -> Option { let value = match self { Job::QueuedJob(job) => job.raw_flow.clone(), Job::CompletedJob(job) => job.raw_flow.clone(), }; value.map(|v| serde_json::from_value(v).ok()).flatten() } pub fn flow_status(&self) -> Option { let value = match self { Job::QueuedJob(job) => job.flow_status.clone(), Job::CompletedJob(job) => job.flow_status.clone(), }; value.map(|v| serde_json::from_value(v).ok()).flatten() } } #[derive(sqlx::FromRow)] struct UnifiedJob { workspace_id: String, typ: String, id: Uuid, parent_job: Option, created_by: String, created_at: chrono::DateTime, started_at: Option>, scheduled_for: Option>, running: Option, script_hash: Option, script_path: Option, args: Option, duration_ms: Option, success: Option, deleted: bool, canceled: bool, canceled_by: Option, job_kind: JobKind, schedule_path: Option, permissioned_as: String, is_flow_step: bool, language: Option, is_skipped: bool, email: String, visible_to_owner: bool, suspend: Option, mem_peak: Option, } impl From for Job { fn from(uj: UnifiedJob) -> Self { match uj.typ.as_ref() { "CompletedJob" => Job::CompletedJob(CompletedJob { workspace_id: uj.workspace_id, id: uj.id, parent_job: uj.parent_job, created_by: uj.created_by, created_at: uj.created_at, started_at: uj.started_at.unwrap_or(uj.created_at), duration_ms: uj.duration_ms.unwrap(), success: uj.success.unwrap(), script_hash: uj.script_hash, script_path: uj.script_path, args: uj.args, result: None, logs: None, flow_status: None, deleted: uj.deleted, canceled: uj.canceled, canceled_by: uj.canceled_by, raw_code: None, canceled_reason: None, job_kind: uj.job_kind, schedule_path: uj.schedule_path, permissioned_as: uj.permissioned_as, raw_flow: None, is_flow_step: uj.is_flow_step, language: uj.language, is_skipped: uj.is_skipped, email: uj.email, visible_to_owner: uj.visible_to_owner, mem_peak: uj.mem_peak, }), "QueuedJob" => Job::QueuedJob(QueuedJob { workspace_id: uj.workspace_id, id: uj.id, parent_job: uj.parent_job, created_by: uj.created_by, created_at: uj.created_at, started_at: uj.started_at, script_hash: uj.script_hash, script_path: uj.script_path, args: uj.args, running: uj.running.unwrap(), scheduled_for: uj.scheduled_for.unwrap(), logs: None, flow_status: None, raw_code: None, raw_lock: None, canceled: uj.canceled, canceled_by: uj.canceled_by, canceled_reason: None, last_ping: None, job_kind: uj.job_kind, schedule_path: uj.schedule_path, permissioned_as: uj.permissioned_as, raw_flow: None, is_flow_step: uj.is_flow_step, language: uj.language, same_worker: false, pre_run_error: None, email: uj.email, visible_to_owner: uj.visible_to_owner, suspend: uj.suspend, mem_peak: uj.mem_peak, }), t => panic!("job type {} not valid", t), } } } #[derive(Deserialize)] struct CancelJob { reason: Option, } #[derive(Deserialize)] struct Preview { content: String, path: Option, args: Option>, language: ScriptLang, } #[derive(Deserialize)] struct PreviewFlow { value: FlowValue, path: Option, args: Option>, } pub struct QueryOrBody(pub Option); #[axum::async_trait] impl FromRequest for QueryOrBody where D: DeserializeOwned, S: Send + Sync, { type Rejection = Response; async fn from_request( req: Request, state: &S, ) -> std::result::Result { return if req.method() == axum::http::Method::GET { let Query(InPayload { payload }) = Query::from_request(req, state) .await .map_err(IntoResponse::into_response)?; payload .map(|p| { decode_payload(p) .map(QueryOrBody) .map_err(|err| (StatusCode::BAD_REQUEST, format!("{err:#?}"))) .map_err(IntoResponse::into_response) }) .unwrap_or(Ok(QueryOrBody(None))) } else { Json::from_request(req, state) .await .map(|Json(v)| QueryOrBody(Some(v))) .map_err(IntoResponse::into_response) }; #[derive(Deserialize)] struct InPayload { payload: Option, } fn decode_payload>(t: T) -> anyhow::Result { let vec = base64::engine::general_purpose::URL_SAFE .decode(t) .context("invalid base64")?; serde_json::from_slice(vec.as_slice()).context("invalid json") } } } pub async fn run_flow_by_path( authed: Authed, Extension(user_db): Extension, Path((w_id, flow_path)): Path<(String, StripPath)>, Query(run_query): Query, headers: HeaderMap, Json(args): Json>>, ) -> error::Result<(StatusCode, String)> { let flow_path = flow_path.to_path(); let mut tx = user_db.begin(&authed).await?; let scheduled_for = run_query.get_scheduled_for(&mut tx).await?; let args = run_query.add_include_headers(headers, args.unwrap_or_default()); let (uuid, tx) = push( tx, &w_id, JobPayload::Flow(flow_path.to_string()), args, &authed.username, &authed.email, username_to_permissioned_as(&authed.username), scheduled_for, None, run_query.parent_job, false, false, None, !run_query.invisible_to_owner.unwrap_or(false), ) .await?; tx.commit().await?; Ok((StatusCode::CREATED, uuid.to_string())) } pub async fn run_job_by_path( authed: Authed, Extension(user_db): Extension, Path((w_id, script_path)): Path<(String, StripPath)>, Query(run_query): Query, headers: HeaderMap, Json(args): Json>>, ) -> error::Result<(StatusCode, String)> { let script_path = script_path.to_path(); let mut tx = user_db.begin(&authed).await?; let job_payload = script_path_to_payload(script_path, &mut tx, &w_id).await?; let scheduled_for = run_query.get_scheduled_for(&mut tx).await?; let args = run_query.add_include_headers(headers, args.unwrap_or_default()); let (uuid, tx) = push( tx, &w_id, job_payload, args, &authed.username, &authed.email, username_to_permissioned_as(&authed.username), scheduled_for, None, run_query.parent_job, false, false, None, !run_query.invisible_to_owner.unwrap_or(false), ) .await?; tx.commit().await?; Ok((StatusCode::CREATED, uuid.to_string())) } struct Guard { done: bool, id: Uuid, w_id: String, db: UserDB, authed: Authed, } impl Drop for Guard { fn drop(&mut self) { if !&self.done { let id = self.id; let username = self.authed.username.clone(); let w_id = self.w_id.clone(); let db = self.db.clone(); let authed = self.authed.clone(); tracing::info!("http connection broke, marking job {id} as canceled"); tokio::spawn(async move { let tx = db.begin(&authed).await.ok(); if let Some(mut tx) = tx { let _ = sqlx::query!( "UPDATE queue SET canceled = true, canceled_reason = 'http connection broke', canceled_by = $1 WHERE id = $2 AND workspace_id = $3", username, id, w_id ) .execute(&mut tx) .await; let _ = tx.commit().await; } }); } } } async fn run_wait_result( authed: Authed, Extension(user_db): Extension, timeout: i32, uuid: Uuid, Path((w_id, _)): Path<(String, T)>, ) -> error::JsonResult { let mut result = None; let iters = if timeout <= 0 { 20 } else if timeout <= 1 { timeout * 10 } else { 10 + ((timeout - 1) * 2) }; let mut g = Guard { done: false, id: uuid, w_id: w_id.clone(), db: user_db.clone(), authed: authed.clone(), }; for i in 0..iters { let mut tx = user_db.clone().begin(&authed).await?; result = sqlx::query_scalar!( "SELECT result FROM completed_job WHERE id = $1 AND workspace_id = $2", uuid, &w_id ) .fetch_optional(&mut tx) .await? .flatten(); drop(tx); if result.is_some() { break; } let delay = if i < 10 { 100 } else { 500 }; tokio::time::sleep(core::time::Duration::from_millis(delay)).await; } if let Some(result) = result { g.done = true; Ok(Json(result)) } else { Err(Error::ExecutionErr(format!("timeout after {}s", timeout))) } } pub async fn check_queue_too_long(db: DB, queue_limit: Option) -> error::Result<()> { if let Some(limit) = queue_limit { let count = sqlx::query_scalar!( "SELECT COUNT(*) FROM queue WHERE canceled = false AND (scheduled_for <= now() OR (suspend_until IS NOT NULL AND ( suspend <= 0 OR suspend_until <= now())))", ) .fetch_one(&db) .await? .unwrap_or(0); if count > queue_limit.unwrap() { return Err(Error::InternalErr(format!( "Number of queued job is too high: {count} > {limit}" ))); } } Ok(()) } lazy_static::lazy_static! { pub static ref QUEUE_LIMIT_WAIT_RESULT: Option = std::env::var("QUEUE_LIMIT_WAIT_RESULT") .ok() .and_then(|x| x.parse().ok()); pub static ref TIMEOUT_WAIT_RESULT: i32 = std::env::var("TIMEOUT_WAIT_RESULT") .ok() .and_then(|x| x.parse().ok()) .unwrap_or(20); } pub async fn run_wait_result_job_by_path( authed: Authed, Extension(user_db): Extension, Extension(db): Extension, Path((w_id, script_path)): Path<(String, StripPath)>, Query(run_query): Query, headers: HeaderMap, Json(args): Json>>, ) -> error::JsonResult { check_queue_too_long(db, QUEUE_LIMIT_WAIT_RESULT.or(run_query.queue_limit)).await?; let script_path = script_path.to_path(); let mut tx = user_db.clone().begin(&authed).await?; let job_payload = script_path_to_payload(script_path, &mut tx, &w_id).await?; let scheduled_for = run_query.get_scheduled_for(&mut tx).await?; let args = run_query.add_include_headers(headers, args.unwrap_or_default()); let (uuid, tx) = push( tx, &w_id, job_payload, args, &authed.username, &authed.email, username_to_permissioned_as(&authed.username), scheduled_for, None, run_query.parent_job, false, false, None, !run_query.invisible_to_owner.unwrap_or(false), ) .await?; tx.commit().await?; run_wait_result( authed, Extension(user_db), *TIMEOUT_WAIT_RESULT, uuid, Path((w_id, script_path)), ) .await } pub async fn run_wait_result_job_by_hash( authed: Authed, Extension(user_db): Extension, Extension(db): Extension, Path((w_id, script_hash)): Path<(String, ScriptHash)>, Query(run_query): Query, headers: HeaderMap, Json(args): Json>>, ) -> error::JsonResult { check_queue_too_long(db, run_query.queue_limit).await?; let hash = script_hash.0; let mut tx = user_db.clone().begin(&authed).await?; let path = get_path_for_hash(&mut tx, &w_id, hash).await?; let scheduled_for = run_query.get_scheduled_for(&mut tx).await?; let args = run_query.add_include_headers(headers, args.unwrap_or_default()); let (uuid, tx) = push( tx, &w_id, JobPayload::ScriptHash { hash: ScriptHash(hash), path }, args, &authed.username, &authed.email, username_to_permissioned_as(&authed.username), scheduled_for, None, run_query.parent_job, false, false, None, !run_query.invisible_to_owner.unwrap_or(false), ) .await?; tx.commit().await?; run_wait_result( authed, Extension(user_db), *TIMEOUT_WAIT_RESULT, uuid, Path((w_id, script_hash)), ) .await } pub async fn run_wait_result_flow_by_path( authed: Authed, Extension(user_db): Extension, Extension(db): Extension, Path((w_id, flow_path)): Path<(String, StripPath)>, Query(run_query): Query, headers: HeaderMap, Json(args): Json>>, ) -> error::JsonResult { check_queue_too_long(db, run_query.queue_limit).await?; let flow_path = flow_path.to_path(); let mut tx = user_db.clone().begin(&authed).await?; let scheduled_for = run_query.get_scheduled_for(&mut tx).await?; let args = run_query.add_include_headers(headers, args.unwrap_or_default()); let (uuid, tx) = push( tx, &w_id, JobPayload::Flow(flow_path.to_string()), args, &authed.username, &authed.email, username_to_permissioned_as(&authed.username), scheduled_for, None, run_query.parent_job, false, false, None, !run_query.invisible_to_owner.unwrap_or(false), ) .await?; tx.commit().await?; run_wait_result( authed, Extension(user_db), *TIMEOUT_WAIT_RESULT, uuid, Path((w_id, flow_path)), ) .await } // a similar function exists on the worker pub async fn script_path_to_payload<'c>( script_path: &str, db: &mut Transaction<'c, Postgres>, w_id: &String, ) -> std::result::Result { let job_payload = if script_path.starts_with("hub/") { JobPayload::ScriptHub { path: script_path.to_owned() } } else { let script_hash = windmill_common::get_latest_hash_for_path(db, w_id, script_path).await?; JobPayload::ScriptHash { hash: script_hash, path: script_path.to_owned() } }; Ok(job_payload) } async fn run_preview_job( authed: Authed, Extension(user_db): Extension, Path(w_id): Path, Query(run_query): Query, headers: HeaderMap, Json(preview): Json, ) -> error::Result<(StatusCode, String)> { let mut tx = user_db.begin(&authed).await?; let scheduled_for = run_query.get_scheduled_for(&mut tx).await?; let args = run_query.add_include_headers(headers, preview.args.unwrap_or_default()); let (uuid, tx) = push( tx, &w_id, JobPayload::Code(RawCode { content: preview.content, path: preview.path, language: preview.language, lock: None, }), args, &authed.username, &authed.email, username_to_permissioned_as(&authed.username), scheduled_for, None, None, false, false, None, true, ) .await?; tx.commit().await?; Ok((StatusCode::CREATED, uuid.to_string())) } async fn run_preview_flow_job( authed: Authed, Extension(user_db): Extension, Path(w_id): Path, Query(run_query): Query, headers: HeaderMap, Json(raw_flow): Json, ) -> error::Result<(StatusCode, String)> { let mut tx = user_db.begin(&authed).await?; let scheduled_for = run_query.get_scheduled_for(&mut tx).await?; let args = run_query.add_include_headers(headers, raw_flow.args.unwrap_or_default()); let (uuid, tx) = push( tx, &w_id, JobPayload::RawFlow { value: raw_flow.value, path: raw_flow.path }, args, &authed.username, &authed.email, username_to_permissioned_as(&authed.username), scheduled_for, None, None, false, false, None, true, ) .await?; tx.commit().await?; Ok((StatusCode::CREATED, uuid.to_string())) } pub async fn run_job_by_hash( authed: Authed, Extension(user_db): Extension, Path((w_id, script_hash)): Path<(String, ScriptHash)>, Query(run_query): Query, headers: HeaderMap, Json(args): Json>>, ) -> error::Result<(StatusCode, String)> { let hash = script_hash.0; let mut tx = user_db.begin(&authed).await?; let path = get_path_for_hash(&mut tx, &w_id, hash).await?; let scheduled_for = run_query.get_scheduled_for(&mut tx).await?; let args = run_query.add_include_headers(headers, args.unwrap_or_default()); let (uuid, tx) = push( tx, &w_id, JobPayload::ScriptHash { hash: ScriptHash(hash), path }, args, &authed.username, &authed.email, username_to_permissioned_as(&authed.username), scheduled_for, None, run_query.parent_job, false, false, None, !run_query.invisible_to_owner.unwrap_or(false), ) .await?; tx.commit().await?; Ok((StatusCode::CREATED, uuid.to_string())) } #[derive(Deserialize)] pub struct JobUpdateQuery { pub running: bool, pub log_offset: i32, } #[derive(Serialize)] pub struct JobUpdate { pub running: Option, pub completed: Option, pub new_logs: Option, pub mem_peak: Option, } async fn get_job_update( Extension(db): Extension, Path((w_id, id)): Path<(String, Uuid)>, Query(JobUpdateQuery { running, log_offset }): Query, ) -> error::JsonResult { let mut tx = db.begin().await?; let record = sqlx::query!( "SELECT substr(logs, $1) as logs, mem_peak FROM queue WHERE workspace_id = $2 AND id = $3", log_offset, &w_id, &id ) .fetch_optional(&mut tx) .await?; if let Some(record) = record { tx.commit().await?; Ok(Json(JobUpdate { running: if !running { Some(true) } else { None }, completed: None, new_logs: record.logs, mem_peak: record.mem_peak, })) } else { let logs = query_scalar!( "SELECT substr(logs, $1) as logs FROM completed_job WHERE workspace_id = $2 AND id = \ $3", log_offset, &w_id, &id ) .fetch_optional(&mut tx) .await?; let logs = not_found_if_none(logs, "Job", id.to_string())?; tx.commit().await?; Ok(Json(JobUpdate { running: Some(false), completed: Some(true), new_logs: logs, mem_peak: record.map(|r| r.mem_peak).flatten(), })) } } fn list_completed_jobs_query( w_id: &str, per_page: usize, offset: usize, lq: &ListCompletedQuery, fields: &[&str], ) -> SqlBuilder { let mut sqlb = SqlBuilder::select_from("completed_job") .fields(fields) .order_by("created_at", lq.order_desc.unwrap_or(true)) .and_where_eq("workspace_id", "?".bind(&w_id)) .offset(offset) .limit(per_page) .clone(); if let Some(ps) = &lq.script_path_start { sqlb.and_where_like_left("script_path", "?".bind(ps)); } if let Some(p) = &lq.script_path_exact { sqlb.and_where_eq("script_path", "?".bind(p)); } if let Some(h) = &lq.script_hash { sqlb.and_where_eq("script_hash", "?".bind(h)); } if let Some(cb) = &lq.created_by { sqlb.and_where_eq("created_by", "?".bind(cb)); } if let Some(r) = &lq.success { sqlb.and_where_eq("success", r); } if let Some(pj) = &lq.parent_job { sqlb.and_where_eq("parent_job", "?".bind(pj)); } if let Some(dt) = &lq.created_before { sqlb.and_where_lt("created_at", format!("to_timestamp({})", dt.timestamp())); } if let Some(dt) = &lq.created_after { sqlb.and_where_gt("created_at", format!("to_timestamp({})", dt.timestamp())); } if let Some(sk) = &lq.is_skipped { sqlb.and_where_eq("is_skipped", sk); } if let Some(fs) = &lq.is_flow_step { sqlb.and_where_eq("is_flow_step", fs); } if let Some(jk) = &lq.job_kinds { sqlb.and_where_in( "job_kind", &jk.split(',').into_iter().map(quote).collect::>(), ); } sqlb } #[derive(Deserialize, Clone)] pub struct ListCompletedQuery { pub script_path_start: Option, pub script_path_exact: Option, pub script_hash: Option, pub created_by: Option, pub created_before: Option>, pub created_after: Option>, pub success: Option, pub parent_job: Option, pub order_desc: Option, pub job_kinds: Option, pub is_skipped: Option, pub is_flow_step: Option, pub suspended: Option, } async fn list_completed_jobs( Extension(db): Extension, Path(w_id): Path, Query(pagination): Query, Query(lq): Query, ) -> error::JsonResult> { let (per_page, offset) = paginate(pagination); let sql = list_completed_jobs_query( &w_id, per_page, offset, &lq, &[ "id", "workspace_id", "parent_job", "created_by", "created_at", "started_at", "duration_ms", "success", "script_hash", "script_path", "args", "result", "null as logs", "deleted", "canceled", "canceled_by", "canceled_reason", "job_kind", "schedule_path", "permissioned_as", "null as raw_code", "null as flow_status", "null as raw_flow", "is_flow_step", "language", "is_skipped", "email", "visible_to_owner", "mem_peak", ], ) .sql()?; let jobs = sqlx::query_as::<_, CompletedJob>(&sql) .fetch_all(&db) .await?; Ok(Json(jobs)) } async fn get_completed_job( Extension(db): Extension, Path((w_id, id)): Path<(String, Uuid)>, ) -> error::JsonResult { let job_o = sqlx::query_as::<_, CompletedJob>( "SELECT * FROM completed_job WHERE id = $1 AND workspace_id = $2", ) .bind(id) .bind(w_id) .fetch_optional(&db) .await?; let job = not_found_if_none(job_o, "Completed Job", id.to_string())?; Ok(Json(job)) } async fn get_completed_job_result( Extension(db): Extension, Path((w_id, id)): Path<(String, Uuid)>, ) -> error::JsonResult> { let result_o = sqlx::query_scalar!( "SELECT result FROM completed_job WHERE id = $1 AND workspace_id = $2", id, w_id, ) .fetch_optional(&db) .await?; let result = not_found_if_none(result_o, "Completed Job", id.to_string())?; Ok(Json(result)) } async fn delete_completed_job( authed: Authed, Extension(user_db): Extension, Path((w_id, id)): Path<(String, Uuid)>, ) -> error::JsonResult { let mut tx = user_db.begin(&authed).await?; require_admin(authed.is_admin, &authed.username)?; let job_o = sqlx::query_as::<_, CompletedJob>( "UPDATE completed_job SET logs = '', result = null, deleted = true WHERE id = $1 AND workspace_id = $2 \ RETURNING *", ) .bind(id) .bind(&w_id) .fetch_optional(&mut tx) .await?; let job = not_found_if_none(job_o, "Completed Job", id.to_string())?; audit_log( &mut tx, &authed.username, "jobs.delete", ActionKind::Delete, &w_id, Some(&id.to_string()), None, ) .await?; tx.commit().await?; Ok(Json(job)) }