mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 08:04:25 +00:00
feat: secure suspended resume event + configurable timeout (#721)
* fix suspend count for early message + delete resume_job * all * done everything * done everything
This commit is contained in:
Generated
+252
-490
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -45,7 +45,6 @@ url = "^2"
|
||||
async-oauth2 = "^0"
|
||||
reqwest = { version = "^0", features = ["json"] }
|
||||
time = "^0"
|
||||
slack-http-verifier = "^0"
|
||||
serde_urlencoded = "^0"
|
||||
tokio-tar = "^0"
|
||||
tempfile = "^3"
|
||||
@@ -60,6 +59,8 @@ swc_ecma_parser = "^0"
|
||||
swc_ecma_ast = "^0"
|
||||
base64 = "^0"
|
||||
unicode-general-category = "^0"
|
||||
hmac = "^0"
|
||||
sha2 = "^0"
|
||||
|
||||
sqlx = { version = "^0", features = ["offline", "macros", "migrate", "uuid", "json", "chrono", "postgres", "runtime-tokio-rustls"]}
|
||||
dotenv = "^0"
|
||||
|
||||
+22
-2
@@ -2551,7 +2551,7 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/jobs/resume/{id}:
|
||||
/w/{workspace}/jobs/resume/{job_id}/{resume_id}/{signature}:
|
||||
get:
|
||||
summary: resume a job for a suspended flow
|
||||
operationId: resumeSuspendedJob
|
||||
@@ -2560,6 +2560,16 @@ paths:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/JobId"
|
||||
- name: resume_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: signature
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: payload
|
||||
in: query
|
||||
schema:
|
||||
@@ -2593,7 +2603,7 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/jobs/cancel/{id}:
|
||||
/w/{workspace}/jobs/cancel/{job_id}/{resume_id}/{signature}:
|
||||
get:
|
||||
summary: cancel a job for a suspended flow
|
||||
operationId: cancelSuspendedJob
|
||||
@@ -2602,6 +2612,16 @@ paths:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/JobId"
|
||||
- name: resume_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: signature
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: payload
|
||||
in: query
|
||||
schema:
|
||||
|
||||
+32
-32
@@ -1178,38 +1178,6 @@
|
||||
},
|
||||
"query": "SELECT * from resource_type WHERE name = $1 AND (workspace_id = $2 OR workspace_id = 'starter')"
|
||||
},
|
||||
"5a8eeff8ac0f52408720f8204bae085045cc4313bb31ab57442b093182633101": {
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"name": "flow_status",
|
||||
"ordinal": 1,
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"name": "suspend",
|
||||
"ordinal": 2,
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
}
|
||||
},
|
||||
"query": "\n SELECT id, flow_status, suspend\n FROM queue\n WHERE id = ( SELECT parent_job FROM queue WHERE id = $1\n UNION\n SELECT parent_job from completed_job WHERE id = $1 )\n FOR UPDATE\n "
|
||||
},
|
||||
"5b7a1d16d8109a65479ab33d411c60d14ea91d870fdff8606d7aa4ad39f0ba00": {
|
||||
"describe": {
|
||||
"columns": [
|
||||
@@ -1887,6 +1855,38 @@
|
||||
},
|
||||
"query": "INSERT INTO completed_job AS cj\n ( workspace_id\n , id\n , parent_job\n , created_by\n , created_at\n , started_at\n , duration_ms\n , success\n , script_hash\n , script_path\n , args\n , result\n , logs\n , raw_code\n , canceled\n , canceled_by\n , canceled_reason\n , job_kind\n , schedule_path\n , permissioned_as\n , flow_status\n , raw_flow\n , is_flow_step\n , is_skipped\n , language )\n VALUES ($1, $2, $3, $4, $5, $6, EXTRACT(milliseconds FROM (now() - $6)), $7, $8, $9,$10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)\n ON CONFLICT (id) DO UPDATE SET success = $7, result = $11, logs = concat(cj.logs, $12)"
|
||||
},
|
||||
"853788436dbe987853433e8dc83665f68bd127de31d4c807abafeead896f6ac4": {
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"name": "flow_status",
|
||||
"ordinal": 1,
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"name": "suspend",
|
||||
"ordinal": 2,
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
}
|
||||
},
|
||||
"query": "\n SELECT id, flow_status, suspend\n FROM queue\n WHERE id = ( SELECT parent_job FROM queue WHERE id = $1 UNION ALL SELECT parent_job FROM completed_job WHERE id = $1)\n FOR UPDATE\n "
|
||||
},
|
||||
"88a3f58a1a315200fdd2e4bb8638246ee21818f8aaaf56f6e9d7ddce1490d886": {
|
||||
"describe": {
|
||||
"columns": [
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
use lettre::{
|
||||
transport::smtp::authentication::Credentials, AsyncSmtpTransport, AsyncTransport, Message,
|
||||
Tokio1Executor,
|
||||
};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
pub struct EmailSender {
|
||||
pub from: String,
|
||||
pub server: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl EmailSender {
|
||||
pub async fn send_email(&self, email: Message) -> Result<()> {
|
||||
let creds = Credentials::new(self.from.to_string(), self.password.to_string());
|
||||
|
||||
// Open a remote connection to gmail
|
||||
let mailer: AsyncSmtpTransport<Tokio1Executor> =
|
||||
AsyncSmtpTransport::<Tokio1Executor>::relay(&self.server)
|
||||
.unwrap()
|
||||
.credentials(creds)
|
||||
.build();
|
||||
|
||||
mailer
|
||||
.send(email)
|
||||
.await
|
||||
.map_err(|x| Error::InternalErr(format!("Impossible to send email {x}")))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,14 @@ INSERT INTO workspace
|
||||
(id, name, owner, domain)
|
||||
VALUES ('test-workspace', 'test-workspace', 'test-user', null);
|
||||
|
||||
INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES
|
||||
('test-workspace', 'test@windmill.dev', 'test-user', true, 'Admin');
|
||||
|
||||
INSERT INTO workspace_key(workspace_id, kind, key) VALUES
|
||||
('test-workspace', 'cloud', 'test-key');
|
||||
|
||||
GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin;
|
||||
GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_user;
|
||||
|
||||
CREATE FUNCTION "notify_insert_on_completed_job" ()
|
||||
RETURNS TRIGGER AS $$
|
||||
|
||||
+10
-3
@@ -151,6 +151,14 @@ impl Default for ExponentialDelay {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct Suspend {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub required_events: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timeout: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct FlowModule {
|
||||
#[serde(default)]
|
||||
@@ -159,9 +167,8 @@ pub struct FlowModule {
|
||||
pub value: FlowModuleValue,
|
||||
pub stop_after_if: Option<StopAfterIf>,
|
||||
pub summary: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub suspend: u16,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub suspend: Option<Suspend>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub retry: Option<Retry>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
||||
+68
-42
@@ -9,6 +9,7 @@
|
||||
use axum::extract::Host;
|
||||
|
||||
use anyhow::Context;
|
||||
use hmac::Mac;
|
||||
use sql_builder::prelude::*;
|
||||
use sqlx::{query_scalar, Postgres, Transaction};
|
||||
use std::collections::HashMap;
|
||||
@@ -17,12 +18,14 @@ use tracing::instrument;
|
||||
use crate::{
|
||||
audit::{audit_log, ActionKind},
|
||||
db::{UserDB, DB},
|
||||
error::{self, to_anyhow, Error, OrElseNotFound},
|
||||
error::{self, to_anyhow, Error},
|
||||
flows::FlowValue,
|
||||
oauth2::HmacSha256,
|
||||
schedule::get_schedule_opt,
|
||||
scripts::{get_hub_script_by_path, ScriptHash, ScriptLang},
|
||||
users::{owner_to_token_owner, Authed},
|
||||
utils::{now_from_db, require_admin, Pagination, StripPath},
|
||||
variables::get_workspace_key,
|
||||
worker,
|
||||
worker_flow::{
|
||||
init_flow_status, FlowStatus, FlowStatusModule, MAX_RETRY_ATTEMPTS, MAX_RETRY_INTERVAL,
|
||||
@@ -69,14 +72,30 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/completed/delete/:id", post(delete_completed_job))
|
||||
.route("/get/:id", get(get_job))
|
||||
.route("/getupdate/:id", get(get_job_update))
|
||||
.route(
|
||||
"/job_signature/:job_id/:resume_id",
|
||||
get(create_job_signature),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn global_service() -> Router {
|
||||
Router::new()
|
||||
.route("/resume/:id", get(resume_suspended_job))
|
||||
.route("/resume/:id", post(resume_suspended_job))
|
||||
.route("/cancel/:id", get(cancel_suspended_job))
|
||||
.route("/cancel/:id", post(cancel_suspended_job))
|
||||
.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),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow, Serialize, Clone)]
|
||||
@@ -118,7 +137,7 @@ impl QueuedJob {
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow, Serialize)]
|
||||
struct CompletedJob {
|
||||
pub struct CompletedJob {
|
||||
workspace_id: String,
|
||||
id: Uuid,
|
||||
parent_job: Option<Uuid>,
|
||||
@@ -925,7 +944,7 @@ async fn get_job(
|
||||
Ok(Json(job))
|
||||
}
|
||||
|
||||
async fn get_job_by_id<'c>(
|
||||
pub async fn get_job_by_id<'c>(
|
||||
mut tx: Transaction<'c, Postgres>,
|
||||
w_id: &str,
|
||||
id: Uuid,
|
||||
@@ -963,58 +982,65 @@ pub async fn get_queued_job<'c>(
|
||||
pub async fn resume_suspended_job(
|
||||
/* unauthed */
|
||||
Extension(db): Extension<DB>,
|
||||
Path((_, job)): Path<(String, Uuid)>,
|
||||
Path((w_id, job, resume_id, secret)): Path<(String, Uuid, u32, String)>,
|
||||
QueryOrBody(value): QueryOrBody<serde_json::Value>,
|
||||
) -> error::Result<StatusCode> {
|
||||
let value = value.unwrap_or(serde_json::Value::Null);
|
||||
NewResumeJob { job, value, is_cancel: false }
|
||||
.insert(&db)
|
||||
.await?;
|
||||
insert_resume_job(&db, &w_id, job, resume_id, secret, false, value).await?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
pub async fn cancel_suspended_job(
|
||||
/* unauthed */
|
||||
Extension(db): Extension<DB>,
|
||||
Path((_, job)): Path<(String, Uuid)>,
|
||||
Path((w_id, job, resume_id, secret)): Path<(String, Uuid, u32, String)>,
|
||||
QueryOrBody(value): QueryOrBody<serde_json::Value>,
|
||||
) -> error::Result<StatusCode> {
|
||||
let value = value.unwrap_or(serde_json::Value::Null);
|
||||
NewResumeJob { job, value, is_cancel: true }
|
||||
.insert(&db)
|
||||
.await?;
|
||||
insert_resume_job(&db, &w_id, job, resume_id, secret, true, value).await?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
struct NewResumeJob {
|
||||
pub job: Uuid,
|
||||
pub value: Value,
|
||||
pub is_cancel: bool,
|
||||
pub async fn create_job_signature(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, job_id, resume_id)): Path<(String, Uuid, u32)>,
|
||||
) -> error::Result<String> {
|
||||
let key = get_workspace_key(&w_id, &mut user_db.begin(&authed).await?).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());
|
||||
Ok(hex::encode(mac.finalize().into_bytes()))
|
||||
}
|
||||
|
||||
impl NewResumeJob {
|
||||
async fn insert(self, db: &DB) -> error::Result<()> {
|
||||
insert_resume_job(db, self).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn insert_resume_job(db: &DB, new: NewResumeJob) -> error::Result<()> {
|
||||
async fn insert_resume_job(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
job_id: Uuid,
|
||||
resume_id: u32,
|
||||
secret: String,
|
||||
is_cancel: bool,
|
||||
value: serde_json::Value,
|
||||
) -> 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_id.as_bytes());
|
||||
mac.update(resume_id.to_be_bytes().as_ref());
|
||||
mac.verify_slice(hex::decode(secret)?.as_ref())
|
||||
.map_err(|_| anyhow::anyhow!("Invalid signature"))?;
|
||||
let flow = sqlx::query!(
|
||||
r#"
|
||||
SELECT id, flow_status, suspend
|
||||
FROM queue
|
||||
WHERE id = ( SELECT parent_job FROM queue WHERE id = $1
|
||||
UNION
|
||||
SELECT parent_job from completed_job WHERE id = $1 )
|
||||
FOR UPDATE
|
||||
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
|
||||
"#,
|
||||
new.job,
|
||||
job_id,
|
||||
)
|
||||
.fetch_optional(&mut tx)
|
||||
.await?
|
||||
.or_else_not_found(new.job)?;
|
||||
.ok_or_else(|| anyhow::anyhow!("parent flow job not found"))?;
|
||||
|
||||
sqlx::query!(
|
||||
r#"
|
||||
@@ -1022,11 +1048,11 @@ async fn insert_resume_job(db: &DB, new: NewResumeJob) -> error::Result<()> {
|
||||
(id, job, flow, value, is_cancel)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
"#,
|
||||
Uuid::from(Ulid::new()),
|
||||
new.job,
|
||||
Uuid::from_u128(job_id.as_u128() ^ resume_id as u128),
|
||||
job_id,
|
||||
flow.id,
|
||||
new.value,
|
||||
new.is_cancel,
|
||||
value,
|
||||
is_cancel,
|
||||
)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
@@ -1044,11 +1070,11 @@ async fn insert_resume_job(db: &DB, new: NewResumeJob) -> error::Result<()> {
|
||||
let status =
|
||||
serde_json::from_value::<FlowStatus>(flow.flow_status.context("no flow status")?)
|
||||
.context("deserialize flow status")?;
|
||||
if matches!(status.current_step(), Some(FlowStatusModule::WaitingForEvents { job, .. }) if job == &new.job)
|
||||
if matches!(status.current_step(), Some(FlowStatusModule::WaitingForEvents { job, .. }) if job == &job_id)
|
||||
{
|
||||
sqlx::query!(
|
||||
"UPDATE queue SET suspend = $1 WHERE id = $2",
|
||||
if new.is_cancel { 0 } else { suspend },
|
||||
if is_cancel { 0 } else { suspend },
|
||||
flow.id,
|
||||
)
|
||||
.execute(&mut tx)
|
||||
@@ -1060,9 +1086,9 @@ async fn insert_resume_job(db: &DB, new: NewResumeJob) -> error::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, Debug)]
|
||||
#[serde(tag = "type")]
|
||||
enum Job {
|
||||
pub enum Job {
|
||||
QueuedJob(QueuedJob),
|
||||
CompletedJob(CompletedJob),
|
||||
}
|
||||
|
||||
+4
-11
@@ -12,7 +12,6 @@ use axum::{handler::Handler, middleware::from_extractor, routing::get, Extension
|
||||
use db::DB;
|
||||
use futures::FutureExt;
|
||||
use git_version::git_version;
|
||||
use slack_http_verifier::SlackVerifier;
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
use tower::ServiceBuilder;
|
||||
use tower_cookies::CookieManagerLayer;
|
||||
@@ -25,7 +24,6 @@ extern crate dotenv;
|
||||
mod audit;
|
||||
mod client;
|
||||
mod db;
|
||||
mod email;
|
||||
mod error;
|
||||
mod external_ip;
|
||||
mod flows;
|
||||
@@ -57,11 +55,10 @@ mod workspaces;
|
||||
|
||||
use error::Error;
|
||||
|
||||
pub use crate::email::EmailSender;
|
||||
use crate::{
|
||||
db::UserDB,
|
||||
error::to_anyhow,
|
||||
oauth2::build_oauth_clients,
|
||||
oauth2::{build_oauth_clients, SlackVerifier},
|
||||
tracing_init::{MyMakeSpan, MyOnResponse},
|
||||
utils::rd_string,
|
||||
};
|
||||
@@ -99,16 +96,14 @@ struct CloudHosted(bool);
|
||||
pub async fn run_server(
|
||||
db: DB,
|
||||
addr: SocketAddr,
|
||||
base_url: &str,
|
||||
es: EmailSender,
|
||||
base_url: String,
|
||||
mut rx: tokio::sync::broadcast::Receiver<()>,
|
||||
) -> anyhow::Result<()> {
|
||||
let user_db = UserDB::new(db.clone());
|
||||
|
||||
let auth_cache = Arc::new(users::AuthCache::new(db.clone()));
|
||||
let argon2 = Arc::new(Argon2::default());
|
||||
let email_sender = Arc::new(es);
|
||||
let basic_clients = Arc::new(build_oauth_clients(base_url).await?);
|
||||
let basic_clients = Arc::new(build_oauth_clients(&base_url).await?);
|
||||
let slack_verifier = Arc::new(
|
||||
std::env::var("SLACK_SIGNING_SECRET")
|
||||
.ok()
|
||||
@@ -150,9 +145,7 @@ pub async fn run_server(
|
||||
.nest("/jobs", jobs::workspaced_service())
|
||||
.nest(
|
||||
"/users",
|
||||
users::workspaced_service()
|
||||
.layer(Extension(argon2.clone()))
|
||||
.layer(Extension(email_sender)),
|
||||
users::workspaced_service().layer(Extension(argon2.clone())),
|
||||
)
|
||||
.nest("/variables", variables::workspaced_service())
|
||||
.nest("/oauth", oauth2::workspaced_service())
|
||||
|
||||
+1
-12
@@ -59,18 +59,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let base_url_2 = base_url.clone();
|
||||
let server_f = async {
|
||||
if server_mode {
|
||||
windmill::run_server(
|
||||
db.clone(),
|
||||
addr,
|
||||
&base_url_2,
|
||||
windmill::EmailSender {
|
||||
from: "bot@windmill.dev".to_string(),
|
||||
server: "smtp.gmail.com".to_string(),
|
||||
password: std::env::var("SMTP_PASSWORD").unwrap_or("NOPASS".to_string()),
|
||||
},
|
||||
rx.resubscribe(),
|
||||
)
|
||||
.await?;
|
||||
windmill::run_server(db.clone(), addr, base_url_2, rx.resubscribe()).await?;
|
||||
}
|
||||
Ok(()) as anyhow::Result<()>
|
||||
};
|
||||
|
||||
+32
-1
@@ -16,7 +16,6 @@ use itertools::Itertools;
|
||||
use oauth2::{Client as OClient, *};
|
||||
use reqwest::Client;
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use slack_http_verifier::SlackVerifier;
|
||||
use sqlx::{Postgres, Transaction};
|
||||
use tokio::{fs::File, io::AsyncReadExt};
|
||||
use tower_cookies::{Cookie, Cookies};
|
||||
@@ -36,6 +35,13 @@ use crate::{
|
||||
BaseUrl,
|
||||
};
|
||||
|
||||
use std::str;
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
|
||||
pub type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
pub fn global_service() -> Router {
|
||||
Router::new()
|
||||
.route("/login/:client", get(login))
|
||||
@@ -959,3 +965,28 @@ fn set_cookie(state: &State, cookies: Cookies) {
|
||||
cookie.set_path("/");
|
||||
cookies.add(cookie);
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SlackVerifier {
|
||||
mac: HmacSha256,
|
||||
}
|
||||
|
||||
impl SlackVerifier {
|
||||
pub fn new<S: AsRef<[u8]>>(secret: S) -> anyhow::Result<SlackVerifier> {
|
||||
HmacSha256::new_from_slice(secret.as_ref())
|
||||
.map(|mac| SlackVerifier { mac })
|
||||
.map_err(|_| anyhow::anyhow!("invalid secret"))
|
||||
}
|
||||
|
||||
pub fn verify(&self, ts: &str, body: &str, exp_sig: &str) -> anyhow::Result<()> {
|
||||
let basestring = format!("v0:{}:{}", ts, body);
|
||||
let mut mac = self.mac.clone();
|
||||
|
||||
mac.update(basestring.as_bytes());
|
||||
let sig = format!("v0={}", hex::encode(mac.finalize().into_bytes()));
|
||||
if sig != exp_sig {
|
||||
Err(anyhow::anyhow!("signature mismatch"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,15 +453,22 @@ pub async fn build_crypt<'c>(
|
||||
db: &mut Transaction<'c, Postgres>,
|
||||
w_id: &str,
|
||||
) -> Result<MagicCrypt256> {
|
||||
let key = get_workspace_key(w_id, db).await?;
|
||||
Ok(magic_crypt::new_magic_crypt!(key, 256))
|
||||
}
|
||||
|
||||
pub async fn get_workspace_key<'c>(
|
||||
w_id: &str,
|
||||
db: &mut Transaction<'c, Postgres>,
|
||||
) -> Result<String> {
|
||||
let key = sqlx::query_scalar!(
|
||||
"SELECT key FROM workspace_key WHERE workspace_id = $1 AND kind = 'cloud'",
|
||||
w_id
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.map_err(|e| Error::InternalErr(format!("fetching crypt key: {e}")))?;
|
||||
|
||||
Ok(magic_crypt::new_magic_crypt!(key, 256))
|
||||
.map_err(|e| Error::InternalErr(format!("fetching workspace key: {e}")))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
pub fn encrypt(mc: &MagicCrypt256, value: &str) -> String {
|
||||
|
||||
+86
-38
@@ -2623,46 +2623,42 @@ def main():
|
||||
}
|
||||
|
||||
mod suspend_resume {
|
||||
|
||||
use crate::jobs::get_job_by_id;
|
||||
|
||||
use super::*;
|
||||
|
||||
use futures::{Stream, StreamExt};
|
||||
|
||||
struct ApiServer {
|
||||
addr: std::net::SocketAddr,
|
||||
tx: tokio::sync::oneshot::Sender<()>,
|
||||
task: tokio::task::JoinHandle<hyper::Result<()>>,
|
||||
tx: tokio::sync::broadcast::Sender<()>,
|
||||
task: tokio::task::JoinHandle<anyhow::Result<()>>,
|
||||
}
|
||||
|
||||
impl ApiServer {
|
||||
async fn start(db: DB) -> Self {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
|
||||
let (tx, rx) = tokio::sync::broadcast::channel::<()>(1);
|
||||
|
||||
let sock = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
|
||||
let addr = sock.local_addr().unwrap();
|
||||
drop(sock);
|
||||
|
||||
use crate::jobs::{cancel_suspended_job, resume_suspended_job};
|
||||
use axum::routing::{get, post};
|
||||
|
||||
let router = axum::Router::new()
|
||||
.route("/w/:workspace/jobs/resume/:id", get(resume_suspended_job))
|
||||
.route("/w/:workspace/jobs/resume/:id", post(resume_suspended_job))
|
||||
.route("/w/:workspace/jobs/cancel/:id", get(cancel_suspended_job))
|
||||
.route("/w/:workspace/jobs/cancel/:id", post(cancel_suspended_job))
|
||||
.layer(axum::extract::Extension(db));
|
||||
|
||||
let serve = axum::Server::from_tcp(sock.into_std().unwrap())
|
||||
.unwrap()
|
||||
.serve(router.into_make_service())
|
||||
.with_graceful_shutdown(async { drop(rx.await) });
|
||||
|
||||
let task = tokio::task::spawn(serve);
|
||||
let task = tokio::task::spawn({
|
||||
crate::run_server(
|
||||
db.clone(),
|
||||
addr,
|
||||
format!("http://localhost:{}", addr.port()),
|
||||
rx,
|
||||
)
|
||||
});
|
||||
|
||||
return Self { addr, tx, task };
|
||||
}
|
||||
|
||||
async fn close(self) -> hyper::Result<()> {
|
||||
let Self { task, tx, .. } = self;
|
||||
async fn close(self) -> anyhow::Result<()> {
|
||||
let Self { tx, task, .. } = self;
|
||||
drop(tx);
|
||||
task.await.unwrap()
|
||||
}
|
||||
@@ -2686,6 +2682,16 @@ def main():
|
||||
}
|
||||
}
|
||||
|
||||
async fn print_job(id: Uuid, db: &DB) -> Result<(), anyhow::Error> {
|
||||
tracing::info!(
|
||||
"{:#?}",
|
||||
get_job_by_id(db.begin().await?, "test-workspace", id)
|
||||
.await?
|
||||
.0
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn flow() -> FlowValue {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"modules": [{
|
||||
@@ -2700,19 +2706,32 @@ def main():
|
||||
"content": "\
|
||||
export async function main(n, port, op) {\
|
||||
const job = Deno.env.get('WM_JOB_ID');
|
||||
const token = Deno.env.get('WM_TOKEN');
|
||||
const r = await fetch(
|
||||
`http://localhost:${port}/w/test-workspace/jobs/${op}/${job}`,\
|
||||
`http://localhost:${port}/api/w/test-workspace/jobs/job_signature/${job}/0?token=${token}`,\
|
||||
{\
|
||||
method: 'GET',\
|
||||
headers: { 'Authorization': `Bearer ${token}` }\
|
||||
}\
|
||||
);\
|
||||
console.log(r);\
|
||||
const secret = await r.text();\
|
||||
console.log('Secret: ' + secret + ' ' + job + ' ' + token);\
|
||||
const r2 = await fetch(
|
||||
`http://localhost:${port}/api/w/test-workspace/jobs/${op}/${job}/0/${secret}`,\
|
||||
{\
|
||||
method: 'POST',\
|
||||
body: JSON.stringify('from job'),\
|
||||
headers: { 'content-type': 'application/json' }\
|
||||
}\
|
||||
);\
|
||||
console.log(r);
|
||||
console.log(await r2.text());\
|
||||
return n + 1;\
|
||||
}",
|
||||
},
|
||||
"suspend": 1,
|
||||
"suspend": {
|
||||
"required_events": 1
|
||||
},
|
||||
}, {
|
||||
"input_transform": {
|
||||
"n": { "type": "javascript", "expr": "previous_result", },
|
||||
@@ -2724,7 +2743,9 @@ def main():
|
||||
"language": "deno",
|
||||
"content": "export function main(n, resume, resumes) { return { n: n + 1, resume, resumes } }"
|
||||
},
|
||||
"suspend": 1,
|
||||
"suspend": {
|
||||
"required_events": 1
|
||||
},
|
||||
}, {
|
||||
"input_transform": {
|
||||
"last": { "type": "javascript", "expr": "previous_result", },
|
||||
@@ -2762,19 +2783,34 @@ def main():
|
||||
let db = db_;
|
||||
|
||||
wait_until_flow_suspends(flow, queue, &db).await;
|
||||
// print_job(flow, &db).await;
|
||||
/* The first job resumes itself. */
|
||||
let _first = completed.next().await.unwrap();
|
||||
// print_job(_first, &db).await;
|
||||
|
||||
/* ... and send a request resume it. */
|
||||
let second = completed.next().await.unwrap();
|
||||
// print_job(second, &db).await;
|
||||
|
||||
let token = create_token_for_owner(&db, "test-workspace", "u/test-user", "", 100, "").await.unwrap();
|
||||
let secret = reqwest::get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/jobs/job_signature/{second}/0?token={token}"
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.text().await.unwrap();
|
||||
println!("{}", secret);
|
||||
|
||||
/* ImZyb20gdGVzdCIK = base64 "from test" */
|
||||
reqwest::get(format!(
|
||||
"http://localhost:{port}/w/test-workspace/jobs/resume/{second}?payload=ImZyb20gdGVzdCIK"
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap();
|
||||
"http://localhost:{port}/api/w/test-workspace/jobs/resume/{second}/0/{secret}?payload=ImZyb20gdGVzdCIK"
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap();
|
||||
|
||||
completed.find(&flow).await.unwrap();
|
||||
})
|
||||
@@ -2852,13 +2888,25 @@ def main():
|
||||
/* ... and send a request resume it. */
|
||||
let second = completed.next().await.unwrap();
|
||||
|
||||
let token = create_token_for_owner(&db, "test-workspace", "u/test-user", "", 100, "").await.unwrap();
|
||||
let secret = reqwest::get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/jobs/job_signature/{second}/0?token={token}"
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.text().await.unwrap();
|
||||
println!("{}", secret);
|
||||
|
||||
/* ImZyb20gdGVzdCIK = base64 "from test" */
|
||||
reqwest::get(format!(
|
||||
"http://localhost:{port}/w/test-workspace/jobs/cancel/{second}?payload=ImZyb20gdGVzdCIK"
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap();
|
||||
"http://localhost:{port}/api/w/test-workspace/jobs/cancel/{second}/0/{secret}?payload=ImZyb20gdGVzdCIK"
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap();
|
||||
|
||||
completed.find(&flow).await.unwrap();
|
||||
})
|
||||
@@ -3213,7 +3261,7 @@ def main(error, port):
|
||||
payload,
|
||||
Some(args),
|
||||
/* user */ "test-user",
|
||||
/* permissioned_as */ Default::default(),
|
||||
/* permissioned_as */ "u/admin".to_string(),
|
||||
/* scheduled_for_o */ None,
|
||||
/* schedule_path */ None,
|
||||
/* parent_job */ None,
|
||||
|
||||
+16
-14
@@ -4,7 +4,7 @@ use std::time::Duration;
|
||||
use crate::{
|
||||
db::DB,
|
||||
error::{self, Error},
|
||||
flows::{FlowModule, FlowModuleValue, FlowValue, InputTransform, Retry},
|
||||
flows::{FlowModule, FlowModuleValue, FlowValue, InputTransform, Retry, Suspend},
|
||||
jobs::{
|
||||
add_completed_job, add_completed_job_error, get_queued_job, postprocess_queued_job, push,
|
||||
script_path_to_payload, JobPayload, QueuedJob,
|
||||
@@ -623,7 +623,7 @@ async fn push_next_flow_job(
|
||||
&status_module,
|
||||
FlowStatusModule::WaitingForPriorSteps | FlowStatusModule::WaitingForEvents { .. }
|
||||
) {
|
||||
if let Some((count, last)) = needs_resume(&flow, &status) {
|
||||
if let Some((suspend, last)) = needs_resume(&flow, &status) {
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
/* Lock this row to prevent the suspend column getting out out of sync
|
||||
@@ -652,7 +652,8 @@ async fn push_next_flow_job(
|
||||
|
||||
resume_messages.extend(resumes.into_iter().map(|r| r.value));
|
||||
|
||||
if is_cancelled.is_none() && resume_messages.len() >= count as usize {
|
||||
let required_events = suspend.required_events.unwrap() as u16;
|
||||
if is_cancelled.is_none() && resume_messages.len() >= required_events as usize {
|
||||
/* If we are woken up after suspending, last_result will be the flow args, but we
|
||||
* should use the result from the last job */
|
||||
if let FlowStatusModule::WaitingForEvents { .. } = &status_module {
|
||||
@@ -663,10 +664,6 @@ async fn push_next_flow_job(
|
||||
.context("previous job result")?;
|
||||
}
|
||||
|
||||
sqlx::query!("DELETE FROM resume_job WHERE job = $1", last)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
|
||||
/* continue on and run this job! */
|
||||
tx.commit().await?;
|
||||
|
||||
@@ -683,9 +680,9 @@ async fn push_next_flow_job(
|
||||
WHERE id = $4
|
||||
",
|
||||
)
|
||||
.bind(json!(FlowStatusModule::WaitingForEvents { count, job: last }))
|
||||
.bind((count - resume_messages.len() as u16) as i32)
|
||||
.bind(30 * MINUTES)
|
||||
.bind(json!(FlowStatusModule::WaitingForEvents { count: required_events, job: last }))
|
||||
.bind((required_events - resume_messages.len() as u16) as i32)
|
||||
.bind(suspend.timeout.map(|t| Duration::from_secs(t.into())).unwrap_or_else(|| 30 * MINUTES))
|
||||
.bind(flow_job.id)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
@@ -1333,18 +1330,23 @@ fn from_now(duration: Duration) -> chrono::DateTime<chrono::Utc> {
|
||||
}
|
||||
|
||||
/// returns previous module non-zero suspend count and job
|
||||
fn needs_resume(flow: &FlowValue, status: &FlowStatus) -> Option<(u16, Uuid)> {
|
||||
fn needs_resume(flow: &FlowValue, status: &FlowStatus) -> Option<(Suspend, Uuid)> {
|
||||
let prev = usize::try_from(status.step)
|
||||
.ok()
|
||||
.and_then(|s| s.checked_sub(1))?;
|
||||
|
||||
let suspend = flow.modules.get(prev)?.suspend;
|
||||
if suspend == 0 {
|
||||
let suspend = flow.modules.get(prev)?.suspend.clone();
|
||||
if suspend
|
||||
.as_ref()
|
||||
.and_then(|s| s.required_events)
|
||||
.unwrap_or(0)
|
||||
== 0
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
if let &FlowStatusModule::Success { job, .. } = status.modules.get(prev)? {
|
||||
Some((suspend, job))
|
||||
Some((suspend.unwrap(), job))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -37,7 +37,10 @@
|
||||
if (isSuspendEnabled && flowModule.suspend != undefined) {
|
||||
flowModule.suspend = undefined
|
||||
} else {
|
||||
flowModule.suspend = 1
|
||||
flowModule.suspend = {
|
||||
required_events: 1,
|
||||
timeout: 1800
|
||||
}
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
@@ -48,7 +51,15 @@
|
||||
<span class="text-xs font-bold">Number of events to wait for</span>
|
||||
|
||||
{#if flowModule.suspend}
|
||||
<input bind:value={flowModule.suspend} type="number" min="1" placeholder="1" />
|
||||
<input bind:value={flowModule.suspend.required_events} type="number" min="1" placeholder="1" />
|
||||
{:else}
|
||||
<input type="number" disabled />
|
||||
{/if}
|
||||
|
||||
<span class="text-xs font-bold">Timeout (in seconds)</span>
|
||||
|
||||
{#if flowModule.suspend}
|
||||
<input bind:value={flowModule.suspend.timeout} type="number" min="1" placeholder="1800" />
|
||||
{:else}
|
||||
<input type="number" disabled />
|
||||
{/if}
|
||||
|
||||
@@ -91,7 +91,12 @@ components:
|
||||
summary:
|
||||
type: string
|
||||
suspend:
|
||||
type: integer
|
||||
type: object
|
||||
properties:
|
||||
required_events:
|
||||
type: integer
|
||||
timeout:
|
||||
type: integer
|
||||
retry:
|
||||
$ref: "#/components/schemas/Retry"
|
||||
required:
|
||||
|
||||
Reference in New Issue
Block a user