Files
windmill/backend/windmill-api/src/triggers/handler.rs
T
d6c642b170 feat: add Azure Event Grid triggers (#8888)
* feat: add Azure Event Grid triggers (EE)

Introduces a new enterprise trigger kind `azure` that supports three
modes via a single unified trigger type:
- basic_push: Azure Event Grid basic — custom topics, system topics
  (Storage, Resource Manager, Key Vault, etc.), domains (push only)
- namespace_push: Event Grid Namespace topics (CloudEvents over HTTP push)
- namespace_pull: Event Grid Namespace topics (HTTP pull with lock-token
  ack/reject for dead-lettering)

Auth uses a Service Principal resource (tenant_id, client_id,
client_secret, subscription_id). Subscriptions are created in
CloudEvents 1.0 schema so the push webhook handler and the pull listener
share one payload parser.

Backend
- New crate `windmill-trigger-azure` (OSS stubs + EE impl symlinked from
  windmill-ee-private)
- Migration `azure_trigger` table with CHECK constraints enforcing
  mode/columns coherence
- `TriggerKind::Azure`, `JobTriggerKind::Azure`,
  `DeployedObject::AzureTrigger` variants
- Push route `/api/azure/w/{workspace}/*path` handles classic
  Event Grid SubscriptionValidation handshake and CloudEvents 1.0
  abuse-protection OPTIONS handshake
- Optional inbound JWT validation (audience check only for v1)
- Feature flag `azure_trigger` propagated through windmill-api,
  windmill-store (resource helper), and added to ee_core

Frontend
- `triggers/azure/` editor with mode toggle (basic/namespace-push/
  namespace-pull) and per-mode config (topic ARM id / namespace +
  topic name / subscription / filters / push auth / pull options)
- Registered in icon map, display names, save functions, badge,
  wrapper, editor, add-trigger menu

OpenAPI
- `AzureTrigger`, `AzureTriggerData`, `AzureMode`,
  `AzureSubscriptionMode`, `AzureDeliveryConfig`, `TestAzureConnection`
  schemas; `/azure_triggers/*` endpoints; client regenerated

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to eaa7c3a9cb37a9ccc93f10a2535d929365acd2d8

This commit updates the EE repository reference after PR #541 was merged in windmill-ee-private.

Previous ee-repo-ref: 9689014e8c12c36c1059fd8fa5758d550b8b8bc9

New ee-repo-ref: eaa7c3a9cb37a9ccc93f10a2535d929365acd2d8

Automated by sync-ee-ref workflow.

* feat(azure-trigger): secret-auth push, ARM discovery, capture isolation, CLI + parity

Frontend:
- Split mode selector into Namespace/Basic + Pull/Push
- ARM resource dropdowns (namespaces, Basic topics, namespace topics)
  populated from the service principal; cascade with stale-selection
  reset on SP / edition change
- Remove stale authenticate toggle + audience input (server-managed
  push_auth_config has replaced them)
- Azure listing page: "Create from template" button; "Also delete Azure
  subscription" toggle in the delete modal; simplified trigger label
  falling back to path
- AzureCapture.svelte: "Test subscription name" with -wm-capture suffix
- CompareWorkspaces.svelte: wire Azure for fork/compare
- Drop Trigger-deployed/event-loss warning (capture subscription is
  isolated with -wm-capture)

Backend:
- Shared-secret push auth (see EE crate for detail)
- JSONB push_auth_config column (renamed from delivery_config), #[serde(skip)]
  so clients/CLI/exports never see it
- Drop redundant enabled column; mode supersedes
- Azure capture infra: AzureTriggerConfig + set_azure_trigger_config +
  azure_payload route + TriggerKind::Azure arm; PT15M queue TTL on
  capture subscriptions so they bound storage after tab close
- Granular ACLs, users offboarding, trash, git-sync deployed-object:
  all include azure_trigger

CLI:
- Add azure to TRIGGER_TYPES, pushObj dispatch, getTypeStrFromPath,
  trigger commands (get/update/create/list/template), sync delete
  switch + regex; e2e test for `trigger new --kind azure`
- system_prompts: SCHEMA_MAPPINGS + schema_names include AzureTrigger;
  auto-generated/* regenerated

Skill:
- .claude/skills/adding-a-trigger/ checklist covering every file that
  needs editing when wiring a new trigger type (learned from this PR)

ee-repo-ref bumped to b0e490cbf3724b7b64c6a5b010e3bdf24acd873c.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(azure-trigger): ci — ShareModal Kind + regenerated system_prompts

- frontend/src/lib/components/ShareModal.svelte: add 'azure_trigger'
  to the Kind type so the listing page's "Permissions" action compiles
  (ts2345 — caught by npm_check on CI, missed by fast-check locally).
- system_prompts/auto-generated/: regenerate to drop the stale
  delivery_config / AzureDeliveryConfig fields from the Azure schema
  (check-freshness on CI).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(azure-trigger): use workspace constant_time_eq crate

Drop hand-rolled constant-time compare in favour of the workspace
constant_time_eq crate (same one used by http_trigger_auth).

ee-repo-ref bumped to 9659382d47286e7f7f66d01b6f5dd8d4ed34848b.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(azure-trigger): pass placeholder + disabled via inputProps

`TextInput`'s `placeholder` and `disabled` go through its `inputProps`
prop — CI's `npm run check` caught the stale top-level passing that
`npm run check:fast` missed. Align with the DefaultEmailConfigSection
pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(azure-trigger): correct LATEST_GIT_SYNC_SCRIPT_PATH version to 28213

The hub deploy of the azure-aware sync-script is version 28213, not
28214. Backend was pinning a non-existent hub script, which broke the
git_sync_e2e suite (every deploy's sync step 404'd).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(azure-trigger): add azure_triggers to token scope selector + skill

- windmill-api/src/token.rs: `build_trigger_scope_domains` was missing
  `("azure_triggers", "Azure Event Grid")`, so the CreateToken UI's scope
  selector didn't surface azure_triggers:read/write. Backend already had
  `ScopeDomain::AzureTriggers` wired (scopes.rs), this just exposes it.
- .claude/skills/adding-a-trigger/SKILL.md: capture both scope-related
  files under the hardcoded-arrays section so future triggers don't miss
  the UI surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(adding-a-trigger-skill): clarify token.rs scope effect

Not a regression — nothing was working before. Skipping TRIGGER_DOMAINS
just means the scope works via API/CLI but has no UI checkbox.

* docs(adding-a-trigger-skill): trim token.rs bullet

* fix(azure-trigger): regen openapi-deref + swap textarea for TextInput

- Run build_openapi.sh to regenerate openapi-deref.{yaml,json} with the
  12 azure_triggers paths + schemas. These files are served by the
  runtime (include_str! in windmill-api/src/lib.rs) to external SDK
  consumers; without this regen the new endpoints wouldn't be advertised.
- Replace the raw <textarea> for event type filters with the
  design-system TextInput in textarea mode (frontend/CLAUDE.md bans raw
  HTML elements).

Addresses cubic + claude PR review items.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-04-23 16:30:18 +00:00

373 lines
11 KiB
Rust

use axum::{routing::post, Router};
use serde::{Deserialize, Serialize};
use windmill_common::{error::JsonResult, DB};
#[allow(unused_imports)]
use windmill_trigger::handler::complete_trigger_routes;
#[allow(unused_imports)]
use windmill_trigger::TriggerCrud;
pub fn generate_trigger_routers() -> Router {
#[allow(unused_mut)]
let mut router = Router::new();
#[cfg(feature = "http_trigger")]
{
use crate::triggers::http::HttpTrigger;
router = router.nest(
HttpTrigger::ROUTE_PREFIX,
complete_trigger_routes(HttpTrigger),
);
}
#[cfg(feature = "websocket")]
{
use crate::triggers::websocket::WebsocketTrigger;
router = router.nest(
WebsocketTrigger::ROUTE_PREFIX,
complete_trigger_routes(WebsocketTrigger),
);
}
#[cfg(all(feature = "enterprise", feature = "kafka", feature = "private"))]
{
use crate::triggers::kafka::KafkaTrigger;
router = router.nest(
KafkaTrigger::ROUTE_PREFIX,
complete_trigger_routes(KafkaTrigger),
);
}
#[cfg(all(feature = "enterprise", feature = "nats", feature = "private"))]
{
use crate::triggers::nats::NatsTrigger;
router = router.nest(
NatsTrigger::ROUTE_PREFIX,
complete_trigger_routes(NatsTrigger),
);
}
#[cfg(feature = "mqtt_trigger")]
{
use crate::triggers::mqtt::MqttTrigger;
router = router.nest(
MqttTrigger::ROUTE_PREFIX,
complete_trigger_routes(MqttTrigger),
);
}
#[cfg(all(feature = "enterprise", feature = "sqs_trigger", feature = "private"))]
{
use crate::triggers::sqs::SqsTrigger;
router = router.nest(
SqsTrigger::ROUTE_PREFIX,
complete_trigger_routes(SqsTrigger),
);
}
#[cfg(all(feature = "enterprise", feature = "gcp_trigger", feature = "private"))]
{
use crate::triggers::gcp::GcpTrigger;
router = router.nest(
GcpTrigger::ROUTE_PREFIX,
complete_trigger_routes(GcpTrigger),
);
}
#[cfg(all(feature = "enterprise", feature = "azure_trigger", feature = "private"))]
{
use crate::triggers::azure::AzureTrigger;
router = router.nest(
AzureTrigger::ROUTE_PREFIX,
complete_trigger_routes(AzureTrigger),
);
}
#[cfg(feature = "postgres_trigger")]
{
use crate::triggers::postgres::PostgresTrigger;
router = router.nest(
PostgresTrigger::ROUTE_PREFIX,
complete_trigger_routes(PostgresTrigger),
);
}
#[cfg(all(feature = "smtp", feature = "private"))]
{
use crate::triggers::email::EmailTrigger;
router = router.nest(
EmailTrigger::ROUTE_PREFIX,
complete_trigger_routes(EmailTrigger),
);
}
{
use windmill_trigger::global_handler::{
cancel_suspended_trigger_jobs, resume_suspended_trigger_jobs,
};
router = router
.route(
"/trigger/{trigger_kind}/resume_suspended_trigger_jobs/{*trigger_path}",
post(resume_suspended_trigger_jobs),
)
.route(
"/trigger/{trigger_kind}/cancel_suspended_trigger_jobs/{*trigger_path}",
post(cancel_suspended_trigger_jobs),
);
}
router
}
#[derive(Serialize, Deserialize, Debug)]
pub struct TriggersCount {
primary_schedule: Option<windmill_trigger::handler::TriggerPrimarySchedule>,
schedule_count: i64,
http_routes_count: i64,
webhook_count: i64,
email_count: i64,
default_email_count: i64,
websocket_count: i64,
kafka_count: i64,
nats_count: i64,
postgres_count: i64,
mqtt_count: i64,
sqs_count: i64,
gcp_count: i64,
azure_count: i64,
nextcloud_count: i64,
google_count: i64,
github_count: i64,
}
pub async fn get_triggers_count_internal(
db: &DB,
w_id: &str,
path: &str,
is_flow: bool,
) -> JsonResult<TriggersCount> {
let primary_schedule = sqlx::query_scalar!(
"SELECT schedule FROM schedule WHERE path = $1 AND script_path = $1 AND is_flow = $2 AND workspace_id = $3",
path,
is_flow,
w_id
)
.fetch_optional(db)
.await?;
let schedule_count = sqlx::query_scalar!(
"SELECT COUNT(*) FROM schedule WHERE script_path = $1 AND is_flow = $2 AND workspace_id = $3",
path,
is_flow,
w_id
)
.fetch_one(db)
.await?
.unwrap_or(0);
#[allow(unused)]
let mut tx = db.begin().await?;
#[cfg(feature = "http_trigger")]
let http_routes_count = {
use crate::triggers::http::HttpTrigger;
let count = HttpTrigger
.trigger_count(&mut tx, w_id, is_flow, path)
.await;
count
};
#[cfg(not(feature = "http_trigger"))]
let http_routes_count = 0;
#[cfg(feature = "websocket")]
let websocket_count = {
use crate::triggers::websocket::WebsocketTrigger;
let count = WebsocketTrigger
.trigger_count(&mut tx, w_id, is_flow, path)
.await;
count
};
#[cfg(not(feature = "websocket"))]
let websocket_count = 0;
#[cfg(all(feature = "kafka", feature = "enterprise", feature = "private"))]
let kafka_count = {
use crate::triggers::kafka::KafkaTrigger;
let count = KafkaTrigger
.trigger_count(&mut tx, w_id, is_flow, path)
.await;
count
};
#[cfg(not(all(feature = "kafka", feature = "enterprise", feature = "private")))]
let kafka_count = 0;
#[cfg(all(feature = "nats", feature = "enterprise", feature = "private"))]
let nats_count = {
use crate::triggers::nats::NatsTrigger;
let count = NatsTrigger
.trigger_count(&mut tx, w_id, is_flow, path)
.await;
count
};
#[cfg(not(all(feature = "nats", feature = "enterprise", feature = "private")))]
let nats_count = 0;
#[cfg(feature = "postgres_trigger")]
let postgres_count = {
use crate::triggers::postgres::PostgresTrigger;
let count = PostgresTrigger
.trigger_count(&mut tx, w_id, is_flow, path)
.await;
count
};
#[cfg(not(feature = "postgres_trigger"))]
let postgres_count = 0;
#[cfg(feature = "mqtt_trigger")]
let mqtt_count = {
use crate::triggers::mqtt::MqttTrigger;
let count = MqttTrigger
.trigger_count(&mut tx, w_id, is_flow, path)
.await;
count
};
#[cfg(not(feature = "mqtt_trigger"))]
let mqtt_count = 0;
#[cfg(all(feature = "sqs_trigger", feature = "enterprise", feature = "private"))]
let sqs_count = {
use crate::triggers::sqs::SqsTrigger;
let count = SqsTrigger.trigger_count(&mut tx, w_id, is_flow, path).await;
count
};
#[cfg(not(all(feature = "sqs_trigger", feature = "enterprise", feature = "private")))]
let sqs_count = 0;
#[cfg(all(feature = "gcp_trigger", feature = "enterprise", feature = "private"))]
let gcp_count = {
use crate::triggers::gcp::GcpTrigger;
let count = GcpTrigger.trigger_count(&mut tx, w_id, is_flow, path).await;
count
};
#[cfg(not(all(feature = "gcp_trigger", feature = "enterprise", feature = "private")))]
let gcp_count = 0;
#[cfg(all(feature = "azure_trigger", feature = "enterprise", feature = "private"))]
let azure_count = {
use crate::triggers::azure::AzureTrigger;
let count = AzureTrigger
.trigger_count(&mut tx, w_id, is_flow, path)
.await;
count
};
#[cfg(not(all(feature = "azure_trigger", feature = "enterprise", feature = "private")))]
let azure_count = 0;
#[cfg(all(feature = "smtp", feature = "enterprise", feature = "private"))]
let email_count = {
use crate::triggers::email::EmailTrigger;
let count = EmailTrigger
.trigger_count(&mut tx, w_id, is_flow, path)
.await;
count
};
#[cfg(not(all(feature = "smtp", feature = "enterprise", feature = "private")))]
let email_count = 0;
tx.commit().await?;
let webhook_count = (if is_flow {
sqlx::query_scalar!(
"SELECT COUNT(*) FROM token WHERE label LIKE 'webhook-%' AND workspace_id = $1 AND scopes @> ARRAY['run:flow/' || $2]::text[]",
w_id,
path,
)
} else {
sqlx::query_scalar!(
"SELECT COUNT(*) FROM token WHERE label LIKE 'webhook-%' AND workspace_id = $1 AND scopes @> ARRAY['run:' || $2]::text[]",
w_id,
path,
)
}).fetch_one(db)
.await?
.unwrap_or(0);
let default_email_count = (if is_flow {
sqlx::query_scalar!(
"SELECT COUNT(*) FROM token WHERE label LIKE 'email-%' AND workspace_id = $1 AND scopes @> ARRAY['run:flow/' || $2]::text[]",
w_id,
path,
)
} else {
sqlx::query_scalar!(
"SELECT COUNT(*) FROM token WHERE label LIKE 'email-%' AND workspace_id = $1 AND scopes @> ARRAY['run:script/' || $2]::text[]",
w_id,
path,
)
}).fetch_one(db)
.await?
.unwrap_or(0);
let nextcloud_count = sqlx::query_scalar!(
"SELECT COUNT(*) FROM native_trigger WHERE workspace_id = $1 AND script_path = $2 AND is_flow = $3 AND service_name = 'nextcloud'",
w_id,
path,
is_flow,
)
.fetch_one(db)
.await?
.unwrap_or(0);
let google_count = sqlx::query_scalar!(
"SELECT COUNT(*) FROM native_trigger WHERE workspace_id = $1 AND script_path = $2 AND is_flow = $3 AND service_name = 'google'",
w_id,
path,
is_flow,
)
.fetch_one(db)
.await?
.unwrap_or(0);
let github_count = sqlx::query_scalar!(
"SELECT COUNT(*) FROM native_trigger WHERE workspace_id = $1 AND script_path = $2 AND is_flow = $3 AND service_name = 'github'",
w_id,
path,
is_flow,
)
.fetch_one(db)
.await?
.unwrap_or(0);
Ok(axum::Json(TriggersCount {
primary_schedule: primary_schedule
.map(|s| windmill_trigger::handler::TriggerPrimarySchedule { schedule: s }),
schedule_count,
http_routes_count,
webhook_count,
default_email_count,
email_count,
websocket_count,
kafka_count,
nats_count,
postgres_count,
mqtt_count,
gcp_count,
azure_count,
sqs_count,
nextcloud_count,
google_count,
github_count,
}))
}