mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-22 16:02:24 +00:00
feat: support application default credentials for gcp pub/sub triggers (#10778)
* feat: support application default credentials for gcp pub/sub triggers Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: address review findings on gcp application default credentials Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: address review nits on gcp application default credentials Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: key the gcp credential-mode permission off the loaded mode Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: gate enabling an ADC gcp trigger on workspace admin Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: lock the gcp trigger row while authorizing a mode change Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: skip admin-only gcp listing when the caller cannot use those credentials Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore: update ee-repo-ref to 54bf630681000c8ed87a7067e357118e015123b1 This commit updates the EE repository reference after PR #738 was merged in windmill-ee-private. Previous ee-repo-ref: 91d0e228a0ad226625278b400c64f96a61404a10 New ee-repo-ref: 54bf630681000c8ed87a7067e357118e015123b1 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com> Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
@@ -285,6 +285,7 @@ On self-hosted instances, you might want to import all the approved resource typ
|
||||
| MIN_FREE_DISK_SPACE_MB | 15000 | Minimum amount of free space on worker. Sends critical alert if worker has less free space. | Worker |
|
||||
| RUN_UPDATE_CA_CERTIFICATE_AT_START | false | If true, runs CA certificate update command at startup before other initialization | All |
|
||||
| RUN_UPDATE_CA_CERTIFICATE_PATH | /usr/sbin/update-ca-certificates | Path to the CA certificate update command/script to run when RUN_UPDATE_CA_CERTIFICATE_AT_START is true | All |
|
||||
| GOOGLE_APPLICATION_CREDENTIALS | None | (ee only) Credentials file for GCP Pub/Sub triggers that authenticate as the instance rather than through a `gcloud` resource (workspace admins only). Application default credentials also resolve the gcloud well-known file and the GCE metadata server. Workload Identity Federation files work with the `file`, `url` and `aws` credential sources; the `executable` source is not supported. | Server |
|
||||
|
||||
## Run a local dev setup
|
||||
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO gcp_trigger (\n gcp_resource_path, project_id, topic_id, subscription_id, delivery_type,\n delivery_config, path, script_path, is_flow, workspace_id, edited_by,\n edited_at, extra_perms, server_id, last_server_ping, error,\n subscription_mode, error_handler_path, error_handler_args, retry,\n auto_acknowledge_msg, ack_deadline, mode, permissioned_as, labels\n )\n SELECT\n gcp_resource_path, project_id, topic_id, subscription_id, delivery_type,\n delivery_config, path, script_path, is_flow, $1, edited_by,\n edited_at, extra_perms, NULL, NULL, NULL,\n subscription_mode, error_handler_path, error_handler_args, retry,\n auto_acknowledge_msg, ack_deadline, 'disabled'::TRIGGER_MODE, permissioned_as, labels\n FROM gcp_trigger WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "30aa4958c8ed0459c9f83e3963bb413248cbb3bab75b0249a9fc4c2fd02da0bc"
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO gcp_trigger (\n gcp_resource_path,\n project_id,\n subscription_id,\n topic_id,\n delivery_type,\n delivery_config,\n workspace_id,\n path,\n script_path,\n is_flow,\n permissioned_as,\n mode,\n edited_by,\n error_handler_path,\n error_handler_args,\n retry,\n auto_acknowledge_msg,\n ack_deadline\n )\n VALUES (\n $1,\n $2,\n $3,\n $4,\n $5,\n $6,\n $7,\n $8,\n $9,\n $10,\n $11,\n $12,\n $13,\n $14,\n $15,\n $16,\n $17,\n $18\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "delivery_mode",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"push",
|
||||
"pull"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Jsonb",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "trigger_mode",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"enabled",
|
||||
"disabled",
|
||||
"suspended"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
"Jsonb",
|
||||
"Bool",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3382ba7f9f437d0def24f8cee2a58402f2e627baa957e0fed3ca09ea9ca869b3"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT gcp_resource_path IS NULL FROM gcp_trigger WHERE workspace_id = $1 AND path = $2 FOR UPDATE",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "?column?",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "9900abe4112310ccdace809aecf1bf6f5d3d350db5e6cd98557d0280c56ff0fa"
|
||||
}
|
||||
+1
-1
@@ -72,7 +72,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE\n gcp_trigger\n SET\n gcp_resource_path = $1,\n subscription_id = $2,\n topic_id = $3,\n delivery_type = $4,\n delivery_config = $5,\n is_flow = $6,\n edited_by = $7,\n permissioned_as = $8,\n script_path = $9,\n path = $10,\n mode = $11,\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 auto_acknowledge_msg = $17,\n ack_deadline = $18,\n project_id = $19\n WHERE\n workspace_id = $12 AND\n path = $13\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "delivery_mode",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"push",
|
||||
"pull"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Jsonb",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "trigger_mode",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"enabled",
|
||||
"disabled",
|
||||
"suspended"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Text",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
"Jsonb",
|
||||
"Bool",
|
||||
"Int4",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c7de1fe77fa2be4701b7185a3b3c91e2db92d2d2f45fd07a354f30e6248f21e6"
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n gcp_resource_path AS \"gcp_resource_path: _\",\n script_path,\n is_flow,\n mode as \"mode: _\",\n workspace_id,\n path,\n edited_by,\n permissioned_as,\n delivery_config AS \"delivery_config: _\",\n retry as \"retry: _\",\n error_handler_path,\n error_handler_args as \"error_handler_args: _\"\n FROM\n gcp_trigger\n WHERE\n workspace_id = $1 AND\n path = $2 AND\n delivery_type = 'push'::DELIVERY_MODE\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "gcp_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": "delivery_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": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "d7a8660d977b1cb516871dff0f7e376d2cbb9a35ba68a5641ebce31aad10f513"
|
||||
}
|
||||
Generated
+12
@@ -5612,15 +5612,21 @@ dependencies = [
|
||||
"base64 0.21.7",
|
||||
"google-cloud-metadata",
|
||||
"google-cloud-token",
|
||||
"hex",
|
||||
"hmac",
|
||||
"home",
|
||||
"jsonwebtoken 9.3.1",
|
||||
"path-clean",
|
||||
"percent-encoding",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
@@ -8935,6 +8941,12 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "path-clean"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17359afc20d7ab31fdb42bb844c8b3bb1dabd7dcf7e68428492da7f16966fcef"
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
|
||||
+4
-1
@@ -522,7 +522,10 @@ rustls-pemfile = "2.2.0"
|
||||
# only used with special deno_core_mac feature to prevent ffi issue on macos, requires libffi to be installed
|
||||
libffi-sys = { version = "2.3.0", features = ["system"]}
|
||||
|
||||
google-cloud-pubsub = "0.30.0"
|
||||
# `external-account` is off in the crate's defaults, which compiles out the token source for
|
||||
# Workload Identity Federation credentials — the keyless setup for running outside GCP. Without it
|
||||
# an ADC file of that type is rejected as an unsupported account type.
|
||||
google-cloud-pubsub = { version = "0.30.0", features = ["external-account"] }
|
||||
google-cloud-googleapis = {version = "0.16.1", features = ["pubsub"]}
|
||||
# TODO: remove once deno fixes the issue on their end
|
||||
# https://github.com/denoland/deno/issues/28557
|
||||
|
||||
@@ -1 +1 @@
|
||||
c6902ec2c51dc0ce30962afbfab3e456c5d9b831
|
||||
54bf630681000c8ed87a7067e357118e015123b1
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
-- Add down migration script here
|
||||
|
||||
DROP INDEX unique_subscription_per_gcp_resource;
|
||||
|
||||
DELETE FROM gcp_trigger WHERE gcp_resource_path IS NULL;
|
||||
|
||||
-- Rows that differed only by project_id, and rows whose subscription no longer fits the narrower
|
||||
-- column, would both make the restored index or constraint fail after the column changes below.
|
||||
DELETE FROM gcp_trigger WHERE char_length(subscription_id::text) > 255;
|
||||
DELETE FROM gcp_trigger t USING gcp_trigger keep
|
||||
WHERE t.ctid > keep.ctid
|
||||
AND t.subscription_id = keep.subscription_id
|
||||
AND t.gcp_resource_path = keep.gcp_resource_path
|
||||
AND t.workspace_id = keep.workspace_id;
|
||||
|
||||
ALTER TABLE gcp_trigger DROP COLUMN project_id;
|
||||
ALTER TABLE gcp_trigger ALTER COLUMN gcp_resource_path SET NOT NULL;
|
||||
|
||||
ALTER TABLE gcp_trigger DROP CONSTRAINT gcp_trigger_subscription_id_check;
|
||||
ALTER TABLE gcp_trigger ALTER COLUMN subscription_id TYPE VARCHAR(255);
|
||||
ALTER TABLE gcp_trigger ADD CONSTRAINT gcp_trigger_subscription_id_check
|
||||
CHECK (char_length(subscription_id::text) >= 3 AND char_length(subscription_id::text) <= 255);
|
||||
|
||||
CREATE UNIQUE INDEX unique_subscription_per_gcp_resource
|
||||
ON gcp_trigger (subscription_id, gcp_resource_path, workspace_id);
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Add up migration script here
|
||||
|
||||
ALTER TABLE gcp_trigger ALTER COLUMN gcp_resource_path DROP NOT NULL;
|
||||
ALTER TABLE gcp_trigger ADD COLUMN project_id VARCHAR(255);
|
||||
|
||||
-- Subscriptions are now stored fully qualified (projects/<project>/subscriptions/<id>) so that one
|
||||
-- Pub/Sub subscription has exactly one representation. A Pub/Sub id may itself be 255 characters,
|
||||
-- so the qualified form needs more room than the bare one did.
|
||||
ALTER TABLE gcp_trigger ALTER COLUMN subscription_id TYPE VARCHAR(400);
|
||||
ALTER TABLE gcp_trigger DROP CONSTRAINT gcp_trigger_subscription_id_check;
|
||||
ALTER TABLE gcp_trigger ADD CONSTRAINT gcp_trigger_subscription_id_check
|
||||
CHECK (char_length(subscription_id::text) >= 3 AND char_length(subscription_id::text) <= 400);
|
||||
|
||||
-- A NULL gcp_resource_path means application default credentials, and NULLs compare as distinct,
|
||||
-- so the plain column index would stop guarding those rows. project_id is deliberately absent:
|
||||
-- with the subscription stored fully qualified it is not part of a subscription's identity, and
|
||||
-- including it would split rows that denote the same subscription.
|
||||
--
|
||||
-- Rows written before this migration hold a bare id, which cannot be backfilled here: the project
|
||||
-- they resolve against lives inside the credentials, not in this table. So a legacy `my-sub` and a
|
||||
-- new `projects/p/subscriptions/my-sub` still read as different subscriptions until the older
|
||||
-- trigger is saved again, which rewrites it in the qualified form.
|
||||
DROP INDEX unique_subscription_per_gcp_resource;
|
||||
CREATE UNIQUE INDEX unique_subscription_per_gcp_resource
|
||||
ON gcp_trigger (subscription_id, COALESCE(gcp_resource_path, ''), workspace_id);
|
||||
@@ -1497,7 +1497,10 @@ async fn test_gcp_trigger_insert_pull(db: Pool<Postgres>) -> anyhow::Result<()>
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
|
||||
assert_eq!(trigger.gcp_resource_path, "u/admin/gcp_resource");
|
||||
assert_eq!(
|
||||
trigger.gcp_resource_path.as_deref(),
|
||||
Some("u/admin/gcp_resource")
|
||||
);
|
||||
assert_eq!(trigger.topic_id, "my-topic");
|
||||
assert_eq!(trigger.subscription_id, "my-subscription");
|
||||
assert_eq!(trigger.delivery_type, "pull");
|
||||
@@ -1607,6 +1610,70 @@ async fn test_gcp_trigger_unique_constraint(db: Pool<Postgres>) -> anyhow::Resul
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_gcp_trigger_unique_constraint_with_default_credentials(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
// A trigger on application default credentials has a NULL gcp_resource_path, and NULLs compare
|
||||
// as distinct, so the unique index only keeps guarding these rows because it coalesces.
|
||||
// Subscriptions are stored fully qualified, which is what makes the subscription alone
|
||||
// sufficient to identify one: project_id is deliberately not part of the key, so a differing
|
||||
// project must not let a second trigger onto the same subscription.
|
||||
let insert_query = r#"
|
||||
INSERT INTO gcp_trigger (
|
||||
path, gcp_resource_path, project_id, topic_id, subscription_id,
|
||||
delivery_type, subscription_mode, script_path, is_flow,
|
||||
workspace_id, edited_by, permissioned_as
|
||||
)
|
||||
VALUES ($1, NULL, $2, $3, $4, $5::delivery_mode, $6::gcp_subscription_mode, $7, false, $8, $9, $10)
|
||||
"#;
|
||||
|
||||
let insert =
|
||||
|path: &'static str, project_id: Option<&'static str>, subscription: &'static str| {
|
||||
sqlx::query(insert_query)
|
||||
.bind(path)
|
||||
.bind(project_id)
|
||||
.bind("projects/my-project/topics/my-topic")
|
||||
.bind(subscription)
|
||||
.bind("pull")
|
||||
.bind("create_update")
|
||||
.bind("f/test/gcp_handler")
|
||||
.bind("test-workspace")
|
||||
.bind("test-user")
|
||||
.bind("u/test-user")
|
||||
.execute(&db)
|
||||
};
|
||||
|
||||
let shared = "projects/my-project/subscriptions/shared-subscription";
|
||||
insert("f/test/gcp_adc_1", Some("my-project"), shared).await?;
|
||||
|
||||
assert!(
|
||||
insert("f/test/gcp_adc_2", Some("my-project"), shared)
|
||||
.await
|
||||
.is_err(),
|
||||
"a second trigger on the same subscription must collide"
|
||||
);
|
||||
assert!(
|
||||
insert("f/test/gcp_adc_3", Some("other-project"), shared)
|
||||
.await
|
||||
.is_err(),
|
||||
"project_id must not let a second trigger onto the same subscription"
|
||||
);
|
||||
assert!(
|
||||
insert("f/test/gcp_adc_4", None, shared).await.is_err(),
|
||||
"an absent project_id must not let a second trigger onto the same subscription"
|
||||
);
|
||||
|
||||
insert(
|
||||
"f/test/gcp_adc_5",
|
||||
Some("my-project"),
|
||||
"projects/other-project/subscriptions/shared-subscription",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Email Trigger Tests (DB-level)
|
||||
// ============================================================================
|
||||
|
||||
@@ -5695,14 +5695,14 @@ async fn clone_triggers_and_schedules(
|
||||
|
||||
sqlx::query!(
|
||||
r#"INSERT INTO gcp_trigger (
|
||||
gcp_resource_path, topic_id, subscription_id, delivery_type,
|
||||
gcp_resource_path, project_id, topic_id, subscription_id, delivery_type,
|
||||
delivery_config, path, script_path, is_flow, workspace_id, edited_by,
|
||||
edited_at, extra_perms, server_id, last_server_ping, error,
|
||||
subscription_mode, error_handler_path, error_handler_args, retry,
|
||||
auto_acknowledge_msg, ack_deadline, mode, permissioned_as, labels
|
||||
)
|
||||
SELECT
|
||||
gcp_resource_path, topic_id, subscription_id, delivery_type,
|
||||
gcp_resource_path, project_id, topic_id, subscription_id, delivery_type,
|
||||
delivery_config, path, script_path, is_flow, $1, edited_by,
|
||||
edited_at, extra_perms, NULL, NULL, NULL,
|
||||
subscription_mode, error_handler_path, error_handler_args, retry,
|
||||
|
||||
@@ -19041,6 +19041,29 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/gcp_triggers/subscriptions/delete:
|
||||
delete:
|
||||
summary: delete a subscription reachable with the instance's application default credentials
|
||||
operationId: deleteGcpSubscriptionWithDefaultCredentials
|
||||
tags:
|
||||
- gcp_trigger
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
description: args to delete subscription from google cloud
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DeleteGcpSubscription"
|
||||
responses:
|
||||
"200":
|
||||
description: gcp trigger deleted
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/gcp_triggers/topics/list/{path}:
|
||||
get:
|
||||
summary: list all topics of google cloud service
|
||||
@@ -19050,6 +19073,26 @@ paths:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Path"
|
||||
- $ref: "#/components/parameters/GcpProjectId"
|
||||
responses:
|
||||
"200":
|
||||
description: get all google topics
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/gcp_triggers/topics/list:
|
||||
get:
|
||||
summary: list all topics reachable with the instance's application default credentials
|
||||
operationId: listGoogleTopicsWithDefaultCredentials
|
||||
tags:
|
||||
- gcp_trigger
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/GcpProjectId"
|
||||
responses:
|
||||
"200":
|
||||
description: get all google topics
|
||||
@@ -19086,6 +19129,31 @@ paths:
|
||||
items:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/gcp_triggers/subscriptions/list:
|
||||
post:
|
||||
summary: list all subscriptions of a topic reachable with the instance's application default credentials
|
||||
operationId: listAllTGoogleTopicSubscriptionsWithDefaultCredentials
|
||||
tags:
|
||||
- gcp_trigger
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
description: args to get subscription's topic from google cloud
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GetAllTopicSubscription"
|
||||
responses:
|
||||
"200":
|
||||
description: get all google topic subscriptions name
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/azure_triggers/create:
|
||||
post:
|
||||
summary: create an Azure Event Grid trigger
|
||||
@@ -24834,6 +24902,12 @@ components:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
GcpProjectId:
|
||||
name: project_id
|
||||
in: query
|
||||
description: GCP project to list resources from, when it is not the project of the credentials
|
||||
schema:
|
||||
type: string
|
||||
S3Sig:
|
||||
name: sig
|
||||
in: query
|
||||
@@ -30165,13 +30239,16 @@ components:
|
||||
properties:
|
||||
gcp_resource_path:
|
||||
type: string
|
||||
description: "Path to the GCP resource containing service account credentials for authentication."
|
||||
description: "Path to the GCP resource containing service account credentials for authentication. Omit to authenticate with the instance's application default credentials."
|
||||
project_id:
|
||||
type: string
|
||||
description: "GCP project the client operates in. Defaults to the project of the credentials. Topics and subscriptions given as fully qualified names are reached whatever it is."
|
||||
topic_id:
|
||||
type: string
|
||||
description: "Google Cloud Pub/Sub topic ID to subscribe to."
|
||||
description: "Google Cloud Pub/Sub topic ID to subscribe to. Accepts a bare ID or a fully qualified name (projects/<project>/topics/<id>)."
|
||||
subscription_id:
|
||||
type: string
|
||||
description: "Google Cloud Pub/Sub subscription ID."
|
||||
description: "Google Cloud Pub/Sub subscription ID. Accepts a bare ID or a fully qualified name (projects/<project>/subscriptions/<id>)."
|
||||
server_id:
|
||||
type: string
|
||||
description: "ID of the server currently handling this trigger (internal use)."
|
||||
@@ -30199,7 +30276,6 @@ components:
|
||||
$ref: "../../openflow.openapi.yaml#/components/schemas/Retry"
|
||||
description: "Retry configuration for failed executions."
|
||||
required:
|
||||
- gcp_resource_path
|
||||
- topic_id
|
||||
- subscription_id
|
||||
- delivery_type
|
||||
@@ -30218,15 +30294,18 @@ components:
|
||||
properties:
|
||||
gcp_resource_path:
|
||||
type: string
|
||||
description: "Path to the GCP resource containing service account credentials for authentication."
|
||||
description: "Path to the GCP resource containing service account credentials for authentication. Omit to authenticate with the instance's application default credentials, which only workspace admins may select."
|
||||
project_id:
|
||||
type: string
|
||||
description: "GCP project the client operates in. Defaults to the project of the credentials. Topics and subscriptions given as fully qualified names are reached whatever it is."
|
||||
subscription_mode:
|
||||
$ref: "#/components/schemas/SubscriptionMode"
|
||||
topic_id:
|
||||
type: string
|
||||
description: "Google Cloud Pub/Sub topic ID to subscribe to."
|
||||
description: "Google Cloud Pub/Sub topic ID to subscribe to. Accepts a bare ID or a fully qualified name (projects/<project>/topics/<id>)."
|
||||
subscription_id:
|
||||
type: string
|
||||
description: "Google Cloud Pub/Sub subscription ID."
|
||||
description: "Google Cloud Pub/Sub subscription ID. Accepts a bare ID or a fully qualified name (projects/<project>/subscriptions/<id>)."
|
||||
base_endpoint:
|
||||
type: string
|
||||
description: "Base URL for push delivery endpoint."
|
||||
@@ -30278,7 +30357,6 @@ components:
|
||||
- path
|
||||
- script_path
|
||||
- is_flow
|
||||
- gcp_resource_path
|
||||
- topic_id
|
||||
- subscription_mode
|
||||
|
||||
@@ -30287,6 +30365,8 @@ components:
|
||||
properties:
|
||||
topic_id:
|
||||
type: string
|
||||
project_id:
|
||||
type: string
|
||||
required:
|
||||
- topic_id
|
||||
|
||||
@@ -30295,6 +30375,8 @@ components:
|
||||
properties:
|
||||
subscription_id:
|
||||
type: string
|
||||
project_id:
|
||||
type: string
|
||||
required:
|
||||
- subscription_id
|
||||
|
||||
|
||||
@@ -200,7 +200,14 @@ pub struct AzureTriggerConfig {
|
||||
#[cfg(all(feature = "enterprise", feature = "gcp_trigger", feature = "private"))]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct GcpTriggerConfig {
|
||||
pub gcp_resource_path: String,
|
||||
/// `None` selects application default credentials. Deliberately not `empty_as_none`: unlike
|
||||
/// every other field here, absent does not mean "unset" but "use the instance's own identity",
|
||||
/// so a blank string left behind by a half-filled form must stay distinguishable and be
|
||||
/// rejected rather than silently selecting the privileged mode.
|
||||
#[serde(default)]
|
||||
pub gcp_resource_path: Option<String>,
|
||||
#[serde(default, deserialize_with = "empty_as_none")]
|
||||
pub project_id: Option<String>,
|
||||
pub subscription_mode: GcpSubscriptionMode,
|
||||
#[serde(default, deserialize_with = "empty_as_none")]
|
||||
pub subscription_id: Option<String>,
|
||||
@@ -424,11 +431,24 @@ async fn set_gcp_trigger_config(
|
||||
return Err(Error::BadRequest("Invalid GCP Pub/Sub config".to_string()));
|
||||
};
|
||||
|
||||
if gcp_config
|
||||
.gcp_resource_path
|
||||
.as_deref()
|
||||
.is_some_and(|path| path.trim().is_empty())
|
||||
{
|
||||
return Err(Error::BadRequest(
|
||||
"GCP resource path cannot be empty. Remove the field entirely to use application \
|
||||
default credentials."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let config = manage_google_subscription(
|
||||
authed,
|
||||
db,
|
||||
w_id,
|
||||
&gcp_config.gcp_resource_path,
|
||||
gcp_config.gcp_resource_path.as_deref(),
|
||||
gcp_config.project_id.as_deref(),
|
||||
&capture_config.path,
|
||||
&gcp_config.topic_id,
|
||||
&mut gcp_config.subscription_id,
|
||||
@@ -1047,7 +1067,7 @@ async fn gcp_payload(
|
||||
user_db.clone(),
|
||||
authed.clone(),
|
||||
&headers,
|
||||
&gcp_trigger_config.gcp_resource_path,
|
||||
gcp_trigger_config.gcp_resource_path.as_deref(),
|
||||
&w_id,
|
||||
config.delivery_config.as_ref().unwrap(),
|
||||
)
|
||||
|
||||
@@ -267,6 +267,22 @@ pub trait TriggerCrud: Send + Sync + 'static {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Authorize a mode transition, for kinds where attaching a listener is itself privileged
|
||||
/// rather than merely a write on the row — a trigger authenticating as the instance instead of
|
||||
/// through a workspace resource, say. Create and update authorize their own config, but a row
|
||||
/// can also arrive by being cloned into a workspace fork, so the transition needs its own
|
||||
/// check. Runs before the mode is written; the default adds no authorization.
|
||||
async fn authorize_set_trigger_mode(
|
||||
&self,
|
||||
_authed: &ApiAuthed,
|
||||
_tx: &mut PgConnection,
|
||||
_workspace_id: &str,
|
||||
_path: &str,
|
||||
_mode: &TriggerMode,
|
||||
) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_trigger_mode(
|
||||
&self,
|
||||
authed: &ApiAuthed,
|
||||
@@ -575,10 +591,7 @@ async fn create_trigger<T: TriggerCrud>(
|
||||
// Reject a forged superadmin run identity in a preserved permissioned_as
|
||||
// (the sentinel guard; a trigger's email is derived from it at execution).
|
||||
let resolved_permissioned_as = new_trigger.base.resolve_permissioned_as(&authed);
|
||||
windmill_common::auth::validate_on_behalf_of(
|
||||
Some(&resolved_permissioned_as),
|
||||
None,
|
||||
)?;
|
||||
windmill_common::auth::validate_on_behalf_of(Some(&resolved_permissioned_as), None)?;
|
||||
|
||||
let on_behalf_of_info = windmill_common::check_on_behalf_of_preservation(
|
||||
new_trigger.base.permissioned_as.as_deref(),
|
||||
@@ -837,10 +850,7 @@ async fn update_trigger<T: TriggerCrud>(
|
||||
// Reject a forged superadmin run identity in a preserved permissioned_as
|
||||
// (the sentinel guard; a trigger's email is derived from it at execution).
|
||||
let resolved_permissioned_as = edit_trigger.base.resolve_permissioned_as(&authed);
|
||||
windmill_common::auth::validate_on_behalf_of(
|
||||
Some(&resolved_permissioned_as),
|
||||
None,
|
||||
)?;
|
||||
windmill_common::auth::validate_on_behalf_of(Some(&resolved_permissioned_as), None)?;
|
||||
|
||||
let on_behalf_of_info = windmill_common::check_on_behalf_of_preservation(
|
||||
edit_trigger.base.permissioned_as.as_deref(),
|
||||
@@ -1150,6 +1160,10 @@ async fn set_trigger_mode<T: TriggerCrud>(
|
||||
}
|
||||
}
|
||||
|
||||
handler
|
||||
.authorize_set_trigger_mode(&authed, &mut *tx, &workspace_id, path, &payload.mode)
|
||||
.await?;
|
||||
|
||||
let before =
|
||||
trigger_history::snapshot_row(&mut *tx, T::TABLE_NAME, &workspace_id, path).await?;
|
||||
|
||||
|
||||
Generated
+11
-4
@@ -8177,13 +8177,21 @@ properties:
|
||||
gcp_resource_path:
|
||||
type: string
|
||||
description: Path to the GCP resource containing service account credentials for
|
||||
authentication.
|
||||
authentication. Omit to authenticate with the instance's application default
|
||||
credentials.
|
||||
project_id:
|
||||
type: string
|
||||
description: GCP project the client operates in. Defaults to the project of the
|
||||
credentials. Topics and subscriptions given as fully qualified names are reached
|
||||
whatever it is.
|
||||
topic_id:
|
||||
type: string
|
||||
description: Google Cloud Pub/Sub topic ID to subscribe to.
|
||||
description: Google Cloud Pub/Sub topic ID to subscribe to. Accepts a bare ID
|
||||
or a fully qualified name (projects/<project>/topics/<id>).
|
||||
subscription_id:
|
||||
type: string
|
||||
description: Google Cloud Pub/Sub subscription ID.
|
||||
description: Google Cloud Pub/Sub subscription ID. Accepts a bare ID or a fully
|
||||
qualified name (projects/<project>/subscriptions/<id>).
|
||||
delivery_type:
|
||||
type: string
|
||||
enum:
|
||||
@@ -8253,7 +8261,6 @@ properties:
|
||||
required:
|
||||
- script_path
|
||||
- is_flow
|
||||
- gcp_resource_path
|
||||
- topic_id
|
||||
- subscription_id
|
||||
- delivery_type
|
||||
|
||||
@@ -380,10 +380,11 @@ export const sqsTriggerRequestSchema = z.object({
|
||||
})
|
||||
|
||||
export const gcpTriggerRequestSchema = z.object({
|
||||
"gcp_resource_path": z.string().describe("Path to the GCP resource containing service account credentials for authentication."),
|
||||
"gcp_resource_path": z.string().describe("Path to the GCP resource containing service account credentials for authentication. Omit to authenticate with the instance's application default credentials, which only workspace admins may select.").optional(),
|
||||
"project_id": z.string().describe("GCP project the client operates in. Defaults to the project of the credentials. Topics and subscriptions given as fully qualified names are reached whatever it is.").optional(),
|
||||
"subscription_mode": z.enum(["existing", "create_update"]).describe("The mode of subscription. 'existing' means using an existing GCP subscription, while 'create_update' involves creating or updating a new subscription."),
|
||||
"topic_id": z.string().describe("Google Cloud Pub/Sub topic ID to subscribe to."),
|
||||
"subscription_id": z.string().describe("Google Cloud Pub/Sub subscription ID.").optional(),
|
||||
"topic_id": z.string().describe("Google Cloud Pub/Sub topic ID to subscribe to. Accepts a bare ID or a fully qualified name (projects/<project>/topics/<id>)."),
|
||||
"subscription_id": z.string().describe("Google Cloud Pub/Sub subscription ID. Accepts a bare ID or a fully qualified name (projects/<project>/subscriptions/<id>).").optional(),
|
||||
"base_endpoint": z.string().describe("Base URL for push delivery endpoint.").optional(),
|
||||
"delivery_type": z.enum(["push", "pull"]).describe("Delivery mode for messages. 'push' for HTTP push delivery where messages are sent to a webhook endpoint, 'pull' for polling where the trigger actively fetches messages.").optional(),
|
||||
"delivery_config": z.object({
|
||||
|
||||
@@ -15,10 +15,11 @@
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { base } from '$lib/base'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { getTriggerWorkspace } from '$lib/components/triggers/triggerWorkspace'
|
||||
|
||||
import { Button, Url } from '$lib/components/common'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { RefreshCw } from 'lucide-svelte'
|
||||
import Alert from '$lib/components/common/alert/Alert.svelte'
|
||||
import TestingBadge from '../testingBadge.svelte'
|
||||
@@ -41,43 +42,72 @@
|
||||
}
|
||||
|
||||
async function loadAllPubSubTopicsFromProject() {
|
||||
if (!emptyStringTrimmed(gcp_resource_path)) {
|
||||
try {
|
||||
loadingTopic = true
|
||||
topic_items = await GcpTriggerService.listGoogleTopics({
|
||||
workspace: wsId!,
|
||||
path: gcp_resource_path
|
||||
})
|
||||
} catch (error) {
|
||||
sendUserToast(error.body, true)
|
||||
}
|
||||
loadingTopic = false
|
||||
// Listing is admin-only under application default credentials, and a non-admin viewing an
|
||||
// inherited trigger cannot change the topic anyway, so asking would only raise a 403 toast.
|
||||
if (!hasCredentials || blockedByAdminGate) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
loadingTopic = true
|
||||
topic_items = usesDefaultCredentials
|
||||
? await GcpTriggerService.listGoogleTopicsWithDefaultCredentials({
|
||||
workspace: wsId!,
|
||||
projectId: project_id
|
||||
})
|
||||
: await GcpTriggerService.listGoogleTopics({
|
||||
workspace: wsId!,
|
||||
path: gcp_resource_path!,
|
||||
projectId: project_id
|
||||
})
|
||||
} catch (error) {
|
||||
sendUserToast(error.body, true)
|
||||
}
|
||||
loadingTopic = false
|
||||
}
|
||||
|
||||
async function loadAllSubscriptionFromGooglePubSubTopic() {
|
||||
if (!emptyStringTrimmed(gcp_resource_path) && !emptyStringTrimmed(topic_id)) {
|
||||
try {
|
||||
loadingSubscription = true
|
||||
subscription_items = await GcpTriggerService.listAllTgoogleTopicSubscriptions({
|
||||
workspace: wsId!,
|
||||
path: gcp_resource_path,
|
||||
requestBody: {
|
||||
topic_id
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
sendUserToast(error.body, true)
|
||||
}
|
||||
loadingSubscription = false
|
||||
if (!hasCredentials || blockedByAdminGate || emptyStringTrimmed(topic_id)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
loadingSubscription = true
|
||||
const requestBody = { topic_id, project_id }
|
||||
subscription_items = usesDefaultCredentials
|
||||
? await GcpTriggerService.listAllTgoogleTopicSubscriptionsWithDefaultCredentials({
|
||||
workspace: wsId!,
|
||||
requestBody
|
||||
})
|
||||
: await GcpTriggerService.listAllTgoogleTopicSubscriptions({
|
||||
workspace: wsId!,
|
||||
path: gcp_resource_path!,
|
||||
requestBody
|
||||
})
|
||||
} catch (error) {
|
||||
sendUserToast(error.body, true)
|
||||
}
|
||||
loadingSubscription = false
|
||||
}
|
||||
|
||||
/** Subscriptions come back fully qualified so cross-project ones survive the round trip. The
|
||||
* project is what tells two same-named subscriptions apart, which is exactly the case a
|
||||
* cross-project topic creates, so it stays in the label rather than being trimmed away. */
|
||||
function subscriptionLabel(name: string): string {
|
||||
const parts = name.split('/')
|
||||
const id = parts.pop() ?? name
|
||||
const project = parts.length >= 2 ? parts[1] : undefined
|
||||
return project ? `${id} (${project})` : id
|
||||
}
|
||||
|
||||
interface Props {
|
||||
can_write?: boolean
|
||||
headless?: boolean
|
||||
isValid?: boolean
|
||||
gcp_resource_path?: string
|
||||
gcp_resource_path?: string | undefined
|
||||
/** Authenticate as the server itself instead of with a `gcloud` resource. */
|
||||
use_default_credentials?: boolean
|
||||
/** Whether the config was *loaded* in that mode, as opposed to switched into it here. */
|
||||
loaded_uses_default_credentials?: boolean
|
||||
project_id?: string
|
||||
subscription_id?: string
|
||||
topic_id?: string
|
||||
delivery_type?: DeliveryType | undefined
|
||||
@@ -96,7 +126,10 @@
|
||||
can_write = false,
|
||||
headless = false,
|
||||
isValid = $bindable(false),
|
||||
gcp_resource_path = $bindable(''),
|
||||
gcp_resource_path = $bindable(),
|
||||
use_default_credentials = $bindable(),
|
||||
loaded_uses_default_credentials = false,
|
||||
project_id = $bindable(),
|
||||
subscription_id = $bindable(''),
|
||||
topic_id = $bindable(''),
|
||||
delivery_type = $bindable('pull'),
|
||||
@@ -111,13 +144,50 @@
|
||||
create_update_subscription_id = $bindable('')
|
||||
}: Props = $props()
|
||||
|
||||
if (gcp_resource_path) {
|
||||
/** Only workspace admins may point a trigger at the server's own GCP identity, which no
|
||||
* resource ACL covers. The backend enforces this too; hiding it keeps a non-admin from
|
||||
* building a config that cannot be saved. Someone who inherits such a trigger still sees the
|
||||
* mode it is in. */
|
||||
const usesDefaultCredentials = $derived(use_default_credentials ?? false)
|
||||
// Keyed on the loaded mode, not the live one: reading the live mode would make the toggle a
|
||||
// one-way door, disabling itself the moment a non-admin switched an inherited ADC trigger away.
|
||||
const canUseDefaultCredentials = $derived(
|
||||
$userStore?.is_admin === true || loaded_uses_default_credentials
|
||||
)
|
||||
const hasCredentials = $derived(usesDefaultCredentials || !emptyStringTrimmed(gcp_resource_path))
|
||||
/** Saving re-provisions the subscription with the instance's credentials, so the backend runs
|
||||
* the admin check on every write, not only when the mode is switched. A non-admin who inherits
|
||||
* such a trigger can open it, so say why saving is unavailable instead of letting them hit a
|
||||
* bare 403. */
|
||||
const blockedByAdminGate = $derived(usesDefaultCredentials && $userStore?.is_admin !== true)
|
||||
|
||||
// One-shot on mount, so read the props rather than the derived: referencing `$derived` state
|
||||
// here captures its initial value anyway, and Svelte warns about it.
|
||||
if (gcp_resource_path || use_default_credentials) {
|
||||
loadAllPubSubTopicsFromProject()
|
||||
}
|
||||
|
||||
function onCredentialsModeChange(useDefault: boolean) {
|
||||
use_default_credentials = useDefault
|
||||
gcp_resource_path = useDefault ? undefined : ''
|
||||
// The topic and subscription belong to the credentials that listed them. Keeping them
|
||||
// across a switch leaves the form valid and saveable against names the new credentials may
|
||||
// not have, or worse may have in a different project.
|
||||
topic_items = []
|
||||
subscription_items = []
|
||||
topic_id = ''
|
||||
subscription_id = ''
|
||||
cloud_subscription_id = ''
|
||||
create_update_subscription_id = ''
|
||||
if (useDefault) {
|
||||
loadAllPubSubTopicsFromProject()
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
isValid =
|
||||
!emptyStringTrimmed(gcp_resource_path) &&
|
||||
hasCredentials &&
|
||||
!blockedByAdminGate &&
|
||||
!emptyStringTrimmed(topic_id) &&
|
||||
!emptyStringTrimmed(subscription_id)
|
||||
})
|
||||
@@ -152,25 +222,82 @@
|
||||
{/snippet}
|
||||
<div class="flex flex-col w-full gap-4">
|
||||
<Subsection label="Connection setup">
|
||||
<div class="flex flex-col gap-1 mt-2">
|
||||
<ResourcePicker
|
||||
workspace={wsId}
|
||||
resourceType="gcloud"
|
||||
bind:value={
|
||||
() => gcp_resource_path,
|
||||
(v) => {
|
||||
gcp_resource_path = v
|
||||
loadAllPubSubTopicsFromProject()
|
||||
<div class="flex flex-col gap-3 mt-2">
|
||||
<ToggleButtonGroup
|
||||
selected={usesDefaultCredentials ? 'default' : 'resource'}
|
||||
on:selected={(e) => onCredentialsModeChange(e.detail === 'default')}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
label="Service account"
|
||||
value="resource"
|
||||
tooltip="Authenticate with a service account key held in a GCP resource."
|
||||
showTooltipIcon
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
label="Application default credentials"
|
||||
value="default"
|
||||
disabled={!canUseDefaultCredentials}
|
||||
tooltip={canUseDefaultCredentials
|
||||
? 'Authenticate as the Windmill server itself, using the credentials of its environment (workload identity, the metadata server, or GOOGLE_APPLICATION_CREDENTIALS).'
|
||||
: 'Workspace admins can authenticate as the Windmill server itself. Ask one to set this up.'}
|
||||
showTooltipIcon
|
||||
{item}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
|
||||
{#if !usesDefaultCredentials}
|
||||
<ResourcePicker
|
||||
workspace={wsId}
|
||||
resourceType="gcloud"
|
||||
bind:value={
|
||||
() => gcp_resource_path,
|
||||
(v) => {
|
||||
gcp_resource_path = v
|
||||
loadAllPubSubTopicsFromProject()
|
||||
}
|
||||
}
|
||||
}
|
||||
/>
|
||||
{#if !emptyStringTrimmed(gcp_resource_path)}
|
||||
<TestTriggerConnection kind="gcp" args={{ gcp_resource_path }} />
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<Subsection
|
||||
label="Project ID"
|
||||
tooltip="The project topics and subscriptions are listed and created in. Leave empty to use the project of the credentials. Names given in full (projects/<project>/topics/<id>) are reached whatever this is set to."
|
||||
>
|
||||
<div class="mt-2">
|
||||
<!-- Typing does not refetch: every keystroke would be a Pub/Sub call for a
|
||||
project id that is not finished being typed. The refresh button next to
|
||||
the topic picker is what reloads the lists. -->
|
||||
<!-- Cleared means "unset", not an empty or whitespace-only string: that would
|
||||
travel as `?project_id=`, dirty the config, and reach the column as a
|
||||
value `empty_as_none` does not trim away. -->
|
||||
<TextInput
|
||||
bind:value={() => project_id ?? '', (v) => (project_id = emptyStringTrimmed(v) ? undefined : v)}
|
||||
inputProps={{
|
||||
placeholder: 'my-gcp-project',
|
||||
disabled: !can_write,
|
||||
autocomplete: 'off'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Subsection>
|
||||
|
||||
{#if blockedByAdminGate}
|
||||
<Alert title="Workspace admin required" type="info" size="xs">
|
||||
This trigger authenticates as the Windmill server. Saving changes to it needs
|
||||
workspace admin, because saving re-provisions the subscription with those credentials.
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
{#if hasCredentials}
|
||||
<TestTriggerConnection kind="gcp" args={{ gcp_resource_path, project_id }} />
|
||||
{/if}
|
||||
</div>
|
||||
</Subsection>
|
||||
|
||||
{#if gcp_resource_path}
|
||||
{#if hasCredentials}
|
||||
<div class="flex flex-col gap-1">
|
||||
<Subsection
|
||||
label="Topic"
|
||||
@@ -204,7 +331,7 @@
|
||||
</Subsection>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !emptyStringTrimmed(gcp_resource_path) && !emptyStringTrimmed(topic_id)}
|
||||
{#if hasCredentials && !emptyStringTrimmed(topic_id)}
|
||||
<Section
|
||||
label="Subscription"
|
||||
tooltip="Choose whether to create or update a Pub/Sub subscription, or link an existing one from your Google Cloud project."
|
||||
@@ -324,7 +451,10 @@
|
||||
(t) => ((subscription_id = t), (cloud_subscription_id = t))
|
||||
}
|
||||
onClear={() => (subscription_id = '')}
|
||||
items={safeSelectItems(subscription_items)}
|
||||
items={subscription_items.map((s) => ({
|
||||
value: s,
|
||||
label: subscriptionLabel(s)
|
||||
}))}
|
||||
placeholder="Choose a subscription"
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -59,7 +59,11 @@
|
||||
let can_write = $state(true)
|
||||
let drawerLoading = $state(true)
|
||||
let topic_id: string = $state('')
|
||||
let gcp_resource_path: string = $state('')
|
||||
let gcp_resource_path: string | undefined = $state('')
|
||||
// `undefined` rather than `''` is what marks application default credentials, and the two
|
||||
// survive a round trip through the deployed config and the draft as `null` vs `''`.
|
||||
let use_default_credentials = $state(false)
|
||||
let project_id: string | undefined = $state(undefined)
|
||||
let subscription_id: string = $state('')
|
||||
let isValid = $state(false)
|
||||
let delivery_config: PushConfig | undefined = $state(undefined)
|
||||
@@ -79,6 +83,11 @@
|
||||
let retry: Retry | undefined = $state()
|
||||
let suspendedJobsModal = $state<TriggerSuspendedJobsModal | null>(null)
|
||||
let originalConfig = $state<Record<string, any> | undefined>(undefined)
|
||||
/** The credential mode the config was loaded in, as opposed to the one currently selected in
|
||||
* the form. A non-admin may keep a trigger that already uses the instance's credentials, but
|
||||
* may not move one onto them, so the permission has to key off this rather than the live mode.
|
||||
* Set at load rather than derived from the deployed row, so a draft-only trigger counts too. */
|
||||
let loadedUsesDefaultCredentials = $state(false)
|
||||
let {
|
||||
useDrawer = true,
|
||||
description = undefined,
|
||||
@@ -177,7 +186,20 @@
|
||||
initialScriptPath = ''
|
||||
fixedScriptPath = fixedScriptPath_ ?? ''
|
||||
script_path = fixedScriptPath
|
||||
gcp_resource_path = defaultValues?.gcp_resource_path ?? ''
|
||||
// A draft or capture config reaches `openNew` too, and it stores the credentials mode as
|
||||
// an explicit `gcp_resource_path: null`. Only an absent key means "fresh trigger", so
|
||||
// collapsing null and missing here would silently reopen an ADC config in service
|
||||
// account mode.
|
||||
const reopenedAsDefaultCredentials =
|
||||
!!defaultValues &&
|
||||
'gcp_resource_path' in defaultValues &&
|
||||
defaultValues.gcp_resource_path == null
|
||||
gcp_resource_path = reopenedAsDefaultCredentials
|
||||
? undefined
|
||||
: (defaultValues?.gcp_resource_path ?? '')
|
||||
use_default_credentials = reopenedAsDefaultCredentials
|
||||
loadedUsesDefaultCredentials = reopenedAsDefaultCredentials
|
||||
project_id = defaultValues?.project_id ?? undefined
|
||||
delivery_type = defaultValues?.delivery_type ?? 'pull'
|
||||
delivery_config = defaultValues?.delivery_config ?? undefined
|
||||
subscription_id = ''
|
||||
@@ -234,7 +256,10 @@
|
||||
async function loadTriggerConfig(cfg?: Record<string, any>): Promise<void> {
|
||||
script_path = cfg?.script_path
|
||||
initialScriptPath = cfg?.script_path
|
||||
gcp_resource_path = cfg?.gcp_resource_path
|
||||
gcp_resource_path = cfg?.gcp_resource_path ?? undefined
|
||||
use_default_credentials = cfg?.gcp_resource_path == null
|
||||
loadedUsesDefaultCredentials = use_default_credentials
|
||||
project_id = cfg?.project_id ?? undefined
|
||||
delivery_type = cfg?.delivery_type
|
||||
subscription_id = cfg?.subscription_id
|
||||
delivery_config = cfg?.delivery_config
|
||||
@@ -277,7 +302,8 @@
|
||||
|
||||
function getGcpConfig() {
|
||||
return {
|
||||
gcp_resource_path,
|
||||
gcp_resource_path: gcp_resource_path ?? null,
|
||||
project_id,
|
||||
subscription_mode,
|
||||
subscription_id,
|
||||
delivery_type,
|
||||
@@ -300,7 +326,8 @@
|
||||
|
||||
function getGcpCaptureConfig() {
|
||||
return {
|
||||
gcp_resource_path,
|
||||
gcp_resource_path: gcp_resource_path ?? null,
|
||||
project_id,
|
||||
subscription_mode,
|
||||
subscription_id,
|
||||
delivery_type,
|
||||
@@ -317,15 +344,26 @@
|
||||
const previousMode = mode
|
||||
mode = newMode
|
||||
if (!trigger?.draftConfig) {
|
||||
const ok = await withForkConflictRetry(
|
||||
(force) =>
|
||||
GcpTriggerService.setGcpTriggerMode({
|
||||
path: initialPath,
|
||||
workspace: wsId ?? '',
|
||||
requestBody: { mode: newMode, force }
|
||||
}),
|
||||
'GCP Pub/Sub trigger'
|
||||
)
|
||||
let ok: boolean
|
||||
try {
|
||||
ok = await withForkConflictRetry(
|
||||
(force) =>
|
||||
GcpTriggerService.setGcpTriggerMode({
|
||||
path: initialPath,
|
||||
workspace: wsId ?? '',
|
||||
requestBody: { mode: newMode, force }
|
||||
}),
|
||||
'GCP Pub/Sub trigger'
|
||||
)
|
||||
} catch (err) {
|
||||
// `withForkConflictRetry` re-throws anything that is not a fork conflict, and
|
||||
// enabling a trigger on application default credentials is rejected for non-admins,
|
||||
// so a refusal here is expected rather than exceptional: put the toggle back and say
|
||||
// why, instead of leaving it showing a mode the server did not accept.
|
||||
mode = previousMode
|
||||
sendUserToast(err?.body ?? err?.message ?? 'Could not change trigger mode', true)
|
||||
return
|
||||
}
|
||||
if (!ok) {
|
||||
mode = previousMode
|
||||
return
|
||||
@@ -516,6 +554,9 @@
|
||||
<GcpTriggerEditorConfigSection
|
||||
bind:isValid
|
||||
bind:gcp_resource_path
|
||||
bind:use_default_credentials
|
||||
loaded_uses_default_credentials={loadedUsesDefaultCredentials}
|
||||
bind:project_id
|
||||
bind:subscription_id
|
||||
bind:delivery_type
|
||||
bind:delivery_config
|
||||
|
||||
@@ -19,7 +19,8 @@ export async function saveGcpTriggerFromCfg(
|
||||
: {}
|
||||
|
||||
const requestBody: GcpTriggerData = {
|
||||
gcp_resource_path: cfg.gcp_resource_path,
|
||||
gcp_resource_path: cfg.gcp_resource_path ?? undefined,
|
||||
project_id: cfg.project_id,
|
||||
subscription_mode: cfg.subscription_mode,
|
||||
subscription_id: cfg.subscription_id,
|
||||
delivery_type: cfg.delivery_type,
|
||||
|
||||
@@ -241,7 +241,13 @@ export const TRIGGER_KINDS: Record<
|
||||
AmqpTriggerService.createAmqpTrigger({ workspace, requestBody })
|
||||
},
|
||||
gcp: {
|
||||
configFields: ['gcp_resource_path', 'topic_id', 'delivery_type', 'subscription_mode'],
|
||||
configFields: [
|
||||
'gcp_resource_path',
|
||||
'project_id',
|
||||
'topic_id',
|
||||
'delivery_type',
|
||||
'subscription_mode'
|
||||
],
|
||||
provisionsOnCreate: true,
|
||||
badge: 'GCP Pub/Sub',
|
||||
route: 'gcp_triggers',
|
||||
@@ -541,7 +547,8 @@ export function triggerDetails(t: WorkspaceTrigger): Array<{ label: string; valu
|
||||
push('Queue', c.queue_name)
|
||||
break
|
||||
case 'gcp':
|
||||
push('Resource', c.gcp_resource_path)
|
||||
push('Resource', c.gcp_resource_path ?? 'Application default credentials')
|
||||
push('Project', c.project_id)
|
||||
push('Topic', c.topic_id)
|
||||
push('Subscription', c.subscription_id)
|
||||
break
|
||||
|
||||
@@ -397,7 +397,7 @@
|
||||
/>
|
||||
{:else if items?.length}
|
||||
<div class="border rounded-md divide-y">
|
||||
{#each items.slice(0, nbDisplayed) as { gcp_resource_path, topic_id, workspace_id, delivery_type, path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, subscription_id, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)}
|
||||
{#each items.slice(0, nbDisplayed) as { gcp_resource_path, project_id, topic_id, workspace_id, delivery_type, path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, subscription_id, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)}
|
||||
{@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_gcp', path) ?? is_draft}
|
||||
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
|
||||
{@const ping = new Date()}
|
||||
@@ -596,13 +596,17 @@
|
||||
subscriptionToDelete = subscription_id
|
||||
currentTopic = topic_id
|
||||
deleteSubscriptionCallback = async () => {
|
||||
const message = await GcpTriggerService.deleteGcpSubscription({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path: gcp_resource_path,
|
||||
requestBody: {
|
||||
subscription_id
|
||||
}
|
||||
})
|
||||
const requestBody = { subscription_id, project_id }
|
||||
const message = gcp_resource_path
|
||||
? await GcpTriggerService.deleteGcpSubscription({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path: gcp_resource_path,
|
||||
requestBody
|
||||
})
|
||||
: await GcpTriggerService.deleteGcpSubscriptionWithDefaultCredentials({
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody
|
||||
})
|
||||
sendUserToast(message)
|
||||
}
|
||||
deleteGcpTriggerCallback = async () => {
|
||||
|
||||
@@ -33,13 +33,21 @@ properties:
|
||||
gcp_resource_path:
|
||||
type: string
|
||||
description: Path to the GCP resource containing service account credentials for
|
||||
authentication.
|
||||
authentication. Omit to authenticate with the instance's application default
|
||||
credentials.
|
||||
project_id:
|
||||
type: string
|
||||
description: GCP project the client operates in. Defaults to the project of the
|
||||
credentials. Topics and subscriptions given as fully qualified names are reached
|
||||
whatever it is.
|
||||
topic_id:
|
||||
type: string
|
||||
description: Google Cloud Pub/Sub topic ID to subscribe to.
|
||||
description: Google Cloud Pub/Sub topic ID to subscribe to. Accepts a bare ID
|
||||
or a fully qualified name (projects/<project>/topics/<id>).
|
||||
subscription_id:
|
||||
type: string
|
||||
description: Google Cloud Pub/Sub subscription ID.
|
||||
description: Google Cloud Pub/Sub subscription ID. Accepts a bare ID or a fully
|
||||
qualified name (projects/<project>/subscriptions/<id>).
|
||||
delivery_type:
|
||||
type: string
|
||||
enum:
|
||||
@@ -109,7 +117,6 @@ properties:
|
||||
required:
|
||||
- script_path
|
||||
- is_flow
|
||||
- gcp_resource_path
|
||||
- topic_id
|
||||
- subscription_id
|
||||
- delivery_type
|
||||
|
||||
Reference in New Issue
Block a user