Files
windmill/backend/windmill-api/src/approvals.rs
Ramtin Mesgari 5da494b197 refactor: remove legacy database views v2_as_queue and v2_as_completed_job (#6689)
* refactor: remove legacy database views v2_as_queue and v2_as_completed_job

Signed-off-by: Ramtin Mesgari <26694963+iamramtin@users.noreply.github.com>

* fix tests

* fix jobs.rs

* end

* fix

* improvement

* improvement

---------

Signed-off-by: Ramtin Mesgari <26694963+iamramtin@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2025-11-04 13:08:02 +00:00

303 lines
10 KiB
Rust

use crate::auth::OptTokened;
use crate::db::{ApiAuthed, DB};
use crate::jobs::{
cancel_suspended_job, get_resume_urls_internal, resume_suspended_job, QueryApprover,
QueryOrBody, ResumeUrls,
};
use axum::{
extract::{Path, Query},
Extension,
};
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use serde_json::Value;
use std::collections::HashMap;
use std::str::FromStr;
use uuid::Uuid;
use windmill_common::cache;
use windmill_common::error::Error;
use windmill_common::jobs::JobKind;
use windmill_common::scripts::ScriptHash;
#[derive(Debug, Deserialize, Serialize)]
pub struct ResumeSchema {
pub schema: Schema,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Schema {
pub order: Vec<String>,
pub required: Vec<String>,
pub properties: HashMap<String, ResumeFormField>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(rename_all = "lowercase")]
pub enum FieldType {
Boolean,
String,
Number,
Integer,
#[serde(other)]
Unknown,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ResumeFormField {
pub r#type: FieldType,
pub format: Option<String>,
pub default: Option<serde_json::Value>,
pub description: Option<String>,
pub title: Option<String>,
pub r#enum: Option<Vec<String>>,
#[serde(rename = "enumLabels")]
pub enum_labels: Option<HashMap<String, String>>,
pub nullable: Option<bool>,
pub placeholder: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ResumeFormRow {
pub resume_form: Option<serde_json::Value>,
pub hide_cancel: Option<bool>,
}
#[derive(Deserialize)]
pub struct QueryMessage {
pub message: Option<String>,
}
#[derive(Deserialize)]
pub struct QueryFlowStepId {
pub flow_step_id: String,
}
#[derive(Deserialize, Debug)]
pub struct QueryDefaultArgsJson {
pub default_args_json: Option<serde_json::Value>,
}
#[derive(Deserialize, Debug)]
pub struct QueryDynamicEnumJson {
pub dynamic_enums_json: Option<serde_json::Value>,
}
#[derive(Debug)]
pub struct ApprovalFormDetails {
pub message_str: String,
pub urls: ResumeUrls,
pub schema: Option<ResumeFormRow>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy)]
pub enum MessageFormat {
Slack,
Teams,
}
pub fn extract_w_id_from_resume_url(resume_url: &str) -> Result<&str, Error> {
let re = Regex::new(r"/api/w/(?P<w_id>[^/]+)/jobs_u/(?P<action>resume|cancel)/(?P<job_id>[^/]+)/(?P<resume_id>[^/]+)/(?P<secret>[a-fA-F0-9]+)(?:\?approver=(?P<approver>[^&]+))?").unwrap();
let captures = re.captures(resume_url).ok_or_else(|| {
tracing::error!("Resume URL does not match the pattern.");
Error::BadRequest("Invalid URL format.".to_string())
})?;
Ok(captures.name("w_id").map_or("", |m| m.as_str()))
}
pub async fn handle_resume_action(
authed: Option<ApiAuthed>,
opt_tokened: OptTokened,
db: DB,
resume_url: &str,
form_data: Value,
action: &str,
) -> Result<(), Error> {
// Extract information from resume_url using regex
let re = Regex::new(r"/api/w/(?P<w_id>[^/]+)/jobs_u/(?P<action>resume|cancel)/(?P<job_id>[^/]+)/(?P<resume_id>[^/]+)/(?P<secret>[a-fA-F0-9]+)(?:\?approver=(?P<approver>[^&]+))?").unwrap();
let captures = re.captures(resume_url).ok_or_else(|| {
tracing::error!("Resume URL does not match the pattern.");
Error::BadRequest("Invalid URL format.".to_string())
})?;
let (w_id, job_id, resume_id, secret, approver) = (
captures.name("w_id").map_or("", |m| m.as_str()),
captures.name("job_id").map_or("", |m| m.as_str()),
captures.name("resume_id").map_or("", |m| m.as_str()),
captures.name("secret").map_or("", |m| m.as_str()),
captures.name("approver").map(|m| m.as_str().to_string()),
);
let approver = QueryApprover { approver };
// Convert job_id and resume_id to appropriate types
let job_uuid = Uuid::from_str(job_id)
.map_err(|_| Error::BadRequest("Invalid job ID format.".to_string()))?;
let resume_id_parsed = resume_id
.parse::<u32>()
.map_err(|_| Error::BadRequest("Invalid resume ID format.".to_string()))?;
// Call the appropriate function based on the action
let res = if action == "resume" {
resume_suspended_job(
authed,
opt_tokened,
Extension(db.clone()),
Path((
w_id.to_string(),
job_uuid,
resume_id_parsed,
secret.to_string(),
)),
Query(approver),
QueryOrBody(Some(form_data)),
)
.await
} else {
cancel_suspended_job(
authed,
opt_tokened,
Extension(db.clone()),
Path((
w_id.to_string(),
job_uuid,
resume_id_parsed,
secret.to_string(),
)),
Query(approver),
QueryOrBody(Some(form_data)),
)
.await
};
tracing::debug!("Job action result: {:#?}", res);
res?;
Ok(())
}
pub async fn get_approval_form_details(
db: DB,
w_id: &str,
job_id: Uuid,
flow_step_id: Option<&str>,
resume_id: u32,
approver: Option<&str>,
message: Option<&str>,
format: MessageFormat,
) -> Result<ApprovalFormDetails, Error> {
let res = get_resume_urls_internal(
axum::Extension(db.clone()),
Path((w_id.to_string(), job_id, resume_id)),
Query(QueryApprover { approver: approver.map(|a| a.to_string()) }),
)
.await?;
let urls = res.0;
tracing::debug!("Job ID: {:?}", job_id);
// TODO: do we have a helper function for this?
let (job_kind, script_hash, raw_flow, parent_job_id, created_at, created_by, script_path, args) = sqlx::query!(
"WITH job_info AS (
-- Query for Teams (running jobs)
SELECT
parent_j.kind AS \"job_kind!: JobKind\",
parent_j.runnable_id AS \"script_hash: ScriptHash\",
parent_j.raw_flow AS \"raw_flow: sqlx::types::Json<Box<RawValue>>\",
child_j.parent_job AS \"parent_job: Uuid\",
parent_j.created_at AS \"created_at!: chrono::NaiveDateTime\",
parent_j.created_by AS \"created_by!\",
parent_j.runnable_path as script_path,
parent_j.args AS \"args: sqlx::types::Json<Box<RawValue>>\"
FROM v2_job_queue child_q JOIN v2_job child_j USING (id)
JOIN v2_job parent_j ON parent_j.id = child_j.parent_job
WHERE child_j.id = $1 AND child_j.workspace_id = $2
UNION ALL
-- Query for Slack (completed jobs)
SELECT
parent_j.kind AS \"job_kind!: JobKind\",
parent_j.runnable_id AS \"script_hash: ScriptHash\",
parent_j.raw_flow AS \"raw_flow: sqlx::types::Json<Box<RawValue>>\",
completed_j.parent_job AS \"parent_job: Uuid\",
completed_j.created_at AS \"created_at!: chrono::NaiveDateTime\",
completed_j.created_by AS \"created_by!\",
parent_j.runnable_path as script_path,
parent_j.args AS \"args: sqlx::types::Json<Box<RawValue>>\"
FROM v2_job_completed completed_c JOIN v2_job completed_j USING (id)
JOIN v2_job parent_j ON parent_j.id = completed_j.parent_job
WHERE completed_j.id = $1 AND completed_j.workspace_id = $2
)
SELECT * FROM job_info LIMIT 1",
job_id,
&w_id
)
.fetch_optional(&db)
.await
.map_err(|e| Error::BadRequest(e.to_string()))?
.ok_or_else(|| Error::BadRequest("This workflow is no longer running and has either already timed out or been cancelled or completed.".to_string()))
.map(|r| (r.job_kind, r.script_hash, r.raw_flow, r.parent_job, r.created_at, r.created_by, r.script_path, r.args))?;
let flow_data = match cache::job::fetch_flow(&db, &job_kind, script_hash).await {
Ok(data) => data,
Err(_) => {
if let Some(parent_job_id) = parent_job_id.as_ref() {
cache::job::fetch_preview_flow(&db, parent_job_id, raw_flow).await?
} else {
return Err(Error::BadRequest(
"This workflow is no longer running and has either already timed out or been cancelled or completed.".to_string(),
));
}
}
};
let flow_value = &flow_data.flow;
let flow_step_id = flow_step_id.unwrap_or("");
let module = flow_value.modules.iter().find(|m| m.id == flow_step_id);
tracing::debug!("Module: {:#?}", module);
let schema = module.and_then(|module| {
module.suspend.as_ref().map(|suspend| ResumeFormRow {
resume_form: suspend.resume_form.clone(),
hide_cancel: suspend.hide_cancel,
})
});
let args_str = args.map_or("None".to_string(), |a| a.get().to_string());
let parent_job_id_str = parent_job_id.map_or("None".to_string(), |id| id.to_string());
let script_path_str = script_path.as_deref().unwrap_or("None");
let created_at_formatted = created_at.format("%Y-%m-%d %H:%M:%S").to_string();
let bold_format = match format {
MessageFormat::Slack => "*{}*",
MessageFormat::Teams => "**{}**",
};
let mut message_str = format!(
"A workflow has been suspended and is waiting for approval:\n\n\
{}: {created_by}\n\n\
{}: {created_at_formatted}\n\n\
{}: {script_path_str}\n\n\
{}: {args_str}\n\n\
{}: {parent_job_id_str}\n\n",
bold_format.replace("{}", "Created by"),
bold_format.replace("{}", "Created at"),
bold_format.replace("{}", "Script path"),
bold_format.replace("{}", "Args"),
bold_format.replace("{}", "Flow ID")
);
// Append custom message if provided
if let Some(msg) = message {
message_str.push_str(msg);
}
tracing::debug!("Schema: {:#?}", schema);
Ok(ApprovalFormDetails { message_str, urls, schema })
}