* refactor: extract windmill-api-scripts and windmill-api-users subcrates Split the monolithic windmill-api crate by extracting scripts.rs, flows.rs, users.rs, and users_oss.rs into dedicated subcrates. This reduces incremental rebuild times when editing these modules. Changes: - Create windmill-api-scripts crate (scripts.rs + flows.rs, ~4.3K lines) - Create windmill-api-users crate (users.rs + users_oss.rs, ~2.4K lines) - Move clear_schedule to windmill-queue (shared by scripts, flows, workspaces) - Move username utilities (VALID_USERNAME, INVALID_USERNAME_CHARS, generate_instance_wide_unique_username) to windmill-common/src/usernames.rs - Move COOKIE_DOMAIN, IS_SECURE, WithStarredInfoQuery, BulkDeleteRequest, WebhookShared to windmill-common for cross-crate access - Original files in windmill-api become thin stubs with pub use re-exports - EE-dependent route handlers remain in windmill-api (create_user, rename_user, set_password, reset_password, etc.) - Feature forwarding for enterprise, private, parquet, no_auth Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: extract windmill-api-workspaces subcrate (Step 3) Move workspaces.rs, workspaces_extra.rs, workspaces_oss.rs, and workspaces_ee.rs into a new windmill-api-workspaces crate (~7K lines). Routes that depend on windmill-api internals (AI copilot, teams, tarball export, critical alerts, stripe) remain in the windmill-api stub. The subcrate handles all other workspace management routes. Also moved send_email_if_possible to windmill-common/email_oss.rs to make it available across subcrates without circular deps. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * all * refactor: extract windmill-api-groups subcrate (groups.rs + folders.rs) Extract groups.rs (1,093 lines) and folders.rs (833 lines) into a new windmill-api-groups subcrate. Both modules had clean dependencies on already-extracted crates (windmill-api-auth, windmill-common, windmill-api-workspaces). Also removes unused re-exports of get_instance_username_or_create_pending and INVALID_USERNAME_CHARS from windmill-api/src/utils.rs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: add granular_acls.rs and folder_history.rs to windmill-api-groups Extract granular_acls.rs (395 lines) and folder_history.rs (68 lines) into the windmill-api-groups subcrate. Both modules only depend on already-extracted crates and belong to the same access-control domain as groups and folders. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove unused imports and dead code from subcrate extraction - Remove unused BASE_URL import from lib.rs - Remove workspaces_extra.rs and workspaces_oss.rs re-export stubs (no consumers in windmill-api) - Remove dead send_email_if_possible OSS stub (callers moved to windmill-api-users) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * all * chore: bust CI cargo cache for subcrate split Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: re-export BASE_URL for EE files that use crate::BASE_URL Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: forward no_auth feature to windmill-api-users, remove dead code - Add "windmill-api-users/no_auth" to windmill-api's no_auth feature so the login bypass in users.rs:1600 activates correctly - Remove dead send_email_if_possible from windmill-api-users/users_oss.rs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: re-enable cargo cache for backend tests Cache was disabled to bust stale entries from before subcrate split. Now that a clean build has run, re-enable for faster CI. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: install mold+clang in CI workflows The .cargo/config.toml uses mold linker for x86_64-linux. Build scripts require linking even during cargo check. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: increase cargo test timeout to 30 min Exit code 143 (SIGTERM) means the 20-min timeout was hit during compilation without cache. Bump to 30 min as safety net. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: limit cargo build jobs to 4 to prevent OOM in CI Exit code 143 (SIGTERM) after 8 min = OOM kill during compilation. 8 parallel LLVM codegen jobs exhaust memory on ubicloud-standard-8. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
293 lines
12 KiB
Rust
293 lines
12 KiB
Rust
use std::time::Duration;
|
|
|
|
use quick_cache::sync::Cache;
|
|
use serde::Serialize;
|
|
use tokio::{select, sync::mpsc};
|
|
|
|
#[cfg(feature = "prometheus")]
|
|
use crate::METRICS_ENABLED;
|
|
|
|
use crate::db::DB;
|
|
use crate::oauth2::InstanceEvent;
|
|
use crate::utils::configure_client;
|
|
|
|
#[cfg(feature = "prometheus")]
|
|
lazy_static::lazy_static! {
|
|
// TODO: these aren't synced, they should be moved into the queue abstraction once/if that happens.
|
|
static ref WEBHOOK_REQUEST_COUNT: prometheus::Histogram = prometheus::register_histogram!(
|
|
"webhook_request",
|
|
"Histogram of webhook requests made"
|
|
)
|
|
.unwrap();
|
|
|
|
}
|
|
|
|
lazy_static::lazy_static! {
|
|
|
|
pub static ref INSTANCE_EVENTS_WEBHOOK: Option<String> = std::env::var("INSTANCE_EVENTS_WEBHOOK").ok();
|
|
|
|
pub static ref WEBHOOK_CACHE: Cache<String, Option<String>> = Cache::new(100);
|
|
|
|
}
|
|
|
|
pub enum WebhookPayload {
|
|
WorkspaceEvent(String, WebhookMessage),
|
|
InstanceEvent(InstanceEvent),
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(tag = "type")]
|
|
pub enum WebhookMessage {
|
|
// See https://serde.rs/enum-representations.html#internally-tagged for how this looks in JSON
|
|
CreateApp { workspace: String, path: String },
|
|
DeleteApp { workspace: String, path: String },
|
|
UpdateApp { workspace: String, old_path: String, new_path: String },
|
|
CreateFlow { workspace: String, path: String },
|
|
UpdateFlow { workspace: String, old_path: String, new_path: String },
|
|
ArchiveFlow { workspace: String, path: String },
|
|
DeleteFlow { workspace: String, path: String },
|
|
CreateFolder { workspace: String, name: String },
|
|
UpdateFolder { workspace: String, name: String },
|
|
DeleteFolder { workspace: String, name: String },
|
|
DeleteResource { workspace: String, path: String },
|
|
CreateResource { workspace: String, path: String },
|
|
UpdateResource { workspace: String, old_path: String, new_path: String },
|
|
CreateResourceType { name: String },
|
|
DeleteResourceType { name: String },
|
|
UpdateResourceType { name: String },
|
|
CreateScript { workspace: String, path: String, hash: String },
|
|
UpdateScript { workspace: String, path: String, hash: String },
|
|
DeleteScript { workspace: String, hash: String },
|
|
DeleteScriptPath { workspace: String, path: String },
|
|
CreateVariable { workspace: String, path: String },
|
|
UpdateVariable { workspace: String, old_path: String, new_path: String },
|
|
DeleteVariable { workspace: String, path: String },
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct WebhookShared {
|
|
pub channel: mpsc::UnboundedSender<WebhookPayload>,
|
|
}
|
|
|
|
impl WebhookShared {
|
|
pub fn new(mut shutdown_rx: tokio::sync::broadcast::Receiver<()>, db: DB) -> Self {
|
|
let (tx, mut rx) = mpsc::unbounded_channel::<WebhookPayload>();
|
|
let _process = tokio::spawn(async move {
|
|
let client = configure_client(
|
|
reqwest::Client::builder()
|
|
.connect_timeout(Duration::from_secs(5))
|
|
// TODO: investigate pool timeouts and such if TCP load is high
|
|
.timeout(Duration::from_secs(5)),
|
|
)
|
|
.build()
|
|
.unwrap();
|
|
|
|
loop {
|
|
select! {
|
|
biased;
|
|
_ = shutdown_rx.recv() => break,
|
|
r = rx.recv() => match r {
|
|
Some(WebhookPayload::WorkspaceEvent(workspace_id, message)) => {
|
|
let webhook_opt = match WEBHOOK_CACHE.get(&workspace_id) {
|
|
Some(guard) => {
|
|
guard
|
|
},
|
|
None => {
|
|
let Ok(mut webhook_opt) =
|
|
sqlx::query_scalar!(
|
|
"SELECT webhook FROM workspace_settings WHERE workspace_id = $1",
|
|
workspace_id
|
|
)
|
|
.fetch_one(
|
|
&db,
|
|
)
|
|
.await else {
|
|
tracing::error!("Webhook Message to send - but cannot get workspace settings! Workspace: {workspace_id}");
|
|
continue;
|
|
};
|
|
if webhook_opt.as_ref().is_some_and(|x| x.is_empty()) {
|
|
webhook_opt = None;
|
|
}
|
|
WEBHOOK_CACHE.insert(workspace_id, webhook_opt.clone());
|
|
webhook_opt
|
|
}
|
|
};
|
|
if let Some(url) = webhook_opt {
|
|
#[cfg(feature = "prometheus")]
|
|
let timer = if METRICS_ENABLED.load(std::sync::atomic::Ordering::Relaxed) { Some(WEBHOOK_REQUEST_COUNT.start_timer()) } else { None };
|
|
tracing::info!("Sending webhook message to {}", url);
|
|
let _ = client.post(url).json(&message).send().await;
|
|
#[cfg(feature = "prometheus")]
|
|
timer.map(|x| x.stop_and_record());
|
|
}
|
|
},
|
|
Some(WebhookPayload::InstanceEvent(event)) => {
|
|
#[cfg(feature = "prometheus")]
|
|
if METRICS_ENABLED.load(std::sync::atomic::Ordering::Relaxed) { Some(WEBHOOK_REQUEST_COUNT.start_timer()) } else { None };
|
|
let r = client.post(INSTANCE_EVENTS_WEBHOOK.as_ref().unwrap()).json(&event).send().await;
|
|
if let Err(e) = r {
|
|
tracing::error!("Error sending instance event: {}", e);
|
|
}
|
|
},
|
|
None => break,
|
|
},
|
|
}
|
|
}
|
|
});
|
|
|
|
Self { channel: tx }
|
|
}
|
|
|
|
pub fn send_message(&self, workspace_id: String, message: WebhookMessage) {
|
|
let _ = self.channel.send(WebhookPayload::WorkspaceEvent(
|
|
workspace_id.clone(),
|
|
message,
|
|
));
|
|
}
|
|
|
|
pub fn send_instance_event(&self, event: InstanceEvent) {
|
|
if INSTANCE_EVENTS_WEBHOOK.is_none() {
|
|
return;
|
|
}
|
|
let _ = self.channel.send(WebhookPayload::InstanceEvent(event));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_webhook_message_create_script() {
|
|
let msg = WebhookMessage::CreateScript {
|
|
workspace: "demo".to_string(),
|
|
path: "f/test/script".to_string(),
|
|
hash: "abc123".to_string(),
|
|
};
|
|
let json = serde_json::to_value(&msg).unwrap();
|
|
assert_eq!(json["type"], "CreateScript");
|
|
assert_eq!(json["workspace"], "demo");
|
|
assert_eq!(json["path"], "f/test/script");
|
|
assert_eq!(json["hash"], "abc123");
|
|
}
|
|
|
|
#[test]
|
|
fn test_webhook_message_update_flow() {
|
|
let msg = WebhookMessage::UpdateFlow {
|
|
workspace: "staging".to_string(),
|
|
old_path: "f/old/flow".to_string(),
|
|
new_path: "f/new/flow".to_string(),
|
|
};
|
|
let json = serde_json::to_value(&msg).unwrap();
|
|
assert_eq!(json["type"], "UpdateFlow");
|
|
assert_eq!(json["old_path"], "f/old/flow");
|
|
assert_eq!(json["new_path"], "f/new/flow");
|
|
}
|
|
|
|
#[test]
|
|
fn test_webhook_message_delete_resource() {
|
|
let msg = WebhookMessage::DeleteResource {
|
|
workspace: "prod".to_string(),
|
|
path: "u/admin/db".to_string(),
|
|
};
|
|
let json = serde_json::to_value(&msg).unwrap();
|
|
assert_eq!(json["type"], "DeleteResource");
|
|
assert_eq!(json["workspace"], "prod");
|
|
assert_eq!(json["path"], "u/admin/db");
|
|
}
|
|
|
|
#[test]
|
|
fn test_webhook_message_create_folder() {
|
|
let msg = WebhookMessage::CreateFolder {
|
|
workspace: "demo".to_string(),
|
|
name: "shared".to_string(),
|
|
};
|
|
let json = serde_json::to_value(&msg).unwrap();
|
|
assert_eq!(json["type"], "CreateFolder");
|
|
assert_eq!(json["name"], "shared");
|
|
}
|
|
|
|
#[test]
|
|
fn test_webhook_message_resource_type() {
|
|
let msg = WebhookMessage::CreateResourceType { name: "postgresql".to_string() };
|
|
let json = serde_json::to_value(&msg).unwrap();
|
|
assert_eq!(json["type"], "CreateResourceType");
|
|
assert_eq!(json["name"], "postgresql");
|
|
// Should NOT have workspace field
|
|
assert!(json.get("workspace").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_webhook_message_all_variants_have_type_tag() {
|
|
let messages: Vec<WebhookMessage> = vec![
|
|
WebhookMessage::CreateApp { workspace: "w".into(), path: "p".into() },
|
|
WebhookMessage::DeleteApp { workspace: "w".into(), path: "p".into() },
|
|
WebhookMessage::UpdateApp {
|
|
workspace: "w".into(),
|
|
old_path: "o".into(),
|
|
new_path: "n".into(),
|
|
},
|
|
WebhookMessage::CreateFlow { workspace: "w".into(), path: "p".into() },
|
|
WebhookMessage::UpdateFlow {
|
|
workspace: "w".into(),
|
|
old_path: "o".into(),
|
|
new_path: "n".into(),
|
|
},
|
|
WebhookMessage::ArchiveFlow { workspace: "w".into(), path: "p".into() },
|
|
WebhookMessage::DeleteFlow { workspace: "w".into(), path: "p".into() },
|
|
WebhookMessage::CreateFolder { workspace: "w".into(), name: "n".into() },
|
|
WebhookMessage::UpdateFolder { workspace: "w".into(), name: "n".into() },
|
|
WebhookMessage::DeleteFolder { workspace: "w".into(), name: "n".into() },
|
|
WebhookMessage::DeleteResource { workspace: "w".into(), path: "p".into() },
|
|
WebhookMessage::CreateResource { workspace: "w".into(), path: "p".into() },
|
|
WebhookMessage::UpdateResource {
|
|
workspace: "w".into(),
|
|
old_path: "o".into(),
|
|
new_path: "n".into(),
|
|
},
|
|
WebhookMessage::CreateResourceType { name: "n".into() },
|
|
WebhookMessage::DeleteResourceType { name: "n".into() },
|
|
WebhookMessage::UpdateResourceType { name: "n".into() },
|
|
WebhookMessage::CreateScript {
|
|
workspace: "w".into(),
|
|
path: "p".into(),
|
|
hash: "h".into(),
|
|
},
|
|
WebhookMessage::UpdateScript {
|
|
workspace: "w".into(),
|
|
path: "p".into(),
|
|
hash: "h".into(),
|
|
},
|
|
WebhookMessage::DeleteScript { workspace: "w".into(), hash: "h".into() },
|
|
WebhookMessage::DeleteScriptPath { workspace: "w".into(), path: "p".into() },
|
|
WebhookMessage::CreateVariable { workspace: "w".into(), path: "p".into() },
|
|
WebhookMessage::UpdateVariable {
|
|
workspace: "w".into(),
|
|
old_path: "o".into(),
|
|
new_path: "n".into(),
|
|
},
|
|
WebhookMessage::DeleteVariable { workspace: "w".into(), path: "p".into() },
|
|
];
|
|
|
|
for msg in &messages {
|
|
let json = serde_json::to_value(msg).unwrap();
|
|
assert!(
|
|
json.get("type").is_some(),
|
|
"Missing 'type' tag in: {}",
|
|
serde_json::to_string(msg).unwrap()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_webhook_message_type_tags_are_variant_names() {
|
|
let msg = WebhookMessage::CreateApp { workspace: "w".into(), path: "p".into() };
|
|
let json = serde_json::to_value(&msg).unwrap();
|
|
assert_eq!(json["type"], "CreateApp");
|
|
|
|
let msg = WebhookMessage::DeleteVariable { workspace: "w".into(), path: "p".into() };
|
|
let json = serde_json::to_value(&msg).unwrap();
|
|
assert_eq!(json["type"], "DeleteVariable");
|
|
}
|
|
}
|