Files
windmill/backend/windmill-common/src/webhook.rs
Ruben Fiszel c580572252 refactor: extract windmill-api-scripts and windmill-api-users subcrates (#7850)
* 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>
2026-02-08 12:12:03 +00:00

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");
}
}