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>
This commit is contained in:
hugocasa
2026-04-23 18:30:18 +02:00
committed by GitHub
parent 7fa924e67e
commit d6c642b170
110 changed files with 6732 additions and 1454 deletions
+265
View File
@@ -0,0 +1,265 @@
---
name: adding-a-trigger
description: Checklist for adding a new TriggerCrud-based trigger type to Windmill (Azure, GCP, Kafka, etc.). Use when wiring a new trigger kind across backend, frontend, CLI, and capture infrastructure.
---
# Skill: Adding a New Trigger Type
Use this skill when adding a trigger kind that implements `TriggerCrud` (Kafka, GCP, Azure, MQTT, SQS, NATS, Postgres, Email…). For native triggers (Nextcloud, Google Drive — things wired through `windmill-native-triggers`), use the `native-trigger` skill instead.
The goal of this doc is to enumerate every file that needs to change. Missing any one of them leads to silent regressions: sync drops the trigger, capture button does nothing, workspace forks lose it, sidebar counters undercount. Follow the checklist top-to-bottom — each section is independent enough to be validated on its own.
Throughout this doc, substitute `{kind}` for the new trigger kind (`azure`, `kafka`, …), `{Kind}` for PascalCase (`Azure`, `Kafka`), `{KIND}` for SCREAMING (`AZURE`, `KAFKA`).
## Reference implementations
- **GCP** — closest analogue to Azure. Has push + pull, OIDC auth, ARM-like resource paths, capture handler. Grep for `gcp_trigger` / `GcpTrigger`.
- **Kafka** — simpler (pull-only, streaming). Good for trivial integrations.
- **Azure** — most recently added (2026). Shared-secret push auth, Event Grid namespaces + basic topics, ARM resource discovery, Namespace-pull data-plane. Grep for `azure_trigger` / `AzureTrigger`.
## 1. Database migration
Create a migration: `cargo sqlx migrate add -r add_{kind}_trigger` from `backend/`. Never write timestamps manually.
The `up.sql` usually defines:
- An optional enum type (e.g. `AZURE_MODE`) if the trigger has sub-kinds
- The `{kind}_trigger` table with at minimum these columns (mirrored from kafka/gcp):
- primary: `(workspace_id, path)`
- `script_path`, `is_flow`, `enabled`, `mode`, `permissioned_as`, `edited_by`, `email`
- `edited_at`, `error`, `server_id`, `last_server_ping`
- `error_handler_path`, `error_handler_args jsonb`, `retry jsonb`
- trigger-specific fields
- Indexes on foreign keys + any frequently-filtered columns
- Foreign key to `workspace`
Down migration drops the table and any enum types.
## 2. Backend crate (`windmill-trigger-{kind}`)
Create a new crate under `backend/windmill-trigger-{kind}/` with:
- `Cargo.toml`: features `enterprise`, `private` if EE, standard deps
- `src/lib.rs`: `pub use mod_ee::*;` behind `#[cfg(all(feature = "enterprise", feature = "private"))]`
- `src/mod_ee.rs`: core types + helpers
- `src/handler_ee.rs`: `TriggerCrud` impl + route handlers
- `src/listener_ee.rs`: (only if streaming/pull-based) `Listener` trait impl
Required in `mod_ee.rs`:
- `{Kind}Config` struct (persisted shape, `FromRow`)
- `{Kind}ConfigRequest` struct (what API receives — usually similar to Config but with validation fields)
- `{Kind}Trigger` unit struct (implements the traits)
- `impl TriggerJobArgs for {Kind}Trigger` — sets `TRIGGER_KIND`, `Payload`, `v1_payload_fn`
Required in `handler_ee.rs`:
- `#[async_trait] impl TriggerCrud for {Kind}Trigger` with:
- `type Trigger = Trigger<{Kind}Config>`
- `type TriggerConfigRequest = {Kind}ConfigRequest`
- `const ROUTE_PREFIX: &'static str = "/{kind}_triggers";`
- `const TABLE_NAME`, `ADDITIONAL_SELECT_FIELDS`
- `get_deployed_object`, `validate_config`, `create_trigger`, `update_trigger`, `delete_trigger`, `test_connection`
- `additional_routes` (optional — mount extra endpoints for things like ARM resource listing, topic discovery)
Register the crate in `backend/Cargo.toml` as a workspace member and as a dep of `windmill-api` behind the feature flag.
## 3. Wire into `windmill-api` (feature-gated everywhere)
**`backend/windmill-api/src/triggers/handler.rs`** — mount the trigger crate:
```rust
#[cfg(all(feature = "enterprise", feature = "{kind}_trigger", feature = "private"))]
{
use crate::triggers::{kind}::{Kind}Trigger;
router = router.nest({Kind}Trigger::ROUTE_PREFIX, complete_trigger_routes({Kind}Trigger));
}
```
**`backend/windmill-api/src/triggers/{kind}/mod.rs`** — re-export the crate:
```rust
pub use windmill_trigger_{kind}::*;
```
**`backend/windmill-api/src/lib.rs`** — if the trigger receives inbound pushes, add a webhook route:
```rust
.nest("/{kind}/w/{workspace_id}", {
#[cfg(all(feature = "enterprise", feature = "{kind}_trigger", feature = "private"))]
{ triggers::{kind}::handler_oss::{kind}_push_route_handler() }
#[cfg(not(...))]
{ Router::new() }
})
```
## 4. `TriggerKind` enum (`backend/windmill-types/src/triggers.rs`)
Already has slots for most triggers but verify your variant exists:
- Add `{Kind}` to the `TriggerKind` enum
- Add match arm in `to_key()`
- Add match arm in `from_str`
- Add match arm in `JobTriggerKind` (if jobs need kind tagging)
## 5. OpenAPI (`backend/windmill-api/openapi.yaml`)
This file is huge and the single most-forgotten place. Add:
- `/w/{workspace}/{kind}_triggers/create` + `/update/{path}` + `/delete/{path}` + `/get/{path}` + `/list` + `/exists/{path}` + `/setmode/{path}` + `/test` paths (mirror gcp section)
- Any `additional_routes` your handler exposes (resource discovery, etc.)
- Schemas: `{Kind}Trigger`, `{Kind}TriggerData`, `{Kind}Mode` (if enum), `{Kind}DeliveryConfig`, helper request/response types
- Add `{kind}` to `CaptureTriggerKind` enum
- Add `{kind}_used: boolean` to the `UsedTriggers` response schema
Regenerate frontend client: `npm run generate-backend-client` from `frontend/`.
## 6. `UsedTriggers` + workspace export
**`backend/windmill-api-workspaces/src/workspaces.rs`** — add `{kind}_used: bool` to the `UsedTriggers` struct and add an `EXISTS(SELECT 1 FROM {kind}_trigger …)` to the `get_used_triggers` query.
**`backend/windmill-api/src/workspaces_export.rs`** — add export block mirroring gcp's (export lists all triggers, serializes them to YAML/JSON).
## 6.5 Hardcoded trigger-kind arrays (silent-failure hotspots)
Several files keep **hardcoded arrays** of trigger kind strings. Miss one and ACL checks / user offboarding / trash drop your kind:
- **`backend/windmill-api-groups/src/granular_acls.rs`** — `KINDS: [&str; N]`. **Increment N** (the compile error is cryptic otherwise). Controls which kinds accept granular ACL operations.
- **`backend/windmill-api-users/src/users.rs`** (`extra_perms_tables`) — which tables get `extra_perms` entries cleaned when a user is deleted.
- **`backend/windmill-api/src/offboarding.rs`** — three separate arrays (enumeration, fork-copy, and delete paths). **All three** need the new kind.
- **`backend/windmill-api/src/trash.rs`** — `valid_tables` for the trash / restore API.
- **`backend/windmill-git-sync/src/lib.rs`** — add a test assertion for `DeployedObject::{Kind}Trigger.get_kind() == "{kind}_trigger"` (the `get_kind` match arm itself lives in the enum impl — already required by the Rust compiler).
- **`backend/windmill-api-auth/src/scopes.rs`** — add the `{Kind}Triggers` variant to `ScopeDomain` enum + `as_str` match + `from_str` match. Required for the OAuth/token system to recognise `{kind}_triggers:read|write` scopes.
- **`backend/windmill-api/src/token.rs`** (`build_trigger_scope_domains``TRIGGER_DOMAINS`) — add `("{kind}_triggers", "{Kind display name}")` so the CreateToken UI's scope selector surfaces the `read` / `write` checkboxes.
**OpenAPI enums** to extend (do NOT forget — generated client will allow it but server rejects as 400):
- `CaptureTriggerKind` enum
- Three `kind` enums under `/w/{workspace}/acls/{get,add,remove}/{kind}/{path}` (yes, same list repeated three times)
After editing any of these, run a full `cargo check` with your feature flag + `gcp_trigger` + other core flags — the `KINDS: [&str; N]` length mismatch only surfaces when the crate compiles.
## 7. Capture infrastructure (`backend/windmill-api/src/capture.rs`)
If the trigger supports push delivery, it also needs a capture endpoint so users can test it:
- `{Kind}TriggerConfig` struct (gated by feature flags)
- `TriggerConfig::{Kind}` variant
- `set_{kind}_trigger_config` function (creates the subscription/equivalent pointing at the capture URL — use your `manage_{kind}_subscription` helper with `trigger_mode=false`)
- Both real + no-op versions behind feature gates
- `TriggerKind::{Kind} => set_{kind}_trigger_config(...)` arm in `set_config`
- `{kind}_payload` async handler — validates auth (if any), processes payload, calls `insert_capture_payload`
- Route: `.route("/{kind}/{runnable_kind}/{*path}", post({kind}_payload))` inside `workspaced_unauthed_service` — and expand the surrounding `#[cfg(any(...))]` to include your feature flag
## 8. CLI (`cli/`) — easy to miss, breaks sync silently
Check all of these:
**`cli/src/types.ts`:**
- Add `"{kind}"` to `TRIGGER_TYPES` array
- Add `"{kind}_trigger"` to `getTypeStrFromPath` return union
- Add match case in `getTypeStrFromPath`'s `typeEnding ===` chain
- Add `pushTrigger("{kind}", ...)` branch in `pushObj`
**`cli/src/commands/trigger/trigger.ts`:**
- Import `{Kind}Trigger` type
- Add `{kind}: {Kind}Trigger` to the `Trigger` type map
- Add `{kind}: wmill.get{Kind}Trigger`, `update{Kind}Trigger`, `create{Kind}Trigger` to each function map
- Add `{kind}: { ... }` template to `triggerTemplates`
- Add `list{Kind}Triggers` call + spread in the `list` aggregation
- Update `--kind` option descriptions to mention the new kind
**`cli/src/commands/sync/sync.ts`:**
- Add `path.endsWith(".{kind}_trigger" + ext)` in the file-type filter
- Add `typ == "{kind}_trigger"` in `getTypeOrder`
- Add `"{kind}_trigger"` to the delete-suffix regex (~line 3092)
- Add a `case "{kind}_trigger"` in the delete switch
**`cli/src/guidance/skills.ts`** — **DO NOT EDIT DIRECTLY**. It's auto-generated by `system_prompts/generate.py`. Instead:
- Edit `system_prompts/utils.py` → append `('{Kind}Trigger', '{kind}_trigger')` to the `SCHEMA_MAPPINGS['triggers']` list (this is the master list — the one in `generate.py` is duplicated and `utils.py` wins)
- Then run `python3 system_prompts/generate.py` — it regenerates `cli/src/guidance/skills.ts` with the schema extracted from `backend/windmill-api/openapi.yaml`
- Commit the regenerated file
## 9. Frontend — editor + drawer
Under `frontend/src/lib/components/triggers/{kind}/`:
- `{Kind}TriggerPanel.svelte` — the tile shown in the triggers listing
- `{Kind}TriggerEditor.svelte` — outer drawer wrapper
- `{Kind}TriggerEditorInner.svelte` — state + business logic; must expose:
- `openEdit(path, isFlow, defaultValues?)` method
- `isEditor` prop, `onConfigChange` + `onCaptureConfigChange` callbacks
- `get{Kind}Config()` + `get{Kind}CaptureConfig()` helpers
- `captureConfig = $derived.by(untrack(() => isEditor) ? get{Kind}CaptureConfig : () => ({}))`
- `$effect(() => { const args = [captureConfig, isValid] as const; untrack(() => onCaptureConfigChange?.(...args)) })`
- `{Kind}TriggerEditorConfigSection.svelte` — form fields; use design-system components (`TextInput`, `Select`, `Toggle`, `ToggleButtonGroup`), never raw `<input>`
- `{Kind}Capture.svelte` — capture panel; wraps `CaptureSection` with `captureType="{kind}"`
- `utils.ts``requestBody` builders and any trigger-type-specific helpers
## 10. Frontend — global integration
Easy to miss:
- **`frontend/src/lib/components/triggers.ts`** — add `'{kind}'` to the `TriggerKind` union
- **`frontend/src/lib/components/triggers/CaptureWrapper.svelte`**:
- Import `{Kind}Capture`
- Add to `isStreamingCapture()` array (streaming = pull-style; push-style is typically `false`)
- Add `{:else if captureType === '{kind}'}` branch with the `<{Kind}Capture>` render
- **`frontend/src/lib/components/sidebar/SidebarContent.svelte`** — import the icon, add the nav entry
- **`frontend/src/lib/components/sidebar/OperatorMenu.svelte`** — add the operator-mode entry
- **`frontend/src/routes/(root)/(logged)/+layout.svelte`** — destructure `{kind}_used` from `/get_used_triggers` response, push `'{kind}'` into `usedKinds`
- **`frontend/src/lib/components/search/GlobalSearchModal.svelte`** — import icon, add "Go to {Kind} ..." entry
- **`frontend/src/lib/components/offboarding-utils.ts`** — add mappings `{kind}_trigger: '{kind}_triggers'` and `{kind}_trigger: '{kind} trigger'`
- **`frontend/src/lib/components/icons/{Kind}Icon.svelte`** — single-path SVG, `fill={color ?? 'currentColor'}`, `size` prop default 16 (match existing icons — don't hardcode colors, don't use `width`/`height` props)
- **`frontend/src/routes/(root)/(logged)/{kind}_triggers/+page.svelte`** — listing page (mirror `gcp_triggers/+page.svelte` for push+pull, `kafka_triggers` for pure streaming)
- **`frontend/src/lib/components/CompareWorkspaces.svelte`** — workspace fork / compare tool. Needs: service import, editor import, `{kind}Editor` `$state`, `case '{kind}'` in `openTriggerDetails()`, entry in `triggerServices` object (list/delete/normalize), and `<{Kind}TriggerEditor bind:this={{kind}Editor} />` in the template
## 10.5 AI system prompts (`system_prompts/`)
- **`system_prompts/utils.py`** — append `('{Kind}Trigger', '{kind}_trigger')` to `SCHEMA_MAPPINGS['triggers']` (master list used by code generation + CLI skills)
- **`system_prompts/generate.py`** — also has a duplicated `schema_types` list (~line 903) for the AI `triggers` skill content. Add `('{Kind}Trigger', '{kind}_trigger')` there too
- **`system_prompts/generate.py`** `schema_names` (~line 1192) — add `'{Kind}Trigger'` (add `'New{Kind}Trigger'` only if the OpenAPI declares one; GCP and Azure don't)
- Run `python3 system_prompts/generate.py` — this rewrites `cli/src/guidance/skills.ts` and all `auto-generated/` docs. Commit the regenerated files
## 11. Validation
Run all of these before declaring done:
```bash
# Backend
cd backend
cargo check --features enterprise,{kind}_trigger,private # minimal
cargo check --features enterprise,azure_trigger,private,gcp_trigger,http_trigger,mqtt_trigger,postgres_trigger,sqs_trigger,kafka,nats,smtp,websocket # full
# SQLx offline data (never run `cargo sqlx prepare` directly — use the wrapper)
./update_sqlx.sh
# Frontend
cd frontend
npm run generate-backend-client
npm run check:fast
```
Smoke test in the UI: create a trigger, save, check it appears in sidebar + search, delete, re-create via CLI `wmill sync`.
## 12. Common pitfalls
- **Forgetting feature gates in `workspaced_unauthed_service()`** — the surrounding `#[cfg(any(...))]` expression must include your feature flag, not just the inner `#[cfg]` on the route
- **`.route(path, ...).route(path, ...)` with same path and different methods** — older axum replaced; use `.route(path, post(h1).options(h2))` to chain methods on the same `MethodRouter`
- **`on:event` directives** — legacy Svelte 4, no-op in runes mode. Use callback props (`onSelected`, `onConfigChange`)
- **`$bindable(default_value)` on optional props** — banned by project CLAUDE.md. Use `$bindable()` + `$derived(prop ?? default)` instead
- **CORS layer intercepting OPTIONS** — tower-http CorsLayer short-circuits OPTIONS before reaching your handler. For server-to-server webhook endpoints, drop the CORS layer entirely (CORS is browser-only)
- **DeliveryAttributeMappings / custom headers for auth** — prefer HMAC or sha256-hashed shared secrets over opaque JWTs when the provider doesn't support signed tokens natively. Store only the hash; regenerate secret on every save
- **ARM / API resource-listing cascades** — if the trigger's resource type is deep (Azure: subscription → RG → namespace → topic), offer dropdowns in the UI populated from the provider's APIs using the user's credential resource
- **Clearing stale selections on dependency change** — when a dropdown's underlying data reloads (e.g., user changes SP or edition), clear selections that no longer match the new list
- **Workspace-scoped tag compatibility** — if the trigger has tags, verify forked workspaces handle them (see commit `0773b5bc85` for a historical fix)
## 13. EE file split
If the trigger is enterprise-only, the code lives in `windmill-ee-private__worktrees/.../windmill-trigger-{kind}/src/*_ee.rs` and is symlinked into the OSS tree. The `windmill-ee-private__worktrees/` directory holds the real files; changes propagate via symlinks. See `docs/enterprise.md` for the workflow.
## 14. Final checklist before PR
- [ ] Migration up/down tested (revert + re-apply)
- [ ] `./update_sqlx.sh` committed the updated `.sqlx/` offline data
- [ ] `cargo check` passes with your feature flag + with all trigger features
- [ ] `npm run check:fast` passes
- [ ] Trigger visible in sidebar with correct icon weight (not oversized/colored — use `currentColor`)
- [ ] Create, edit, delete flow all work in the UI
- [ ] Capture button works (if push-capable)
- [ ] Trigger appears in `/get_used_triggers` → sidebar pulse
- [ ] `wmill sync pull` + `wmill sync push` both round-trip the trigger
- [ ] `wmill trigger list` includes it
- [ ] OpenAPI schemas are complete (no `null` in generated types)
@@ -33,6 +33,7 @@
"nextcloud",
"google",
"ci_test",
"azure",
"github"
]
}
@@ -26,6 +26,7 @@
"default_email",
"nextcloud",
"google",
"azure",
"github"
]
}
@@ -26,6 +26,7 @@
"default_email",
"nextcloud",
"google",
"azure",
"github"
]
}
@@ -43,6 +43,7 @@
"nextcloud",
"google",
"ci_test",
"azure",
"github"
]
}
@@ -37,6 +37,7 @@
"nextcloud",
"google",
"ci_test",
"azure",
"github"
]
}
@@ -73,6 +74,7 @@
"nextcloud",
"google",
"ci_test",
"azure",
"github"
]
}
@@ -26,6 +26,7 @@
"default_email",
"nextcloud",
"google",
"azure",
"github"
]
}
@@ -42,6 +42,7 @@
"default_email",
"nextcloud",
"google",
"azure",
"github"
]
}
@@ -26,6 +26,7 @@
"default_email",
"nextcloud",
"google",
"azure",
"github"
]
}
@@ -32,6 +32,7 @@
"default_email",
"nextcloud",
"google",
"azure",
"github"
]
}
@@ -39,6 +39,7 @@
"default_email",
"nextcloud",
"google",
"azure",
"github"
]
}
@@ -34,6 +34,7 @@
"default_email",
"nextcloud",
"google",
"azure",
"github"
]
}
@@ -74,6 +75,7 @@
"default_email",
"nextcloud",
"google",
"azure",
"github"
]
}
@@ -37,6 +37,7 @@
"default_email",
"nextcloud",
"google",
"azure",
"github"
]
}
@@ -159,6 +159,7 @@
"nextcloud",
"google",
"ci_test",
"azure",
"github"
]
}
@@ -31,6 +31,7 @@
"default_email",
"nextcloud",
"google",
"azure",
"github"
]
}
@@ -26,6 +26,7 @@
"default_email",
"nextcloud",
"google",
"azure",
"github"
]
}
@@ -126,6 +126,7 @@
"nextcloud",
"google",
"ci_test",
"azure",
"github"
]
}
@@ -43,6 +43,7 @@
"nextcloud",
"google",
"ci_test",
"azure",
"github"
]
}
@@ -29,6 +29,7 @@
"default_email",
"nextcloud",
"google",
"azure",
"github"
]
}
@@ -26,6 +26,7 @@
"default_email",
"nextcloud",
"google",
"azure",
"github"
]
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n EXISTS(SELECT 1 FROM websocket_trigger WHERE workspace_id = $1) AS \"websocket_used!\",\n EXISTS(SELECT 1 FROM http_trigger WHERE workspace_id = $1) AS \"http_routes_used!\",\n EXISTS(SELECT 1 FROM kafka_trigger WHERE workspace_id = $1) as \"kafka_used!\",\n EXISTS(SELECT 1 FROM nats_trigger WHERE workspace_id = $1) as \"nats_used!\",\n EXISTS(SELECT 1 FROM postgres_trigger WHERE workspace_id = $1) AS \"postgres_used!\",\n EXISTS(SELECT 1 FROM mqtt_trigger WHERE workspace_id = $1) AS \"mqtt_used!\",\n EXISTS(SELECT 1 FROM sqs_trigger WHERE workspace_id = $1) AS \"sqs_used!\",\n EXISTS(SELECT 1 FROM gcp_trigger WHERE workspace_id = $1) AS \"gcp_used!\",\n EXISTS(SELECT 1 FROM email_trigger WHERE workspace_id = $1) AS \"email_used!\",\n EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'nextcloud'::native_trigger_service) AS \"nextcloud_used!\",\n EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'google'::native_trigger_service) AS \"google_used!\",\n EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'github'::native_trigger_service) AS \"github_used!\"\n ",
"query": "\n SELECT\n EXISTS(SELECT 1 FROM websocket_trigger WHERE workspace_id = $1) AS \"websocket_used!\",\n EXISTS(SELECT 1 FROM http_trigger WHERE workspace_id = $1) AS \"http_routes_used!\",\n EXISTS(SELECT 1 FROM kafka_trigger WHERE workspace_id = $1) as \"kafka_used!\",\n EXISTS(SELECT 1 FROM nats_trigger WHERE workspace_id = $1) as \"nats_used!\",\n EXISTS(SELECT 1 FROM postgres_trigger WHERE workspace_id = $1) AS \"postgres_used!\",\n EXISTS(SELECT 1 FROM mqtt_trigger WHERE workspace_id = $1) AS \"mqtt_used!\",\n EXISTS(SELECT 1 FROM sqs_trigger WHERE workspace_id = $1) AS \"sqs_used!\",\n EXISTS(SELECT 1 FROM gcp_trigger WHERE workspace_id = $1) AS \"gcp_used!\",\n EXISTS(SELECT 1 FROM azure_trigger WHERE workspace_id = $1) AS \"azure_used!\",\n EXISTS(SELECT 1 FROM email_trigger WHERE workspace_id = $1) AS \"email_used!\",\n EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'nextcloud'::native_trigger_service) AS \"nextcloud_used!\",\n EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'google'::native_trigger_service) AS \"google_used!\",\n EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'github'::native_trigger_service) AS \"github_used!\"\n ",
"describe": {
"columns": [
{
@@ -45,21 +45,26 @@
},
{
"ordinal": 8,
"name": "email_used!",
"name": "azure_used!",
"type_info": "Bool"
},
{
"ordinal": 9,
"name": "nextcloud_used!",
"name": "email_used!",
"type_info": "Bool"
},
{
"ordinal": 10,
"name": "google_used!",
"name": "nextcloud_used!",
"type_info": "Bool"
},
{
"ordinal": 11,
"name": "google_used!",
"type_info": "Bool"
},
{
"ordinal": 12,
"name": "github_used!",
"type_info": "Bool"
}
@@ -81,8 +86,9 @@
null,
null,
null,
null,
null
]
},
"hash": "952f244a06950ccfc70651cb48cdfb7766f7d62d34e68e65b975aaed9e104a5a"
"hash": "83d79dd52a708da7c0d55a171744214dc62651c8ae4ffa2be8b5aa68d8f8e791"
}
@@ -37,6 +37,7 @@
"default_email",
"nextcloud",
"google",
"azure",
"github"
]
}
@@ -26,6 +26,7 @@
"default_email",
"nextcloud",
"google",
"azure",
"github"
]
}
@@ -34,6 +34,7 @@
"default_email",
"nextcloud",
"google",
"azure",
"github"
]
}
@@ -33,6 +33,7 @@
"nextcloud",
"google",
"ci_test",
"azure",
"github"
]
}
@@ -189,6 +189,7 @@
"nextcloud",
"google",
"ci_test",
"azure",
"github"
]
}
@@ -0,0 +1,53 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO azure_trigger (\n azure_resource_path, azure_mode, scope_resource_id, topic_name,\n subscription_name, event_type_filters,\n push_auth_config, workspace_id, path, script_path, is_flow,\n permissioned_as, mode, edited_by, email,\n error_handler_path, error_handler_args, retry\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7,\n $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18\n )\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
{
"Custom": {
"name": "azure_trigger_mode",
"kind": {
"Enum": [
"basic_push",
"namespace_push",
"namespace_pull"
]
}
}
},
"Text",
"Varchar",
"Varchar",
"Jsonb",
"Jsonb",
"Varchar",
"Varchar",
"Varchar",
"Bool",
"Varchar",
{
"Custom": {
"name": "trigger_mode",
"kind": {
"Enum": [
"enabled",
"disabled",
"suspended"
]
}
}
},
"Varchar",
"Varchar",
"Varchar",
"Jsonb",
"Jsonb"
]
},
"nullable": []
},
"hash": "ad25201d0eea65972234cade87a95d8cd99fc26e5bd466942423cbd09efcebe4"
}
@@ -164,6 +164,7 @@
"nextcloud",
"google",
"ci_test",
"azure",
"github"
]
}
@@ -26,6 +26,7 @@
"default_email",
"nextcloud",
"google",
"azure",
"github"
]
}
@@ -109,6 +109,7 @@
"nextcloud",
"google",
"ci_test",
"azure",
"github"
]
}
@@ -33,6 +33,7 @@
"default_email",
"nextcloud",
"google",
"azure",
"github"
]
}
@@ -26,6 +26,7 @@
"default_email",
"nextcloud",
"google",
"azure",
"github"
]
}
@@ -109,6 +109,7 @@
"nextcloud",
"google",
"ci_test",
"azure",
"github"
]
}
@@ -249,6 +249,7 @@
"nextcloud",
"google",
"ci_test",
"azure",
"github"
]
}
@@ -27,6 +27,7 @@
"default_email",
"nextcloud",
"google",
"azure",
"github"
]
}
@@ -0,0 +1,53 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE azure_trigger SET\n azure_resource_path = $1,\n azure_mode = $2,\n scope_resource_id = $3,\n topic_name = $4,\n subscription_name = $5,\n event_type_filters = $6,\n push_auth_config = $7,\n is_flow = $8,\n edited_by = $9,\n permissioned_as = $10,\n script_path = $11,\n path = $12,\n mode = $13,\n edited_at = now(),\n error = NULL,\n server_id = NULL,\n error_handler_path = $14,\n error_handler_args = $15,\n retry = $16\n WHERE workspace_id = $17 AND path = $18\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
{
"Custom": {
"name": "azure_trigger_mode",
"kind": {
"Enum": [
"basic_push",
"namespace_push",
"namespace_pull"
]
}
}
},
"Text",
"Varchar",
"Varchar",
"Jsonb",
"Jsonb",
"Bool",
"Varchar",
"Varchar",
"Varchar",
"Varchar",
{
"Custom": {
"name": "trigger_mode",
"kind": {
"Enum": [
"enabled",
"disabled",
"suspended"
]
}
}
},
"Varchar",
"Jsonb",
"Jsonb",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "d9ef1def7044e58722c5c69c31e9b9f6877de8bc7d4714915bbd572946d9270a"
}
@@ -189,6 +189,7 @@
"nextcloud",
"google",
"ci_test",
"azure",
"github"
]
}
@@ -33,6 +33,7 @@
"default_email",
"nextcloud",
"google",
"azure",
"github"
]
}
@@ -26,6 +26,7 @@
"default_email",
"nextcloud",
"google",
"azure",
"github"
]
}
@@ -26,6 +26,7 @@
"default_email",
"nextcloud",
"google",
"azure",
"github"
]
}
@@ -0,0 +1,100 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n azure_resource_path,\n script_path,\n is_flow,\n mode as \"mode: _\",\n workspace_id,\n path,\n edited_by,\n permissioned_as,\n push_auth_config AS \"push_auth_config: _\",\n retry as \"retry: _\",\n error_handler_path,\n error_handler_args as \"error_handler_args: _\"\n FROM\n azure_trigger\n WHERE\n workspace_id = $1 AND\n path = $2 AND\n azure_mode IN ('basic_push'::AZURE_TRIGGER_MODE, 'namespace_push'::AZURE_TRIGGER_MODE)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "azure_resource_path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "script_path",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "is_flow",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "mode: _",
"type_info": {
"Custom": {
"name": "trigger_mode",
"kind": {
"Enum": [
"enabled",
"disabled",
"suspended"
]
}
}
}
},
{
"ordinal": 4,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "edited_by",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "permissioned_as",
"type_info": "Varchar"
},
{
"ordinal": 8,
"name": "push_auth_config: _",
"type_info": "Jsonb"
},
{
"ordinal": 9,
"name": "retry: _",
"type_info": "Jsonb"
},
{
"ordinal": 10,
"name": "error_handler_path",
"type_info": "Varchar"
},
{
"ordinal": 11,
"name": "error_handler_args: _",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
true,
true,
true,
true
]
},
"hash": "efdcdf0f8d24a23682bb3792ebaffeb7a8dc5632043b6bf4a3dd680c9b99b5df"
}
+35
View File
@@ -16084,6 +16084,7 @@ dependencies = [
"windmill-runtime-nativets",
"windmill-test-utils",
"windmill-trigger",
"windmill-trigger-azure",
"windmill-trigger-gcp",
"windmill-trigger-kafka",
"windmill-trigger-mqtt",
@@ -16263,6 +16264,7 @@ dependencies = [
"windmill-queue",
"windmill-store",
"windmill-trigger",
"windmill-trigger-azure",
"windmill-trigger-email",
"windmill-trigger-gcp",
"windmill-trigger-http",
@@ -17533,6 +17535,39 @@ dependencies = [
"windmill-queue",
]
[[package]]
name = "windmill-trigger-azure"
version = "1.689.0"
dependencies = [
"anyhow",
"async-trait",
"axum 0.8.4",
"base64 0.22.1",
"bytes",
"chrono",
"constant_time_eq 0.3.1",
"hex",
"http 1.4.0",
"itertools 0.14.0",
"lazy_static",
"quick_cache",
"rand 0.9.0",
"reqwest 0.13.1",
"serde",
"serde_json",
"sha2 0.10.9",
"sqlx",
"thiserror 2.0.18",
"tokio",
"tokio-util",
"tracing",
"windmill-api-auth",
"windmill-common",
"windmill-git-sync",
"windmill-store",
"windmill-trigger",
]
[[package]]
name = "windmill-trigger-email"
version = "1.689.0"
+6 -2
View File
@@ -27,6 +27,7 @@ members = [
"./windmill-trigger-nats",
"./windmill-trigger-sqs",
"./windmill-trigger-gcp",
"./windmill-trigger-azure",
"./windmill-trigger-http",
"./windmill-native-triggers",
"./windmill-alerting",
@@ -142,6 +143,7 @@ mqtt_trigger = ["windmill-api/mqtt_trigger"]
native_trigger = ["windmill-api/native_trigger"]
sqs_trigger = ["windmill-api/sqs_trigger", "windmill-common/aws_auth", "windmill-api/openidconnect"]
gcp_trigger = ["windmill-api/gcp_trigger"]
azure_trigger = ["windmill-api/azure_trigger"]
smtp = ["windmill-api/smtp", "windmill-common/smtp", "windmill-queue/smtp"]
license = ["windmill-api/license", "windmill-api-settings/license"]
oauth2 = ["windmill-api/oauth2"]
@@ -183,7 +185,7 @@ oss_core = [
ce_core = ["oss_core", "private", "operator"]
ee_core = [
"enterprise", "stripe", "prometheus", "cloud",
"kafka", "sqs_trigger", "nats", "gcp_trigger",
"kafka", "sqs_trigger", "nats", "gcp_trigger", "azure_trigger",
"jemalloc", "otel", "operator"
]
ee_server = ["enterprise_saml", "tantivy", "agent_worker_server", "local_reports"]
@@ -197,7 +199,7 @@ ee_rhel = ["ce_core", "ee_core", "kafka-gssapi", "all_languages"]
ee_windows = ["ce_core", "ee_core", "all_languages_windows"]
all_sqlx_features = ["all_languages", "enterprise", "enterprise_saml", "embedding", "parquet", "prometheus", "flow_testing",
"openidconnect", "cloud", "jemalloc", "tantivy", "sqlx", "kafka", "kafka-gssapi", "nats", "otel", "dind", "websocket", "http_trigger",
"postgres_trigger", "mcp", "mqtt_trigger", "sqs_trigger", "gcp_trigger", "smtp", "stripe",
"postgres_trigger", "mcp", "mqtt_trigger", "sqs_trigger", "gcp_trigger", "azure_trigger", "smtp", "stripe",
"license", "oauth2", "zip", "static_frontend", "scoped_cache", "agent_worker_server", "bedrock", "native_trigger", "quickjs",
"windmill-git-sync/all_sqlx_features"]
@@ -277,6 +279,7 @@ windmill-trigger-kafka.workspace = true
windmill-trigger-nats.workspace = true
windmill-trigger-sqs.workspace = true
windmill-trigger-gcp.workspace = true
windmill-trigger-azure.workspace = true
windmill-api-auth.workspace = true
axum.workspace = true
serde.workspace = true
@@ -327,6 +330,7 @@ windmill-trigger-email = { path = "./windmill-trigger-email" }
windmill-trigger-nats = { path = "./windmill-trigger-nats" }
windmill-trigger-sqs = { path = "./windmill-trigger-sqs" }
windmill-trigger-gcp = { path = "./windmill-trigger-gcp" }
windmill-trigger-azure = { path = "./windmill-trigger-azure" }
windmill-trigger-http = { path = "./windmill-trigger-http" }
windmill-native-triggers = { path = "./windmill-native-triggers" }
windmill-alerting = { path = "./windmill-alerting" }
+1 -1
View File
@@ -1 +1 @@
2c2b8dc99689f54b8cd916fb9472fd5698b09478
9659382d47286e7f7f66d01b6f5dd8d4ed34848b
@@ -0,0 +1,5 @@
-- Add down migration script here
DROP TABLE IF EXISTS azure_trigger;
DROP TYPE IF EXISTS AZURE_TRIGGER_MODE;
-- Note: TRIGGER_KIND and JOB_TRIGGER_KIND enums cannot have values removed
-- so we leave 'azure' in place as a dead value (matches other trigger migrations)
@@ -0,0 +1,95 @@
-- Add up migration script here
ALTER TYPE TRIGGER_KIND ADD VALUE IF NOT EXISTS 'azure';
ALTER TYPE JOB_TRIGGER_KIND ADD VALUE IF NOT EXISTS 'azure';
CREATE TYPE AZURE_TRIGGER_MODE AS ENUM ('basic_push', 'namespace_push', 'namespace_pull');
CREATE TABLE azure_trigger (
azure_resource_path VARCHAR(255) NOT NULL,
azure_mode AZURE_TRIGGER_MODE NOT NULL,
scope_resource_id TEXT NOT NULL,
topic_name VARCHAR(255),
subscription_name VARCHAR(255) NOT NULL,
event_type_filters JSONB,
push_auth_config JSONB,
path VARCHAR(255) NOT NULL,
script_path VARCHAR(255) NOT NULL,
is_flow BOOLEAN NOT NULL,
workspace_id VARCHAR(50) NOT NULL,
edited_by VARCHAR(50) NOT NULL,
email VARCHAR(255) NOT NULL,
edited_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
extra_perms JSONB NOT NULL DEFAULT '{}',
server_id VARCHAR(50),
last_server_ping TIMESTAMPTZ,
error TEXT,
mode TRIGGER_MODE NOT NULL DEFAULT 'enabled',
permissioned_as VARCHAR(255) NOT NULL,
error_handler_path VARCHAR(255),
error_handler_args JSONB,
retry JSONB,
labels TEXT[],
PRIMARY KEY (path, workspace_id),
CONSTRAINT azure_topic_name_matches_mode CHECK (
(azure_mode = 'basic_push' AND topic_name IS NULL) OR
(azure_mode IN ('namespace_push', 'namespace_pull') AND topic_name IS NOT NULL)
),
CONSTRAINT azure_push_auth_config_matches_mode CHECK (
(azure_mode IN ('basic_push', 'namespace_push') AND push_auth_config IS NOT NULL) OR
(azure_mode = 'namespace_pull' AND push_auth_config IS NULL)
)
);
CREATE UNIQUE INDEX unique_subscription_per_azure_scope
ON azure_trigger (subscription_name, scope_resource_id, workspace_id);
CREATE INDEX idx_azure_trigger_labels ON azure_trigger USING GIN (labels) WHERE labels IS NOT NULL;
GRANT ALL ON azure_trigger TO windmill_user;
GRANT ALL ON azure_trigger TO windmill_admin;
ALTER TABLE azure_trigger ENABLE ROW LEVEL SECURITY;
CREATE POLICY admin_policy ON azure_trigger FOR ALL TO windmill_admin USING (true);
CREATE POLICY see_folder_extra_perms_user_select ON azure_trigger FOR SELECT TO windmill_user
USING (SPLIT_PART(azure_trigger.path, '/', 1) = 'f' AND SPLIT_PART(azure_trigger.path, '/', 2) = any(regexp_split_to_array(current_setting('session.folders_read'), ',')::text[]));
CREATE POLICY see_folder_extra_perms_user_insert ON azure_trigger FOR INSERT TO windmill_user
WITH CHECK (SPLIT_PART(azure_trigger.path, '/', 1) = 'f' AND SPLIT_PART(azure_trigger.path, '/', 2) = any(regexp_split_to_array(current_setting('session.folders_write'), ',')::text[]));
CREATE POLICY see_folder_extra_perms_user_update ON azure_trigger FOR UPDATE TO windmill_user
USING (SPLIT_PART(azure_trigger.path, '/', 1) = 'f' AND SPLIT_PART(azure_trigger.path, '/', 2) = any(regexp_split_to_array(current_setting('session.folders_write'), ',')::text[]));
CREATE POLICY see_folder_extra_perms_user_delete ON azure_trigger FOR DELETE TO windmill_user
USING (SPLIT_PART(azure_trigger.path, '/', 1) = 'f' AND SPLIT_PART(azure_trigger.path, '/', 2) = any(regexp_split_to_array(current_setting('session.folders_write'), ',')::text[]));
CREATE POLICY see_own ON azure_trigger FOR ALL TO windmill_user
USING (SPLIT_PART(azure_trigger.path, '/', 1) = 'u' AND SPLIT_PART(azure_trigger.path, '/', 2) = current_setting('session.user'));
CREATE POLICY see_member ON azure_trigger FOR ALL TO windmill_user
USING (SPLIT_PART(azure_trigger.path, '/', 1) = 'g' AND SPLIT_PART(azure_trigger.path, '/', 2) = any(regexp_split_to_array(current_setting('session.groups'), ',')::text[]));
CREATE POLICY see_extra_perms_user_select ON azure_trigger FOR SELECT TO windmill_user
USING (extra_perms ? CONCAT('u/', current_setting('session.user')));
CREATE POLICY see_extra_perms_user_insert ON azure_trigger FOR INSERT TO windmill_user
WITH CHECK ((extra_perms ->> CONCAT('u/', current_setting('session.user')))::boolean);
CREATE POLICY see_extra_perms_user_update ON azure_trigger FOR UPDATE TO windmill_user
USING ((extra_perms ->> CONCAT('u/', current_setting('session.user')))::boolean);
CREATE POLICY see_extra_perms_user_delete ON azure_trigger FOR DELETE TO windmill_user
USING ((extra_perms ->> CONCAT('u/', current_setting('session.user')))::boolean);
CREATE POLICY see_extra_perms_groups_select ON azure_trigger FOR SELECT TO windmill_user
USING (extra_perms ?| regexp_split_to_array(current_setting('session.pgroups'), ',')::text[]);
CREATE POLICY see_extra_perms_groups_insert ON azure_trigger FOR INSERT TO windmill_user
WITH CHECK (exists(
SELECT key, value FROM jsonb_each_text(extra_perms)
WHERE SPLIT_PART(key, '/', 1) = 'g' AND key = ANY(regexp_split_to_array(current_setting('session.pgroups'), ',')::text[])
AND value::boolean));
CREATE POLICY see_extra_perms_groups_update ON azure_trigger FOR UPDATE TO windmill_user
USING (exists(
SELECT key, value FROM jsonb_each_text(extra_perms)
WHERE SPLIT_PART(key, '/', 1) = 'g' AND key = ANY(regexp_split_to_array(current_setting('session.pgroups'), ',')::text[])
AND value::boolean));
CREATE POLICY see_extra_perms_groups_delete ON azure_trigger FOR DELETE TO windmill_user
USING (exists(
SELECT key, value FROM jsonb_each_text(extra_perms)
WHERE SPLIT_PART(key, '/', 1) = 'g' AND key = ANY(regexp_split_to_array(current_setting('session.pgroups'), ',')::text[])
AND value::boolean));
+3
View File
@@ -259,6 +259,7 @@ pub enum ScopeDomain {
MqttTriggers,
SqsTriggers,
GcpTriggers,
AzureTriggers,
PostgresTriggers,
EmailTriggers,
@@ -317,6 +318,7 @@ impl ScopeDomain {
Self::MqttTriggers => "mqtt_triggers",
Self::SqsTriggers => "sqs_triggers",
Self::GcpTriggers => "gcp_triggers",
Self::AzureTriggers => "azure_triggers",
Self::PostgresTriggers => "postgres_triggers",
Self::EmailTriggers => "email_triggers",
Self::NativeTriggers => "native_triggers",
@@ -366,6 +368,7 @@ impl ScopeDomain {
"mqtt_triggers" => Some(Self::MqttTriggers),
"sqs_triggers" => Some(Self::SqsTriggers),
"gcp_triggers" => Some(Self::GcpTriggers),
"azure_triggers" => Some(Self::AzureTriggers),
"postgres_triggers" => Some(Self::PostgresTriggers),
"email_triggers" => Some(Self::EmailTriggers),
"audit" => Some(Self::Audit),
@@ -24,7 +24,7 @@ use windmill_common::{
utils::{not_found_if_none, StripPath},
};
const KINDS: [&str; 19] = [
const KINDS: [&str; 20] = [
"script",
"group_",
"resource",
@@ -41,6 +41,7 @@ const KINDS: [&str; 19] = [
"postgres_trigger",
"mqtt_trigger",
"gcp_trigger",
"azure_trigger",
"sqs_trigger",
"email_trigger",
"volume",
+1
View File
@@ -1690,6 +1690,7 @@ pub async fn delete_workspace_user_internal(
"nats_trigger",
"sqs_trigger",
"gcp_trigger",
"azure_trigger",
"email_trigger",
];
for table in &extra_perms_tables {
@@ -3138,6 +3138,7 @@ struct UsedTriggers {
pub mqtt_used: bool,
pub sqs_used: bool,
pub gcp_used: bool,
pub azure_used: bool,
pub email_used: bool,
pub nextcloud_used: bool,
pub google_used: bool,
@@ -3162,6 +3163,7 @@ async fn get_used_triggers(
EXISTS(SELECT 1 FROM mqtt_trigger WHERE workspace_id = $1) AS "mqtt_used!",
EXISTS(SELECT 1 FROM sqs_trigger WHERE workspace_id = $1) AS "sqs_used!",
EXISTS(SELECT 1 FROM gcp_trigger WHERE workspace_id = $1) AS "gcp_used!",
EXISTS(SELECT 1 FROM azure_trigger WHERE workspace_id = $1) AS "azure_used!",
EXISTS(SELECT 1 FROM email_trigger WHERE workspace_id = $1) AS "email_used!",
EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'nextcloud'::native_trigger_service) AS "nextcloud_used!",
EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'google'::native_trigger_service) AS "google_used!",
+4 -2
View File
@@ -10,8 +10,8 @@ path = "src/lib.rs"
[features]
default = []
private = ["windmill-audit/private", "windmill-common/private", "windmill-api-auth/private", "windmill-store/private", "windmill-api-users/private", "windmill-api-workspaces/private", "windmill-api-groups/private", "windmill-api-configs/private", "windmill-api-settings/private", "windmill-api-agent-workers?/private", "windmill-trigger-kafka?/private", "windmill-trigger-postgres?/private", "windmill-trigger-mqtt?/private", "windmill-trigger-websocket?/private", "windmill-trigger-nats?/private", "windmill-trigger-sqs?/private", "windmill-trigger-gcp?/private", "windmill-trigger-email?/private", "windmill-git-sync/private", "windmill-autoscaling?/private"]
enterprise = ["windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker?/enterprise", "windmill-api-auth/enterprise", "windmill-store/enterprise", "windmill-api-jobs/enterprise", "windmill-api-scripts/enterprise", "windmill-api-flows/enterprise", "windmill-api-users/enterprise", "windmill-api-workspaces/enterprise", "windmill-api-groups/enterprise", "windmill-api-configs/enterprise", "windmill-api-settings/enterprise", "windmill-api-schedule/enterprise", "windmill-api-agent-workers?/enterprise", "windmill-trigger/enterprise", "windmill-trigger-kafka?/enterprise", "windmill-trigger-postgres?/enterprise", "windmill-trigger-mqtt?/enterprise", "windmill-trigger-websocket?/enterprise", "windmill-trigger-email?/enterprise", "windmill-trigger-nats?/enterprise", "windmill-trigger-sqs?/enterprise", "windmill-trigger-gcp?/enterprise", "windmill-trigger-http?/enterprise", "windmill-native-triggers?/enterprise", "dep:windmill-autoscaling", "windmill-autoscaling/enterprise"]
private = ["windmill-audit/private", "windmill-common/private", "windmill-api-auth/private", "windmill-store/private", "windmill-api-users/private", "windmill-api-workspaces/private", "windmill-api-groups/private", "windmill-api-configs/private", "windmill-api-settings/private", "windmill-api-agent-workers?/private", "windmill-trigger-kafka?/private", "windmill-trigger-postgres?/private", "windmill-trigger-mqtt?/private", "windmill-trigger-websocket?/private", "windmill-trigger-nats?/private", "windmill-trigger-sqs?/private", "windmill-trigger-gcp?/private", "windmill-trigger-azure?/private", "windmill-trigger-email?/private", "windmill-git-sync/private", "windmill-autoscaling?/private"]
enterprise = ["windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker?/enterprise", "windmill-api-auth/enterprise", "windmill-store/enterprise", "windmill-api-jobs/enterprise", "windmill-api-scripts/enterprise", "windmill-api-flows/enterprise", "windmill-api-users/enterprise", "windmill-api-workspaces/enterprise", "windmill-api-groups/enterprise", "windmill-api-configs/enterprise", "windmill-api-settings/enterprise", "windmill-api-schedule/enterprise", "windmill-api-agent-workers?/enterprise", "windmill-trigger/enterprise", "windmill-trigger-kafka?/enterprise", "windmill-trigger-postgres?/enterprise", "windmill-trigger-mqtt?/enterprise", "windmill-trigger-websocket?/enterprise", "windmill-trigger-email?/enterprise", "windmill-trigger-nats?/enterprise", "windmill-trigger-sqs?/enterprise", "windmill-trigger-gcp?/enterprise", "windmill-trigger-azure?/enterprise", "windmill-trigger-http?/enterprise", "windmill-native-triggers?/enterprise", "dep:windmill-autoscaling", "windmill-autoscaling/enterprise"]
stripe = []
run_inline = ["dep:windmill-worker", "windmill-api-configs/run_inline"]
agent_worker_server = ["dep:windmill-worker", "dep:windmill-api-agent-workers"]
@@ -37,6 +37,7 @@ mqtt_trigger = ["dep:windmill-trigger-mqtt", "windmill-store/mqtt_trigger"]
native_trigger = ["dep:windmill-native-triggers", "windmill-native-triggers/native_trigger", "dep:strum", "oauth2"]
sqs_trigger = ["dep:windmill-trigger-sqs", "windmill-store/sqs_trigger"]
gcp_trigger = ["dep:windmill-trigger-gcp", "windmill-store/gcp_trigger"]
azure_trigger = ["dep:windmill-trigger-azure", "windmill-store/azure_trigger"]
cloud = ["windmill-common/cloud", "windmill-api-auth/cloud", "windmill-store/cloud", "windmill-api-workspaces/cloud"]
mcp = ["dep:windmill-mcp", "windmill-mcp/server", "windmill-mcp/auth", "windmill-api-auth/mcp", "windmill-store/mcp"]
bedrock = ["windmill-ai/bedrock", "dep:aws-sdk-bedrock", "dep:aws-sdk-bedrockruntime", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-types"]
@@ -145,6 +146,7 @@ windmill-trigger-email = { workspace = true, optional = true }
windmill-trigger-nats = { workspace = true, optional = true }
windmill-trigger-sqs = { workspace = true, optional = true }
windmill-trigger-gcp = { workspace = true, optional = true }
windmill-trigger-azure = { workspace = true, optional = true }
windmill-trigger-http = { workspace = true, optional = true }
windmill-native-triggers = { workspace = true, optional = true }
windmill-alerting.workspace = true
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+465
View File
@@ -4575,6 +4575,8 @@ paths:
type: boolean
gcp_used:
type: boolean
azure_used:
type: boolean
sqs_used:
type: boolean
email_used:
@@ -4593,6 +4595,7 @@ paths:
- postgres_used
- mqtt_used
- gcp_used
- azure_used
- sqs_used
- email_used
- nextcloud_used
@@ -15219,6 +15222,296 @@ paths:
items:
type: string
/w/{workspace}/azure_triggers/create:
post:
summary: create an Azure Event Grid trigger
operationId: createAzureTrigger
tags:
- azure_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AzureTriggerData"
responses:
"201":
description: azure trigger created
content:
text/plain:
schema:
type: string
/w/{workspace}/azure_triggers/update/{path}:
post:
summary: update an Azure Event Grid trigger
operationId: updateAzureTrigger
tags:
- azure_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AzureTriggerData"
responses:
"200":
description: azure trigger updated
content:
text/plain:
schema:
type: string
/w/{workspace}/azure_triggers/delete/{path}:
delete:
summary: delete an Azure Event Grid trigger
operationId: deleteAzureTrigger
tags:
- azure_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
responses:
"200":
description: azure trigger deleted
content:
text/plain:
schema:
type: string
/w/{workspace}/azure_triggers/get/{path}:
get:
summary: get an Azure Event Grid trigger
operationId: getAzureTrigger
tags:
- azure_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
responses:
"200":
description: azure trigger
content:
application/json:
schema:
$ref: "#/components/schemas/AzureTrigger"
/w/{workspace}/azure_triggers/list:
get:
summary: list azure triggers
operationId: listAzureTriggers
tags:
- azure_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
- name: path
description: filter by exact path
in: query
schema:
type: string
- name: is_flow
in: query
schema:
type: boolean
- name: path_start
in: query
schema:
type: string
responses:
"200":
description: azure trigger list
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/AzureTrigger"
/w/{workspace}/azure_triggers/exists/{path}:
get:
summary: check whether an azure trigger exists
operationId: existsAzureTrigger
tags:
- azure_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
responses:
"200":
description: true/false
content:
application/json:
schema:
type: boolean
/w/{workspace}/azure_triggers/setmode/{path}:
post:
summary: set azure trigger mode
operationId: setAzureTriggerMode
tags:
- azure_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
mode:
$ref: "#/components/schemas/TriggerMode"
required:
- mode
responses:
"200":
description: trigger mode updated
content:
text/plain:
schema:
type: string
/w/{workspace}/azure_triggers/test:
post:
summary: test Azure service principal connection
operationId: testAzureConnection
tags:
- azure_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/TestAzureConnection"
responses:
"200":
description: connection successful
content:
text/plain:
schema:
type: string
/w/{workspace}/azure_triggers/namespaces/topics/list/{path}:
post:
summary: list topics under an Event Grid Namespace
operationId: listAzureNamespaceTopics
tags:
- azure_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AzureListTopics"
responses:
"200":
description: topic list
content:
application/json:
schema:
type: array
items:
type: object
/w/{workspace}/azure_triggers/namespaces/subscriptions/list/{path}:
post:
summary: list subscriptions under a Namespace topic
operationId: listAzureNamespaceSubscriptions
tags:
- azure_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AzureListSubscriptions"
responses:
"200":
description: subscription list
content:
application/json:
schema:
type: array
items:
type: object
/w/{workspace}/azure_triggers/subscriptions/delete/{path}:
delete:
summary: delete an Event Grid subscription on Azure
operationId: deleteAzureSubscription
tags:
- azure_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AzureDeleteSubscription"
responses:
"200":
description: subscription deleted
content:
text/plain:
schema:
type: string
/w/{workspace}/azure_triggers/namespaces/list/{path}:
post:
summary: list Event Grid Namespaces the service principal can access
operationId: listAzureNamespaces
tags:
- azure_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
responses:
"200":
description: namespace list
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/AzureArmResource"
/w/{workspace}/azure_triggers/basic/topics/list/{path}:
post:
summary: list Basic Event Grid topics + system topics the service principal can access
operationId: listAzureBasicTopics
tags:
- azure_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
responses:
"200":
description: topic list
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/AzureArmResource"
/w/{workspace}/postgres_triggers/postgres/version/{path}:
get:
summary: get postgres version
@@ -17099,6 +17392,7 @@ paths:
postgres_trigger,
mqtt_trigger,
gcp_trigger,
azure_trigger,
sqs_trigger,
email_trigger,
volume,
@@ -17145,6 +17439,7 @@ paths:
postgres_trigger,
mqtt_trigger,
gcp_trigger,
azure_trigger,
sqs_trigger,
email_trigger,
volume,
@@ -17202,6 +17497,7 @@ paths:
postgres_trigger,
mqtt_trigger,
gcp_trigger,
azure_trigger,
sqs_trigger,
email_trigger,
volume,
@@ -22899,6 +23195,7 @@ components:
- mqtt
- sqs
- gcp
- azure
- google
- github
@@ -23386,6 +23683,8 @@ components:
type: number
gcp_count:
type: number
azure_count:
type: number
sqs_count:
type: number
nextcloud_count:
@@ -24060,6 +24359,171 @@ components:
required:
- subscription_id
AzureMode:
type: string
enum:
- basic_push
- namespace_push
- namespace_pull
description: "Azure Event Grid trigger mode."
AzureArmResource:
type: object
description: "An ARM resource the service principal can see."
properties:
id:
type: string
name:
type: string
location:
type: string
type:
type: string
required:
- id
- name
- type
AzureDeleteSubscription:
type: object
properties:
azure_mode:
$ref: "#/components/schemas/AzureMode"
scope_resource_id:
type: string
topic_name:
type: string
nullable: true
subscription_name:
type: string
required:
- azure_mode
- scope_resource_id
- subscription_name
AzureTrigger:
allOf:
- $ref: "#/components/schemas/TriggerExtraProperty"
type: object
description: "An Azure Event Grid trigger that executes a script or flow when events arrive."
properties:
azure_resource_path:
type: string
azure_mode:
$ref: "#/components/schemas/AzureMode"
scope_resource_id:
type: string
description: "ARM resource ID of the topic (basic) or namespace (namespace modes)."
topic_name:
type: string
nullable: true
description: "Topic name within the namespace (namespace modes only)."
subscription_name:
type: string
event_type_filters:
type: array
items:
type: string
nullable: true
server_id:
type: string
last_server_ping:
type: string
format: date-time
error:
type: string
error_handler_path:
type: string
error_handler_args:
$ref: "#/components/schemas/ScriptArgs"
retry:
$ref: "../../openflow.openapi.yaml#/components/schemas/Retry"
required:
- azure_resource_path
- azure_mode
- scope_resource_id
- subscription_name
AzureTriggerData:
type: object
description: "Data for creating or updating an Azure Event Grid trigger."
properties:
azure_resource_path:
type: string
azure_mode:
$ref: "#/components/schemas/AzureMode"
scope_resource_id:
type: string
topic_name:
type: string
nullable: true
subscription_name:
type: string
base_endpoint:
type: string
description: "Base URL for push delivery endpoints (push modes only)."
event_type_filters:
type: array
items:
type: string
path:
type: string
script_path:
type: string
is_flow:
type: boolean
mode:
$ref: "#/components/schemas/TriggerMode"
error_handler_path:
type: string
error_handler_args:
$ref: "#/components/schemas/ScriptArgs"
retry:
$ref: "../../openflow.openapi.yaml#/components/schemas/Retry"
permissioned_as:
type: string
preserve_permissioned_as:
type: boolean
labels:
type: array
items:
type: string
required:
- path
- script_path
- is_flow
- azure_resource_path
- azure_mode
- scope_resource_id
- subscription_name
TestAzureConnection:
type: object
properties:
azure_resource_path:
type: string
required:
- azure_resource_path
AzureListTopics:
type: object
properties:
scope_resource_id:
type: string
required:
- scope_resource_id
AzureListSubscriptions:
type: object
properties:
scope_resource_id:
type: string
topic_name:
type: string
required:
- scope_resource_id
- topic_name
AwsAuthResourceType:
type: string
enum:
@@ -26361,6 +26825,7 @@ components:
sqs,
mqtt,
gcp,
azure,
email,
]
+166 -2
View File
@@ -114,7 +114,8 @@ pub fn workspaced_unauthed_service() -> Router {
#[cfg(any(
feature = "http_trigger",
all(feature = "enterprise", feature = "gcp_trigger")
all(feature = "enterprise", feature = "gcp_trigger"),
all(feature = "enterprise", feature = "azure_trigger")
))]
{
#[cfg(feature = "http_trigger")]
@@ -125,12 +126,16 @@ pub fn workspaced_unauthed_service() -> Router {
#[cfg(all(feature = "enterprise", feature = "gcp_trigger", feature = "private"))]
let router = router.route("/gcp/{runnable_kind}/{*path}", post(gcp_payload));
#[cfg(all(feature = "enterprise", feature = "azure_trigger", feature = "private"))]
let router = router.route("/azure/{runnable_kind}/{*path}", post(azure_payload));
router
}
#[cfg(not(any(
feature = "http_trigger",
all(feature = "enterprise", feature = "gcp_trigger")
all(feature = "enterprise", feature = "gcp_trigger"),
all(feature = "enterprise", feature = "azure_trigger")
)))]
{
router
@@ -170,6 +175,26 @@ pub struct SqsTriggerConfig {
pub aws_auth_resource_type: AwsAuthResourceType,
}
#[cfg(all(feature = "enterprise", feature = "azure_trigger", feature = "private"))]
#[derive(Debug, Serialize, Deserialize)]
pub struct AzureTriggerConfig {
pub azure_resource_path: String,
pub azure_mode: crate::triggers::azure::AzureMode,
pub scope_resource_id: String,
#[serde(default, deserialize_with = "empty_as_none")]
pub topic_name: Option<String>,
pub subscription_name: String,
#[serde(default, deserialize_with = "empty_as_none")]
pub base_endpoint: Option<String>,
#[serde(default)]
pub event_type_filters: Option<Vec<String>>,
/// Server-managed. Populated by `set_azure_trigger_config` after
/// `manage_azure_subscription` regenerates the secret; skipped on
/// (de)serialization so clients never see or send it.
#[serde(skip, default)]
pub push_auth_config: Option<crate::triggers::azure::PushAuthConfig>,
}
#[cfg(all(feature = "enterprise", feature = "gcp_trigger", feature = "private"))]
#[derive(Debug, Serialize, Deserialize)]
pub struct GcpTriggerConfig {
@@ -248,6 +273,8 @@ enum TriggerConfig {
Mqtt(MqttTriggerConfig),
#[cfg(all(feature = "enterprise", feature = "gcp_trigger", feature = "private"))]
Gcp(GcpTriggerConfig),
#[cfg(all(feature = "enterprise", feature = "azure_trigger", feature = "private"))]
Azure(AzureTriggerConfig),
#[cfg(all(feature = "enterprise", feature = "smtp", feature = "private"))]
Email(EmailTriggerConfig),
}
@@ -419,6 +446,78 @@ async fn set_gcp_trigger_config(
Ok(capture_config)
}
#[cfg(all(feature = "enterprise", feature = "azure_trigger", feature = "private"))]
async fn set_azure_trigger_config(
w_id: &str,
authed: ApiAuthed,
db: &DB,
mut capture_config: NewCaptureConfig,
) -> Result<NewCaptureConfig> {
use crate::triggers::azure::{manage_azure_subscription, AzureConfigRequest};
let Some(TriggerConfig::Azure(azure_config)) = capture_config.trigger_config else {
return Err(Error::BadRequest(
"Invalid Azure Event Grid config".to_string(),
));
};
// Suffix subscription name so capture never clobbers the deployed trigger's
// subscription. Azure allows [A-Za-z0-9-]{3,50}; reserve 11 chars for
// "-wm-capture" (mirrors Kafka's `_wm_capture` convention — hyphen since
// Azure names disallow underscores).
let mut sub_name = azure_config.subscription_name;
if sub_name.len() > 39 {
sub_name.truncate(39);
}
sub_name.push_str("-wm-capture");
let mut req = AzureConfigRequest {
azure_resource_path: azure_config.azure_resource_path,
azure_mode: azure_config.azure_mode,
scope_resource_id: azure_config.scope_resource_id,
topic_name: azure_config.topic_name,
subscription_name: sub_name,
base_endpoint: azure_config.base_endpoint,
event_type_filters: azure_config.event_type_filters,
push_auth_config: azure_config.push_auth_config,
};
manage_azure_subscription(
authed,
db,
w_id,
&mut req,
&capture_config.path,
capture_config.is_flow,
false,
)
.await?;
capture_config.trigger_config = Some(TriggerConfig::Azure(AzureTriggerConfig {
azure_resource_path: req.azure_resource_path,
azure_mode: req.azure_mode,
scope_resource_id: req.scope_resource_id,
topic_name: req.topic_name,
subscription_name: req.subscription_name,
base_endpoint: req.base_endpoint,
event_type_filters: req.event_type_filters,
push_auth_config: req.push_auth_config,
}));
Ok(capture_config)
}
#[inline]
#[cfg(not(all(feature = "enterprise", feature = "azure_trigger", feature = "private")))]
async fn set_azure_trigger_config(
_w_id: &str,
_authed: ApiAuthed,
_db: &DB,
capture_config: NewCaptureConfig,
) -> Result<NewCaptureConfig> {
Ok(capture_config)
}
async fn set_config(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
@@ -431,6 +530,7 @@ async fn set_config(
set_postgres_trigger_config(&w_id, authed.clone(), &db, user_db.clone(), nc).await?
}
TriggerKind::Gcp => set_gcp_trigger_config(&w_id, authed.clone(), &db, nc).await?,
TriggerKind::Azure => set_azure_trigger_config(&w_id, authed.clone(), &db, nc).await?,
_ => nc,
};
@@ -960,6 +1060,70 @@ async fn gcp_payload(
Ok(StatusCode::NO_CONTENT)
}
#[cfg(all(feature = "enterprise", feature = "azure_trigger", feature = "private"))]
async fn azure_payload(
Extension(db): Extension<DB>,
Path((w_id, runnable_kind, path)): Path<(String, RunnableKind, String)>,
headers: HeaderMap,
request: Request,
) -> Result<StatusCode> {
use crate::triggers::azure::{
cloud_event_to_args, process_azure_push_request, validate_push_secret, AzureTrigger,
PushOutcome,
};
use crate::triggers::trigger_helpers::TriggerJobArgs;
let is_flow = matches!(runnable_kind, RunnableKind::Flow);
let (azure_trigger_config, owner, _email): (AzureTriggerConfig, _, _) =
get_capture_trigger_config_and_owner(&db, &w_id, &path, is_flow, &TriggerKind::Azure)
.await?;
// Basic handshake posts don't carry the secret header; let them through.
let is_classic_handshake = headers
.get("aeg-event-type")
.and_then(|h| h.to_str().ok())
.map(|v| v.eq_ignore_ascii_case("SubscriptionValidation"))
.unwrap_or(false);
if !is_classic_handshake {
let dc = azure_trigger_config
.push_auth_config
.as_ref()
.ok_or_else(|| {
Error::NotAuthorized("azure capture missing push_auth_config".to_string())
})?;
validate_push_secret(&headers, dc)?;
}
let outcome = process_azure_push_request(headers, request).await?;
let (cloud_events, headers_map) = match outcome {
PushOutcome::Handshake(_) => {
// Capture path doesn't need to echo validation response — return 200.
return Ok(StatusCode::OK);
}
PushOutcome::Events { cloud_events, headers } => (cloud_events, headers),
};
for event in cloud_events {
let (payload, trigger_info) = cloud_event_to_args(&event, &headers_map);
let (main_args, preprocessor_args) =
AzureTrigger::build_capture_payloads(&payload, trigger_info);
let _ = insert_capture_payload(
&db,
&w_id,
&path,
is_flow,
&TriggerKind::Azure,
main_args,
preprocessor_args,
&owner,
)
.await?;
}
Ok(StatusCode::NO_CONTENT)
}
#[cfg(feature = "http_trigger")]
async fn http_payload(
Extension(db): Extension<DB>,
+18
View File
@@ -900,6 +900,24 @@ pub async fn run_server(
Router::new()
}
})
.nest("/azure/w/{workspace_id}", {
#[cfg(all(
feature = "enterprise",
feature = "azure_trigger",
feature = "private"
))]
{
triggers::azure::handler_oss::azure_push_route_handler()
}
#[cfg(not(all(
feature = "enterprise",
feature = "azure_trigger",
feature = "private"
)))]
{
Router::new()
}
})
.route("/version", get(git_v))
.nest("/health/status", health::status_service())
.route("/min_keep_alive_version", get(min_keep_alive_version))
+3
View File
@@ -189,6 +189,7 @@ async fn get_offboard_preview(
"nats_trigger",
"sqs_trigger",
"gcp_trigger",
"azure_trigger",
"email_trigger",
];
let mut triggers = HashMap::new();
@@ -748,6 +749,7 @@ async fn check_path_conflicts(
"nats_trigger",
"sqs_trigger",
"gcp_trigger",
"azure_trigger",
"email_trigger",
];
@@ -1020,6 +1022,7 @@ async fn offboard_user_from_workspace<'c>(
"nats_trigger",
"sqs_trigger",
"gcp_trigger",
"azure_trigger",
"email_trigger",
];
+1
View File
@@ -26,6 +26,7 @@ fn build_trigger_scope_domains() -> Vec<ScopeDomain> {
("mqtt_triggers", "MQTT"),
("sqs_triggers", "AWS SQS"),
("gcp_triggers", "GCP Pub/Sub"),
("azure_triggers", "Azure Event Grid"),
("postgres_triggers", "PostgreSQL"),
("email_triggers", "Email"),
];
+1
View File
@@ -469,6 +469,7 @@ async fn restore_trigger(tx: &mut sqlx::PgConnection, item: &TrashItemWithData)
"mqtt_trigger",
"sqs_trigger",
"gcp_trigger",
"azure_trigger",
"email_trigger",
];
@@ -0,0 +1 @@
pub use windmill_trigger_azure::*;
@@ -81,6 +81,16 @@ pub fn generate_trigger_routers() -> Router {
);
}
#[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;
@@ -135,6 +145,7 @@ pub struct TriggersCount {
mqtt_count: i64,
sqs_count: i64,
gcp_count: i64,
azure_count: i64,
nextcloud_count: i64,
google_count: i64,
github_count: i64,
@@ -252,6 +263,17 @@ pub async fn get_triggers_count_internal(
#[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;
@@ -341,6 +363,7 @@ pub async fn get_triggers_count_internal(
postgres_count,
mqtt_count,
gcp_count,
azure_count,
sqs_count,
nextcloud_count,
google_count,
@@ -67,6 +67,14 @@ pub fn start_all_listeners(db: DB, killpill_rx: &tokio::sync::broadcast::Receive
listen_to(GcpTrigger, db.clone(), gcp_killpill_rx);
}
#[cfg(all(feature = "azure_trigger", feature = "enterprise", feature = "private"))]
{
let azure_killpill_rx = killpill_rx.resubscribe();
use crate::triggers::azure::AzureTrigger;
listen_to(AzureTrigger, db.clone(), azure_killpill_rx);
}
#[cfg(all(feature = "sqs_trigger", feature = "enterprise", feature = "private"))]
{
let gcp_killpill_rx = killpill_rx.resubscribe();
+2
View File
@@ -1,4 +1,6 @@
// Concrete trigger submodules (feature-gated)
#[cfg(all(feature = "azure_trigger", feature = "enterprise", feature = "private"))]
pub mod azure;
#[cfg(all(feature = "smtp", feature = "private"))]
pub mod email;
#[cfg(all(feature = "gcp_trigger", feature = "enterprise", feature = "private"))]
@@ -23,6 +23,7 @@ use crate::{apps::AppWithLastVersion, db::DB, folders::Folder};
feature = "kafka",
feature = "sqs_trigger",
feature = "gcp_trigger",
feature = "azure_trigger",
feature = "nats",
feature = "smtp",
),
@@ -768,6 +769,23 @@ pub(crate) async fn tarball_workspace(
}
}
#[cfg(all(feature = "enterprise", feature = "azure_trigger", feature = "private"))]
{
use crate::triggers::azure::AzureTrigger;
let handler = AzureTrigger;
let azure_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
for trigger in azure_triggers {
let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap();
archive
.write_to_archive(
&trigger_str,
&format!("{}.azure_trigger.json", trigger.base.path),
)
.await?;
}
}
#[cfg(all(feature = "enterprise", feature = "nats", feature = "private"))]
{
use crate::triggers::nats::NatsTrigger;
+1 -1
View File
@@ -157,7 +157,7 @@ pub enum ObjectType {
WorkspaceDependencies,
}
pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28191/sync-script-to-git-repo-windmill";
pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28213/sync-script-to-git-repo-windmill";
/// Prefix used to identify fork workspaces. A workspace whose id starts with this string is a
/// fork of another workspace.
+8
View File
@@ -39,6 +39,7 @@ pub enum DeployedObject {
MqttTrigger { path: String, parent_path: Option<String> },
SqsTrigger { path: String, parent_path: Option<String> },
GcpTrigger { path: String, parent_path: Option<String> },
AzureTrigger { path: String, parent_path: Option<String> },
EmailTrigger { path: String, parent_path: Option<String> },
Settings { setting_type: String },
Key { key_type: String },
@@ -67,6 +68,7 @@ impl DeployedObject {
DeployedObject::MqttTrigger { path, .. } => path.to_owned(),
DeployedObject::SqsTrigger { path, .. } => path.to_owned(),
DeployedObject::GcpTrigger { path, .. } => path.to_owned(),
DeployedObject::AzureTrigger { path, .. } => path.to_owned(),
DeployedObject::EmailTrigger { path, .. } => path.to_owned(),
DeployedObject::Settings { .. } => "settings.yaml".to_string(),
DeployedObject::Key { .. } => "encryption_key.yaml".to_string(),
@@ -107,6 +109,7 @@ impl DeployedObject {
DeployedObject::MqttTrigger { parent_path, .. } => parent_path.to_owned(),
DeployedObject::SqsTrigger { parent_path, .. } => parent_path.to_owned(),
DeployedObject::GcpTrigger { parent_path, .. } => parent_path.to_owned(),
DeployedObject::AzureTrigger { parent_path, .. } => parent_path.to_owned(),
DeployedObject::EmailTrigger { parent_path, .. } => parent_path.to_owned(),
DeployedObject::Settings { .. } => None,
DeployedObject::Key { .. } => None,
@@ -135,6 +138,7 @@ impl DeployedObject {
DeployedObject::MqttTrigger { .. } => "mqtt_trigger",
DeployedObject::SqsTrigger { .. } => "sqs_trigger",
DeployedObject::GcpTrigger { .. } => "gcp_trigger",
DeployedObject::AzureTrigger { .. } => "azure_trigger",
DeployedObject::EmailTrigger { .. } => "email_trigger",
DeployedObject::Settings { .. } => "settings",
DeployedObject::Key { .. } => "key",
@@ -369,6 +373,10 @@ mod tests {
DeployedObject::GcpTrigger { path: "t".to_string(), parent_path: None }.get_kind(),
"gcp_trigger"
);
assert_eq!(
DeployedObject::AzureTrigger { path: "t".to_string(), parent_path: None }.get_kind(),
"azure_trigger"
);
assert_eq!(
DeployedObject::EmailTrigger { path: "t".to_string(), parent_path: None }.get_kind(),
"email_trigger"
+1
View File
@@ -21,6 +21,7 @@ postgres_trigger = []
mqtt_trigger = []
sqs_trigger = []
gcp_trigger = []
azure_trigger = []
kafka = []
nats = []
openidconnect = ["windmill-common/openidconnect"]
+1
View File
@@ -1820,6 +1820,7 @@ async fn update_resource_type(
any(
feature = "sqs_trigger",
feature = "gcp_trigger",
feature = "azure_trigger",
feature = "kafka",
feature = "nats"
)
+43
View File
@@ -0,0 +1,43 @@
[package]
name = "windmill-trigger-azure"
version.workspace = true
authors.workspace = true
edition.workspace = true
[lib]
name = "windmill_trigger_azure"
path = "src/lib.rs"
[features]
default = []
enterprise = ["windmill-common/enterprise", "windmill-store/enterprise", "windmill-trigger/enterprise"]
private = ["windmill-common/private", "windmill-store/private"]
[dependencies]
windmill-common = { workspace = true, default-features = false }
windmill-api-auth.workspace = true
windmill-store = { workspace = true, features = ["azure_trigger"] }
windmill-trigger.workspace = true
windmill-git-sync.workspace = true
axum.workspace = true
serde.workspace = true
serde_json.workspace = true
sqlx.workspace = true
tokio.workspace = true
tokio-util.workspace = true
tracing.workspace = true
async-trait.workspace = true
itertools.workspace = true
anyhow.workspace = true
base64.workspace = true
bytes.workspace = true
http.workspace = true
quick_cache.workspace = true
lazy_static.workspace = true
reqwest.workspace = true
chrono.workspace = true
thiserror.workspace = true
sha2.workspace = true
rand.workspace = true
hex.workspace = true
constant_time_eq.workspace = true
@@ -0,0 +1,65 @@
#[allow(unused)]
#[cfg(feature = "private")]
pub use super::handler_ee::*;
#[cfg(not(feature = "private"))]
use {
super::AzureTrigger,
async_trait::async_trait,
sqlx::PgConnection,
windmill_api_auth::ApiAuthed,
windmill_common::{
error::{Error, Result},
DB,
},
windmill_git_sync::DeployedObject,
windmill_trigger::{TriggerCrud, TriggerData},
};
#[cfg(not(feature = "private"))]
#[async_trait]
impl TriggerCrud for AzureTrigger {
type Trigger = ();
type TriggerConfig = ();
type TriggerConfigRequest = ();
type TestConnectionConfig = ();
const TABLE_NAME: &'static str = "";
const TRIGGER_TYPE: &'static str = "";
const SUPPORTS_SERVER_STATE: bool = false;
const SUPPORTS_TEST_CONNECTION: bool = false;
const ROUTE_PREFIX: &'static str = "/azure_triggers";
const DEPLOYMENT_NAME: &'static str = "";
const IS_ALLOWED_ON_CLOUD: bool = false;
fn get_deployed_object(path: String, parent_path: Option<String>) -> DeployedObject {
DeployedObject::AzureTrigger { path, parent_path }
}
async fn create_trigger(
&self,
_db: &DB,
_executor: &mut PgConnection,
_authed: &ApiAuthed,
_w_id: &str,
_trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
Err(Error::BadRequest(
"Azure triggers are not available in open source version".to_string(),
))
}
async fn update_trigger(
&self,
_db: &DB,
_executor: &mut PgConnection,
_authed: &ApiAuthed,
_workspace_id: &str,
_path: &str,
_trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
Err(Error::BadRequest(
"Azure triggers are not available in open source version".to_string(),
))
}
}
+15
View File
@@ -0,0 +1,15 @@
#[cfg(feature = "private")]
mod handler_ee;
pub mod handler_oss;
#[cfg(feature = "private")]
mod listener_ee;
pub mod listener_oss;
#[cfg(feature = "private")]
mod mod_ee;
#[cfg(feature = "private")]
pub use mod_ee::*;
#[derive(Clone, Copy)]
pub struct AzureTrigger;
@@ -0,0 +1,52 @@
#[allow(unused)]
#[cfg(feature = "private")]
pub use super::listener_ee::*;
#[cfg(not(feature = "private"))]
use {
super::AzureTrigger,
serde_json::{value::RawValue, Value},
std::{collections::HashMap, sync::Arc},
tokio::sync::RwLock,
windmill_common::{error::Result, jobs::JobTriggerKind, triggers::TriggerKind, DB},
windmill_trigger::{listener::ListeningTrigger, trigger_helpers::TriggerJobArgs, Listener},
};
#[cfg(not(feature = "private"))]
impl TriggerJobArgs for AzureTrigger {
type Payload = Value;
const TRIGGER_KIND: TriggerKind = TriggerKind::Azure;
fn v1_payload_fn(_payload: &Self::Payload) -> HashMap<String, Box<RawValue>> {
HashMap::new()
}
}
#[cfg(not(feature = "private"))]
#[async_trait::async_trait]
impl Listener for AzureTrigger {
type Consumer = ();
type Extra = ();
type ExtraState = ();
const JOB_TRIGGER_KIND: JobTriggerKind = JobTriggerKind::Azure;
async fn get_consumer(
&self,
_db: &DB,
_listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_err_message: Arc<RwLock<Option<String>>>,
_killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> Result<Option<Self::Consumer>> {
Ok(None)
}
async fn consume(
&self,
_db: &DB,
_consumer: Self::Consumer,
_listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_err_message: Arc<RwLock<Option<String>>>,
_killpill_rx: tokio::sync::broadcast::Receiver<()>,
_extra_state: Option<&Self::ExtraState>,
) {
()
}
}
+2
View File
@@ -36,6 +36,7 @@ pub enum JobTriggerKind {
Postgres,
Schedule,
Gcp,
Azure,
Nextcloud,
Google,
Github,
@@ -58,6 +59,7 @@ impl std::fmt::Display for JobTriggerKind {
JobTriggerKind::Postgres => "postgres",
JobTriggerKind::Schedule => "schedule",
JobTriggerKind::Gcp => "gcp",
JobTriggerKind::Azure => "azure",
JobTriggerKind::Nextcloud => "nextcloud",
JobTriggerKind::Google => "google",
JobTriggerKind::Github => "github",
+3
View File
@@ -19,6 +19,7 @@ pub enum TriggerKind {
Sqs,
Postgres,
Gcp,
Azure,
Nextcloud,
Google,
Github,
@@ -38,6 +39,7 @@ impl TriggerKind {
TriggerKind::Sqs => "sqs".to_string(),
TriggerKind::Postgres => "postgres".to_string(),
TriggerKind::Gcp => "gcp".to_string(),
TriggerKind::Azure => "azure".to_string(),
TriggerKind::Nextcloud => "nextcloud".to_string(),
TriggerKind::Google => "google".to_string(),
TriggerKind::Github => "github".to_string(),
@@ -59,6 +61,7 @@ impl fmt::Display for TriggerKind {
TriggerKind::Sqs => "sqs",
TriggerKind::Postgres => "postgres",
TriggerKind::Gcp => "gcp",
TriggerKind::Azure => "azure",
TriggerKind::Nextcloud => "nextcloud",
TriggerKind::Google => "google",
TriggerKind::Github => "github",
+1
View File
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "wmill-dev",
+9 -1
View File
@@ -1495,6 +1495,7 @@ export async function elementsToMap(
path.endsWith(".mqtt_trigger" + ext) ||
path.endsWith(".sqs_trigger" + ext) ||
path.endsWith(".gcp_trigger" + ext) ||
path.endsWith(".azure_trigger" + ext) ||
path.endsWith(".email_trigger" + ext) ||
path.endsWith("_native_trigger" + ext))
) {
@@ -1873,6 +1874,7 @@ function getOrderFromPath(p: string) {
typ == "mqtt_trigger" ||
typ == "sqs_trigger" ||
typ == "gcp_trigger" ||
typ == "azure_trigger" ||
typ == "email_trigger" ||
typ == "native_trigger"
) {
@@ -3132,7 +3134,7 @@ export async function push(
}
}
const rules = folderRulesCache.get(folderName)!;
const remotePath = change.path.replace(/\.(script|schedule|http_trigger|websocket_trigger|kafka_trigger|nats_trigger|postgres_trigger|mqtt_trigger|sqs_trigger|gcp_trigger|email_trigger)\.(yaml|json)$/, "").replace(/(\.flow|__flow)\/flow\.(yaml|json)$/, "").replace(/\.(app|raw_app)(\/app\.(yaml|json))?$/, "");
const remotePath = change.path.replace(/\.(script|schedule|http_trigger|websocket_trigger|kafka_trigger|nats_trigger|postgres_trigger|mqtt_trigger|sqs_trigger|gcp_trigger|azure_trigger|email_trigger)\.(yaml|json)$/, "").replace(/(\.flow|__flow)\/flow\.(yaml|json)$/, "").replace(/\.(app|raw_app)(\/app\.(yaml|json))?$/, "");
const relative = remotePath.slice(`f/${folderName}/`.length);
if (!relative) continue;
for (const rule of rules) {
@@ -3765,6 +3767,12 @@ export async function push(
path: removeSuffix(target, ".gcp_trigger.json"),
});
break;
case "azure_trigger":
await wmill.deleteAzureTrigger({
workspace: workspaceId,
path: removeSuffix(target, ".azure_trigger.json"),
});
break;
case "email_trigger":
await wmill.deleteEmailTrigger({
workspace: workspaceId,
+20 -3
View File
@@ -5,6 +5,7 @@ import { stringify as yamlStringify } from "yaml";
import * as wmill from "../../../gen/services.gen.ts";
import {
GcpTrigger,
AzureTrigger,
HttpTrigger,
KafkaTrigger,
MqttTrigger,
@@ -48,6 +49,7 @@ type Trigger = {
mqtt: MqttTrigger;
sqs: SqsTrigger;
gcp: GcpTrigger;
azure: AzureTrigger;
email: EmailTrigger;
};
@@ -83,6 +85,7 @@ async function getTrigger<K extends TriggerType>(
mqtt: wmill.getMqttTrigger,
sqs: wmill.getSqsTrigger,
gcp: wmill.getGcpTrigger,
azure: wmill.getAzureTrigger,
email: wmill.getEmailTrigger,
};
const triggerFunction = triggerFunctions[triggerType];
@@ -112,6 +115,7 @@ async function updateTrigger<K extends TriggerType>(
mqtt: wmill.updateMqttTrigger,
sqs: wmill.updateSqsTrigger,
gcp: wmill.updateGcpTrigger,
azure: wmill.updateAzureTrigger,
email: wmill.updateEmailTrigger,
};
const triggerFunction = triggerFunctions[triggerType];
@@ -139,6 +143,7 @@ async function createTrigger<K extends TriggerType>(
mqtt: wmill.createMqttTrigger,
sqs: wmill.createSqsTrigger,
gcp: wmill.createGcpTrigger,
azure: wmill.createAzureTrigger,
email: wmill.createEmailTrigger,
};
const triggerFunction = triggerFunctions[triggerType];
@@ -393,6 +398,15 @@ const triggerTemplates: Record<TriggerType, Record<string, any>> = {
subscription_mode: "create_update",
enabled: false,
},
azure: {
script_path: "",
is_flow: false,
azure_resource_path: "",
azure_mode: "namespace_pull",
scope_resource_id: "",
subscription_name: "",
enabled: false,
},
email: {
script_path: "",
is_flow: false,
@@ -523,6 +537,7 @@ async function list(opts: GlobalOptions & { json?: boolean }) {
mqttTriggers,
sqsTriggers,
gcpTriggers,
azureTriggers,
emailTriggers,
] = await Promise.all([
listOrEmpty(() => wmill.listHttpTriggers({ workspace: ws })),
@@ -533,6 +548,7 @@ async function list(opts: GlobalOptions & { json?: boolean }) {
listOrEmpty(() => wmill.listMqttTriggers({ workspace: ws })),
listOrEmpty(() => wmill.listSqsTriggers({ workspace: ws })),
listOrEmpty(() => wmill.listGcpTriggers({ workspace: ws })),
listOrEmpty(() => wmill.listAzureTriggers({ workspace: ws })),
listOrEmpty(() => wmill.listEmailTriggers({ workspace: ws })),
]);
const triggers = [
@@ -544,6 +560,7 @@ async function list(opts: GlobalOptions & { json?: boolean }) {
...mqttTriggers.map((x) => ({ path: x.path, kind: "mqtt" })),
...sqsTriggers.map((x) => ({ path: x.path, kind: "sqs" })),
...gcpTriggers.map((x) => ({ path: x.path, kind: "gcp" })),
...azureTriggers.map((x) => ({ path: x.path, kind: "azure" })),
...emailTriggers.map((x) => ({ path: x.path, kind: "email" })),
];
@@ -622,11 +639,11 @@ const command = new Command()
.command("get", "get a trigger's details")
.arguments("<path:string>")
.option("--json", "Output as JSON (for piping to jq)")
.option("--kind <kind:string>", "Trigger kind (http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, email). Recommended for faster lookup")
.option("--kind <kind:string>", "Trigger kind (http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email). Recommended for faster lookup")
.action(get as any)
.command("new", "create a new trigger locally")
.arguments("<path:string>")
.option("--kind <kind:string>", "Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, email)")
.option("--kind <kind:string>", "Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email)")
.action(newTrigger as any)
.command(
"push",
@@ -641,7 +658,7 @@ const command = new Command()
.arguments("<path:string> <email:string>")
.option(
"--kind <kind:string>",
"Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, email)"
"Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email)"
)
.action((async (opts: any, triggerPath: string, email: string) => {
const workspace = await resolveWorkspace(opts);
+89 -3
View File
@@ -5760,12 +5760,12 @@ trigger related commands
- \`--json\` - Output as JSON (for piping to jq)
- \`trigger get <path:string>\` - get a trigger's details
- \`--json\` - Output as JSON (for piping to jq)
- \`--kind <kind:string>\` - Trigger kind (http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, email). Recommended for faster lookup
- \`--kind <kind:string>\` - Trigger kind (http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email). Recommended for faster lookup
- \`trigger new <path:string>\` - create a new trigger locally
- \`--kind <kind:string>\` - Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, email)
- \`--kind <kind:string>\` - Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email)
- \`trigger push <file_path:string> <remote_path:string>\` - push a local trigger spec. This overrides any remote versions.
- \`trigger set-permissioned-as <path:string> <email:string>\` - Set the email (run-as user) for a trigger (requires admin or wm_deployers group)
- \`--kind <kind:string>\` - Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, email)
- \`--kind <kind:string>\` - Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email)
### user
@@ -5873,6 +5873,91 @@ workspace related commands
// YAML schema content for triggers and schedules
export const SCHEMAS: Record<string, string> = {
"azure_trigger": `type: object
properties:
script_path:
type: string
description: Path to the script or flow to execute when triggered
permissioned_as:
type: string
description: The user or group this trigger runs as (permissioned_as)
is_flow:
type: boolean
description: True if script_path points to a flow, false if it points to a script
labels:
type: array
items:
type: string
azure_resource_path:
type: string
azure_mode:
type: string
enum:
- basic_push
- namespace_push
- namespace_pull
description: Azure Event Grid trigger mode.
scope_resource_id:
type: string
description: ARM resource ID of the topic (basic) or namespace (namespace modes).
topic_name:
type: string
description: Topic name within the namespace (namespace modes only).
subscription_name:
type: string
event_type_filters:
type: array
items:
type: string
error_handler_path:
type: string
error_handler_args:
type: object
description: The arguments to pass to the script or flow
retry:
type: object
properties:
constant:
type: object
description: Retry with constant delay between attempts
properties:
attempts:
type: integer
description: Number of retry attempts
seconds:
type: integer
description: Seconds to wait between retries
exponential:
type: object
description: Retry with exponential backoff (delay doubles each time)
properties:
attempts:
type: integer
description: Number of retry attempts
multiplier:
type: integer
description: Multiplier for exponential backoff
seconds:
type: integer
minimum: 1
description: Initial delay in seconds
random_factor:
type: integer
minimum: 0
maximum: 100
description: Random jitter percentage (0-100) to avoid thundering herd
retry_if:
$ref: '#/components/schemas/RetryIf'
description: Retry configuration for failed module executions
required:
- script_path
- permissioned_as
- is_flow
- azure_resource_path
- azure_mode
- scope_resource_id
- subscription_name
`,
"gcp_trigger": `type: object
properties:
script_path:
@@ -6807,6 +6892,7 @@ export const SCHEMA_MAPPINGS: Record<string, SchemaMapping[]> = {
{ name: "MqttTrigger", schemaKey: "mqtt_trigger", filePattern: "*.mqtt_trigger.yaml" },
{ name: "SqsTrigger", schemaKey: "sqs_trigger", filePattern: "*.sqs_trigger.yaml" },
{ name: "GcpTrigger", schemaKey: "gcp_trigger", filePattern: "*.gcp_trigger.yaml" },
{ name: "AzureTrigger", schemaKey: "azure_trigger", filePattern: "*.azure_trigger.yaml" },
],
"schedules": [
{ name: "Schedule", schemaKey: "schedule", filePattern: "*.schedule.yaml" },
+5
View File
@@ -61,6 +61,7 @@ export const TRIGGER_TYPES = [
"mqtt",
"sqs",
"gcp",
"azure",
"email",
] as const;
@@ -204,6 +205,8 @@ export async function pushObj(
await pushTrigger("sqs", workspace, p, befObj, newObj, permissionedAsContext);
} else if (typeEnding === "gcp_trigger") {
await pushTrigger("gcp", workspace, p, befObj, newObj, permissionedAsContext);
} else if (typeEnding === "azure_trigger") {
await pushTrigger("azure", workspace, p, befObj, newObj, permissionedAsContext);
} else if (typeEnding === "email_trigger") {
await pushTrigger("email", workspace, p, befObj, newObj, permissionedAsContext);
} else if (typeEnding === "native_trigger") {
@@ -263,6 +266,7 @@ export function getTypeStrFromPath(
| "mqtt_trigger"
| "sqs_trigger"
| "gcp_trigger"
| "azure_trigger"
| "email_trigger"
| "native_trigger"
| "user"
@@ -343,6 +347,7 @@ export function getTypeStrFromPath(
typeEnding === "mqtt_trigger" ||
typeEnding === "sqs_trigger" ||
typeEnding === "gcp_trigger" ||
typeEnding === "azure_trigger" ||
typeEnding === "email_trigger" ||
typeEnding === "user" ||
typeEnding === "group" ||
+28
View File
@@ -668,4 +668,32 @@ describe("new command", () => {
expect(content).toContain("topics");
});
});
test("trigger new --kind azure creates azure trigger yaml template", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
await mkdir(join(tempDir, "f", "test"), { recursive: true });
const result = await backend.runCLICommand(
["trigger", "new", "f/test/azure_trigger", "--kind", "azure"],
tempDir
);
expect(result.code).toEqual(0);
const filePath = join(
tempDir,
"f/test/azure_trigger.azure_trigger.yaml"
);
const fileStat = await stat(filePath);
expect(fileStat.isFile()).toBe(true);
const content = await readFile(filePath, "utf-8");
expect(content).toContain("azure_resource_path");
expect(content).toContain("azure_mode");
expect(content).toContain("scope_resource_id");
expect(content).toContain("subscription_name");
});
});
});
@@ -25,6 +25,7 @@
EmailTriggerService,
FlowService,
FolderService,
AzureTriggerService,
GcpTriggerService,
HttpTriggerService,
KafkaTriggerService,
@@ -53,6 +54,7 @@
import MqttTriggerEditor from './triggers/mqtt/MqttTriggerEditor.svelte'
import SqsTriggerEditor from './triggers/sqs/SqsTriggerEditor.svelte'
import GcpTriggerEditor from './triggers/gcp/GcpTriggerEditor.svelte'
import AzureTriggerEditor from './triggers/azure/AzureTriggerEditor.svelte'
import EmailTriggerEditor from './triggers/email/EmailTriggerEditor.svelte'
import { userWorkspaces, workspaceStore } from '$lib/stores'
@@ -640,6 +642,7 @@
let mqttEditor: MqttTriggerEditor | undefined = $state()
let sqsEditor: SqsTriggerEditor | undefined = $state()
let gcpEditor: GcpTriggerEditor | undefined = $state()
let azureEditor: AzureTriggerEditor | undefined = $state()
let emailEditor: EmailTriggerEditor | undefined = $state()
function openTriggerDetails(trigger: ForkTrigger) {
@@ -672,6 +675,9 @@
case 'gcp':
gcpEditor?.openEdit(trigger.path, isFlow)
break
case 'azure':
azureEditor?.openEdit(trigger.path, isFlow)
break
case 'emails':
emailEditor?.openEdit(trigger.path, isFlow)
break
@@ -794,6 +800,19 @@
extraLabel: item.topic_id
})
},
azure: {
list: (ws: string) => AzureTriggerService.listAzureTriggers({ workspace: ws }),
delete: (ws: string, path: string) =>
AzureTriggerService.deleteAzureTrigger({ workspace: ws, path }),
normalize: (item: any): ForkTrigger => ({
path: item.path,
triggerKind: 'azure',
scriptPath: item.script_path,
isFlow: item.is_flow,
enabled: item.mode === 'enabled',
extraLabel: item.topic_name ?? item.scope_resource_id
})
},
emails: {
list: (ws: string) => EmailTriggerService.listEmailTriggers({ workspace: ws }),
delete: (ws: string, path: string) =>
@@ -1316,6 +1335,7 @@
<MqttTriggerEditor bind:this={mqttEditor} />
<SqsTriggerEditor bind:this={sqsEditor} />
<GcpTriggerEditor bind:this={gcpEditor} />
<AzureTriggerEditor bind:this={azureEditor} />
<EmailTriggerEditor bind:this={emailEditor} />
<ConfirmationModal
+7
View File
@@ -22,6 +22,7 @@
MqttTriggerService,
SqsTriggerService,
GcpTriggerService,
AzureTriggerService,
EmailTriggerService
} from '$lib/gen'
import { superadmin, userStore, workspaceStore } from '$lib/stores'
@@ -55,6 +56,7 @@
| 'mqtt_trigger'
| 'sqs_trigger'
| 'gcp_trigger'
| 'azure_trigger'
| 'email_trigger'
let meta: Meta | undefined = $state(undefined)
interface Props {
@@ -300,6 +302,11 @@
workspace: $workspaceStore!,
path: path
})
} else if (kind === 'azure_trigger') {
return await AzureTriggerService.existsAzureTrigger({
workspace: $workspaceStore!,
path: path
})
} else if (kind === 'email_trigger') {
return await EmailTriggerService.existsEmailTrigger({
workspace: $workspaceStore!,
@@ -41,6 +41,7 @@
| 'sqs_trigger'
| 'postgres_trigger'
| 'gcp_trigger'
| 'azure_trigger'
| 'email_trigger'
| 'volume'
let kind: Kind
@@ -6,6 +6,7 @@
import { type TriggerContext } from '$lib/components/triggers'
import { enterpriseLicense } from '$lib/stores'
import { MqttIcon, NatsIcon, KafkaIcon, AwsIcon, GoogleCloudIcon } from '$lib/components/icons'
import AzureIcon from '$lib/components/icons/AzureIcon.svelte'
import { type Trigger, type TriggerType } from '$lib/components/triggers/utils'
import { Menu, Menubar, MeltButton, MenuItem, Tooltip } from '$lib/components/meltComponents'
import { twMerge } from 'tailwind-merge'
@@ -70,6 +71,7 @@
mqtt: { icon: MqttIcon, countKey: 'mqtt_count', disabled: !$enterpriseLicense },
sqs: { icon: AwsIcon, countKey: 'sqs_count', disabled: !$enterpriseLicense },
gcp: { icon: GoogleCloudIcon, countKey: 'gcp_count', disabled: !$enterpriseLicense },
azure: { icon: AzureIcon, countKey: 'azure_count', disabled: !$enterpriseLicense },
poll: { icon: SchedulePollIcon },
cli: { icon: Terminal },
nextcloud: { icon: NextcloudIcon, countKey: 'nextcloud_count' },
@@ -103,6 +105,7 @@
'mqtt',
'sqs',
'gcp',
'azure',
'email',
'poll',
'cli',
@@ -1,35 +1,22 @@
<script lang="ts">
interface Props {
height?: string;
width?: string;
size?: number
color?: string | undefined
class?: string
}
let { height = '24px', width = '24px' }: Props = $props();
let { size = 16, color = undefined, class: clazz = '' }: Props = $props()
</script>
<svg id="azure" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 374.5 377.3" {width} {height}
><style>
.st0-azure {
fill: #00bef2;
}
.st1-azure {
fill: #fff;
stroke: #fff;
stroke-width: 1.2357;
stroke-linecap: round;
stroke-linejoin: round;
}
</style><g id="layer1" transform="translate(-39.022 -78.115)"
><g id="g1016" transform="translate(-63.947 -88.179)"
><path
id="path1008"
class="st0-azure"
d="M290 166.3c.4 0 .8.5 1.4 1.4.5.8 42.6 51.3 93.6 112.2 51 60.9 92.6 111 92.4 111.3-.1.3-40.7 33.6-90.2 73.9s-91.6 74.6-93.5 76.2c-3.3 2.7-3.5 2.8-4.7 1.6-.7-.7-42.9-35.2-93.8-76.7S102.8 390.5 103 390c.2-.5 42-50.4 93.1-111s92.9-110.7 93.1-111.5c.2-.8.5-1.2.8-1.2z"
/><path
id="path923"
class="st1-azure"
d="M283.1 483.6c-5.8-2.1-12.8-8.1-15.7-13.7-3.6-6.9-3.3-17.7.7-26.3 3.1-6.4 3.1-6.6 1.1-8.1-1.1-.8-14.4-8.2-29.4-16.3-15-8.1-28.1-15.2-29-15.7-1.2-.7-3.2 0-6.8 2.3-11.7 7.4-23.9 6.6-33.5-2.3-6.9-6.4-8.9-10.9-8.9-20.1 0-8.9 1.8-13.5 7.5-19.2 7.7-7.7 18-10.3 27.9-7 5.4 1.8 5.5 1.8 8.9-.8 4-3 36.1-32.3 51.6-47l10.7-10.2-3.2-6.7c-6.5-13.5-3.2-28.5 8.2-37.5 6.2-4.9 10.8-6.4 19.7-6.4 20.8 0 35.3 21.8 27.5 41.3-2.1 5.4-2.1 5.5-.1 8.8 1.7 2.9 30.6 37.8 45.9 55.6 2.7 3.1 5.7 5.6 6.7 5.6s4.4-1 7.6-2.2c14.9-5.9 30.6.7 36.8 15.5 4 9.5.5 22.3-8 30-6 5.4-10.4 7.1-18.4 7.1-5.6 0-7.7-.6-13.6-3.8-4.4-2.4-7.8-3.6-9.2-3.2-2.4.6-39.3 25.9-47.5 32.5-5 4.1-5.4 5.6-2.8 11.7 2.5 6 2.2 15.4-.6 21.3-3.1 6.5-10.8 13-17.5 15-6.8 1.9-10.9 1.9-16.6-.2zm1.7-110.2v-57l-3.2-4.4c-1.8-2.4-3.5-4.4-3.8-4.4-1.3 0-65.9 58.7-65.9 59.9 0 .3 1 3.3 2.2 6.5 1.2 3.3 2.1 8 2 10.7-.1 2.7-.1 5.7-.1 6.7.1 2.3 21.7 16.1 54.1 34.8 8.9 5.2 12 6.5 13.1 5.6 1.3-1.1 1.6-12.2 1.6-58.4zm27.4 50.4c42.8-26.9 50.8-32.3 51.3-34.3.3-1.2.7-5.9.8-10.6l.3-8.4-21.8-25.9c-23.4-27.7-32-37.1-34-37.1-.7 0-4.2 2-7.8 4.4l-6.6 4.4.3 56.9c.3 51 .7 59.6 2.6 59.6.2.1 7-4 14.9-9z"
/></g
></g
></svg
<svg
xmlns="http://www.w3.org/2000/svg"
width={`${size}px`}
height={`${size}px`}
viewBox="0 0 24 24"
fill={color ?? 'currentColor'}
class={clazz}
>
<path
d="M22.379 23.343a1.62 1.62 0 0 0 1.536-1.1l.029-.092q.053-.164.081-.336v-.016a1.68 1.68 0 0 0-.268-1.227L15.147 5.44a1.63 1.63 0 0 0-1.354-.724l-3.473.011L5.94 8.57a6 6 0 0 0-1.386 1.74L.262 17.717a1.63 1.63 0 0 0 1.422 2.429l-.055-.013zM13.398 7.25l5.322 9.183-10.683.024zm-3.363 12.754-8.316.021 8.318-14.4 1.795 3.1-6.516 11.274z"
/>
</svg>
@@ -31,6 +31,7 @@ const TRIGGER_TABLE_TO_ROUTE: Record<string, string> = {
nats_trigger: 'nats_triggers',
sqs_trigger: 'sqs_triggers',
gcp_trigger: 'gcp_triggers',
azure_trigger: 'azure_triggers',
email_trigger: 'email_triggers'
}
@@ -43,6 +44,7 @@ const TRIGGER_TABLE_TO_LABEL: Record<string, string> = {
nats_trigger: 'nats trigger',
sqs_trigger: 'sqs trigger',
gcp_trigger: 'gcp trigger',
azure_trigger: 'azure trigger',
email_trigger: 'email trigger'
}
@@ -40,7 +40,7 @@
import { Alert } from '../common'
import Popover from '../Popover.svelte'
import Logs from 'lucide-svelte/icons/logs'
import { AwsIcon, GoogleCloudIcon, KafkaIcon, MqttIcon, NatsIcon } from '../icons'
import { AwsIcon, AzureIcon, GoogleCloudIcon, KafkaIcon, MqttIcon, NatsIcon } from '../icons'
import RunsSearch from './RunsSearch.svelte'
import AskAiButton from '../copilot/AskAiButton.svelte'
@@ -138,6 +138,13 @@
icon: GoogleCloudIcon,
disabled: $userStore?.operator
},
{
search_id: 'nav:azure_event_grid',
label: 'Go to Azure Event Grid' + (!$enterpriseLicense ? '' : ' (EE)'),
action: (newtab: boolean = false) => gotoPage('/azure_triggers', newtab),
icon: AzureIcon,
disabled: $userStore?.operator
},
{
search_id: 'nav:mqtt_triggers',
label: 'Go to MQTT triggers',
@@ -151,6 +151,12 @@
href: `${base}/gcp_triggers`,
kind: 'gcp'
},
{
label: 'Azure Event Grid triggers',
id: 'triggers',
href: `${base}/azure_triggers`,
kind: 'azure'
},
{ label: 'MQTT triggers', id: 'triggers', href: `${base}/mqtt_triggers`, kind: 'mqtt' },
{ label: 'Email triggers', id: 'triggers', href: `${base}/email_triggers`, kind: 'email' }
] as TriggerMenuLink[]
@@ -74,6 +74,7 @@
} from '$lib/components/meltComponents'
import MenuButton from './MenuButton.svelte'
import GoogleCloudIcon from '../icons/GoogleCloudIcon.svelte'
import AzureIcon from '../icons/AzureIcon.svelte'
async function leaveWorkspace() {
await WorkspaceService.leaveWorkspace({ workspace: $workspaceStore ?? '' })
@@ -365,6 +366,15 @@
aiId: 'sidebar-menu-link-gcp',
aiDescription: 'Button to navigate to GCP Pub/Sub triggers'
},
{
label: 'Azure Event Grid' + ($enterpriseLicense ? '' : ' (EE)'),
href: '/azure_triggers',
icon: AzureIcon,
disabled: $userStore?.operator || !$enterpriseLicense,
kind: 'azure',
aiId: 'sidebar-menu-link-azure',
aiDescription: 'Button to navigate to Azure Event Grid triggers'
},
{
label: 'MQTT',
href: '/mqtt_triggers',
+1
View File
@@ -56,6 +56,7 @@ export type TriggerKind =
| 'mqtt'
| 'sqs'
| 'gcp'
| 'azure'
| 'nextcloud'
| 'google'
| 'github'
@@ -104,6 +104,12 @@
icon: triggerIconMap.gcp,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'Azure Event Grid',
action: () => onAddDraftTrigger?.('azure'),
icon: triggerIconMap.azure,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'Email',
action: () => onAddDraftTrigger?.('email'),
@@ -15,6 +15,7 @@
import MqttCapture from './mqtt/MqttCapture.svelte'
import SqsCapture from './sqs/SqsCapture.svelte'
import GcpCapture from './gcp/GcpCapture.svelte'
import AzureCapture from './azure/AzureCapture.svelte'
import EmailCapture from './email/EmailCapture.svelte'
interface Props {
@@ -74,7 +75,12 @@
if (captureType === 'gcp' && args.delivery_type === 'push') {
return false
}
return ['mqtt', 'sqs', 'websocket', 'postgres', 'kafka', 'nats', 'gcp'].includes(captureType)
if (captureType === 'azure' && args.azure_mode !== 'namespace_pull') {
return false
}
return ['mqtt', 'sqs', 'websocket', 'postgres', 'kafka', 'nats', 'gcp', 'azure'].includes(
captureType
)
}
async function getCaptureConfigs() {
@@ -324,6 +330,20 @@
on:captureToggle={handleCapture}
on:testWithArgs
/>
{:else if captureType === 'azure'}
<AzureCapture
{isValid}
{captureInfo}
{hasPreprocessor}
{isFlow}
{captureLoading}
subscriptionName={args.subscription_name}
on:applyArgs
on:updateSchema
on:addPreprocessor
on:captureToggle={handleCapture}
on:testWithArgs
/>
{:else if captureType === 'email'}
<EmailCapture
local_part={args.local_part}
@@ -28,6 +28,7 @@
MqttTriggerService,
HttpTriggerService,
GcpTriggerService,
AzureTriggerService,
SqsTriggerService,
EmailTriggerService,
NativeTriggerService,
@@ -112,6 +113,7 @@
kafka: () => KafkaTriggerService.deleteKafkaTrigger,
nats: () => NatsTriggerService.deleteNatsTrigger,
gcp: () => GcpTriggerService.deleteGcpTrigger,
azure: () => AzureTriggerService.deleteAzureTrigger,
sqs: () => SqsTriggerService.deleteSqsTrigger,
mqtt: () => MqttTriggerService.deleteMqttTrigger,
http: () => HttpTriggerService.deleteHttpTrigger,
@@ -229,6 +231,14 @@
isFlow,
$userStore
)
} else if (triggerType === 'azure') {
await triggersState.fetchAzureTriggers(
triggersCount,
$workspaceStore,
currentPath,
isFlow,
$userStore
)
} else if (triggerType === 'sqs') {
await triggersState.fetchSqsTriggers(
triggersCount,
@@ -10,6 +10,7 @@
import MqttTriggerPanel from './mqtt/MqttTriggersPanel.svelte'
import SqsTriggerPanel from './sqs/SqsTriggerPanel.svelte'
import GcpTriggerPanel from './gcp/GcpTriggerPanel.svelte'
import AzureTriggerPanel from './azure/AzureTriggerPanel.svelte'
import ScheduledPollPanel from './scheduled/ScheduledPollPanel.svelte'
import WebsocketTriggersPanel from './websocket/WebsocketTriggersPanel.svelte'
import { triggerIconMap, type Trigger } from './utils'
@@ -160,6 +161,15 @@
{customLabel}
{...props}
/>
{:else if selectedTrigger.type === 'azure'}
<AzureTriggerPanel
{isFlow}
path={initialPath || fakeInitialPath}
{selectedTrigger}
defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined}
{customLabel}
{...props}
/>
{:else if selectedTrigger.type === 'email'}
<EmailTriggerPanel
{isFlow}
@@ -0,0 +1,63 @@
<script lang="ts">
import type { CaptureInfo } from '../CaptureSection.svelte'
import CaptureSection from '../CaptureSection.svelte'
import { Url } from '$lib/components/common'
import { fade } from 'svelte/transition'
interface Props {
captureInfo?: CaptureInfo | undefined
isValid?: boolean | undefined
hasPreprocessor?: boolean
isFlow?: boolean
captureLoading?: boolean
subscriptionName?: string | undefined
}
let {
captureInfo = undefined,
isValid = undefined,
hasPreprocessor = false,
isFlow = false,
captureLoading = false,
subscriptionName = undefined
}: Props = $props()
// Mirror the backend suffix in set_azure_trigger_config: truncate to 39 then
// append "-wm-capture". Azure rule: [A-Za-z0-9-]{3,50}.
const captureSubscriptionName = $derived(
subscriptionName
? `${subscriptionName.length > 39 ? subscriptionName.slice(0, 39) : subscriptionName}-wm-capture`
: undefined
)
</script>
{#if captureInfo}
<CaptureSection
captureType="azure"
disabled={isValid === false}
{captureInfo}
on:captureToggle
on:applyArgs
on:updateSchema
on:addPreprocessor
on:testWithArgs
{hasPreprocessor}
{isFlow}
{captureLoading}
>
{#snippet description()}
{#if captureInfo.active}
<p in:fade={{ duration: 100, delay: 50 }} out:fade={{ duration: 50 }}>
Listening to Azure Event Grid events...
</p>
{:else}
<p in:fade={{ duration: 100, delay: 50 }} out:fade={{ duration: 50 }}>
Start capturing to listen to Azure Event Grid events.
</p>
{/if}
{/snippet}
{#if captureSubscriptionName}
<Url label="Test subscription name" url={captureSubscriptionName} />
{/if}
</CaptureSection>
{/if}
@@ -0,0 +1,29 @@
<script lang="ts">
import { tick } from 'svelte'
import AzureTriggerEditorInner from './AzureTriggerEditorInner.svelte'
let { onUpdate }: { onUpdate?: (path?: string) => void } = $props()
let open = $state(false)
export async function openEdit(ePath: string, isFlow: boolean) {
open = true
await tick()
drawer?.openEdit(ePath, isFlow)
}
export async function openNew(
is_flow: boolean,
initial_script_path?: string,
defaultValues?: Record<string, any>
) {
open = true
await tick()
drawer?.openNew(is_flow, initial_script_path, defaultValues)
}
let drawer: AzureTriggerEditorInner | undefined = $state()
</script>
{#if open}
<AzureTriggerEditorInner {onUpdate} bind:this={drawer} />
{/if}
@@ -0,0 +1,351 @@
<script lang="ts">
import Section from '$lib/components/Section.svelte'
import ResourcePicker from '$lib/components/ResourcePicker.svelte'
import Subsection from '$lib/components/Subsection.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import Select from '$lib/components/select/Select.svelte'
import { Alert, Button } from '$lib/components/common'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import type { AzureMode, AzureArmResource } from '$lib/gen'
import { AzureTriggerService } from '$lib/gen'
import { emptyStringTrimmed } from '$lib/utils'
import { workspaceStore } from '$lib/stores'
import { RefreshCw } from 'lucide-svelte'
interface Props {
can_write?: boolean
headless?: boolean
isValid?: boolean
azure_resource_path: string
azure_mode: AzureMode
scope_resource_id: string
topic_name?: string
subscription_name: string
event_type_filters?: string[]
path?: string
}
let {
can_write = false,
headless = false,
isValid = $bindable(false),
azure_resource_path = $bindable(),
azure_mode = $bindable(),
scope_resource_id = $bindable(),
topic_name = $bindable(),
subscription_name = $bindable(),
event_type_filters = $bindable(),
path = ''
}: Props = $props()
type Edition = 'basic' | 'namespace'
type Delivery = 'push' | 'pull'
const edition = $derived<Edition>(azure_mode === 'basic_push' ? 'basic' : 'namespace')
const delivery = $derived<Delivery>(azure_mode === 'namespace_pull' ? 'pull' : 'push')
function setEdition(next: Edition) {
if (next === 'basic') {
azure_mode = 'basic_push'
} else {
azure_mode = delivery === 'pull' ? 'namespace_pull' : 'namespace_push'
}
}
function setDelivery(next: Delivery) {
azure_mode = next === 'pull' ? 'namespace_pull' : 'namespace_push'
}
$effect(() => {
if (azure_mode === 'basic_push' && topic_name !== undefined && topic_name !== '') {
topic_name = undefined
}
})
$effect(() => {
if (emptyStringTrimmed(subscription_name) && !emptyStringTrimmed(path) && $workspaceStore) {
const generated = `windmill-${$workspaceStore}-${path.replaceAll(/[^A-Za-z0-9-]/g, '-')}`
subscription_name = generated.slice(0, 50)
}
})
const is_namespace = $derived(edition === 'namespace')
const has_sp = $derived(!emptyStringTrimmed(azure_resource_path))
const has_scope = $derived(!emptyStringTrimmed(scope_resource_id))
const has_topic = $derived(!is_namespace || !emptyStringTrimmed(topic_name))
const config_ready = $derived(has_sp && has_scope && has_topic)
const scopeLabel = $derived(is_namespace ? 'Namespace' : 'Topic')
const scopeTooltip = $derived(
is_namespace
? 'Event Grid Namespace — loaded via the service principal. Refresh after creating one.'
: 'Basic Event Grid topic or system topic — loaded via the service principal.'
)
// ARM resource loading -----------------------------------------------
let scopeResources = $state<AzureArmResource[]>([])
let scopeLoading = $state(false)
let scopeError = $state<string | undefined>(undefined)
async function loadScopeResources() {
if (!$workspaceStore || emptyStringTrimmed(azure_resource_path)) {
scopeResources = []
return
}
scopeLoading = true
scopeError = undefined
try {
const result = is_namespace
? await AzureTriggerService.listAzureNamespaces({
workspace: $workspaceStore,
path: azure_resource_path
})
: await AzureTriggerService.listAzureBasicTopics({
workspace: $workspaceStore,
path: azure_resource_path
})
scopeResources = result
// Clear selection if it no longer matches any entry in the new list
// (e.g. flipped edition, swapped SP).
if (scope_resource_id && !result.some((r) => r.id === scope_resource_id)) {
scope_resource_id = ''
topic_name = undefined
}
} catch (e: any) {
scopeError = e?.body?.error?.message ?? e?.message ?? String(e)
scopeResources = []
} finally {
scopeLoading = false
}
}
$effect(() => {
// Re-fetch when SP changes or edition flips.
void azure_resource_path
void edition
loadScopeResources()
})
const scopeItems = $derived(
scopeResources.map((r) => ({
label: r.name + (r.type?.toLowerCase().includes('systemtopic') ? ' (system)' : ''),
value: r.id,
subtext: r.id
}))
)
let topics = $state<{ name: string; id: string }[]>([])
let topicsLoading = $state(false)
let topicsError = $state<string | undefined>(undefined)
async function loadTopics() {
if (
!is_namespace ||
!$workspaceStore ||
emptyStringTrimmed(azure_resource_path) ||
emptyStringTrimmed(scope_resource_id)
) {
topics = []
return
}
topicsLoading = true
topicsError = undefined
try {
const result = await AzureTriggerService.listAzureNamespaceTopics({
workspace: $workspaceStore,
path: azure_resource_path,
requestBody: { scope_resource_id }
})
topics = (result as any[]).map((t) => ({ name: t.name, id: t.id }))
if (topic_name && !topics.some((t) => t.name === topic_name)) {
topic_name = undefined
}
} catch (e: any) {
topicsError = e?.body?.error?.message ?? e?.message ?? String(e)
topics = []
} finally {
topicsLoading = false
}
}
$effect(() => {
void scope_resource_id
void azure_resource_path
void is_namespace
loadTopics()
})
const topicItems = $derived(topics.map((t) => ({ label: t.name, value: t.name })))
const AZURE_SUB_NAME_RE = /^[A-Za-z0-9-]{3,50}$/
const subscriptionNameError = $derived.by(() => {
if (emptyStringTrimmed(subscription_name)) return ''
return AZURE_SUB_NAME_RE.test(subscription_name)
? ''
: 'Must be 350 chars, letters/digits/hyphens only.'
})
$effect(() => {
const resource_ok = !emptyStringTrimmed(azure_resource_path)
const scope_ok = !emptyStringTrimmed(scope_resource_id)
const sub_ok = !emptyStringTrimmed(subscription_name) && subscriptionNameError === ''
const topic_ok = !is_namespace || !emptyStringTrimmed(topic_name)
isValid = resource_ok && scope_ok && sub_ok && topic_ok
})
const filterText = $derived(event_type_filters?.join('\n') ?? '')
function setFilterText(value: string) {
const list = value
.split(/\n|,/)
.map((v) => v.trim())
.filter((v) => v.length > 0)
event_type_filters = list.length > 0 ? list : undefined
}
</script>
<Section label="Azure Event Grid" {headless}>
<div class="flex flex-col gap-6">
<Subsection
label="Service Principal"
tooltip="Windmill resource of type `azure` (azureTenantId, azureClientId, azureClientSecret)."
>
<ResourcePicker bind:value={azure_resource_path} resourceType="azure" disabled={!can_write} />
</Subsection>
{#if has_sp}
<Subsection
label="Event Grid edition"
tooltip="Namespace is the newer product (2024 GA) with push + pull delivery and CloudEvents 1.0. Basic is the legacy service — use it for Azure system topics (Storage / Key Vault events etc.)."
>
<ToggleButtonGroup
selected={edition}
onSelected={(v) => setEdition(v as Edition)}
disabled={!can_write}
>
{#snippet children({ item })}
<ToggleButton value="namespace" label="Namespace" {item} />
<ToggleButton value="basic" label="Basic" {item} />
{/snippet}
</ToggleButtonGroup>
</Subsection>
{#if is_namespace}
<Subsection
label="Delivery mode"
tooltip="Push: Azure POSTs events to this Windmill instance (requires a public URL). Pull: Windmill polls Azure, better for retries and not requiring inbound exposure."
>
<ToggleButtonGroup
selected={delivery}
onSelected={(v) => setDelivery(v as Delivery)}
disabled={!can_write}
>
{#snippet children({ item })}
<ToggleButton value="pull" label="Pull" {item} />
<ToggleButton value="push" label="Push" {item} />
{/snippet}
</ToggleButtonGroup>
</Subsection>
{/if}
<Subsection label={scopeLabel} tooltip={scopeTooltip}>
<div class="flex items-center gap-2">
<div class="flex-1">
<Select
bind:value={scope_resource_id}
items={scopeItems}
placeholder={scopeLoading ? 'Loading…' : `Select ${scopeLabel.toLowerCase()}`}
disabled={!can_write || scopeLoading}
clearable
/>
</div>
<Button
variant="subtle"
startIcon={{ icon: RefreshCw }}
iconOnly
disabled={!can_write || scopeLoading}
onclick={loadScopeResources}
/>
</div>
{#if scopeError}
<p class="text-xs text-red-600 mt-1">{scopeError}</p>
{:else if !scopeLoading && scopeResources.length === 0}
<p class="text-xs text-tertiary mt-1">
No {scopeLabel.toLowerCase()} found. Create one in Azure and click refresh.
</p>
{/if}
</Subsection>
{/if}
{#if has_sp && is_namespace && has_scope}
<Subsection label="Topic" tooltip="Topic inside the selected Namespace.">
<div class="flex items-center gap-2">
<div class="flex-1">
<Select
bind:value={topic_name}
items={topicItems}
placeholder={topicsLoading ? 'Loading…' : 'Select topic'}
disabled={!can_write || topicsLoading}
clearable
/>
</div>
<Button
variant="subtle"
startIcon={{ icon: RefreshCw }}
iconOnly
disabled={!can_write || topicsLoading}
onclick={loadTopics}
/>
</div>
{#if topicsError}
<p class="text-xs text-red-600 mt-1">{topicsError}</p>
{/if}
</Subsection>
{/if}
{#if config_ready}
<Subsection
label="Subscription name"
tooltip="Auto-generated from the trigger path. Windmill creates this subscription on Azure (or overwrites it) on save."
>
<TextInput
bind:value={subscription_name}
inputProps={{
placeholder: 'leave empty to auto-generate',
disabled: !can_write
}}
error={subscriptionNameError}
/>
{#if subscriptionNameError}
<p class="text-xs text-red-600 mt-1">{subscriptionNameError}</p>
{/if}
<div class="mt-2">
<Alert title="Saving overwrites this subscription on Azure" type="warning" size="xs">
If a subscription with this name already exists with an incompatible delivery mode
(Push↔Pull) or endpoint type, Windmill will delete and recreate it — any in-flight
events in its queue will be dropped.
</Alert>
</div>
</Subsection>
<Subsection
label="Event type filters"
tooltip="Optional. One per line (or comma-separated). Forwarded as includedEventTypes."
>
<TextInput
value={filterText}
underlyingInputEl="textarea"
class="font-mono"
inputProps={{
placeholder: 'Microsoft.Storage.BlobCreated',
disabled: !can_write,
rows: 3,
oninput: (e) => setFilterText(e.currentTarget.value)
}}
/>
</Subsection>
{/if}
</div>
</Section>
@@ -0,0 +1,480 @@
<script lang="ts">
import { Alert, Button } from '$lib/components/common'
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import Path from '$lib/components/Path.svelte'
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils'
import { Loader2 } from 'lucide-svelte'
import Label from '$lib/components/Label.svelte'
import {
AzureTriggerService,
type AzureMode,
type Retry,
type ErrorHandler,
type TriggerMode
} from '$lib/gen'
import Section from '$lib/components/Section.svelte'
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
import Required from '$lib/components/Required.svelte'
import AzureTriggerEditorConfigSection from './AzureTriggerEditorConfigSection.svelte'
import { type Snippet, untrack } from 'svelte'
import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte'
import PermissionedAsLine from '../PermissionedAsLine.svelte'
import { saveAzureTriggerFromCfg } from './utils'
import { getHandlerType, handleConfigChange, type Trigger } from '../utils'
import { deepEqual } from 'fast-equals'
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
import { base } from '$lib/base'
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
import Tab from '$lib/components/common/tabs/Tab.svelte'
import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte'
import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte'
let drawer: Drawer | undefined = $state(undefined)
let initialPath = $state('')
let edit = $state(true)
let itemKind: 'flow' | 'script' = $state('script')
let script_path = $state('')
let initialScriptPath = $state('')
let fixedScriptPath = $state('')
let path: string = $state('')
let pathError = $state('')
let mode = $state<TriggerMode>('enabled')
let dirtyPath = $state(false)
let can_write = $state(true)
let drawerLoading = $state(true)
let azure_resource_path: string = $state('')
let azure_mode: AzureMode = $state('namespace_pull')
let scope_resource_id: string = $state('')
let topic_name: string | undefined = $state(undefined)
let subscription_name: string = $state('')
let event_type_filters: string[] | undefined = $state(undefined)
let isValid = $state(false)
let initialConfig: Record<string, any> | undefined = undefined
let deploymentLoading = $state(false)
let permissionedAs = $state<string | undefined>(undefined)
let selectedPermissionedAs = $state<string | undefined>(undefined)
let preservePermissionedAs = $state(false)
let base_endpoint = $derived(`${window.location.origin}${base}`)
let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler')
let errorHandlerSelected: ErrorHandler = $state('slack')
let error_handler_path: string | undefined = $state()
let error_handler_args: Record<string, any> = $state({})
let retry: Retry | undefined = $state()
let suspendedJobsModal = $state<TriggerSuspendedJobsModal | null>(null)
let originalConfig = $state<Record<string, any> | undefined>(undefined)
let {
useDrawer = true,
description = undefined,
hideTarget = false,
hideTooltips = false,
isEditor = false,
allowDraft = false,
trigger = undefined,
isDeployed = false,
customLabel = undefined,
onConfigChange = undefined,
onCaptureConfigChange = undefined,
onUpdate = undefined,
onDelete = undefined,
onReset = undefined,
cloudDisabled = false
}: {
useDrawer?: boolean
description?: Snippet | undefined
hideTarget?: boolean
hideTooltips?: boolean
isEditor?: boolean
allowDraft?: boolean
trigger?: Trigger
isDeployed?: boolean
customLabel?: Snippet
onConfigChange?: (cfg: Record<string, any>, saveDisabled: boolean, updated: boolean) => void
onCaptureConfigChange?: (cfg: Record<string, any>, isValid: boolean) => void
onUpdate?: (path?: string) => void
onDelete?: () => void
onReset?: () => void
cloudDisabled?: boolean
} = $props()
let hasChanged = $derived(!deepEqual(getAzureConfig(), originalConfig ?? {}))
const azureConfig = $derived.by(getAzureConfig)
const saveDisabled = $derived(
pathError != '' || emptyString(script_path) || !isValid || !can_write || !hasChanged
)
const captureConfig = $derived.by(untrack(() => isEditor) ? getAzureCaptureConfig : () => ({}))
export async function openEdit(
ePath: string,
isFlow: boolean,
defaultValues?: Record<string, any>
) {
drawerLoading = true
try {
drawer?.openDrawer()
initialPath = ePath
itemKind = isFlow ? 'flow' : 'script'
edit = true
dirtyPath = false
await loadTrigger(defaultValues)
originalConfig = structuredClone($state.snapshot(getAzureConfig()))
} catch (err) {
sendUserToast(`Could not load Azure trigger: ${err.body}`, true)
} finally {
drawerLoading = false
if (!defaultValues) {
initialConfig = structuredClone($state.snapshot(getAzureConfig()))
}
}
}
export async function openNew(
nis_flow: boolean,
fixedScriptPath_?: string,
defaultValues?: Record<string, any>
) {
drawerLoading = true
try {
drawer?.openDrawer()
itemKind = nis_flow ? 'flow' : 'script'
initialScriptPath = ''
fixedScriptPath = fixedScriptPath_ ?? ''
script_path = fixedScriptPath
azure_resource_path = defaultValues?.azure_resource_path ?? ''
azure_mode = defaultValues?.azure_mode ?? 'namespace_pull'
scope_resource_id = defaultValues?.scope_resource_id ?? ''
topic_name = defaultValues?.topic_name ?? undefined
subscription_name = defaultValues?.subscription_name ?? ''
event_type_filters = defaultValues?.event_type_filters ?? undefined
path = defaultValues?.path ?? ''
initialPath = ''
edit = false
dirtyPath = false
mode = defaultValues?.mode ?? 'enabled'
error_handler_path = defaultValues?.error_handler_path ?? undefined
error_handler_args = defaultValues?.error_handler_args ?? {}
retry = defaultValues?.retry ?? undefined
errorHandlerSelected = getHandlerType(error_handler_path ?? '')
permissionedAs = undefined
selectedPermissionedAs = undefined
preservePermissionedAs = false
originalConfig = undefined
} finally {
drawerLoading = false
}
}
async function loadTrigger(defaultConfig?: Record<string, any>): Promise<void> {
if (defaultConfig) {
loadTriggerConfig(defaultConfig)
return
}
try {
const s = await AzureTriggerService.getAzureTrigger({
workspace: $workspaceStore!,
path: initialPath
})
loadTriggerConfig(s)
} catch (error) {
sendUserToast(`Could not load Azure trigger: ${error.body}`, true)
}
}
async function loadTriggerConfig(cfg?: Record<string, any>): Promise<void> {
script_path = cfg?.script_path
initialScriptPath = cfg?.script_path
azure_resource_path = cfg?.azure_resource_path
azure_mode = cfg?.azure_mode
scope_resource_id = cfg?.scope_resource_id
topic_name = cfg?.topic_name ?? undefined
subscription_name = cfg?.subscription_name
event_type_filters = cfg?.event_type_filters
path = cfg?.path
mode = cfg?.mode ?? 'enabled'
can_write = canWrite(cfg?.path, cfg?.extra_perms, $userStore)
error_handler_path = cfg?.error_handler_path
error_handler_args = cfg?.error_handler_args ?? {}
retry = cfg?.retry
errorHandlerSelected = getHandlerType(error_handler_path ?? '')
permissionedAs = cfg?.permissioned_as
selectedPermissionedAs = cfg?.permissioned_as
preservePermissionedAs = !!cfg?.permissioned_as
}
async function updateTrigger(): Promise<void> {
deploymentLoading = true
const cfg = azureConfig
if (!cfg) return
const isSaved = await saveAzureTriggerFromCfg(
initialPath,
cfg,
edit,
$workspaceStore!,
usedTriggerKinds
)
if (isSaved) {
onUpdate?.(cfg.path)
originalConfig = structuredClone($state.snapshot(getAzureConfig()))
initialPath = cfg.path
initialScriptPath = cfg.script_path
if (mode !== 'suspended') drawer?.closeDrawer()
}
deploymentLoading = false
}
function getAzureConfig() {
return {
azure_resource_path,
azure_mode,
scope_resource_id,
topic_name,
subscription_name,
event_type_filters,
base_endpoint,
path,
script_path,
mode,
is_flow: itemKind === 'flow',
error_handler_path,
error_handler_args,
retry,
permissioned_as: selectedPermissionedAs,
preserve_permissioned_as: preservePermissionedAs || undefined
}
}
function getAzureCaptureConfig() {
return {
azure_resource_path,
azure_mode,
scope_resource_id,
topic_name,
subscription_name,
event_type_filters,
base_endpoint,
path
}
}
async function handleToggleMode(newMode: TriggerMode) {
mode = newMode
if (!trigger?.draftConfig) {
await AzureTriggerService.setAzureTriggerMode({
path: initialPath,
workspace: $workspaceStore ?? '',
requestBody: { mode: newMode }
})
sendUserToast(`${capitalize(newMode)} Azure trigger ${initialPath}`)
onUpdate?.(initialPath)
}
if (originalConfig) originalConfig['mode'] = newMode
}
$effect(() => {
if (!drawerLoading) {
handleConfigChange(azureConfig, initialConfig, saveDisabled, edit, onConfigChange)
}
})
$effect(() => {
const args = [captureConfig, isValid] as const
untrack(() => onCaptureConfigChange?.(...args))
})
</script>
{#if mode === 'suspended'}
<TriggerSuspendedJobsModal
bind:this={suspendedJobsModal}
triggerPath={path}
triggerKind="azure"
{hasChanged}
onToggleMode={handleToggleMode}
runnableConfig={{
path: script_path,
kind: itemKind,
retry,
errorHandlerPath: error_handler_path,
errorHandlerArgs: error_handler_args
}}
/>
{/if}
{#if useDrawer}
<Drawer size="800px" bind:this={drawer}>
<DrawerContent
title={edit
? can_write
? `Edit Azure trigger ${initialPath}`
: `Azure trigger ${initialPath}`
: 'New Azure trigger'}
on:close={drawer?.closeDrawer}
>
{#snippet actions()}
{@render actionsButtons()}
{/snippet}
{@render config()}
</DrawerContent>
</Drawer>
{:else}
<Section
label={!customLabel ? 'Azure Event Grid trigger' : ''}
headerClass="grow min-w-0 h-[30px]"
>
{#snippet header()}
{#if customLabel}
{@render customLabel()}
{/if}
{/snippet}
{#snippet action()}
{@render actionsButtons()}
{/snippet}
{@render config()}
</Section>
{/if}
{#snippet actionsButtons()}
{#if !drawerLoading && can_write}
<TriggerEditorToolbar
permissions={drawerLoading || !can_write ? 'none' : 'create'}
{saveDisabled}
{mode}
isLoading={deploymentLoading}
{edit}
{allowDraft}
{isDeployed}
onUpdate={updateTrigger}
{onReset}
{onDelete}
onToggleMode={handleToggleMode}
{cloudDisabled}
{trigger}
{suspendedJobsModal}
/>
{/if}
{/snippet}
{#snippet config()}
{#if drawerLoading}
<div class="flex flex-col items-center justify-center h-full w-full">
<Loader2 size="50" class="animate-spin" />
<p>Loading...</p>
</div>
{:else}
<PermissionedAsLine
{permissionedAs}
{path}
onPermissionedAsChange={(pa, preserve) => {
selectedPermissionedAs = pa
preservePermissionedAs = preserve
}}
/>
<div class="flex flex-col gap-5">
{#if mode === 'suspended'}
<TriggerSuspendedJobsAlert {suspendedJobsModal} />
{/if}
{#if description}
{@render description()}
{/if}
{#if !hideTooltips}
<Alert title="Info" type="info">
{#if edit}
Changes can take up to 30 seconds to take effect.
{:else}
New Azure triggers can take up to 30 seconds to start listening.
{/if}
</Alert>
{/if}
</div>
<div class="flex flex-col gap-12 mt-6">
<div class="flex flex-col gap-4">
<Label label="Path">
<Path
bind:dirty={dirtyPath}
bind:error={pathError}
bind:path
{initialPath}
checkInitialPathExistence={!edit}
namePlaceholder="azure_trigger"
kind="azure_trigger"
disabled={!can_write}
disableEditing={!can_write}
/>
</Label>
</div>
{#if !hideTarget}
<Section label="Runnable">
<p class="text-xs mb-1 text-primary">
Pick a script or flow to be triggered <Required required={true} />
</p>
<div class="flex flex-row mb-2">
<ScriptPicker
disabled={fixedScriptPath != '' || !can_write}
initialPath={fixedScriptPath || initialScriptPath}
kinds={['script']}
allowFlow={true}
bind:itemKind
bind:scriptPath={script_path}
allowRefresh={can_write}
allowEdit={!$userStore?.operator}
clearable
/>
{#if emptyString(script_path)}
<Button
btnClasses="ml-4"
variant="default"
unifiedSize="md"
disabled={!can_write}
href={itemKind === 'flow' ? '/flows/add?hub=81' : '/scripts/add?hub=hub%2F28214'}
target="_blank">Create from template</Button
>
{/if}
</div>
</Section>
{/if}
<AzureTriggerEditorConfigSection
bind:isValid
bind:azure_resource_path
bind:azure_mode
bind:scope_resource_id
bind:topic_name
bind:subscription_name
bind:event_type_filters
{path}
{can_write}
headless={true}
/>
<Section label="Advanced" collapsable>
{#snippet header()}
<TriggerAdvancedBadges {error_handler_path} {retry} />
{/snippet}
<div class="min-h-96">
<Tabs bind:selected={optionTabSelected}>
<Tab value="error_handler" label="Error Handler" />
<Tab value="retries" label="Retries" />
</Tabs>
<div class="mt-4">
<TriggerRetriesAndErrorHandler
{optionTabSelected}
{itemKind}
{can_write}
bind:errorHandlerSelected
bind:error_handler_path
bind:error_handler_args
bind:retry
/>
</div>
</div>
</Section>
<div class="pb-8" />
</div>
{/if}
{/snippet}
@@ -0,0 +1,67 @@
<script lang="ts">
import { isCloudHosted } from '$lib/cloud'
import { Alert } from '$lib/components/common'
import Description from '$lib/components/Description.svelte'
import { enterpriseLicense } from '$lib/stores'
import AzureTriggerEditorInner from './AzureTriggerEditorInner.svelte'
import { onMount } from 'svelte'
let {
selectedTrigger,
isFlow,
path,
isDeployed = false,
defaultValues = undefined,
customLabel = undefined,
...restProps
} = $props()
let azureTriggerEditor: AzureTriggerEditorInner | undefined = $state(undefined)
async function openAzureTriggerEditor(isFlow: boolean, isDraft: boolean) {
if (isDraft) {
azureTriggerEditor?.openNew(isFlow, path, defaultValues)
} else {
azureTriggerEditor?.openEdit(selectedTrigger.path, isFlow, defaultValues)
}
}
onMount(() => {
azureTriggerEditor && openAzureTriggerEditor(isFlow, selectedTrigger.isDraft ?? false)
})
const cloudDisabled = $derived(isCloudHosted())
</script>
{#if !$enterpriseLicense}
<Alert title="EE Only" type="warning" size="xs">
Azure Event Grid triggers are an enterprise only feature.
</Alert>
{:else}
<div class="flex flex-col gap-4">
<AzureTriggerEditorInner
bind:this={azureTriggerEditor}
useDrawer={false}
hideTarget
hideTooltips={!isDeployed || cloudDisabled}
allowDraft={true}
trigger={selectedTrigger}
{customLabel}
{isDeployed}
{cloudDisabled}
{...restProps}
>
{#snippet description()}
{#if cloudDisabled}
<Alert title="Not compatible with multi-tenant cloud" type="warning" size="xs">
Azure Event Grid triggers are disabled in the multi-tenant cloud.
</Alert>
{:else}
<Description link="https://www.windmill.dev/docs/core_concepts/azure_triggers">
Azure Event Grid triggers execute scripts and flows in response to events from Azure
Event Grid (basic) or Event Grid Namespaces (push or pull).
</Description>
{/if}
{/snippet}
</AzureTriggerEditorInner>
</div>
{/if}
@@ -0,0 +1,63 @@
import { AzureTriggerService, type AzureTriggerData } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { get, type Writable } from 'svelte/store'
export async function saveAzureTriggerFromCfg(
initialPath: string,
cfg: Record<string, any>,
edit: boolean,
workspace: string,
usedTriggerKinds: Writable<string[]>
): Promise<boolean> {
try {
const errorHandlerAndRetries = !cfg.is_flow
? {
error_handler_path: cfg.error_handler_path,
error_handler_args: cfg.error_handler_path ? cfg.error_handler_args : undefined,
retry: cfg.retry
}
: {}
const requestBody: AzureTriggerData = {
azure_resource_path: cfg.azure_resource_path,
azure_mode: cfg.azure_mode,
scope_resource_id: cfg.scope_resource_id,
topic_name: cfg.topic_name,
subscription_name: cfg.subscription_name,
base_endpoint: cfg.base_endpoint,
event_type_filters: cfg.event_type_filters,
path: cfg.path,
script_path: cfg.script_path,
mode: cfg.mode,
is_flow: cfg.is_flow,
permissioned_as: cfg.permissioned_as,
preserve_permissioned_as: cfg.preserve_permissioned_as,
...errorHandlerAndRetries
}
if (edit) {
await AzureTriggerService.updateAzureTrigger({
workspace,
path: initialPath,
requestBody
})
sendUserToast(`Azure Event Grid trigger ${cfg.path} updated`)
} else {
await AzureTriggerService.createAzureTrigger({
workspace,
requestBody: {
...requestBody,
mode: 'enabled'
}
})
sendUserToast(`Azure Event Grid trigger ${cfg.path} created`)
}
if (!get(usedTriggerKinds).includes('azure')) {
usedTriggerKinds.update((t) => [...t, 'azure'])
}
return true
} catch (error) {
sendUserToast(error.body || error.message, true)
return false
}
}

Some files were not shown because too many files have changed in this diff Show More