mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 00:03:08 +00:00
Merge branch 'main' into alp/accordion
This commit is contained in:
+13
-1
@@ -12,7 +12,7 @@ RUN apt-get -y update \
|
||||
|
||||
RUN rustup component add rustfmt
|
||||
|
||||
RUN CARGO_NET_GIT_FETCH_WITH_CLI=true cargo install cargo-chef --version ^0.1
|
||||
RUN CARGO_NET_GIT_FETCH_WITH_CLI=true cargo install cargo-chef --version 0.1.68
|
||||
RUN cargo install sccache --version ^0.8
|
||||
ENV RUSTC_WRAPPER=sccache SCCACHE_DIR=/backend/sccache
|
||||
|
||||
@@ -95,6 +95,14 @@ ARG WITH_KUBECTL=true
|
||||
ARG WITH_HELM=true
|
||||
ARG WITH_GIT=true
|
||||
|
||||
# To change latest stable version:
|
||||
# 1. Change placeholder in instanceSettings.ts
|
||||
# 2. Change LATEST_STABLE_PY in dockerfile
|
||||
# 3. Change #[default] annotation for PyVersion in backend
|
||||
ARG LATEST_STABLE_PY=3.11.10
|
||||
ENV UV_PYTHON_INSTALL_DIR=/tmp/windmill/cache/py_runtime
|
||||
ENV UV_PYTHON_PREFERENCE=only-managed
|
||||
|
||||
RUN pip install --upgrade pip==24.2
|
||||
|
||||
RUN apt-get update \
|
||||
@@ -161,6 +169,10 @@ ENV GO_PATH=/usr/local/go/bin/go
|
||||
# Install UV
|
||||
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.5.15/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv
|
||||
|
||||
# Preinstall python runtimes
|
||||
RUN uv python install 3.11.10
|
||||
RUN uv python install $LATEST_STABLE_PY
|
||||
|
||||
RUN curl -sL https://deb.nodesource.com/setup_20.x | bash -
|
||||
RUN apt-get -y update && apt-get install -y curl procps nodejs awscli && apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE postgres_trigger \n SET \n server_id = $1, \n last_server_ping = now(),\n error = 'Connecting...'\n WHERE \n enabled IS TRUE \n AND workspace_id = $2 \n AND path = $3 \n AND (last_server_ping IS NULL \n OR last_server_ping < now() - INTERVAL '15 seconds'\n ) \n RETURNING true\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "?column?",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "01e92a4ba3074f1dce6ec98bc6c3fad4878f48db8c17c6d58590bd5df2e3350a"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO postgres_trigger (\n publication_name,\n replication_slot_name,\n workspace_id, \n path, \n script_path, \n is_flow, \n email, \n enabled, \n postgres_resource_path, \n edited_by\n ) \n VALUES (\n $1, \n $2, \n $3, \n $4, \n $5, \n $6, \n $7, \n $8, \n $9, \n $10\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "124b27de35b49fbdb13a1f772044665a84325e34ae04bf2795fafb7bb6f2f0c6"
|
||||
}
|
||||
+9
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color\n FROM workspace\n JOIN usr ON usr.workspace_id = workspace.id\n JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false",
|
||||
"query": "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color,\n CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings\n FROM workspace\n JOIN usr ON usr.workspace_id = workspace.id\n JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -22,6 +22,11 @@
|
||||
"ordinal": 3,
|
||||
"name": "color",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "operator_settings",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -33,8 +38,9 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "6c845f168b6265e6cf92e4d33c5409edfdc1847d0348df2ea55504ddaaa67736"
|
||||
"hash": "1452033a8e2b160883a649c986d6c7ba2f60f41e1abb6ff8332bd2dfa7379d14"
|
||||
}
|
||||
+6
@@ -127,6 +127,11 @@
|
||||
"ordinal": 24,
|
||||
"name": "color",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 25,
|
||||
"name": "operator_settings",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -159,6 +164,7 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n schemaname AS schema_name,\n tablename AS table_name,\n attnames AS columns,\n rowfilter AS where_clause\n FROM\n pg_publication_tables\n WHERE\n pubname = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "schema_name",
|
||||
"type_info": "Name"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "table_name",
|
||||
"type_info": "Name"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "columns",
|
||||
"type_info": "NameArray"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "where_clause",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Name"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "199a76c04e3f0891ad09af27b9534bbabdd8703bfdf4d43df2c65e50d4ca2c85"
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT \n \n EXISTS(SELECT 1 FROM websocket_trigger WHERE workspace_id = $1) AS \"websocket_used!\", \n \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 ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "websocket_used!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "http_routes_used!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "kafka_used!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "nats_used!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "postgres_used!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "24178c21aadc1aed90f31e9362c6505a642c8f04b883c278b07e7ef5956ce121"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SHOW WAL_LEVEL;",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "wal_level",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "2ef25599ea0c9ef946d6cc70ae048af970aed2638a3f767e152b654aebf68e48"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT pubname AS publication_name FROM pg_publication;",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "publication_name",
|
||||
"type_info": "Name"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4469ee6c206c46951980ea1bc73f126f339d2e3cf97f363be8921084b16dac45"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT \n slot_name,\n active\n FROM\n pg_replication_slots \n WHERE \n plugin = 'pgoutput' AND\n slot_type = 'logical';\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "slot_name",
|
||||
"type_info": "Name"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "active",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "4ee0017771f46f0272817d18edb821940cb5064e3f155b9630b131c09c9dba13"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n DELETE FROM postgres_trigger \n WHERE \n workspace_id = $1 AND \n path = $2\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "508d267b0d77fd12446654a502bf4968ecebec1614580e55de3d5895f0595e52"
|
||||
}
|
||||
+6
@@ -127,6 +127,11 @@
|
||||
"ordinal": 24,
|
||||
"name": "color",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 25,
|
||||
"name": "operator_settings",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -159,6 +164,7 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings SET operator_settings = $1 WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Jsonb",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "597b89889c3820fe7986b834c0e5a0652d6a72024a0c17f5271add38168d1ab3"
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select path from script where hash = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "64920b845c0ce81fb99497c03b249bb6cb06581079b5fc5bea5ddd8e7a895b79"
|
||||
}
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "bool",
|
||||
"name": "?column?",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE \n postgres_trigger\n SET \n last_server_ping = now(),\n error = $1\n WHERE\n workspace_id = $2\n AND path = $3\n AND server_id = $4 \n AND enabled IS TRUE\n RETURNING 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "?column?",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "833a4ecec12dfe67f28016a135ffe682b023d1868a182b7cac16ce799433c257"
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n puballtables AS all_table,\n pubinsert AS insert,\n pubupdate AS update,\n pubdelete AS delete\n FROM\n pg_publication\n WHERE\n pubname = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "all_table",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "insert",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "update",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "delete",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Name"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "86ae16175ace0179e784aacfd381771f0137ecab6671d632febadede729e7783"
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(*) FROM postgres_trigger WHERE script_path = $1 AND is_flow = $2 AND workspace_id = $3",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Bool",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "8e1afb488096330890b1675d2b3052d2064fcc8f373fecfebd40914768b2b1cf"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE postgres_trigger \n SET \n script_path = $1, \n path = $2, \n is_flow = $3, \n edited_by = $4, \n email = $5, \n postgres_resource_path = $6, \n replication_slot_name = $7,\n publication_name = $8,\n edited_at = now(), \n error = NULL,\n server_id = NULL\n WHERE \n workspace_id = $9 AND \n path = $10\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "946977f0d525abf6267bf02e7a887434abd3e213b8c3c488166ca58fe3321147"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE \n postgres_trigger \n SET\n last_server_ping = NULL \n WHERE \n workspace_id = $1 \n AND path = $2 \n AND server_id IS NULL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "95e420b60fba20b36b2c6675998587d8cad3b67d4dfa9de52777d4ea9490b6b7"
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n workspace_id,\n path,\n script_path,\n replication_slot_name,\n publication_name,\n is_flow,\n edited_by,\n email,\n edited_at,\n server_id,\n last_server_ping,\n extra_perms,\n error,\n enabled,\n postgres_resource_path\n FROM\n postgres_trigger\n WHERE\n enabled IS TRUE\n AND (last_server_ping IS NULL OR\n last_server_ping < now() - interval '15 seconds'\n )\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "script_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "replication_slot_name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "publication_name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "is_flow",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "edited_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "edited_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "server_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "last_server_ping",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "extra_perms",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "error",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "enabled",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "postgres_resource_path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9cb31818d4db8a0e294884ab3dec08bfc262f99c875bf16c25bfb5e987efe978"
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value, is_secret \n FROM variable \n WHERE path = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "value",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "is_secret",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a5a03b9235b25bca359235f2e546197f02ca1cf898d7f2686419749b1cb0679e"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE postgres_trigger SET enabled = FALSE, error = $1, server_id = NULL, last_server_ping = NULL WHERE workspace_id = $2 AND path = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a6c168c60bc8c42f70b18565e824efe29311aabfba6e09efa10bab6a551d658b"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT EXISTS(\n SELECT 1 \n FROM postgres_trigger \n WHERE \n path = $1 AND \n workspace_id = $2\n )",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "aae8699bbaa4d6111eabee715a6f4a3600c1ccfe6847bd526a751bc7baf825c5"
|
||||
}
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "bool",
|
||||
"name": "?column?",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n workspace_id,\n path,\n script_path,\n is_flow,\n edited_by,\n email,\n edited_at,\n server_id,\n last_server_ping,\n extra_perms,\n error,\n enabled,\n replication_slot_name,\n publication_name,\n postgres_resource_path\n FROM \n postgres_trigger\n WHERE \n workspace_id = $1 AND \n path = $2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "script_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "is_flow",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "edited_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "edited_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "server_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "last_server_ping",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "extra_perms",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "error",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "enabled",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "replication_slot_name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "publication_name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "postgres_resource_path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "fcc11a9353ea101109aec30f8bdd4b2ce906fffc3c51e77d083121dbd68dadd4"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE postgres_trigger \n SET \n enabled = $1, \n email = $2, \n edited_by = $3, \n edited_at = now(), \n server_id = NULL, \n error = NULL\n WHERE \n path = $4 AND \n workspace_id = $5 \n RETURNING 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "?column?",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "fd1db7530acf3c84b2ab696504905a50d1ed4f69629c43de7d874769c340d909"
|
||||
}
|
||||
Vendored
+6
-5
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"python.analysis.typeCheckingMode": "basic",
|
||||
"rust-analyzer.linkedProjects": [
|
||||
"./windmill-common/Cargo.toml"
|
||||
],
|
||||
"rust-analyzer.linkedProjects": ["./windmill-common/Cargo.toml"],
|
||||
"rust-analyzer.showUnlinkedFileNotification": false,
|
||||
"remote.portsAttributes": {
|
||||
"8000": {
|
||||
@@ -10,5 +8,8 @@
|
||||
"onAutoForward": "openPreview"
|
||||
}
|
||||
},
|
||||
"remote.autoForwardPorts": true
|
||||
}
|
||||
"remote.autoForwardPorts": true,
|
||||
"conventionalCommits.scopes": [
|
||||
"restructring triggers, decoding trigger message on work"
|
||||
],
|
||||
}
|
||||
|
||||
Generated
+73
-7
@@ -6123,6 +6123,15 @@ dependencies = [
|
||||
"indexmap 2.7.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pg_escape"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c7bc82ccbe2c7ef7ceed38dcac90d7ff46681e061e9d7310cbcd409113e303"
|
||||
dependencies = [
|
||||
"phf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
@@ -6262,7 +6271,7 @@ dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-postgres",
|
||||
"tokio-postgres 0.7.12",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6283,6 +6292,33 @@ dependencies = [
|
||||
"stringprep",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postgres-protocol"
|
||||
version = "0.6.7"
|
||||
source = "git+https://github.com/imor/rust-postgres?rev=20265ef38e32a06f76b6f9b678e2077fc2211f6b#20265ef38e32a06f76b6f9b678e2077fc2211f6b"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"fallible-iterator",
|
||||
"hmac",
|
||||
"md-5 0.10.6",
|
||||
"memchr",
|
||||
"rand 0.8.5",
|
||||
"sha2 0.10.8",
|
||||
"stringprep",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postgres-types"
|
||||
version = "0.2.7"
|
||||
source = "git+https://github.com/imor/rust-postgres?rev=20265ef38e32a06f76b6f9b678e2077fc2211f6b#20265ef38e32a06f76b6f9b678e2077fc2211f6b"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"fallible-iterator",
|
||||
"postgres-protocol 0.6.7 (git+https://github.com/imor/rust-postgres?rev=20265ef38e32a06f76b6f9b678e2077fc2211f6b)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postgres-types"
|
||||
version = "0.2.8"
|
||||
@@ -6294,7 +6330,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"fallible-iterator",
|
||||
"postgres-protocol",
|
||||
"postgres-protocol 0.6.7 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"uuid 1.12.1",
|
||||
@@ -7212,7 +7248,7 @@ dependencies = [
|
||||
"borsh",
|
||||
"bytes",
|
||||
"num-traits",
|
||||
"postgres-types",
|
||||
"postgres-types 0.2.8",
|
||||
"rand 0.8.5",
|
||||
"rkyv",
|
||||
"serde",
|
||||
@@ -9512,6 +9548,31 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-postgres"
|
||||
version = "0.7.11"
|
||||
source = "git+https://github.com/imor/rust-postgres?rev=20265ef38e32a06f76b6f9b678e2077fc2211f6b#20265ef38e32a06f76b6f9b678e2077fc2211f6b"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"fallible-iterator",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"log",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"phf",
|
||||
"pin-project-lite",
|
||||
"postgres-protocol 0.6.7 (git+https://github.com/imor/rust-postgres?rev=20265ef38e32a06f76b6f9b678e2077fc2211f6b)",
|
||||
"postgres-types 0.2.7",
|
||||
"rand 0.8.5",
|
||||
"socket2",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"whoami",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-postgres"
|
||||
version = "0.7.12"
|
||||
@@ -9529,8 +9590,8 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"phf",
|
||||
"pin-project-lite",
|
||||
"postgres-protocol",
|
||||
"postgres-types",
|
||||
"postgres-protocol 0.6.7 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"postgres-types 0.2.8",
|
||||
"rand 0.8.5",
|
||||
"socket2",
|
||||
"tokio",
|
||||
@@ -10736,6 +10797,7 @@ dependencies = [
|
||||
"gethostname",
|
||||
"git-version",
|
||||
"lazy_static",
|
||||
"memchr",
|
||||
"object_store",
|
||||
"once_cell",
|
||||
"prometheus",
|
||||
@@ -10778,6 +10840,7 @@ dependencies = [
|
||||
"axum",
|
||||
"base32",
|
||||
"base64 0.22.1",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
@@ -10807,6 +10870,7 @@ dependencies = [
|
||||
"object_store",
|
||||
"openidconnect",
|
||||
"openssl",
|
||||
"pg_escape",
|
||||
"pin-project",
|
||||
"prometheus",
|
||||
"quick_cache",
|
||||
@@ -10816,6 +10880,7 @@ dependencies = [
|
||||
"reqwest 0.12.9",
|
||||
"rsa",
|
||||
"rust-embed",
|
||||
"rust_decimal",
|
||||
"samael",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -10824,11 +10889,13 @@ dependencies = [
|
||||
"sql-builder",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror 2.0.11",
|
||||
"time",
|
||||
"tinyvector",
|
||||
"tokenizers",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-postgres 0.7.11",
|
||||
"tokio-tar",
|
||||
"tokio-tungstenite",
|
||||
"tokio-util",
|
||||
@@ -11187,7 +11254,6 @@ dependencies = [
|
||||
"async-recursion",
|
||||
"axum",
|
||||
"backon",
|
||||
"bigdecimal",
|
||||
"chrono",
|
||||
"chrono-tz 0.10.1",
|
||||
"cron",
|
||||
@@ -11278,7 +11344,7 @@ dependencies = [
|
||||
"tar",
|
||||
"tiberius",
|
||||
"tokio",
|
||||
"tokio-postgres",
|
||||
"tokio-postgres 0.7.12",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"urlencoding",
|
||||
|
||||
+9
-2
@@ -73,6 +73,7 @@ oracledb = ["windmill-worker/oracledb"]
|
||||
mssql = ["windmill-worker/mssql"]
|
||||
bigquery = ["windmill-worker/bigquery"]
|
||||
websocket = ["windmill-api/websocket"]
|
||||
postgres_trigger = ["windmill-api/postgres_trigger"]
|
||||
python = ["windmill-worker/python"]
|
||||
smtp = ["windmill-api/smtp", "windmill-common/smtp"]
|
||||
csharp = ["windmill-worker/csharp"]
|
||||
@@ -114,6 +115,8 @@ serde.workspace = true
|
||||
deno_core = { workspace = true, optional = true }
|
||||
object_store = { workspace = true, optional = true }
|
||||
quote.workspace = true
|
||||
memchr.workspace = true
|
||||
|
||||
|
||||
[target.'cfg(not(target_env = "msvc"))'.dependencies]
|
||||
tikv-jemallocator = { optional = true, workspace = true }
|
||||
@@ -154,6 +157,7 @@ windmill-parser-graphql = { path = "./parsers/windmill-parser-graphql" }
|
||||
windmill-parser-php = { path = "./parsers/windmill-parser-php" }
|
||||
windmill-api-client = { path = "./windmill-api-client" }
|
||||
|
||||
memchr = "2.7.4"
|
||||
axum = { version = "^0.7", features = ["multipart"] }
|
||||
headers = "^0"
|
||||
hyper = { version = "^1", features = ["full"] }
|
||||
@@ -234,7 +238,7 @@ sqlx = { version = "0.8.0", features = [
|
||||
"runtime-tokio-rustls",
|
||||
"bigdecimal"
|
||||
] }
|
||||
bigdecimal = "^0"
|
||||
bigdecimal = {version = "^0"}
|
||||
dotenv = "^0"
|
||||
ulid = { version = "^1", features = ["uuid"] }
|
||||
futures = "^0"
|
||||
@@ -260,6 +264,7 @@ wasm-bindgen-test = "0.3.42"
|
||||
convert_case = "0.6.0"
|
||||
getrandom = "0.2"
|
||||
tokio-postgres = {version = "^0.7", features = ["array-impls", "with-serde_json-1", "with-chrono-0_4", "with-uuid-1", "with-bit-vec-0_6"]}
|
||||
rust-postgres = { package = "tokio-postgres", git = "https://github.com/imor/rust-postgres", rev = "20265ef38e32a06f76b6f9b678e2077fc2211f6b"}
|
||||
bit-vec = "=0.6.3"
|
||||
mappable-rc = "^0"
|
||||
mysql_async = { version = "*", default-features = false, features = ["minimal", "default", "native-tls-tls", "rust_decimal"]}
|
||||
@@ -269,7 +274,7 @@ native-tls = "^0"
|
||||
# samael = { git="https://github.com/njaremko/samael", rev="464d015e3ae393e4b5dd00b4d6baa1b617de0dd6", features = ["xmlsec"] }
|
||||
samael = { version="0.0.14", features = ["xmlsec"] }
|
||||
gcp_auth = "0.9.0"
|
||||
rust_decimal = { version = "^1", features = ["db-postgres"]}
|
||||
rust_decimal = { version = "^1", features = ["db-postgres", "serde-float"]}
|
||||
jsonwebtoken = "8.3.0"
|
||||
pem = "3.0.1"
|
||||
nix = { version = "0.27.1", features = ["process", "signal"] }
|
||||
@@ -287,6 +292,7 @@ openssl = "=0.10"
|
||||
mail-parser = "^0"
|
||||
matchit = "=0.7.3"
|
||||
rdkafka = { version = "0.36.2", features = ["cmake-build", "ssl-vendored"] }
|
||||
pg_escape = "0.1.1"
|
||||
async-nats = "0.38.0"
|
||||
nkeys = "0.4.4"
|
||||
|
||||
@@ -311,6 +317,7 @@ opentelemetry-semantic-conventions = { version = "*", features = ["semconv_exper
|
||||
bollard = "0.18.1"
|
||||
|
||||
tonic = { version = "^0", features = ["tls-native-roots"] }
|
||||
byteorder = "1.5.0"
|
||||
|
||||
tikv-jemallocator = { version = "0.5" }
|
||||
tikv-jemalloc-sys = { version = "^0.5" }
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add down migration script here
|
||||
DROP TABLE IF EXISTS postgres_trigger;
|
||||
@@ -0,0 +1,69 @@
|
||||
-- Add up migration script here
|
||||
CREATE TABLE postgres_trigger(
|
||||
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 NULL,
|
||||
postgres_resource_path VARCHAR(255) NOT NULL,
|
||||
error TEXT NULL,
|
||||
server_id VARCHAR(50) NULL,
|
||||
last_server_ping TIMESTAMPTZ NULL,
|
||||
replication_slot_name VARCHAR(255) NOT NULL,
|
||||
publication_name VARCHAR(255) NOT NULL,
|
||||
enabled BOOLEAN NOT NULL,
|
||||
CONSTRAINT PK_postgres_trigger PRIMARY KEY (path,workspace_id),
|
||||
CONSTRAINT fk_postgres_trigger_workspace FOREIGN KEY (workspace_id)
|
||||
REFERENCES workspace(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
GRANT ALL ON postgres_trigger TO windmill_user;
|
||||
GRANT ALL ON postgres_trigger TO windmill_admin;
|
||||
|
||||
ALTER TABLE postgres_trigger ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY admin_policy ON postgres_trigger FOR ALL TO windmill_admin USING (true);
|
||||
|
||||
CREATE POLICY see_folder_extra_perms_user_select ON postgres_trigger FOR SELECT TO windmill_user
|
||||
USING (SPLIT_PART(postgres_trigger.path, '/', 1) = 'f' AND SPLIT_PART(postgres_trigger.path, '/', 2) = any(regexp_split_to_array(current_setting('session.folders_read'), ',')::text[]));
|
||||
CREATE POLICY see_folder_extra_perms_user_insert ON postgres_trigger FOR INSERT TO windmill_user
|
||||
WITH CHECK (SPLIT_PART(postgres_trigger.path, '/', 1) = 'f' AND SPLIT_PART(postgres_trigger.path, '/', 2) = any(regexp_split_to_array(current_setting('session.folders_write'), ',')::text[]));
|
||||
CREATE POLICY see_folder_extra_perms_user_update ON postgres_trigger FOR UPDATE TO windmill_user
|
||||
USING (SPLIT_PART(postgres_trigger.path, '/', 1) = 'f' AND SPLIT_PART(postgres_trigger.path, '/', 2) = any(regexp_split_to_array(current_setting('session.folders_write'), ',')::text[]));
|
||||
CREATE POLICY see_folder_extra_perms_user_delete ON postgres_trigger FOR DELETE TO windmill_user
|
||||
USING (SPLIT_PART(postgres_trigger.path, '/', 1) = 'f' AND SPLIT_PART(postgres_trigger.path, '/', 2) = any(regexp_split_to_array(current_setting('session.folders_write'), ',')::text[]));
|
||||
|
||||
CREATE POLICY see_own ON postgres_trigger FOR ALL TO windmill_user
|
||||
USING (SPLIT_PART(postgres_trigger.path, '/', 1) = 'u' AND SPLIT_PART(postgres_trigger.path, '/', 2) = current_setting('session.user'));
|
||||
CREATE POLICY see_member ON postgres_trigger FOR ALL TO windmill_user
|
||||
USING (SPLIT_PART(postgres_trigger.path, '/', 1) = 'g' AND SPLIT_PART(postgres_trigger.path, '/', 2) = any(regexp_split_to_array(current_setting('session.groups'), ',')::text[]));
|
||||
|
||||
CREATE POLICY see_extra_perms_user_select ON postgres_trigger FOR SELECT TO windmill_user
|
||||
USING (extra_perms ? CONCAT('u/', current_setting('session.user')));
|
||||
CREATE POLICY see_extra_perms_user_insert ON postgres_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 postgres_trigger FOR UPDATE TO windmill_user
|
||||
USING ((extra_perms ->> CONCAT('u/', current_setting('session.user')))::boolean);
|
||||
CREATE POLICY see_extra_perms_user_delete ON postgres_trigger FOR DELETE TO windmill_user
|
||||
USING ((extra_perms ->> CONCAT('u/', current_setting('session.user')))::boolean);
|
||||
|
||||
CREATE POLICY see_extra_perms_groups_select ON postgres_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 postgres_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 postgres_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 postgres_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));
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE workspace_settings DROP COLUMN operator_settings;
|
||||
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE workspace_settings ADD COLUMN operator_settings JSONB DEFAULT '{
|
||||
"runs": true,
|
||||
"groups": true,
|
||||
"folders": true,
|
||||
"workers": true,
|
||||
"triggers": true,
|
||||
"resources": true,
|
||||
"schedules": true,
|
||||
"variables": true,
|
||||
"audit_logs": true
|
||||
}';
|
||||
@@ -1,11 +1,11 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::{
|
||||
alloc::{self, Layout},
|
||||
ffi::{c_char, c_int, c_void},
|
||||
mem::align_of,
|
||||
ptr,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/* -------------------------------- stdlib.h -------------------------------- */
|
||||
@@ -71,7 +71,6 @@ pub unsafe extern "C" fn free(buf: *mut c_void) {
|
||||
alloc::dealloc(buf, layout);
|
||||
}
|
||||
|
||||
|
||||
// In all these allocations, we store the layout before the data for later retrieval.
|
||||
// This is because we need to know the layout when deallocating the memory.
|
||||
// Here are some helper methods for that:
|
||||
|
||||
@@ -21,7 +21,7 @@ use rustpython_parser::{
|
||||
Parse,
|
||||
};
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_common::error;
|
||||
use windmill_common::{error, worker::PythonAnnotations};
|
||||
|
||||
const DEF_MAIN: &str = "def main(";
|
||||
|
||||
@@ -171,14 +171,75 @@ fn parse_code_for_imports(code: &str, path: &str) -> error::Result<Vec<String>>
|
||||
return Ok(nimports);
|
||||
}
|
||||
|
||||
#[async_recursion]
|
||||
pub async fn parse_python_imports(
|
||||
code: &str,
|
||||
w_id: &str,
|
||||
path: &str,
|
||||
db: &Pool<Postgres>,
|
||||
already_visited: &mut Vec<String>,
|
||||
annotated_pyv_numeric: &mut Option<u32>,
|
||||
) -> error::Result<Vec<String>> {
|
||||
parse_python_imports_inner(
|
||||
code,
|
||||
w_id,
|
||||
path,
|
||||
db,
|
||||
already_visited,
|
||||
annotated_pyv_numeric,
|
||||
&mut annotated_pyv_numeric.and_then(|_| Some(path.to_owned())),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[async_recursion]
|
||||
async fn parse_python_imports_inner(
|
||||
code: &str,
|
||||
w_id: &str,
|
||||
path: &str,
|
||||
db: &Pool<Postgres>,
|
||||
already_visited: &mut Vec<String>,
|
||||
annotated_pyv_numeric: &mut Option<u32>,
|
||||
path_where_annotated_pyv: &mut Option<String>,
|
||||
) -> error::Result<Vec<String>> {
|
||||
let PythonAnnotations { py310, py311, py312, py313, .. } = PythonAnnotations::parse(&code);
|
||||
|
||||
// we pass only if there is none or only one annotation
|
||||
|
||||
// Naive:
|
||||
// 1. Check if there are multiple annotated version
|
||||
// 2. If no, take one and compare with annotated version
|
||||
// 3. We continue if same or replace none with new one
|
||||
|
||||
// Optimized:
|
||||
// 1. Iterate over all annotations compare each with annotated_pyv and replace on flight
|
||||
// 2. If annotated_pyv is different version, throw and error
|
||||
|
||||
// This way we make sure there is no multiple annotations for same script
|
||||
// and we get detailed span on conflicting versions
|
||||
|
||||
let mut check = |is_py_xyz, numeric| -> error::Result<()> {
|
||||
if is_py_xyz {
|
||||
if let Some(v) = annotated_pyv_numeric {
|
||||
if *v != numeric {
|
||||
return Err(error::Error::from(anyhow::anyhow!(
|
||||
"Annotated 2 or more different python versions: \n - py{v} at {}\n - py{numeric} at {path}\nIt is possible to use only one.",
|
||||
path_where_annotated_pyv.clone().unwrap_or("Unknown".to_owned())
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
*annotated_pyv_numeric = Some(numeric);
|
||||
}
|
||||
|
||||
*path_where_annotated_pyv = Some(path.to_owned());
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
check(py310, 310)?;
|
||||
check(py311, 311)?;
|
||||
check(py312, 312)?;
|
||||
check(py313, 313)?;
|
||||
|
||||
let find_requirements = code
|
||||
.lines()
|
||||
.find_position(|x| x.starts_with("#requirements:") || x.starts_with("# requirements:"));
|
||||
@@ -225,11 +286,21 @@ pub async fn parse_python_imports(
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.unwrap_or_else(|| "".to_string());
|
||||
|
||||
if already_visited.contains(&rpath) {
|
||||
vec![]
|
||||
} else {
|
||||
already_visited.push(rpath.clone());
|
||||
parse_python_imports(&code, w_id, &rpath, db, already_visited).await?
|
||||
parse_python_imports_inner(
|
||||
&code,
|
||||
w_id,
|
||||
&rpath,
|
||||
db,
|
||||
already_visited,
|
||||
annotated_pyv_numeric,
|
||||
path_where_annotated_pyv,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
} else {
|
||||
vec![replace_import(n.to_string())]
|
||||
|
||||
@@ -25,6 +25,7 @@ def main():
|
||||
"f/foo/bar",
|
||||
&db,
|
||||
&mut already_visited,
|
||||
&mut None,
|
||||
)
|
||||
.await?;
|
||||
// println!("{}", serde_json::to_string(&r)?);
|
||||
@@ -57,6 +58,7 @@ def main():
|
||||
"f/foo/bar",
|
||||
&db,
|
||||
&mut already_visited,
|
||||
&mut None,
|
||||
)
|
||||
.await?;
|
||||
println!("{}", serde_json::to_string(&r)?);
|
||||
@@ -87,6 +89,7 @@ def main():
|
||||
"f/foo/bar",
|
||||
&db,
|
||||
&mut already_visited,
|
||||
&mut None,
|
||||
)
|
||||
.await?;
|
||||
println!("{}", serde_json::to_string(&r)?);
|
||||
|
||||
+17
-6
@@ -9,8 +9,9 @@
|
||||
use anyhow::Context;
|
||||
use monitor::{
|
||||
load_base_url, load_otel, reload_delete_logs_periodically_setting, reload_indexer_config,
|
||||
reload_nuget_config_setting, reload_timeout_wait_result_setting,
|
||||
send_current_log_file_to_object_store, send_logs_to_object_store,
|
||||
reload_instance_python_version_setting, reload_nuget_config_setting,
|
||||
reload_timeout_wait_result_setting, send_current_log_file_to_object_store,
|
||||
send_logs_to_object_store,
|
||||
};
|
||||
use rand::Rng;
|
||||
use sqlx::{postgres::PgListener, Pool, Postgres};
|
||||
@@ -35,7 +36,7 @@ use windmill_common::{
|
||||
CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING,
|
||||
DEFAULT_TAGS_WORKSPACES_SETTING, ENV_SETTINGS, EXPOSE_DEBUG_METRICS_SETTING,
|
||||
EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING, HUB_BASE_URL_SETTING, INDEXER_SETTING,
|
||||
JOB_DEFAULT_TIMEOUT_SECS_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING,
|
||||
INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING,
|
||||
LICENSE_KEY_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPM_CONFIG_REGISTRY_SETTING,
|
||||
NUGET_CONFIG_SETTING, OAUTH_SETTING, OTEL_SETTING, PIP_INDEX_URL_SETTING,
|
||||
REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING,
|
||||
@@ -69,8 +70,9 @@ use windmill_worker::{
|
||||
get_hub_script_content_and_requirements, BUN_BUNDLE_CACHE_DIR, BUN_CACHE_DIR,
|
||||
BUN_DEPSTAR_CACHE_DIR, CSHARP_CACHE_DIR, DENO_CACHE_DIR, DENO_CACHE_DIR_DEPS,
|
||||
DENO_CACHE_DIR_NPM, GO_BIN_CACHE_DIR, GO_CACHE_DIR, LOCK_CACHE_DIR, PIP_CACHE_DIR,
|
||||
POWERSHELL_CACHE_DIR, PY311_CACHE_DIR, RUST_CACHE_DIR, TAR_PIP_CACHE_DIR, TAR_PY311_CACHE_DIR,
|
||||
TMP_LOGS_DIR, UV_CACHE_DIR,
|
||||
POWERSHELL_CACHE_DIR, PY310_CACHE_DIR, PY311_CACHE_DIR, PY312_CACHE_DIR, PY313_CACHE_DIR,
|
||||
RUST_CACHE_DIR, TAR_PIP_CACHE_DIR, TAR_PY310_CACHE_DIR, TAR_PY311_CACHE_DIR,
|
||||
TAR_PY312_CACHE_DIR, TAR_PY313_CACHE_DIR, TMP_LOGS_DIR, UV_CACHE_DIR,
|
||||
};
|
||||
|
||||
use crate::monitor::{
|
||||
@@ -765,6 +767,9 @@ Windmill Community Edition {GIT_VERSION}
|
||||
PIP_INDEX_URL_SETTING => {
|
||||
reload_pip_index_url_setting(&db).await
|
||||
},
|
||||
INSTANCE_PYTHON_VERSION_SETTING => {
|
||||
reload_instance_python_version_setting(&db).await
|
||||
},
|
||||
NPM_CONFIG_REGISTRY_SETTING => {
|
||||
reload_npm_config_registry_setting(&db).await
|
||||
},
|
||||
@@ -1016,12 +1021,18 @@ pub async fn run_workers(
|
||||
TMP_LOGS_DIR,
|
||||
UV_CACHE_DIR,
|
||||
TAR_PIP_CACHE_DIR,
|
||||
TAR_PY311_CACHE_DIR,
|
||||
DENO_CACHE_DIR,
|
||||
DENO_CACHE_DIR_DEPS,
|
||||
DENO_CACHE_DIR_NPM,
|
||||
BUN_CACHE_DIR,
|
||||
PY310_CACHE_DIR,
|
||||
PY311_CACHE_DIR,
|
||||
PY312_CACHE_DIR,
|
||||
PY313_CACHE_DIR,
|
||||
TAR_PY310_CACHE_DIR,
|
||||
TAR_PY311_CACHE_DIR,
|
||||
TAR_PY312_CACHE_DIR,
|
||||
TAR_PY313_CACHE_DIR,
|
||||
PIP_CACHE_DIR,
|
||||
BUN_DEPSTAR_CACHE_DIR,
|
||||
BUN_BUNDLE_CACHE_DIR,
|
||||
|
||||
+17
-6
@@ -41,10 +41,10 @@ use windmill_common::{
|
||||
BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING,
|
||||
CRITICAL_ERROR_CHANNELS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING,
|
||||
DEFAULT_TAGS_WORKSPACES_SETTING, EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING,
|
||||
EXTRA_PIP_INDEX_URL_SETTING, HUB_BASE_URL_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING,
|
||||
JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING,
|
||||
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPM_CONFIG_REGISTRY_SETTING, NUGET_CONFIG_SETTING,
|
||||
OTEL_SETTING, PIP_INDEX_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING,
|
||||
EXTRA_PIP_INDEX_URL_SETTING, HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING,
|
||||
JOB_DEFAULT_TIMEOUT_SECS_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING,
|
||||
LICENSE_KEY_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPM_CONFIG_REGISTRY_SETTING,
|
||||
NUGET_CONFIG_SETTING, OTEL_SETTING, PIP_INDEX_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING,
|
||||
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RETENTION_PERIOD_SECS_SETTING,
|
||||
SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, TIMEOUT_WAIT_RESULT_SETTING,
|
||||
},
|
||||
@@ -68,8 +68,8 @@ use windmill_common::{
|
||||
use windmill_queue::cancel_job;
|
||||
use windmill_worker::{
|
||||
create_token_for_owner, handle_job_error, AuthedClient, SameWorkerPayload, SameWorkerSender,
|
||||
SendResult, BUNFIG_INSTALL_SCOPES, JOB_DEFAULT_TIMEOUT, KEEP_JOB_DIR, NPM_CONFIG_REGISTRY,
|
||||
NUGET_CONFIG, PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, SCRIPT_TOKEN_EXPIRY,
|
||||
SendResult, BUNFIG_INSTALL_SCOPES, INSTANCE_PYTHON_VERSION, JOB_DEFAULT_TIMEOUT, KEEP_JOB_DIR,
|
||||
NPM_CONFIG_REGISTRY, NUGET_CONFIG, PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, SCRIPT_TOKEN_EXPIRY,
|
||||
};
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
@@ -198,6 +198,7 @@ pub async fn initial_load(
|
||||
reload_pip_index_url_setting(&db).await;
|
||||
reload_npm_config_registry_setting(&db).await;
|
||||
reload_bunfig_install_scopes_setting(&db).await;
|
||||
reload_instance_python_version_setting(&db).await;
|
||||
reload_nuget_config_setting(&db).await;
|
||||
}
|
||||
}
|
||||
@@ -908,6 +909,16 @@ pub async fn reload_pip_index_url_setting(db: &DB) {
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn reload_instance_python_version_setting(db: &DB) {
|
||||
reload_option_setting_with_tracing(
|
||||
db,
|
||||
INSTANCE_PYTHON_VERSION_SETTING,
|
||||
"INSTANCE_PYTHON_VERSION",
|
||||
INSTANCE_PYTHON_VERSION.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn reload_npm_config_registry_setting(db: &DB) {
|
||||
reload_option_setting_with_tracing(
|
||||
db,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::future::Future;
|
||||
use std::{str::FromStr, sync::Arc};
|
||||
use windmill_api_client::types::{NewScript, NewScriptLanguage};
|
||||
use windmill_api_client::types::{NewScript, ScriptLang as NewScriptLanguage};
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
use chrono::Timelike;
|
||||
|
||||
@@ -28,6 +28,7 @@ zip = ["dep:async_zip"]
|
||||
oauth2 = ["dep:async-oauth2"]
|
||||
http_trigger = ["dep:matchit"]
|
||||
static_frontend = ["dep:rust-embed"]
|
||||
postgres_trigger = ["dep:rust-postgres", "dep:pg_escape", "dep:byteorder", "dep:thiserror", "dep:rust_decimal"]
|
||||
|
||||
[dependencies]
|
||||
windmill-queue.workspace = true
|
||||
@@ -107,8 +108,12 @@ rdkafka = { workspace = true, optional = true }
|
||||
async-nats = { workspace = true, optional = true }
|
||||
nkeys = { workspace = true, optional = true }
|
||||
const_format.workspace = true
|
||||
|
||||
pin-project.workspace = true
|
||||
http.workspace = true
|
||||
async-stream.workspace = true
|
||||
ulid.workspace = true
|
||||
rust-postgres = { workspace = true, optional = true }
|
||||
pg_escape = { workspace = true, optional = true }
|
||||
byteorder = { workspace = true, optional = true }
|
||||
thiserror = { workspace = true, optional = true }
|
||||
rust_decimal = { workspace = true, optional = true }
|
||||
+700
-159
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,8 @@
|
||||
use crate::db::{ApiAuthed, DB};
|
||||
use windmill_common::variables::decrypt;
|
||||
use crate::{
|
||||
db::{ApiAuthed, DB},
|
||||
variables::get_variable_or_self,
|
||||
};
|
||||
|
||||
use anthropic::AnthropicCache;
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
@@ -17,7 +20,6 @@ use serde::{Deserialize, Deserializer};
|
||||
use windmill_audit::audit_ee::audit_log;
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_common::error::{to_anyhow, Result};
|
||||
use windmill_common::variables::build_crypt;
|
||||
|
||||
use windmill_common::error::Error;
|
||||
|
||||
@@ -344,38 +346,11 @@ lazy_static! {
|
||||
pub static ref AI_KEY_CACHE: Cache<String, AiCache> = Cache::new(500);
|
||||
}
|
||||
|
||||
struct Variable {
|
||||
value: String,
|
||||
is_secret: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct ProxyQueryParams {
|
||||
no_cache: Option<bool>,
|
||||
}
|
||||
|
||||
async fn get_variable_or_self(path: String, db: &DB, w_id: &str) -> Result<String> {
|
||||
if !path.starts_with("$var:") {
|
||||
return Ok(path);
|
||||
}
|
||||
let path = path.strip_prefix("$var:").unwrap().to_string();
|
||||
let mut variable = sqlx::query_as!(
|
||||
Variable,
|
||||
"SELECT value, is_secret
|
||||
FROM variable
|
||||
WHERE path = $1 AND workspace_id = $2",
|
||||
&path,
|
||||
&w_id
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
if variable.is_secret {
|
||||
let mc = build_crypt(db, w_id).await?;
|
||||
variable.value = decrypt(&mc, variable.value)?;
|
||||
}
|
||||
Ok(variable.value)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct AiResource {
|
||||
pub path: String,
|
||||
|
||||
@@ -59,6 +59,8 @@ mod auth;
|
||||
mod capture;
|
||||
mod concurrency_groups;
|
||||
mod configs;
|
||||
#[cfg(feature = "postgres_trigger")]
|
||||
mod postgres_triggers;
|
||||
mod db;
|
||||
mod drafts;
|
||||
pub mod ee;
|
||||
@@ -308,6 +310,11 @@ pub async fn run_server(
|
||||
let nats_killpill_rx = rx.resubscribe();
|
||||
nats_triggers_ee::start_nats_consumers(db.clone(), nats_killpill_rx).await;
|
||||
}
|
||||
#[cfg(feature = "postgres_trigger")]
|
||||
{
|
||||
let db_killpill_rx = rx.resubscribe();
|
||||
postgres_triggers::start_database(db.clone(), db_killpill_rx).await;
|
||||
}
|
||||
}
|
||||
|
||||
// build our application with a route
|
||||
@@ -377,7 +384,16 @@ pub async fn run_server(
|
||||
Router::new()
|
||||
})
|
||||
.nest("/kafka_triggers", kafka_triggers_service)
|
||||
.nest("/nats_triggers", nats_triggers_service),
|
||||
.nest("/nats_triggers", nats_triggers_service)
|
||||
.nest("/postgres_triggers", {
|
||||
#[cfg(feature = "postgres_trigger")]
|
||||
{
|
||||
postgres_triggers::workspaced_service()
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "postgres_trigger"))]
|
||||
Router::new()
|
||||
}),
|
||||
)
|
||||
.nest("/workspaces", workspaces::global_service())
|
||||
.nest(
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
use thiserror::Error;
|
||||
|
||||
/**
|
||||
* This implementation is inspired by Postgres replication functionality
|
||||
* from https://github.com/supabase/pg_replicate
|
||||
*
|
||||
* Original implementation:
|
||||
* - https://github.dev/supabase/pg_replicate/blob/main/pg_replicate/src/conversions/bool.rs
|
||||
*
|
||||
*/
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ParseBoolError {
|
||||
#[error("invalid input value: {0}")]
|
||||
InvalidInput(String),
|
||||
}
|
||||
|
||||
pub fn parse_bool(s: &str) -> Result<bool, ParseBoolError> {
|
||||
match s {
|
||||
"t" => Ok(true),
|
||||
"f" => Ok(false),
|
||||
_ => Err(ParseBoolError::InvalidInput(s.to_string())),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
use core::str;
|
||||
use std::{
|
||||
num::{ParseFloatError, ParseIntError},
|
||||
str::FromStr,
|
||||
};
|
||||
|
||||
use super::{
|
||||
bool::{parse_bool, ParseBoolError},
|
||||
hex::{from_bytea_hex, ByteaHexParseError},
|
||||
};
|
||||
use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, Utc};
|
||||
use rust_decimal::Decimal;
|
||||
use rust_postgres::types::Type;
|
||||
use serde_json::{to_value, Number, Value};
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
/**
|
||||
* This implementation is inspired by Postgres replication functionality
|
||||
* from https://github.com/supabase/pg_replicate
|
||||
*
|
||||
* Original implementation:
|
||||
* - https://github.com/supabase/pg_replicate/blob/main/pg_replicate/src/conversions/text.rs
|
||||
*
|
||||
*/
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ConverterError {
|
||||
#[error("invalid bool value")]
|
||||
InvalidBool(#[from] ParseBoolError),
|
||||
|
||||
#[error("invalid int value")]
|
||||
InvalidInt(#[from] ParseIntError),
|
||||
|
||||
#[error("invalid float value")]
|
||||
InvalidFloat(#[from] ParseFloatError),
|
||||
|
||||
#[error("invalid numeric: {0}")]
|
||||
InvalidNumeric(#[from] rust_decimal::Error),
|
||||
|
||||
#[error("invalid bytea: {0}")]
|
||||
InvalidBytea(#[from] ByteaHexParseError),
|
||||
|
||||
#[error("invalid uuid: {0}")]
|
||||
InvalidUuid(#[from] uuid::Error),
|
||||
|
||||
#[error("invalid json: {0}")]
|
||||
InvalidJson(#[from] serde_json::Error),
|
||||
|
||||
#[error("invalid timestamp: {0} ")]
|
||||
InvalidTimestamp(#[from] chrono::ParseError),
|
||||
|
||||
#[error("invalid array: {0}")]
|
||||
InvalidArray(#[from] ArrayParseError),
|
||||
|
||||
#[error("{0}")]
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
fn convert_into<T>(number: T) -> Number
|
||||
where
|
||||
T: Sized,
|
||||
serde_json::Number: From<T>,
|
||||
{
|
||||
serde_json::Number::from(number)
|
||||
}
|
||||
|
||||
pub struct Converter;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ArrayParseError {
|
||||
#[error("input too short")]
|
||||
InputTooShort,
|
||||
|
||||
#[error("missing braces")]
|
||||
MissingBraces,
|
||||
}
|
||||
|
||||
fn f64_to_json_number(raw_val: f64) -> Result<Value, ConverterError> {
|
||||
let temp = serde_json::Number::from_f64(raw_val.into())
|
||||
.ok_or(ConverterError::Custom("invalid json-float".to_string()))?;
|
||||
Ok(Value::Number(temp))
|
||||
}
|
||||
|
||||
impl Converter {
|
||||
pub fn try_from_str(typ: Option<Type>, str: &str) -> Result<Value, ConverterError> {
|
||||
let value = match typ.unwrap_or(Type::TEXT) {
|
||||
Type::BOOL => Value::Bool(parse_bool(str)?),
|
||||
Type::BOOL_ARRAY => {
|
||||
Converter::parse_array(str, |str| Ok(Value::Bool(parse_bool(str)?)))?
|
||||
}
|
||||
Type::CHAR | Type::BPCHAR | Type::VARCHAR | Type::NAME | Type::TEXT => {
|
||||
Value::String(str.to_string())
|
||||
}
|
||||
Type::CHAR_ARRAY
|
||||
| Type::BPCHAR_ARRAY
|
||||
| Type::VARCHAR_ARRAY
|
||||
| Type::NAME_ARRAY
|
||||
| Type::TEXT_ARRAY => {
|
||||
Converter::parse_array(str, |str| Ok(Value::String(str.to_string())))?
|
||||
}
|
||||
Type::INT2 => Value::Number(convert_into(str.parse::<i16>()?)),
|
||||
Type::INT2_ARRAY => Converter::parse_array(str, |str| {
|
||||
Ok(Value::Number(convert_into(str.parse::<i16>()?)))
|
||||
})?,
|
||||
Type::INT4 => Value::Number(convert_into(str.parse::<i32>()?)),
|
||||
Type::INT4_ARRAY => Converter::parse_array(str, |str| {
|
||||
Ok(Value::Number(convert_into(str.parse::<i32>()?)))
|
||||
})?,
|
||||
Type::INT8 => Value::Number(convert_into(str.parse::<i64>()?)),
|
||||
Type::INT8_ARRAY => Converter::parse_array(str, |str| {
|
||||
Ok(Value::Number(convert_into(str.parse::<i64>()?)))
|
||||
})?,
|
||||
Type::FLOAT4 => f64_to_json_number(str.parse::<f64>()?)?,
|
||||
Type::FLOAT4_ARRAY => {
|
||||
Converter::parse_array(str, |str| f64_to_json_number(str.parse::<f64>()?))?
|
||||
}
|
||||
Type::FLOAT8 => f64_to_json_number(str.parse::<f64>()?)?,
|
||||
Type::FLOAT8_ARRAY => {
|
||||
Converter::parse_array(str, |str| f64_to_json_number(str.parse::<f64>()?))?
|
||||
}
|
||||
Type::NUMERIC => serde_json::json!(Decimal::from_str(str)?),
|
||||
Type::NUMERIC_ARRAY => {
|
||||
Converter::parse_array(str, |str| Ok(serde_json::json!(Decimal::from_str(str)?)))?
|
||||
}
|
||||
Type::BYTEA => to_value(from_bytea_hex(str)?).unwrap(),
|
||||
Type::BYTEA_ARRAY => {
|
||||
Converter::parse_array(str, |str| Ok(to_value(from_bytea_hex(str)?).unwrap()))?
|
||||
}
|
||||
Type::DATE => {
|
||||
let date = NaiveDate::parse_from_str(str, "%Y-%m-%d")?;
|
||||
Value::String(date.to_string())
|
||||
}
|
||||
Type::DATE_ARRAY => Converter::parse_array(str, |str| {
|
||||
let date = NaiveDate::parse_from_str(str, "%Y-%m-%d")?;
|
||||
Ok(Value::String(date.to_string()))
|
||||
})?,
|
||||
Type::TIME => {
|
||||
let time = NaiveTime::parse_from_str(str, "%H:%M:%S%.f")?;
|
||||
Value::String(time.to_string())
|
||||
}
|
||||
Type::TIME_ARRAY => Converter::parse_array(str, |str| {
|
||||
let time = NaiveTime::parse_from_str(str, "%H:%M:%S%.f")?;
|
||||
Ok(Value::String(time.to_string()))
|
||||
})?,
|
||||
Type::TIMESTAMP => {
|
||||
let timestamp = NaiveDateTime::parse_from_str(str, "%Y-%m-%d %H:%M:%S%.f")?;
|
||||
Value::String(timestamp.to_string())
|
||||
}
|
||||
Type::TIMESTAMP_ARRAY => Converter::parse_array(str, |str| {
|
||||
let timestamp = NaiveDateTime::parse_from_str(str, "%Y-%m-%d %H:%M:%S%.f")?;
|
||||
Ok(Value::String(timestamp.to_string()))
|
||||
})?,
|
||||
Type::TIMESTAMPTZ => {
|
||||
let val =
|
||||
match DateTime::<FixedOffset>::parse_from_str(str, "%Y-%m-%d %H:%M:%S%.f%#z") {
|
||||
Ok(val) => val,
|
||||
Err(_) => {
|
||||
DateTime::<FixedOffset>::parse_from_str(str, "%Y-%m-%d %H:%M:%S%.f%:z")?
|
||||
}
|
||||
};
|
||||
let utc: DateTime<Utc> = val.into();
|
||||
Value::String(utc.to_string())
|
||||
}
|
||||
Type::TIMESTAMPTZ_ARRAY => {
|
||||
match Converter::parse_array(str, |str| {
|
||||
let utc: DateTime<Utc> =
|
||||
DateTime::<FixedOffset>::parse_from_str(str, "%Y-%m-%d %H:%M:%S%.f%#z")?
|
||||
.into();
|
||||
Ok(Value::String(utc.to_string()))
|
||||
}) {
|
||||
Ok(val) => val,
|
||||
Err(_) => Converter::parse_array(str, |str| {
|
||||
let utc: DateTime<Utc> = DateTime::<FixedOffset>::parse_from_str(
|
||||
str,
|
||||
"%Y-%m-%d %H:%M:%S%.f%#z",
|
||||
)?
|
||||
.into();
|
||||
Ok(Value::String(utc.to_string()))
|
||||
})?,
|
||||
}
|
||||
}
|
||||
Type::UUID => Value::String(Uuid::parse_str(str)?.to_string()),
|
||||
Type::UUID_ARRAY => Converter::parse_array(str, |str| {
|
||||
Ok(Value::String(Uuid::parse_str(str)?.to_string()))
|
||||
})?,
|
||||
Type::JSON | Type::JSONB => serde_json::from_str::<serde_json::Value>(str)?,
|
||||
Type::JSON_ARRAY | Type::JSONB_ARRAY => Converter::parse_array(str, |str| {
|
||||
Ok(serde_json::from_str::<serde_json::Value>(str)?)
|
||||
})?,
|
||||
Type::OID => Value::Number(convert_into(str.parse::<u32>()?)),
|
||||
Type::OID_ARRAY => Converter::parse_array(str, |str| {
|
||||
Ok(Value::Number(convert_into(str.parse::<u32>()?)))
|
||||
})?,
|
||||
_ => Value::String(str.to_string()),
|
||||
};
|
||||
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn parse_array<P>(str: &str, mut parse: P) -> Result<Value, ConverterError>
|
||||
where
|
||||
P: FnMut(&str) -> Result<Value, ConverterError>,
|
||||
{
|
||||
if str.len() < 2 {
|
||||
return Err(ArrayParseError::InputTooShort.into());
|
||||
}
|
||||
|
||||
if !str.starts_with('{') || !str.ends_with('}') {
|
||||
return Err(ArrayParseError::MissingBraces.into());
|
||||
}
|
||||
|
||||
let mut res = vec![];
|
||||
let str = &str[1..(str.len() - 1)];
|
||||
let mut val_str = String::with_capacity(10);
|
||||
let mut in_quotes = false;
|
||||
let mut in_escape = false;
|
||||
let mut chars = str.chars();
|
||||
let mut done = str.is_empty();
|
||||
|
||||
while !done {
|
||||
loop {
|
||||
match chars.next() {
|
||||
Some(c) => match c {
|
||||
c if in_escape => {
|
||||
val_str.push(c);
|
||||
in_escape = false;
|
||||
}
|
||||
'"' => in_quotes = !in_quotes,
|
||||
'\\' => in_escape = true,
|
||||
',' if !in_quotes => {
|
||||
break;
|
||||
}
|
||||
c => {
|
||||
val_str.push(c);
|
||||
}
|
||||
},
|
||||
None => {
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let val = if val_str.to_lowercase() == "null" {
|
||||
Value::Null
|
||||
} else {
|
||||
parse(&val_str)?
|
||||
};
|
||||
res.push(val);
|
||||
val_str.clear();
|
||||
}
|
||||
let arr = Value::Array(res);
|
||||
Ok(arr)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
use std::num::ParseIntError;
|
||||
|
||||
|
||||
/**
|
||||
* This implementation is inspired by Postgres replication functionality
|
||||
* from https://github.com/supabase/pg_replicate
|
||||
*
|
||||
* Original implementation:
|
||||
* - https://github.dev/supabase/pg_replicate/blob/main/pg_replicate/src/conversions/hex.rs
|
||||
*
|
||||
*/
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ByteaHexParseError {
|
||||
#[error("missing prefix '\\x'")]
|
||||
InvalidPrefix,
|
||||
|
||||
#[error("invalid byte")]
|
||||
OddNumerOfDigits,
|
||||
|
||||
#[error("parse int result: {0}")]
|
||||
ParseInt(#[from] ParseIntError),
|
||||
}
|
||||
|
||||
pub fn from_bytea_hex(s: &str) -> Result<Vec<u8>, ByteaHexParseError> {
|
||||
if s.len() < 2 || &s[..2] != "\\x" {
|
||||
return Err(ByteaHexParseError::InvalidPrefix);
|
||||
}
|
||||
|
||||
let mut result = Vec::with_capacity((s.len() - 2) / 2);
|
||||
let s = &s[2..];
|
||||
|
||||
if s.len() % 2 != 0 {
|
||||
return Err(ByteaHexParseError::OddNumerOfDigits);
|
||||
}
|
||||
|
||||
for i in (0..s.len()).step_by(2) {
|
||||
let val = u8::from_str_radix(&s[i..i + 2], 16)?;
|
||||
result.push(val);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rust_postgres::types::Type;
|
||||
|
||||
use super::handler::Language;
|
||||
|
||||
fn postgres_to_typescript_type(postgres_type: Option<Type>) -> String {
|
||||
let data_type = match postgres_type {
|
||||
Some(postgres_type) => match postgres_type {
|
||||
Type::BOOL => "boolean",
|
||||
Type::BOOL_ARRAY => "Array<boolean>",
|
||||
Type::CHAR | Type::BPCHAR | Type::VARCHAR | Type::NAME | Type::TEXT => "string",
|
||||
Type::CHAR_ARRAY
|
||||
| Type::BPCHAR_ARRAY
|
||||
| Type::VARCHAR_ARRAY
|
||||
| Type::NAME_ARRAY
|
||||
| Type::TEXT_ARRAY => "Array<string>",
|
||||
Type::INT2 | Type::INT4 | Type::INT8 | Type::NUMERIC => "number",
|
||||
Type::INT2_ARRAY | Type::INT4_ARRAY | Type::INT8_ARRAY => "Array<number>",
|
||||
Type::FLOAT4 | Type::FLOAT8 => "number",
|
||||
Type::FLOAT8_ARRAY | Type::FLOAT4_ARRAY => "Array<number>",
|
||||
Type::NUMERIC_ARRAY => "Array<number>",
|
||||
Type::BYTEA => "Array<number>",
|
||||
Type::BYTEA_ARRAY => "Array<Array<number>>",
|
||||
Type::DATE => "string",
|
||||
Type::DATE_ARRAY => "Array<string>",
|
||||
Type::TIME => "string",
|
||||
Type::TIME_ARRAY => "Array<string>",
|
||||
Type::TIMESTAMPTZ | Type::TIMESTAMP => "Date",
|
||||
Type::TIMESTAMPTZ_ARRAY | Type::TIMESTAMP_ARRAY => "Array<Date>",
|
||||
Type::UUID => "string",
|
||||
Type::UUID_ARRAY => "Array<string>",
|
||||
Type::JSON | Type::JSONB | Type::JSON_ARRAY | Type::JSONB_ARRAY => "unknown",
|
||||
Type::OID => "number",
|
||||
Type::OID_ARRAY => "Array<number>",
|
||||
_ => "string",
|
||||
},
|
||||
None => "string",
|
||||
};
|
||||
|
||||
data_type.to_string()
|
||||
}
|
||||
|
||||
fn into_body_struct(language: Language, mapped_info: Vec<MappingInfo>) -> String {
|
||||
let mut block = String::new();
|
||||
match language {
|
||||
Language::Typescript => {
|
||||
block.push_str("{\r\n");
|
||||
for field in mapped_info {
|
||||
let typescript_type = postgres_to_typescript_type(field.data_type);
|
||||
let mut key = field.column_name;
|
||||
if field.is_nullable {
|
||||
key.push('?');
|
||||
}
|
||||
let full_field = format!("\t\t{}: {},\r\n", key, typescript_type);
|
||||
block.push_str(&full_field);
|
||||
}
|
||||
block.push_str("\t}");
|
||||
}
|
||||
}
|
||||
block
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MappingInfo {
|
||||
data_type: Option<Type>,
|
||||
is_nullable: bool,
|
||||
column_name: String,
|
||||
}
|
||||
|
||||
impl MappingInfo {
|
||||
pub fn new(column_name: String, data_type: Option<Type>, is_nullable: bool) -> Self {
|
||||
Self { column_name, data_type, is_nullable }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Mapper {
|
||||
to_template: HashMap<String, HashMap<String, Vec<MappingInfo>>>,
|
||||
language: Language,
|
||||
}
|
||||
|
||||
impl Mapper {
|
||||
pub fn new(
|
||||
to_template: HashMap<String, HashMap<String, Vec<MappingInfo>>>,
|
||||
language: Language,
|
||||
) -> Self {
|
||||
Self { to_template, language }
|
||||
}
|
||||
|
||||
fn into_typescript_template(self) -> Vec<String> {
|
||||
let mut struct_definitions = Vec::new();
|
||||
for (_, mapping_info) in self.to_template {
|
||||
let last_elem = mapping_info.len() - 1;
|
||||
for (i, (_, mapped_info)) in mapping_info.into_iter().enumerate() {
|
||||
let mut struct_body = into_body_struct(Language::Typescript, mapped_info);
|
||||
let struct_body = if i != last_elem {
|
||||
struct_body.push_str("\r\n");
|
||||
struct_body
|
||||
} else {
|
||||
struct_body
|
||||
};
|
||||
struct_definitions.push(struct_body);
|
||||
}
|
||||
}
|
||||
struct_definitions
|
||||
}
|
||||
|
||||
pub fn get_template(self) -> String {
|
||||
let struct_definition = match self.language {
|
||||
Language::Typescript => self.into_typescript_template(),
|
||||
};
|
||||
|
||||
let struct_definition = if struct_definition.is_empty() {
|
||||
"any".to_string()
|
||||
} else {
|
||||
struct_definition.join("\t| ")
|
||||
};
|
||||
|
||||
format!(
|
||||
r#"
|
||||
|
||||
|
||||
export async function main(
|
||||
transaction_type: "insert" | "update" | "delete",
|
||||
schema_name: string,
|
||||
table_name: string,
|
||||
row: {}
|
||||
) {{
|
||||
}}
|
||||
"#,
|
||||
struct_definition
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
use crate::{
|
||||
db::{ApiAuthed, DB},
|
||||
jobs::{run_flow_by_path_inner, run_script_by_path_inner, RunJobQuery},
|
||||
resources::get_resource_value_interpolated_internal,
|
||||
users::fetch_api_authed,
|
||||
};
|
||||
use serde_json::value::RawValue;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::{
|
||||
routing::{delete, get, post},
|
||||
Router,
|
||||
};
|
||||
use handler::{
|
||||
alter_publication, create_postgres_trigger, create_publication, create_slot,
|
||||
create_template_script, delete_postgres_trigger, delete_publication, drop_slot_name,
|
||||
exists_postgres_trigger, get_postgres_trigger, get_publication_info, get_template_script,
|
||||
is_database_in_logical_level, list_database_publication, list_postgres_triggers,
|
||||
list_slot_name, set_enabled, update_postgres_trigger, Database, PostgresTrigger,
|
||||
};
|
||||
use windmill_common::{db::UserDB, error::Error, utils::StripPath};
|
||||
use windmill_queue::PushArgsOwned;
|
||||
|
||||
mod bool;
|
||||
mod converter;
|
||||
mod handler;
|
||||
mod hex;
|
||||
mod mapper;
|
||||
mod relation;
|
||||
mod replication_message;
|
||||
mod trigger;
|
||||
|
||||
pub use trigger::start_database;
|
||||
|
||||
pub async fn get_database_resource(
|
||||
authed: ApiAuthed,
|
||||
user_db: Option<UserDB>,
|
||||
db: &DB,
|
||||
database_resource_path: &str,
|
||||
w_id: &str,
|
||||
) -> Result<Database, Error> {
|
||||
let resource = get_resource_value_interpolated_internal(
|
||||
&authed,
|
||||
user_db,
|
||||
&db,
|
||||
&w_id,
|
||||
&database_resource_path,
|
||||
None,
|
||||
"",
|
||||
)
|
||||
.await
|
||||
.map_err(|_| Error::NotFound("Database resource do not exist".to_string()))?;
|
||||
|
||||
let resource = match resource {
|
||||
Some(resource) => serde_json::from_value::<Database>(resource).map_err(Error::SerdeJson)?,
|
||||
None => {
|
||||
return {
|
||||
Err(Error::NotFound(
|
||||
"Database resource do not exist".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(resource)
|
||||
}
|
||||
|
||||
fn publication_service() -> Router {
|
||||
Router::new()
|
||||
.route("/get/:publication_name/*path", get(get_publication_info))
|
||||
.route("/create/:publication_name/*path", post(create_publication))
|
||||
.route("/update/:publication_name/*path", post(alter_publication))
|
||||
.route(
|
||||
"/delete/:publication_name/*path",
|
||||
delete(delete_publication),
|
||||
)
|
||||
.route("/list/*path", get(list_database_publication))
|
||||
}
|
||||
|
||||
fn slot_service() -> Router {
|
||||
Router::new()
|
||||
.route("/list/*path", get(list_slot_name))
|
||||
.route("/create/*path", post(create_slot))
|
||||
.route("/delete/*path", delete(drop_slot_name))
|
||||
}
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/create", post(create_postgres_trigger))
|
||||
.route("/list", get(list_postgres_triggers))
|
||||
.route("/get/*path", get(get_postgres_trigger))
|
||||
.route("/update/*path", post(update_postgres_trigger))
|
||||
.route("/delete/*path", delete(delete_postgres_trigger))
|
||||
.route("/exists/*path", get(exists_postgres_trigger))
|
||||
.route("/setenabled/*path", post(set_enabled))
|
||||
.route("/get_template_script/:id", get(get_template_script))
|
||||
.route("/create_template_script", post(create_template_script))
|
||||
.route(
|
||||
"/is_valid_postgres_configuration/*path",
|
||||
get(is_database_in_logical_level),
|
||||
)
|
||||
.nest("/publication", publication_service())
|
||||
.nest("/slot", slot_service())
|
||||
}
|
||||
|
||||
async fn run_job(
|
||||
args: Option<HashMap<String, Box<RawValue>>>,
|
||||
extra: Option<HashMap<String, Box<RawValue>>>,
|
||||
db: &DB,
|
||||
trigger: &PostgresTrigger,
|
||||
) -> anyhow::Result<()> {
|
||||
let args = PushArgsOwned { args: args.unwrap_or_default(), extra };
|
||||
let label_prefix = Some(format!("db-{}-", trigger.path));
|
||||
|
||||
let authed = fetch_api_authed(
|
||||
trigger.edited_by.clone(),
|
||||
trigger.email.clone(),
|
||||
&trigger.workspace_id,
|
||||
db,
|
||||
Some("anonymous".to_string()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let user_db = UserDB::new(db.clone());
|
||||
|
||||
let run_query = RunJobQuery::default();
|
||||
|
||||
if trigger.is_flow {
|
||||
run_flow_by_path_inner(
|
||||
authed,
|
||||
db.clone(),
|
||||
user_db,
|
||||
trigger.workspace_id.clone(),
|
||||
StripPath(trigger.script_path.to_owned()),
|
||||
run_query,
|
||||
args,
|
||||
label_prefix,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
run_script_by_path_inner(
|
||||
authed,
|
||||
db.clone(),
|
||||
user_db,
|
||||
trigger.workspace_id.clone(),
|
||||
StripPath(trigger.script_path.to_owned()),
|
||||
run_query,
|
||||
args,
|
||||
label_prefix,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use core::str;
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
use std::{collections::HashMap, str::Utf8Error};
|
||||
|
||||
use super::{
|
||||
converter::{Converter, ConverterError},
|
||||
replication_message::{Columns, RelationBody, TupleData},
|
||||
};
|
||||
use rust_postgres::types::Oid;
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RelationConversionError {
|
||||
#[error("Could not find matching table")]
|
||||
FailToFindMatchingTable,
|
||||
|
||||
#[error("Binary data not supported")]
|
||||
BinaryFormatNotSupported,
|
||||
|
||||
#[error("decode error: {0}")]
|
||||
FromBytes(#[from] ConverterError),
|
||||
|
||||
#[error("invalid string value")]
|
||||
InvalidStr(#[from] Utf8Error),
|
||||
}
|
||||
|
||||
pub struct RelationConverter(HashMap<Oid, RelationBody>);
|
||||
|
||||
impl RelationConverter {
|
||||
pub fn new() -> Self {
|
||||
Self(HashMap::new())
|
||||
}
|
||||
|
||||
pub fn add_relation(&mut self, relation: RelationBody) {
|
||||
self.0.insert(relation.o_id, relation);
|
||||
}
|
||||
|
||||
pub fn get_columns(&self, o_id: Oid) -> Result<&Columns, RelationConversionError> {
|
||||
self.0
|
||||
.get(&o_id)
|
||||
.map(|relation_body| &relation_body.columns)
|
||||
.ok_or(RelationConversionError::FailToFindMatchingTable)
|
||||
}
|
||||
|
||||
pub fn get_relation(&self, o_id: Oid) -> Result<&RelationBody, RelationConversionError> {
|
||||
self.0
|
||||
.get(&o_id)
|
||||
.ok_or(RelationConversionError::FailToFindMatchingTable)
|
||||
}
|
||||
|
||||
pub fn body_to_json(
|
||||
&self,
|
||||
to_decode: (Oid, Vec<TupleData>),
|
||||
) -> Result<Map<String, Value>, RelationConversionError> {
|
||||
let (o_id, tuple_data) = to_decode;
|
||||
let mut object: Map<String, Value> = Map::new();
|
||||
let columns = self.get_columns(o_id)?;
|
||||
|
||||
for (i, column) in columns.iter().enumerate() {
|
||||
let value = match &tuple_data[i] {
|
||||
TupleData::Null | TupleData::UnchangedToast => Value::Null,
|
||||
TupleData::Binary(_) => {
|
||||
return Err(RelationConversionError::BinaryFormatNotSupported)
|
||||
}
|
||||
TupleData::Text(bytes) => {
|
||||
let str = str::from_utf8(&bytes[..])?;
|
||||
Converter::try_from_str(column.type_o_id.clone(), str)?
|
||||
}
|
||||
};
|
||||
|
||||
object.insert(column.name.clone(), value);
|
||||
}
|
||||
Ok(object)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
#![allow(unused)]
|
||||
|
||||
use core::str;
|
||||
use std::{
|
||||
cmp,
|
||||
io::{self, Cursor, Read},
|
||||
str::Utf8Error,
|
||||
};
|
||||
|
||||
use byteorder::{BigEndian, ReadBytesExt};
|
||||
use bytes::Bytes;
|
||||
use rust_postgres::types::{Oid, Type};
|
||||
use thiserror::Error;
|
||||
|
||||
use super::trigger::LogicalReplicationSettings;
|
||||
const PRIMARY_KEEPALIVE_BYTE: u8 = b'k';
|
||||
const X_LOG_DATA_BYTE: u8 = b'w';
|
||||
|
||||
/**
|
||||
* This implementation is inspired by Postgres replication functionality
|
||||
* from https://github.com/supabase/pg_replicate
|
||||
*
|
||||
* Original implementation:
|
||||
* - https://github.com/supabase/pg_replicate/blob/main/pg_replicate/src/conversions/cdc_event.rs
|
||||
*
|
||||
*/
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PrimaryKeepAliveBody {
|
||||
pub wal_end: u64,
|
||||
pub timestamp: i64,
|
||||
pub reply: bool,
|
||||
}
|
||||
|
||||
impl PrimaryKeepAliveBody {
|
||||
pub fn new(wal_end: u64, timestamp: i64, reply: bool) -> PrimaryKeepAliveBody {
|
||||
PrimaryKeepAliveBody { wal_end, timestamp, reply }
|
||||
}
|
||||
}
|
||||
|
||||
const BEGIN_BYTE: u8 = b'B';
|
||||
const COMMIT_BYTE: u8 = b'C';
|
||||
const ORIGIN_BYTE: u8 = b'O';
|
||||
const RELATION_BYTE: u8 = b'R';
|
||||
const TYPE_BYTE: u8 = b'Y';
|
||||
const INSERT_BYTE: u8 = b'I';
|
||||
const UPDATE_BYTE: u8 = b'U';
|
||||
const DELETE_BYTE: u8 = b'D';
|
||||
const TUPLE_NEW_BYTE: u8 = b'N';
|
||||
const TUPLE_KEY_BYTE: u8 = b'K';
|
||||
const TUPLE_OLD_BYTE: u8 = b'O';
|
||||
const TUPLE_DATA_NULL_BYTE: u8 = b'n';
|
||||
const TUPLE_DATA_TOAST_BYTE: u8 = b'u';
|
||||
const TUPLE_DATA_TEXT_BYTE: u8 = b't';
|
||||
const TUPLE_DATA_BINARY_BYTE: u8 = b'b';
|
||||
|
||||
const REPLICA_IDENTITY_DEFAULT_BYTE: i8 = 0x64;
|
||||
const REPLICA_IDENTITY_NOTHING_BYTE: i8 = 0x6E;
|
||||
const REPLICA_IDENTITY_FULL_BYTE: i8 = 0x66;
|
||||
const REPLICA_IDENTITY_INDEX_BYTE: i8 = 0x69;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ReplicaIdentity {
|
||||
Default,
|
||||
Nothing,
|
||||
Full,
|
||||
Index,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Column {
|
||||
pub flags: i8,
|
||||
pub name: String,
|
||||
pub type_o_id: Option<Type>,
|
||||
pub type_modifier: i32,
|
||||
}
|
||||
|
||||
impl Column {
|
||||
pub fn new(flags: i8, name: String, type_o_id: Option<Type>, type_modifier: i32) -> Self {
|
||||
Self { flags, name, type_o_id, type_modifier }
|
||||
}
|
||||
}
|
||||
|
||||
pub type Columns = Vec<Column>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RelationBody {
|
||||
pub transaction_id: Option<i32>,
|
||||
pub o_id: Oid,
|
||||
pub namespace: String,
|
||||
pub name: String,
|
||||
pub replica_identity: ReplicaIdentity,
|
||||
pub columns: Columns,
|
||||
}
|
||||
|
||||
impl RelationBody {
|
||||
pub fn new(
|
||||
transaction_id: Option<i32>,
|
||||
o_id: Oid,
|
||||
namespace: String,
|
||||
name: String,
|
||||
replica_identity: ReplicaIdentity,
|
||||
columns: Columns,
|
||||
) -> Self {
|
||||
Self { transaction_id, o_id, namespace, name, replica_identity, columns }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct InsertBody {
|
||||
pub transaction_id: Option<i32>,
|
||||
pub o_id: Oid,
|
||||
pub tuple: Vec<TupleData>,
|
||||
}
|
||||
|
||||
impl InsertBody {
|
||||
pub fn new(transaction_id: Option<i32>, o_id: Oid, tuple: Vec<TupleData>) -> Self {
|
||||
Self { transaction_id, o_id, tuple }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UpdateBody {
|
||||
transaction_id: Option<i32>,
|
||||
pub o_id: Oid,
|
||||
pub old_tuple: Option<Vec<TupleData>>,
|
||||
pub key_tuple: Option<Vec<TupleData>>,
|
||||
pub new_tuple: Vec<TupleData>,
|
||||
}
|
||||
|
||||
impl UpdateBody {
|
||||
pub fn new(
|
||||
transaction_id: Option<i32>,
|
||||
o_id: Oid,
|
||||
old_tuple: Option<Vec<TupleData>>,
|
||||
key_tuple: Option<Vec<TupleData>>,
|
||||
new_tuple: Vec<TupleData>,
|
||||
) -> Self {
|
||||
Self { transaction_id, o_id, old_tuple, key_tuple, new_tuple }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DeleteBody {
|
||||
transaction_id: Option<i32>,
|
||||
pub o_id: Oid,
|
||||
pub old_tuple: Option<Vec<TupleData>>,
|
||||
pub key_tuple: Option<Vec<TupleData>>,
|
||||
}
|
||||
|
||||
impl DeleteBody {
|
||||
pub fn new(
|
||||
transaction_id: Option<i32>,
|
||||
o_id: Oid,
|
||||
old_tuple: Option<Vec<TupleData>>,
|
||||
key_tuple: Option<Vec<TupleData>>,
|
||||
) -> Self {
|
||||
Self { transaction_id, o_id, old_tuple, key_tuple }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum TupleData {
|
||||
Null,
|
||||
UnchangedToast,
|
||||
Text(Bytes),
|
||||
Binary(Bytes),
|
||||
}
|
||||
|
||||
impl TupleData {
|
||||
fn parse(buf: &mut Buffer) -> Result<Vec<TupleData>, ConversionError> {
|
||||
let number_of_columns = buf.read_i16::<BigEndian>()?;
|
||||
let mut tuples = Vec::with_capacity(number_of_columns as usize);
|
||||
for _ in 0..number_of_columns {
|
||||
let byte = buf.read_u8()?;
|
||||
let tuple_data = match byte {
|
||||
TUPLE_DATA_NULL_BYTE => TupleData::Null,
|
||||
TUPLE_DATA_TOAST_BYTE => TupleData::UnchangedToast,
|
||||
TUPLE_DATA_TEXT_BYTE => {
|
||||
let len = buf.read_i32::<BigEndian>()?;
|
||||
let mut data = vec![0; len as usize];
|
||||
buf.read_exact(&mut data)?;
|
||||
TupleData::Text(data.into())
|
||||
}
|
||||
TUPLE_DATA_BINARY_BYTE => {
|
||||
let len = buf.read_i32::<BigEndian>()?;
|
||||
let mut data = vec![0; len as usize];
|
||||
buf.read_exact(&mut data)?;
|
||||
TupleData::Binary(data.into())
|
||||
}
|
||||
byte => {
|
||||
return Err(ConversionError::Io(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("unknown replication message byte `{}`", byte),
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
tuples.push(tuple_data);
|
||||
}
|
||||
|
||||
Ok(tuples)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum TransactionBody {
|
||||
Insert(InsertBody),
|
||||
Update(UpdateBody),
|
||||
Delete(DeleteBody),
|
||||
}
|
||||
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug)]
|
||||
pub enum LogicalReplicationMessage {
|
||||
Begin,
|
||||
Commit,
|
||||
Relation(RelationBody),
|
||||
Type,
|
||||
Insert(InsertBody),
|
||||
Update(UpdateBody),
|
||||
Delete(DeleteBody),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct XLogDataBody {
|
||||
pub wal_start: u64,
|
||||
pub wal_end: u64,
|
||||
pub timestamp: i64,
|
||||
pub data: Bytes,
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ConversionError {
|
||||
#[error("Error: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
#[error("Utf8Error conversion: {0}")]
|
||||
Utf8(#[from] Utf8Error),
|
||||
}
|
||||
|
||||
struct Buffer {
|
||||
bytes: Bytes,
|
||||
idx: usize,
|
||||
}
|
||||
|
||||
impl Buffer {
|
||||
pub fn new(bytes: Bytes, idx: usize) -> Buffer {
|
||||
Buffer { bytes, idx }
|
||||
}
|
||||
|
||||
fn slice(&self) -> &[u8] {
|
||||
&self.bytes[self.idx..]
|
||||
}
|
||||
|
||||
fn read_cstr(&mut self) -> Result<String, ConversionError> {
|
||||
match self.slice().iter().position(|&x| x == 0) {
|
||||
Some(pos) => {
|
||||
let start = self.idx;
|
||||
let end = start + pos;
|
||||
let cstr = str::from_utf8(&self.bytes[start..end])?.to_owned();
|
||||
self.idx = end + 1;
|
||||
Ok(cstr)
|
||||
}
|
||||
None => Err(ConversionError::Io(io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
"unexpected EOF",
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for Buffer {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let len = {
|
||||
let slice = self.slice();
|
||||
let len = cmp::min(slice.len(), buf.len());
|
||||
buf[..len].copy_from_slice(&slice[..len]);
|
||||
len
|
||||
};
|
||||
self.idx += len;
|
||||
Ok(len)
|
||||
}
|
||||
}
|
||||
|
||||
impl XLogDataBody {
|
||||
pub fn new(wal_start: u64, wal_end: u64, timestamp: i64, data: Bytes) -> XLogDataBody {
|
||||
XLogDataBody { wal_start, wal_end, timestamp, data }
|
||||
}
|
||||
|
||||
pub fn parse(
|
||||
self,
|
||||
logical_replication_settings: &LogicalReplicationSettings,
|
||||
) -> Result<LogicalReplicationMessage, ConversionError> {
|
||||
let mut buf = Buffer::new(self.data.clone(), 0);
|
||||
let byte = buf.read_u8()?;
|
||||
|
||||
let logical_replication_message = match byte {
|
||||
BEGIN_BYTE => {
|
||||
buf.read_i64::<BigEndian>()?;
|
||||
buf.read_i64::<BigEndian>()?;
|
||||
buf.read_i32::<BigEndian>()?;
|
||||
|
||||
LogicalReplicationMessage::Begin
|
||||
}
|
||||
COMMIT_BYTE => {
|
||||
buf.read_i8()?;
|
||||
buf.read_u64::<BigEndian>()?;
|
||||
buf.read_u64::<BigEndian>()?;
|
||||
buf.read_i64::<BigEndian>()?;
|
||||
LogicalReplicationMessage::Commit
|
||||
}
|
||||
RELATION_BYTE => {
|
||||
let transaction_id = match logical_replication_settings.streaming {
|
||||
true => Some(buf.read_i32::<BigEndian>()?),
|
||||
false => None,
|
||||
};
|
||||
|
||||
let o_id = buf.read_u32::<BigEndian>()?;
|
||||
let namespace = buf.read_cstr()?;
|
||||
let name = buf.read_cstr()?;
|
||||
let replica_identity = match buf.read_i8()? {
|
||||
REPLICA_IDENTITY_DEFAULT_BYTE => ReplicaIdentity::Default,
|
||||
REPLICA_IDENTITY_NOTHING_BYTE => ReplicaIdentity::Nothing,
|
||||
REPLICA_IDENTITY_FULL_BYTE => ReplicaIdentity::Full,
|
||||
REPLICA_IDENTITY_INDEX_BYTE => ReplicaIdentity::Index,
|
||||
byte => {
|
||||
return Err(ConversionError::Io(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("unknown replica identity byte `{}`", byte),
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let num_of_column = buf.read_i16::<BigEndian>()?;
|
||||
|
||||
let mut columns = Vec::with_capacity(num_of_column as usize);
|
||||
for _ in 0..num_of_column {
|
||||
let flags = buf.read_i8()?;
|
||||
let name = buf.read_cstr()?;
|
||||
let o_id = buf.read_u32::<BigEndian>()?;
|
||||
let type_modifier = buf.read_i32::<BigEndian>()?;
|
||||
let type_o_id = Type::from_oid(o_id);
|
||||
let column = Column::new(flags, name, type_o_id, type_modifier);
|
||||
|
||||
columns.push(column);
|
||||
}
|
||||
|
||||
LogicalReplicationMessage::Relation(RelationBody::new(
|
||||
transaction_id,
|
||||
o_id,
|
||||
namespace,
|
||||
name,
|
||||
replica_identity,
|
||||
columns,
|
||||
))
|
||||
}
|
||||
TYPE_BYTE => {
|
||||
buf.read_u32::<BigEndian>()?;
|
||||
buf.read_cstr()?;
|
||||
buf.read_cstr()?;
|
||||
|
||||
LogicalReplicationMessage::Type
|
||||
}
|
||||
INSERT_BYTE => {
|
||||
let transaction_id = match logical_replication_settings.streaming {
|
||||
true => Some(buf.read_i32::<BigEndian>()?),
|
||||
false => None,
|
||||
};
|
||||
let o_id = buf.read_u32::<BigEndian>()?;
|
||||
let byte = buf.read_u8()?;
|
||||
|
||||
let tuple = match byte {
|
||||
TUPLE_NEW_BYTE => TupleData::parse(&mut buf)?,
|
||||
byte => {
|
||||
return Err(ConversionError::Io(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("unexpected tuple byte `{}`", byte),
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
LogicalReplicationMessage::Insert(InsertBody::new(transaction_id, o_id, tuple))
|
||||
}
|
||||
UPDATE_BYTE => {
|
||||
let transaction_id = match logical_replication_settings.streaming {
|
||||
true => Some(buf.read_i32::<BigEndian>()?),
|
||||
false => None,
|
||||
};
|
||||
let o_id = buf.read_u32::<BigEndian>()?;
|
||||
let byte = buf.read_u8()?;
|
||||
let mut key_tuple = None;
|
||||
let mut old_tuple = None;
|
||||
|
||||
let new_tuple = match byte {
|
||||
TUPLE_NEW_BYTE => TupleData::parse(&mut buf)?,
|
||||
TUPLE_OLD_BYTE | TUPLE_KEY_BYTE => {
|
||||
if byte == TUPLE_OLD_BYTE {
|
||||
old_tuple = Some(TupleData::parse(&mut buf)?);
|
||||
} else {
|
||||
key_tuple = Some(TupleData::parse(&mut buf)?);
|
||||
}
|
||||
match buf.read_u8()? {
|
||||
TUPLE_NEW_BYTE => TupleData::parse(&mut buf)?,
|
||||
byte => {
|
||||
return Err(ConversionError::Io(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("unexpected tuple byte `{}`", byte),
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
byte => {
|
||||
return Err(ConversionError::Io(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("unknown tuple byte `{}`", byte),
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
LogicalReplicationMessage::Update(UpdateBody::new(
|
||||
transaction_id,
|
||||
o_id,
|
||||
old_tuple,
|
||||
key_tuple,
|
||||
new_tuple,
|
||||
))
|
||||
}
|
||||
DELETE_BYTE => {
|
||||
let transaction_id = match logical_replication_settings.streaming {
|
||||
true => Some(buf.read_i32::<BigEndian>()?),
|
||||
false => None,
|
||||
};
|
||||
let o_id = buf.read_u32::<BigEndian>()?;
|
||||
let tag = buf.read_u8()?;
|
||||
|
||||
let mut key_tuple = None;
|
||||
let mut old_tuple = None;
|
||||
|
||||
match tag {
|
||||
TUPLE_OLD_BYTE => old_tuple = Some(TupleData::parse(&mut buf)?),
|
||||
TUPLE_KEY_BYTE => key_tuple = Some(TupleData::parse(&mut buf)?),
|
||||
tag => {
|
||||
return Err(ConversionError::Io(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("unknown tuple tag `{}`", tag),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
LogicalReplicationMessage::Delete(DeleteBody::new(
|
||||
transaction_id,
|
||||
o_id,
|
||||
old_tuple,
|
||||
key_tuple,
|
||||
))
|
||||
}
|
||||
byte => {
|
||||
return Err(ConversionError::Io(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("unknown replication message tag `{}`", byte),
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(logical_replication_message)
|
||||
}
|
||||
}
|
||||
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug)]
|
||||
pub enum ReplicationMessage {
|
||||
XLogData(XLogDataBody),
|
||||
PrimaryKeepAlive(PrimaryKeepAliveBody),
|
||||
}
|
||||
|
||||
impl ReplicationMessage {
|
||||
pub fn parse(buf: Bytes) -> io::Result<Self> {
|
||||
let (byte, mut message) = buf.split_first().unwrap();
|
||||
|
||||
let replication_message = match *byte {
|
||||
X_LOG_DATA_BYTE => {
|
||||
let len = buf.len();
|
||||
let wal_start = message.read_u64::<BigEndian>()?;
|
||||
let wal_end = message.read_u64::<BigEndian>()?;
|
||||
let timestamp = message.read_i64::<BigEndian>()?;
|
||||
let len = len - message.len();
|
||||
let data = buf.slice(len..);
|
||||
ReplicationMessage::XLogData(XLogDataBody::new(wal_start, wal_end, timestamp, data))
|
||||
}
|
||||
PRIMARY_KEEPALIVE_BYTE => {
|
||||
let wal_end = message.read_u64::<BigEndian>()?;
|
||||
let timestamp = message.read_i64::<BigEndian>()?;
|
||||
let reply = message.read_u8()?;
|
||||
ReplicationMessage::PrimaryKeepAlive(PrimaryKeepAliveBody::new(
|
||||
wal_end,
|
||||
timestamp,
|
||||
reply == 1,
|
||||
))
|
||||
}
|
||||
byte => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("unknown replication message byte `{}`", byte),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(replication_message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
use std::{collections::HashMap, pin::Pin};
|
||||
|
||||
use crate::{
|
||||
db::DB,
|
||||
postgres_triggers::{
|
||||
get_database_resource,
|
||||
relation::RelationConverter,
|
||||
replication_message::{
|
||||
LogicalReplicationMessage::{Begin, Commit, Delete, Insert, Relation, Type, Update},
|
||||
ReplicationMessage,
|
||||
},
|
||||
run_job,
|
||||
},
|
||||
users::fetch_api_authed,
|
||||
};
|
||||
use bytes::{BufMut, Bytes, BytesMut};
|
||||
use chrono::TimeZone;
|
||||
use futures::{pin_mut, SinkExt, StreamExt};
|
||||
use pg_escape::{quote_identifier, quote_literal};
|
||||
use rand::seq::SliceRandom;
|
||||
use rust_postgres::{Client, Config, CopyBothDuplex, NoTls, SimpleQueryMessage};
|
||||
use windmill_common::{
|
||||
db::UserDB, utils::report_critical_error, worker::to_raw_value, INSTANCE_NAME,
|
||||
};
|
||||
|
||||
use super::{
|
||||
handler::{Database, PostgresTrigger},
|
||||
replication_message::PrimaryKeepAliveBody,
|
||||
};
|
||||
|
||||
pub struct LogicalReplicationSettings {
|
||||
pub streaming: bool,
|
||||
}
|
||||
|
||||
impl LogicalReplicationSettings {
|
||||
pub fn new(streaming: bool) -> Self {
|
||||
Self { streaming }
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
trait RowExist {
|
||||
fn row_exist(&self) -> bool;
|
||||
}
|
||||
|
||||
impl RowExist for Vec<SimpleQueryMessage> {
|
||||
fn row_exist(&self) -> bool {
|
||||
self.iter()
|
||||
.find_map(|element| {
|
||||
if let SimpleQueryMessage::CommandComplete(value) = element {
|
||||
Some(*value)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.is_some_and(|value| value > 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
enum Error {
|
||||
#[error("Error from database: {0}")]
|
||||
Postgres(rust_postgres::Error),
|
||||
#[error("Error : {0}")]
|
||||
Common(windmill_common::error::Error),
|
||||
}
|
||||
|
||||
pub struct PostgresSimpleClient(Client);
|
||||
|
||||
impl PostgresSimpleClient {
|
||||
async fn new(database: &Database) -> Result<Self, Error> {
|
||||
let mut config = Config::new();
|
||||
config
|
||||
.dbname(&database.dbname)
|
||||
.host(&database.host)
|
||||
.port(database.port)
|
||||
.user(&database.user)
|
||||
.replication_mode(rust_postgres::config::ReplicationMode::Logical);
|
||||
|
||||
if !database.password.is_empty() {
|
||||
config.password(&database.password);
|
||||
}
|
||||
|
||||
let (client, connection) = config.connect(NoTls).await.map_err(Error::Postgres)?;
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = connection.await {
|
||||
tracing::debug!("{:#?}", e);
|
||||
};
|
||||
tracing::info!("Successfully Connected into database");
|
||||
});
|
||||
|
||||
Ok(PostgresSimpleClient(client))
|
||||
}
|
||||
|
||||
async fn get_logical_replication_stream(
|
||||
&self,
|
||||
publication_name: &str,
|
||||
logical_replication_slot_name: &str,
|
||||
) -> Result<(CopyBothDuplex<Bytes>, LogicalReplicationSettings), Error> {
|
||||
let options = format!(
|
||||
r#"("proto_version" '2', "publication_names" {})"#,
|
||||
quote_literal(publication_name),
|
||||
);
|
||||
|
||||
let query = format!(
|
||||
r#"START_REPLICATION SLOT {} LOGICAL 0/0 {}"#,
|
||||
quote_identifier(logical_replication_slot_name),
|
||||
options
|
||||
);
|
||||
|
||||
Ok((
|
||||
self.0
|
||||
.copy_both_simple::<bytes::Bytes>(query.as_str())
|
||||
.await
|
||||
.map_err(Error::Postgres)?,
|
||||
LogicalReplicationSettings::new(false),
|
||||
))
|
||||
}
|
||||
|
||||
async fn send_status_update(
|
||||
primary_keep_alive: PrimaryKeepAliveBody,
|
||||
copy_both_stream: &mut Pin<&mut CopyBothDuplex<Bytes>>,
|
||||
) {
|
||||
let mut buf = BytesMut::new();
|
||||
let ts = chrono::Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap();
|
||||
let ts = chrono::Utc::now()
|
||||
.signed_duration_since(ts)
|
||||
.num_microseconds()
|
||||
.unwrap_or(0);
|
||||
|
||||
buf.put_u8(b'r');
|
||||
buf.put_u64(primary_keep_alive.wal_end);
|
||||
buf.put_u64(primary_keep_alive.wal_end);
|
||||
buf.put_u64(primary_keep_alive.wal_end);
|
||||
buf.put_i64(ts);
|
||||
buf.put_u8(0);
|
||||
copy_both_stream.send(buf.freeze()).await.unwrap();
|
||||
tracing::debug!("Send update status message");
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_ping(
|
||||
db: &DB,
|
||||
postgres_trigger: &PostgresTrigger,
|
||||
error: Option<&str>,
|
||||
) -> Option<()> {
|
||||
let updated = sqlx::query_scalar!(
|
||||
r#"
|
||||
UPDATE
|
||||
postgres_trigger
|
||||
SET
|
||||
last_server_ping = now(),
|
||||
error = $1
|
||||
WHERE
|
||||
workspace_id = $2
|
||||
AND path = $3
|
||||
AND server_id = $4
|
||||
AND enabled IS TRUE
|
||||
RETURNING 1
|
||||
"#,
|
||||
error,
|
||||
&postgres_trigger.workspace_id,
|
||||
&postgres_trigger.path,
|
||||
*INSTANCE_NAME
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await;
|
||||
|
||||
match updated {
|
||||
Ok(updated) => {
|
||||
if updated.flatten().is_none() {
|
||||
// allow faster restart of database trigger
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE
|
||||
postgres_trigger
|
||||
SET
|
||||
last_server_ping = NULL
|
||||
WHERE
|
||||
workspace_id = $1
|
||||
AND path = $2
|
||||
AND server_id IS NULL"#,
|
||||
&postgres_trigger.workspace_id,
|
||||
&postgres_trigger.path,
|
||||
)
|
||||
.execute(db)
|
||||
.await
|
||||
.ok();
|
||||
tracing::info!(
|
||||
"Postgres trigger {} changed, disabled, or deleted, stopping...",
|
||||
postgres_trigger.path
|
||||
);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"Error updating ping of postgres trigger {}: {:?}",
|
||||
postgres_trigger.path,
|
||||
err
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Some(())
|
||||
}
|
||||
|
||||
async fn loop_ping(db: &DB, postgres_trigger: &PostgresTrigger, error: Option<&str>) {
|
||||
loop {
|
||||
if update_ping(db, postgres_trigger, error).await.is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn disable_with_error(postgres_trigger: &PostgresTrigger, db: &DB, error: String) -> () {
|
||||
match sqlx::query!(
|
||||
"UPDATE postgres_trigger SET enabled = FALSE, error = $1, server_id = NULL, last_server_ping = NULL WHERE workspace_id = $2 AND path = $3",
|
||||
error,
|
||||
postgres_trigger.workspace_id,
|
||||
postgres_trigger.path,
|
||||
)
|
||||
.execute(db).await {
|
||||
Ok(_) => {
|
||||
report_critical_error(format!("Disabling postgres trigger {} because of error: {}", postgres_trigger.path, error), db.clone(), Some(&postgres_trigger.workspace_id), None).await;
|
||||
},
|
||||
Err(disable_err) => {
|
||||
report_critical_error(
|
||||
format!("Could not disable postgres trigger {} with err {}, disabling because of error {}", postgres_trigger.path, disable_err, error),
|
||||
db.clone(),
|
||||
Some(&postgres_trigger.workspace_id),
|
||||
None,
|
||||
).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn listen_to_transactions(
|
||||
postgres_trigger: &PostgresTrigger,
|
||||
db: DB,
|
||||
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
|
||||
) {
|
||||
let start_logical_replication_streaming = async {
|
||||
let authed = fetch_api_authed(
|
||||
postgres_trigger.edited_by.clone(),
|
||||
postgres_trigger.email.clone(),
|
||||
&postgres_trigger.workspace_id,
|
||||
&db,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(Error::Common)?;
|
||||
|
||||
let database = get_database_resource(
|
||||
authed,
|
||||
Some(UserDB::new(db.clone())),
|
||||
&db,
|
||||
&postgres_trigger.postgres_resource_path,
|
||||
&postgres_trigger.workspace_id,
|
||||
)
|
||||
.await
|
||||
.map_err(Error::Common)?;
|
||||
|
||||
let client = PostgresSimpleClient::new(&database).await?;
|
||||
|
||||
let (logical_replication_stream, logical_replication_settings) = client
|
||||
.get_logical_replication_stream(
|
||||
&postgres_trigger.publication_name,
|
||||
&postgres_trigger.replication_slot_name,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok::<_, Error>((logical_replication_stream, logical_replication_settings))
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = killpill_rx.recv() => {
|
||||
return;
|
||||
}
|
||||
_ = loop_ping(&db, postgres_trigger, Some("Connecting...")) => {
|
||||
return;
|
||||
}
|
||||
result = start_logical_replication_streaming => {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = killpill_rx.recv() => {
|
||||
return;
|
||||
}
|
||||
_ = loop_ping(&db, postgres_trigger, None) => {
|
||||
return;
|
||||
}
|
||||
_ = {
|
||||
async {
|
||||
match result {
|
||||
Ok((logical_replication_stream, logical_replication_settings)) => {
|
||||
pin_mut!(logical_replication_stream);
|
||||
let mut relations = RelationConverter::new();
|
||||
tracing::info!("Starting to listen for postgres trigger {}", postgres_trigger.path);
|
||||
loop {
|
||||
let message = logical_replication_stream.next().await;
|
||||
|
||||
let message = match message {
|
||||
Some(message) => message,
|
||||
None => {
|
||||
tracing::info!("Stream for postgres trigger {} is empty, leaving....", postgres_trigger.path);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let message = match message {
|
||||
Ok(message) => message,
|
||||
Err(err) => {
|
||||
let err = format!("Postgres trigger named {} had an error while receiving a message : {}", &postgres_trigger.path, err.to_string());
|
||||
disable_with_error(&postgres_trigger, &db, err).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let logical_message = match ReplicationMessage::parse(message) {
|
||||
Ok(logical_message) => logical_message,
|
||||
Err(err) => {
|
||||
let err = format!("Postgres trigger named: {} had an error while parsing message: {}", postgres_trigger.path, err.to_string());
|
||||
disable_with_error(&postgres_trigger, &db, err).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
match logical_message {
|
||||
ReplicationMessage::PrimaryKeepAlive(primary_keep_alive) => {
|
||||
if primary_keep_alive.reply {
|
||||
PostgresSimpleClient::send_status_update(primary_keep_alive, &mut logical_replication_stream).await;
|
||||
}
|
||||
}
|
||||
ReplicationMessage::XLogData(x_log_data) => {
|
||||
let logical_replication_message = match x_log_data.parse(&logical_replication_settings) {
|
||||
Ok(logical_replication_message) => logical_replication_message,
|
||||
Err(err) => {
|
||||
tracing::error!("Postgres trigger named: {} had an error while trying to parse incomming stream message: {}", &postgres_trigger.path, err.to_string());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let json = match logical_replication_message {
|
||||
Relation(relation_body) => {
|
||||
relations.add_relation(relation_body);
|
||||
None
|
||||
}
|
||||
Begin | Type | Commit => {
|
||||
None
|
||||
}
|
||||
Insert(insert) => {
|
||||
Some((insert.o_id, relations.body_to_json((insert.o_id, insert.tuple)), "insert"))
|
||||
}
|
||||
Update(update) => {
|
||||
Some((update.o_id, relations.body_to_json((update.o_id, update.new_tuple)), "update"))
|
||||
}
|
||||
Delete(delete) => {
|
||||
let body = delete.old_tuple.unwrap_or(delete.key_tuple.unwrap());
|
||||
Some((delete.o_id, relations.body_to_json((delete.o_id, body)), "delete"))
|
||||
}
|
||||
};
|
||||
if let Some((o_id, Ok(body), transaction_type)) = json {
|
||||
let relation = match relations.get_relation(o_id) {
|
||||
Ok(relation) => relation,
|
||||
Err(err) => {
|
||||
tracing::error!("Postgres trigger named: {}, error: {}", &postgres_trigger.path, err.to_string());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let database_info = HashMap::from([
|
||||
("schema_name".to_string(), to_raw_value(&relation.namespace)),
|
||||
("table_name".to_string(), to_raw_value(&relation.name)),
|
||||
("transaction_type".to_string(), to_raw_value(&transaction_type)),
|
||||
("row".to_string(), to_raw_value(&body)),
|
||||
]);
|
||||
let extra = Some(HashMap::from([(
|
||||
"wm_trigger".to_string(),
|
||||
to_raw_value(&serde_json::json!({"kind": "postgres", })),
|
||||
)]));
|
||||
let _ = run_job(Some(database_info), extra, &db, postgres_trigger).await;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("Postgres trigger error while trying to start_logical_replication_streaming: {}", err.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
} => {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_to_listen_to_database_transactions(
|
||||
pg_trigger: PostgresTrigger,
|
||||
db: DB,
|
||||
killpill_rx: tokio::sync::broadcast::Receiver<()>,
|
||||
) {
|
||||
let postgres_trigger = sqlx::query_scalar!(
|
||||
r#"
|
||||
UPDATE postgres_trigger
|
||||
SET
|
||||
server_id = $1,
|
||||
last_server_ping = now(),
|
||||
error = 'Connecting...'
|
||||
WHERE
|
||||
enabled IS TRUE
|
||||
AND workspace_id = $2
|
||||
AND path = $3
|
||||
AND (last_server_ping IS NULL
|
||||
OR last_server_ping < now() - INTERVAL '15 seconds'
|
||||
)
|
||||
RETURNING true
|
||||
"#,
|
||||
*INSTANCE_NAME,
|
||||
pg_trigger.workspace_id,
|
||||
pg_trigger.path,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await;
|
||||
match postgres_trigger {
|
||||
Ok(has_lock) => {
|
||||
if has_lock.flatten().unwrap_or(false) {
|
||||
tracing::info!("Spawning new task to listen_to_database_transaction");
|
||||
tokio::spawn(async move {
|
||||
listen_to_transactions(&pg_trigger, db.clone(), killpill_rx).await;
|
||||
});
|
||||
} else {
|
||||
tracing::info!(
|
||||
"Postgres trigger {} already being listened to",
|
||||
pg_trigger.path
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
"Error acquiring lock for postgres trigger {}: {:?}",
|
||||
pg_trigger.path,
|
||||
err
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async fn listen_to_unlistened_database_events(
|
||||
db: &DB,
|
||||
killpill_rx: &tokio::sync::broadcast::Receiver<()>,
|
||||
) {
|
||||
let postgres_triggers = sqlx::query_as!(
|
||||
PostgresTrigger,
|
||||
r#"
|
||||
SELECT
|
||||
workspace_id,
|
||||
path,
|
||||
script_path,
|
||||
replication_slot_name,
|
||||
publication_name,
|
||||
is_flow,
|
||||
edited_by,
|
||||
email,
|
||||
edited_at,
|
||||
server_id,
|
||||
last_server_ping,
|
||||
extra_perms,
|
||||
error,
|
||||
enabled,
|
||||
postgres_resource_path
|
||||
FROM
|
||||
postgres_trigger
|
||||
WHERE
|
||||
enabled IS TRUE
|
||||
AND (last_server_ping IS NULL OR
|
||||
last_server_ping < now() - interval '15 seconds'
|
||||
)
|
||||
"#
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await;
|
||||
|
||||
match postgres_triggers {
|
||||
Ok(mut triggers) => {
|
||||
triggers.shuffle(&mut rand::thread_rng());
|
||||
for trigger in triggers {
|
||||
try_to_listen_to_database_transactions(
|
||||
trigger,
|
||||
db.clone(),
|
||||
killpill_rx.resubscribe(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("Error fetching postgres triggers: {:?}", err);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub async fn start_database(db: DB, mut killpill_rx: tokio::sync::broadcast::Receiver<()>) {
|
||||
tokio::spawn(async move {
|
||||
listen_to_unlistened_database_events(&db, &killpill_rx).await;
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = killpill_rx.recv() => {
|
||||
return;
|
||||
}
|
||||
_ = tokio::time::sleep(tokio::time::Duration::from_secs(15)) => {
|
||||
listen_to_unlistened_database_events(&db, &killpill_rx).await
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -448,10 +448,16 @@ async fn transform_schemas(
|
||||
let is_required = required.unwrap().contains(key);
|
||||
|
||||
let default_value = default_args_json.and_then(|json| json.get(key).cloned());
|
||||
let dynamic_enums_value = dynamic_enums_json.and_then(|json| json.get(key).cloned());
|
||||
let dynamic_enums_value =
|
||||
dynamic_enums_json.and_then(|json| json.get(key).cloned());
|
||||
|
||||
let input_block =
|
||||
create_input_block(key, schema, is_required, default_value, dynamic_enums_value);
|
||||
let input_block = create_input_block(
|
||||
key,
|
||||
schema,
|
||||
is_required,
|
||||
default_value,
|
||||
dynamic_enums_value,
|
||||
);
|
||||
match input_block {
|
||||
serde_json::Value::Array(arr) => blocks.extend(arr),
|
||||
_ => blocks.push(input_block),
|
||||
@@ -536,7 +542,7 @@ fn create_input_block(
|
||||
|
||||
// Handle date-time format
|
||||
if let FieldType::String = schema.r#type {
|
||||
if schema.format.as_deref() == Some("date-time") {
|
||||
if schema.format.as_deref() == Some("date-time") {
|
||||
tracing::debug!("Date-time type");
|
||||
let now = chrono::Local::now();
|
||||
let current_date = now.format("%Y-%m-%d").to_string();
|
||||
|
||||
@@ -20,6 +20,7 @@ pub struct TriggersCount {
|
||||
websocket_count: i64,
|
||||
kafka_count: i64,
|
||||
nats_count: i64,
|
||||
postgres_count: i64,
|
||||
}
|
||||
pub(crate) async fn get_triggers_count_internal(
|
||||
db: &DB,
|
||||
@@ -86,6 +87,16 @@ pub(crate) async fn get_triggers_count_internal(
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
|
||||
let postgres_count = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM postgres_trigger WHERE script_path = $1 AND is_flow = $2 AND workspace_id = $3",
|
||||
path,
|
||||
is_flow,
|
||||
w_id
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
|
||||
let webhook_count = (if is_flow {
|
||||
sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM token WHERE label LIKE 'webhook-%' AND workspace_id = $1 AND scopes @> ARRAY['run:flow/' || $2]::text[]",
|
||||
@@ -129,6 +140,7 @@ pub(crate) async fn get_triggers_count_internal(
|
||||
websocket_count,
|
||||
kafka_count,
|
||||
nats_count,
|
||||
postgres_count,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -676,3 +676,28 @@ pub async fn get_value_internal<'c>(
|
||||
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
pub async fn get_variable_or_self(path: String, db: &DB, w_id: &str) -> Result<String> {
|
||||
if !path.starts_with("$var:") {
|
||||
return Ok(path);
|
||||
}
|
||||
let path = path.strip_prefix("$var:").unwrap().to_string();
|
||||
|
||||
let record = sqlx::query!(
|
||||
"SELECT value, is_secret
|
||||
FROM variable
|
||||
WHERE path = $1 AND workspace_id = $2",
|
||||
&path,
|
||||
&w_id
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
|
||||
let mut value = record.value;
|
||||
if record.is_secret {
|
||||
let mc = build_crypt(db, w_id).await?;
|
||||
value = decrypt(&mc, value)?;
|
||||
}
|
||||
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
@@ -380,7 +380,7 @@ async fn exists_websocket_trigger(
|
||||
async fn listen_to_unlistened_websockets(
|
||||
db: &DB,
|
||||
killpill_rx: &tokio::sync::broadcast::Receiver<()>,
|
||||
) -> () {
|
||||
) {
|
||||
match sqlx::query_as::<_, WebsocketTrigger>(
|
||||
r#"SELECT *
|
||||
FROM websocket_trigger
|
||||
|
||||
@@ -35,7 +35,7 @@ use windmill_audit::ActionKind;
|
||||
use windmill_common::db::UserDB;
|
||||
use windmill_common::s3_helpers::LargeFileStorage;
|
||||
use windmill_common::users::username_to_permissioned_as;
|
||||
use windmill_common::variables::build_crypt;
|
||||
use windmill_common::variables::{build_crypt, decrypt, encrypt};
|
||||
use windmill_common::worker::to_raw_value;
|
||||
#[cfg(feature = "enterprise")]
|
||||
use windmill_common::workspaces::WorkspaceDeploymentUISettings;
|
||||
@@ -52,7 +52,6 @@ use windmill_git_sync::handle_deployment_metadata;
|
||||
#[cfg(feature = "enterprise")]
|
||||
use windmill_common::utils::require_admin_or_devops;
|
||||
|
||||
use windmill_common::variables::{decrypt, encrypt};
|
||||
use hyper::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{FromRow, Postgres, Transaction};
|
||||
@@ -122,7 +121,8 @@ pub fn workspaced_service() -> Router {
|
||||
"/critical_alerts/acknowledge_all",
|
||||
post(acknowledge_all_critical_alerts),
|
||||
)
|
||||
.route("/critical_alerts/mute", post(mute_critical_alerts));
|
||||
.route("/critical_alerts/mute", post(mute_critical_alerts))
|
||||
.route("/operator_settings", post(update_operator_settings));
|
||||
|
||||
#[cfg(feature = "stripe")]
|
||||
{
|
||||
@@ -189,6 +189,7 @@ pub struct WorkspaceSettings {
|
||||
pub default_scripts: Option<serde_json::Value>,
|
||||
pub mute_critical_alerts: Option<bool>,
|
||||
pub color: Option<String>,
|
||||
pub operator_settings: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(FromRow, Serialize, Debug)]
|
||||
@@ -287,6 +288,7 @@ struct UserWorkspace {
|
||||
pub name: String,
|
||||
pub username: String,
|
||||
pub color: Option<String>,
|
||||
pub operator_settings: Option<Option<serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -1304,6 +1306,7 @@ struct UsedTriggers {
|
||||
pub http_routes_used: bool,
|
||||
pub kafka_used: bool,
|
||||
pub nats_used: bool,
|
||||
pub postgres_used: bool,
|
||||
}
|
||||
|
||||
async fn get_used_triggers(
|
||||
@@ -1314,12 +1317,17 @@ async fn get_used_triggers(
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let websocket_used = sqlx::query_as!(
|
||||
UsedTriggers,
|
||||
r#"SELECT
|
||||
EXISTS(SELECT 1 FROM websocket_trigger WHERE workspace_id = $1) as "websocket_used!",
|
||||
EXISTS(SELECT 1 FROM http_trigger WHERE workspace_id = $1) as "http_routes_used!",
|
||||
r#"
|
||||
SELECT
|
||||
|
||||
EXISTS(SELECT 1 FROM websocket_trigger WHERE workspace_id = $1) AS "websocket_used!",
|
||||
|
||||
EXISTS(SELECT 1 FROM http_trigger WHERE workspace_id = $1) AS "http_routes_used!",
|
||||
EXISTS(SELECT 1 FROM kafka_trigger WHERE workspace_id = $1) as "kafka_used!",
|
||||
EXISTS(SELECT 1 FROM nats_trigger WHERE workspace_id = $1) as "nats_used!""#,
|
||||
w_id,
|
||||
EXISTS(SELECT 1 FROM nats_trigger WHERE workspace_id = $1) as "nats_used!",
|
||||
EXISTS(SELECT 1 FROM postgres_trigger WHERE workspace_id = $1) AS "postgres_used!"
|
||||
"#,
|
||||
w_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
@@ -1361,7 +1369,8 @@ async fn user_workspaces(
|
||||
let mut tx = db.begin().await?;
|
||||
let workspaces = sqlx::query_as!(
|
||||
UserWorkspace,
|
||||
"SELECT workspace.id, workspace.name, usr.username, workspace_settings.color
|
||||
"SELECT workspace.id, workspace.name, usr.username, workspace_settings.color,
|
||||
CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings
|
||||
FROM workspace
|
||||
JOIN usr ON usr.workspace_id = workspace.id
|
||||
JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id
|
||||
@@ -2130,3 +2139,41 @@ async fn mute_critical_alerts(
|
||||
pub async fn mute_critical_alerts() -> Error {
|
||||
Error::NotFound("Critical Alerts require EE".to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct ChangeOperatorSettings {
|
||||
runs: bool,
|
||||
schedules: bool,
|
||||
resources: bool,
|
||||
variables: bool,
|
||||
triggers: bool,
|
||||
audit_logs: bool,
|
||||
groups: bool,
|
||||
folders: bool,
|
||||
workers: bool,
|
||||
}
|
||||
|
||||
async fn update_operator_settings(
|
||||
authed: ApiAuthed,
|
||||
Path(w_id): Path<String>,
|
||||
Extension(db): Extension<DB>,
|
||||
Json(settings): Json<ChangeOperatorSettings>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
let settings_json = serde_json::json!(settings);
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET operator_settings = $1 WHERE workspace_id = $2",
|
||||
settings_json,
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok("Operator settings updated successfully".to_string())
|
||||
}
|
||||
|
||||
@@ -25,18 +25,16 @@ use axum::{
|
||||
use http::HeaderName;
|
||||
use itertools::Itertools;
|
||||
|
||||
use windmill_common::db::UserDB;
|
||||
use windmill_common::schedule::Schedule;
|
||||
use windmill_common::variables::build_crypt;
|
||||
|
||||
use windmill_common::variables::decrypt;
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{to_anyhow, Error, Result},
|
||||
flows::Flow,
|
||||
schedule::Schedule,
|
||||
scripts::{Schema, Script, ScriptLang},
|
||||
variables::ExportableListableVariable,
|
||||
variables::{build_crypt, ExportableListableVariable},
|
||||
};
|
||||
|
||||
use windmill_common::variables::decrypt;
|
||||
use hyper::header;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -61,6 +61,7 @@ async-stream.workspace = true
|
||||
const_format.workspace = true
|
||||
crc.workspace = true
|
||||
windmill-macros.workspace = true
|
||||
|
||||
semver.workspace = true
|
||||
croner = "2.0.6"
|
||||
quick_cache.workspace = true
|
||||
|
||||
@@ -66,6 +66,8 @@ pub enum Error {
|
||||
AiError(String),
|
||||
#[error("{0}")]
|
||||
AlreadyCompleted(String),
|
||||
#[error("Find python error: {0}")]
|
||||
FindPythonError(String),
|
||||
#[error("{0}")]
|
||||
Utf8(#[from] std::string::FromUtf8Error),
|
||||
#[error("Encoding/decoding error: {0}")]
|
||||
|
||||
@@ -14,6 +14,7 @@ pub const NUGET_CONFIG_SETTING: &str = "nuget_config";
|
||||
|
||||
pub const EXTRA_PIP_INDEX_URL_SETTING: &str = "pip_extra_index_url";
|
||||
pub const PIP_INDEX_URL_SETTING: &str = "pip_index_url";
|
||||
pub const INSTANCE_PYTHON_VERSION_SETTING: &str = "instance_python_version";
|
||||
pub const SCIM_TOKEN_SETTING: &str = "scim_token";
|
||||
pub const SAML_METADATA_SETTING: &str = "saml_metadata";
|
||||
pub const SMTP_SETTING: &str = "smtp_settings";
|
||||
@@ -39,7 +40,7 @@ pub const JWT_SECRET_SETTING: &str = "jwt_secret";
|
||||
pub const EMAIL_DOMAIN_SETTING: &str = "email_domain";
|
||||
pub const OTEL_SETTING: &str = "otel";
|
||||
|
||||
pub const ENV_SETTINGS: [&str; 54] = [
|
||||
pub const ENV_SETTINGS: [&str; 55] = [
|
||||
"DISABLE_NSJAIL",
|
||||
"MODE",
|
||||
"NUM_WORKERS",
|
||||
@@ -62,6 +63,7 @@ pub const ENV_SETTINGS: [&str; 54] = [
|
||||
"GOPRIVATE",
|
||||
"GOPROXY",
|
||||
"NETRC",
|
||||
"INSTANCE_PYTHON_VERSION",
|
||||
"PIP_INDEX_URL",
|
||||
"PIP_EXTRA_INDEX_URL",
|
||||
"PIP_TRUSTED_HOST",
|
||||
|
||||
@@ -248,18 +248,16 @@ pub async fn connect_db(
|
||||
Err(_) => {
|
||||
if server_mode {
|
||||
DEFAULT_MAX_CONNECTIONS_SERVER
|
||||
} else if indexer_mode {
|
||||
DEFAULT_MAX_CONNECTIONS_INDEXER
|
||||
} else {
|
||||
if indexer_mode {
|
||||
DEFAULT_MAX_CONNECTIONS_INDEXER
|
||||
} else {
|
||||
DEFAULT_MAX_CONNECTIONS_WORKER
|
||||
+ std::env::var("NUM_WORKERS")
|
||||
.ok()
|
||||
.map(|x| x.parse().ok())
|
||||
.flatten()
|
||||
.unwrap_or(1)
|
||||
- 1
|
||||
}
|
||||
DEFAULT_MAX_CONNECTIONS_WORKER
|
||||
+ std::env::var("NUM_WORKERS")
|
||||
.ok()
|
||||
.map(|x| x.parse().ok())
|
||||
.flatten()
|
||||
.unwrap_or(1)
|
||||
- 1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::{worker::WORKER_GROUP, BASE_URL, DB};
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use magic_crypt::{MagicCrypt256, MagicCryptError, MagicCryptTrait};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::error;
|
||||
|
||||
use crate::{worker::WORKER_GROUP, BASE_URL, DB};
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref SECRET_SALT: Option<String> = std::env::var("SECRET_SALT").ok();
|
||||
}
|
||||
@@ -133,7 +133,7 @@ pub async fn get_secret_value_as_admin(
|
||||
let r = if variable.is_secret {
|
||||
let value = variable.value;
|
||||
if !value.is_empty() {
|
||||
let mc = build_crypt(&db, &w_id).await?;
|
||||
let mc = build_crypt(db, w_id).await?;
|
||||
decrypt_value_with_mc(value, mc).await?
|
||||
} else {
|
||||
"".to_string()
|
||||
@@ -145,17 +145,14 @@ pub async fn get_secret_value_as_admin(
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
pub async fn decrypt_value_with_mc(
|
||||
value: String,
|
||||
mc: MagicCrypt256,
|
||||
) -> Result<String, crate::error::Error> {
|
||||
Ok(mc.decrypt_base64_to_string(value).map_err(|e| match e {
|
||||
pub async fn decrypt_value_with_mc(value: String, mc: MagicCrypt256) -> Result<String> {
|
||||
mc.decrypt_base64_to_string(value).map_err(|e| match e {
|
||||
MagicCryptError::DecryptError(_) => crate::error::Error::InternalErr(
|
||||
"Could not decrypt value. The value may have been encrypted with a different key."
|
||||
.to_string(),
|
||||
),
|
||||
_ => crate::error::Error::InternalErr(e.to_string()),
|
||||
})?)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn encrypt(mc: &MagicCrypt256, value: &str) -> String {
|
||||
|
||||
@@ -333,14 +333,18 @@ fn parse_file<T: FromStr>(path: &str) -> Option<T> {
|
||||
.flatten()
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
#[annotations("#")]
|
||||
pub struct PythonAnnotations {
|
||||
pub no_cache: bool,
|
||||
pub no_uv: bool,
|
||||
pub no_uv_install: bool,
|
||||
pub no_uv_compile: bool,
|
||||
|
||||
pub no_postinstall: bool,
|
||||
pub py310: bool,
|
||||
pub py311: bool,
|
||||
pub py312: bool,
|
||||
pub py313: bool,
|
||||
}
|
||||
|
||||
#[annotations("//")]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ futures-core.workspace = true
|
||||
futures.workspace = true
|
||||
itertools.workspace = true
|
||||
async-recursion.workspace = true
|
||||
bigdecimal.workspace = true
|
||||
axum.workspace = true
|
||||
serde_urlencoded.workspace = true
|
||||
regex.workspace = true
|
||||
|
||||
@@ -20,6 +20,7 @@ clone_newuser: {CLONE_NEWUSER}
|
||||
|
||||
keep_caps: true
|
||||
keep_env: true
|
||||
mount_proc: true
|
||||
|
||||
mount {
|
||||
src: "/bin"
|
||||
@@ -79,6 +80,11 @@ mount {
|
||||
is_bind: true
|
||||
rw: true
|
||||
}
|
||||
mount {
|
||||
src: "{PY_INSTALL_DIR}"
|
||||
dst: "{PY_INSTALL_DIR}"
|
||||
is_bind: true
|
||||
}
|
||||
|
||||
mount {
|
||||
src: "/dev/urandom"
|
||||
|
||||
@@ -27,6 +27,7 @@ CMD="/usr/local/bin/uv pip install
|
||||
--no-color
|
||||
--no-deps
|
||||
--link-mode=copy
|
||||
$PY_PATH
|
||||
$INDEX_URL_ARG $EXTRA_INDEX_URL_ARG $TRUSTED_HOST_ARG
|
||||
--index-strategy unsafe-best-match
|
||||
--system
|
||||
|
||||
@@ -16,6 +16,7 @@ clone_newuser: {CLONE_NEWUSER}
|
||||
|
||||
keep_caps: false
|
||||
keep_env: true
|
||||
mount_proc: true
|
||||
|
||||
mount {
|
||||
src: "/bin"
|
||||
@@ -110,6 +111,12 @@ mount {
|
||||
is_bind: true
|
||||
}
|
||||
|
||||
mount {
|
||||
src: "{PY_INSTALL_DIR}"
|
||||
dst: "{PY_INSTALL_DIR}"
|
||||
is_bind: true
|
||||
}
|
||||
|
||||
mount {
|
||||
src: "/dev/urandom"
|
||||
dst: "/dev/urandom"
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
#[cfg(unix)]
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
os::unix::fs::PermissionsExt,
|
||||
path::PathBuf,
|
||||
process::Stdio,
|
||||
};
|
||||
use std::{collections::HashMap, os::unix::fs::PermissionsExt, path::PathBuf, process::Stdio};
|
||||
|
||||
#[cfg(windows)]
|
||||
use std::{
|
||||
@@ -29,10 +24,11 @@ use windmill_queue::{append_logs, CanceledBy};
|
||||
use crate::{
|
||||
bash_executor::BIN_BASH,
|
||||
common::{
|
||||
check_executor_binary_exists, get_reserved_variables, read_and_check_result, start_child_process, transform_json, OccupancyMetrics
|
||||
check_executor_binary_exists, get_reserved_variables, read_and_check_result,
|
||||
start_child_process, transform_json, OccupancyMetrics,
|
||||
},
|
||||
handle_child::handle_child,
|
||||
python_executor::{create_dependencies_dir, handle_python_reqs, uv_pip_compile},
|
||||
python_executor::{create_dependencies_dir, handle_python_reqs, uv_pip_compile, PyVersion},
|
||||
AuthedClientBackgroundTask, DISABLE_NSJAIL, DISABLE_NUSER, HOME_ENV, NSJAIL_PATH, PATH_ENV,
|
||||
PROXY_ENVS, TZ_ENV,
|
||||
};
|
||||
@@ -88,6 +84,7 @@ async fn handle_ansible_python_deps(
|
||||
worker_name,
|
||||
w_id,
|
||||
&mut Some(occupancy_metrics),
|
||||
PyVersion::Py311,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
@@ -115,7 +112,7 @@ async fn handle_ansible_python_deps(
|
||||
job_dir,
|
||||
worker_dir,
|
||||
&mut Some(occupancy_metrics),
|
||||
false,
|
||||
crate::python_executor::PyVersion::Py311,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
@@ -200,7 +197,11 @@ pub async fn handle_ansible_job(
|
||||
envs: HashMap<String, String>,
|
||||
occupancy_metrics: &mut OccupancyMetrics,
|
||||
) -> windmill_common::error::Result<Box<RawValue>> {
|
||||
check_executor_binary_exists("ansible-playbook", ANSIBLE_PLAYBOOK_PATH.as_str(), "ansible")?;
|
||||
check_executor_binary_exists(
|
||||
"ansible-playbook",
|
||||
ANSIBLE_PLAYBOOK_PATH.as_str(),
|
||||
"ansible",
|
||||
)?;
|
||||
|
||||
let (logs, reqs, playbook) = windmill_parser_yaml::parse_ansible_reqs(inner_content)?;
|
||||
append_logs(&job.id, &job.workspace_id, logs, db).await;
|
||||
|
||||
@@ -906,7 +906,7 @@ pub async fn handle_bun_job(
|
||||
#[cfg(windows)]
|
||||
{
|
||||
target = format!("{job_dir}\\main.js");
|
||||
symlink = std::os::windows::fs::symlink_dir(&local_path, &target);
|
||||
symlink = std::fs::hard_link(&local_path, &target);
|
||||
}
|
||||
|
||||
symlink.map_err(|e| {
|
||||
|
||||
@@ -17,19 +17,24 @@ use std::sync::Arc;
|
||||
pub async fn build_tar_and_push(
|
||||
s3_client: Arc<dyn ObjectStore>,
|
||||
folder: String,
|
||||
// python_311
|
||||
python_xyz: String,
|
||||
no_uv: bool,
|
||||
) -> error::Result<()> {
|
||||
use object_store::path::Path;
|
||||
|
||||
use crate::{TAR_PIP_CACHE_DIR, TAR_PY311_CACHE_DIR};
|
||||
use crate::{TAR_PIP_CACHE_DIR, TAR_PYBASE_CACHE_DIR};
|
||||
|
||||
tracing::info!("Started building and pushing piptar {folder}");
|
||||
let start = Instant::now();
|
||||
|
||||
// e.g. tiny==1.0.0
|
||||
let folder_name = folder.split("/").last().unwrap();
|
||||
|
||||
let prefix = if no_uv {
|
||||
TAR_PIP_CACHE_DIR
|
||||
} else {
|
||||
TAR_PY311_CACHE_DIR
|
||||
&format!("{TAR_PYBASE_CACHE_DIR}/{}", python_xyz)
|
||||
};
|
||||
let tar_path = format!("{prefix}/{folder_name}_tar.tar",);
|
||||
|
||||
@@ -53,7 +58,7 @@ pub async fn build_tar_and_push(
|
||||
.put(
|
||||
&Path::from(format!(
|
||||
"/tar/{}/{folder_name}.tar",
|
||||
if no_uv { "pip" } else { "python_311" }
|
||||
if no_uv { "pip" } else { &python_xyz }
|
||||
)),
|
||||
std::fs::read(&tar_path)?.into(),
|
||||
)
|
||||
@@ -82,6 +87,8 @@ pub async fn build_tar_and_push(
|
||||
pub async fn pull_from_tar(
|
||||
client: Arc<dyn ObjectStore>,
|
||||
folder: String,
|
||||
// python_311
|
||||
python_xyz: String,
|
||||
no_uv: bool,
|
||||
) -> error::Result<()> {
|
||||
use windmill_common::s3_helpers::attempt_fetch_bytes;
|
||||
@@ -91,14 +98,13 @@ pub async fn pull_from_tar(
|
||||
tracing::info!("Attempting to pull piptar {folder_name} from bucket");
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
let tar_path = format!(
|
||||
"tar/{}/{folder_name}.tar",
|
||||
if no_uv { "pip" } else { "python_311" }
|
||||
if no_uv { "pip".to_owned() } else { python_xyz }
|
||||
);
|
||||
let bytes = attempt_fetch_bytes(client, &tar_path).await?;
|
||||
|
||||
// tracing::info!("B: {target} {folder}");
|
||||
|
||||
extract_tar(bytes, &folder).await.map_err(|e| {
|
||||
tracing::error!("Failed to extract piptar {folder_name}. Error: {:?}", e);
|
||||
e
|
||||
|
||||
@@ -25,6 +25,8 @@ mod job_logger_ee;
|
||||
mod js_eval;
|
||||
#[cfg(feature = "mysql")]
|
||||
mod mysql_executor;
|
||||
#[cfg(feature = "oracledb")]
|
||||
mod oracledb_executor;
|
||||
mod pg_executor;
|
||||
#[cfg(feature = "php")]
|
||||
mod php_executor;
|
||||
@@ -36,8 +38,6 @@ mod rust_executor;
|
||||
mod worker;
|
||||
mod worker_flow;
|
||||
mod worker_lockfiles;
|
||||
#[cfg(feature = "oracledb")]
|
||||
mod oracledb_executor;
|
||||
pub use worker::*;
|
||||
|
||||
pub use result_processor::handle_job_error;
|
||||
|
||||
@@ -20,7 +20,7 @@ use windmill_parser_sql::{
|
||||
use windmill_queue::CanceledBy;
|
||||
|
||||
use crate::{
|
||||
common::{check_executor_binary_exists, build_args_map, OccupancyMetrics},
|
||||
common::{build_args_map, check_executor_binary_exists, OccupancyMetrics},
|
||||
handle_child::run_future_with_polling_update_job_poller,
|
||||
AuthedClientBackgroundTask,
|
||||
};
|
||||
@@ -302,7 +302,11 @@ pub async fn do_oracledb(
|
||||
column_order: &mut Option<Vec<String>>,
|
||||
occupancy_metrics: &mut OccupancyMetrics,
|
||||
) -> windmill_common::error::Result<Box<RawValue>> {
|
||||
check_executor_binary_exists("the Oracle client lib", ORACLE_LIB_DIR.as_str(), "Oracle Database")?;
|
||||
check_executor_binary_exists(
|
||||
"the Oracle client lib",
|
||||
ORACLE_LIB_DIR.as_str(),
|
||||
"Oracle Database",
|
||||
)?;
|
||||
|
||||
let args = build_args_map(job, client, db).await?.map(Json);
|
||||
let job_args = if args.is_some() {
|
||||
|
||||
@@ -15,7 +15,8 @@ use windmill_queue::{append_logs, CanceledBy};
|
||||
|
||||
use crate::{
|
||||
common::{
|
||||
check_executor_binary_exists, create_args_and_out_file, get_main_override, get_reserved_variables, read_result, start_child_process, OccupancyMetrics
|
||||
check_executor_binary_exists, create_args_and_out_file, get_main_override,
|
||||
get_reserved_variables, read_result, start_child_process, OccupancyMetrics,
|
||||
},
|
||||
handle_child::handle_child,
|
||||
AuthedClientBackgroundTask, COMPOSER_CACHE_DIR, COMPOSER_PATH, DISABLE_NSJAIL, DISABLE_NUSER,
|
||||
|
||||
@@ -22,7 +22,10 @@ use uuid::Uuid;
|
||||
#[cfg(all(feature = "enterprise", feature = "parquet", unix))]
|
||||
use windmill_common::ee::{get_license_plan, LicensePlan};
|
||||
use windmill_common::{
|
||||
error::{self, Error},
|
||||
error::{
|
||||
self,
|
||||
Error::{self},
|
||||
},
|
||||
jobs::{QueuedJob, PREPROCESSOR_FAKE_ENTRYPOINT},
|
||||
utils::calculate_hash,
|
||||
worker::{write_file, PythonAnnotations, WORKER_CONFIG},
|
||||
@@ -51,18 +54,18 @@ lazy_static::lazy_static! {
|
||||
static ref PIP_TRUSTED_HOST: Option<String> = std::env::var("PIP_TRUSTED_HOST").ok();
|
||||
static ref PIP_INDEX_CERT: Option<String> = std::env::var("PIP_INDEX_CERT").ok();
|
||||
|
||||
pub static ref USE_SYSTEM_PYTHON: bool = std::env::var("USE_SYSTEM_PYTHON")
|
||||
.ok().map(|flag| flag == "true").unwrap_or(false);
|
||||
|
||||
pub static ref USE_PIP_COMPILE: bool = std::env::var("USE_PIP_COMPILE")
|
||||
.ok().map(|flag| flag == "true").unwrap_or(false);
|
||||
|
||||
/// Use pip install
|
||||
pub static ref USE_PIP_INSTALL: bool = std::env::var("USE_PIP_INSTALL")
|
||||
.ok().map(|flag| flag == "true").unwrap_or(false);
|
||||
|
||||
|
||||
static ref RELATIVE_IMPORT_REGEX: Regex = Regex::new(r#"(import|from)\s(((u|f)\.)|\.)"#).unwrap();
|
||||
|
||||
static ref EPHEMERAL_TOKEN_CMD: Option<String> = std::env::var("EPHEMERAL_TOKEN_CMD").ok();
|
||||
|
||||
}
|
||||
|
||||
const NSJAIL_CONFIG_DOWNLOAD_PY_CONTENT: &str = include_str!("../nsjail/download.py.config.proto");
|
||||
@@ -82,12 +85,292 @@ use crate::{
|
||||
create_args_and_out_file, get_main_override, get_reserved_variables, read_file,
|
||||
read_result, start_child_process, OccupancyMetrics,
|
||||
},
|
||||
handle_child::{get_mem_peak, handle_child},
|
||||
AuthedClientBackgroundTask, DISABLE_NSJAIL, DISABLE_NUSER, HOME_ENV, LOCK_CACHE_DIR,
|
||||
NSJAIL_PATH, PATH_ENV, PIP_CACHE_DIR, PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, PROXY_ENVS,
|
||||
PY311_CACHE_DIR, TZ_ENV, UV_CACHE_DIR,
|
||||
handle_child::handle_child,
|
||||
AuthedClientBackgroundTask, DISABLE_NSJAIL, DISABLE_NUSER, HOME_ENV, INSTANCE_PYTHON_VERSION,
|
||||
LOCK_CACHE_DIR, NSJAIL_PATH, PATH_ENV, PIP_CACHE_DIR, PIP_EXTRA_INDEX_URL, PIP_INDEX_URL,
|
||||
PROXY_ENVS, PY_INSTALL_DIR, TZ_ENV, UV_CACHE_DIR,
|
||||
};
|
||||
|
||||
// To change latest stable version:
|
||||
// 1. Change placeholder in instanceSettings.ts
|
||||
// 2. Change LATEST_STABLE_PY in dockerfile
|
||||
// 3. Change #[default] annotation for PyVersion in backend
|
||||
#[derive(Eq, PartialEq, Clone, Copy, Default, Debug)]
|
||||
pub enum PyVersion {
|
||||
Py310,
|
||||
#[default]
|
||||
Py311,
|
||||
Py312,
|
||||
Py313,
|
||||
}
|
||||
|
||||
impl PyVersion {
|
||||
pub async fn from_instance_version() -> Self {
|
||||
match INSTANCE_PYTHON_VERSION.read().await.clone() {
|
||||
Some(v) => PyVersion::from_string_with_dots(&v).unwrap_or_else(|| {
|
||||
let v = PyVersion::default();
|
||||
tracing::error!(
|
||||
"Cannot parse INSTANCE_PYTHON_VERSION ({:?}), fallback to latest_stable ({v:?})",
|
||||
*INSTANCE_PYTHON_VERSION
|
||||
);
|
||||
v
|
||||
}),
|
||||
// Use latest stable
|
||||
None => PyVersion::default(),
|
||||
}
|
||||
}
|
||||
/// e.g.: `/tmp/windmill/cache/python_3xy`
|
||||
pub fn to_cache_dir(&self) -> String {
|
||||
use windmill_common::worker::ROOT_CACHE_DIR;
|
||||
format!("{ROOT_CACHE_DIR}{}", &self.to_cache_dir_top_level())
|
||||
}
|
||||
/// e.g.: `python_3xy`
|
||||
pub fn to_cache_dir_top_level(&self) -> String {
|
||||
format!("python_{}", self.to_string_no_dot())
|
||||
}
|
||||
/// e.g.: `3xy`
|
||||
pub fn to_string_no_dot(&self) -> String {
|
||||
self.to_string_with_dot().replace('.', "")
|
||||
}
|
||||
/// e.g.: `3.xy`
|
||||
pub fn to_string_with_dot(&self) -> &str {
|
||||
use PyVersion::*;
|
||||
match self {
|
||||
Py310 => "3.10",
|
||||
Py311 => "3.11",
|
||||
Py312 => "3.12",
|
||||
Py313 => "3.13",
|
||||
}
|
||||
}
|
||||
pub fn from_string_with_dots(value: &str) -> Option<Self> {
|
||||
use PyVersion::*;
|
||||
match value {
|
||||
"3.10" => Some(Py310),
|
||||
"3.11" => Some(Py311),
|
||||
"3.12" => Some(Py312),
|
||||
"3.13" => Some(Py313),
|
||||
"default" => Some(PyVersion::default()),
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
"Cannot convert string (\"{value}\") to PyVersion\nExpected format x.yz"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn from_string_no_dots(value: &str) -> Option<Self> {
|
||||
use PyVersion::*;
|
||||
match value {
|
||||
"310" => Some(Py310),
|
||||
"311" => Some(Py311),
|
||||
"312" => Some(Py312),
|
||||
"313" => Some(Py313),
|
||||
"default" => Some(PyVersion::default()),
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
"Cannot convert string (\"{value}\") to PyVersion\nExpected format xyz"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
/// e.g.: `# py3xy` -> `PyVersion::Py3XY`
|
||||
pub fn parse_version(line: &str) -> Option<Self> {
|
||||
Self::from_string_no_dots(line.replace(" ", "").replace("#py", "").as_str())
|
||||
}
|
||||
pub fn from_py_annotations(a: PythonAnnotations) -> Option<Self> {
|
||||
let PythonAnnotations { py310, py311, py312, py313, .. } = a;
|
||||
use PyVersion::*;
|
||||
if py313 {
|
||||
Some(Py313)
|
||||
} else if py312 {
|
||||
Some(Py312)
|
||||
} else if py311 {
|
||||
Some(Py311)
|
||||
} else if py310 {
|
||||
Some(Py310)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
pub fn from_numeric(n: u32) -> Option<Self> {
|
||||
use PyVersion::*;
|
||||
match n {
|
||||
310 => Some(Py310),
|
||||
311 => Some(Py311),
|
||||
312 => Some(Py312),
|
||||
313 => Some(Py313),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn to_numeric(&self) -> u32 {
|
||||
use PyVersion::*;
|
||||
match self {
|
||||
Py310 => 310,
|
||||
Py311 => 311,
|
||||
Py312 => 312,
|
||||
Py313 => 313,
|
||||
}
|
||||
}
|
||||
pub async fn get_python(
|
||||
&self,
|
||||
job_id: &Uuid,
|
||||
mem_peak: &mut i32,
|
||||
// canceled_by: &mut Option<CanceledBy>,
|
||||
db: &Pool<Postgres>,
|
||||
worker_name: &str,
|
||||
w_id: &str,
|
||||
occupancy_metrics: &mut Option<&mut OccupancyMetrics>,
|
||||
) -> error::Result<Option<String>> {
|
||||
// lazy_static::lazy_static! {
|
||||
// static ref PYTHON_PATHS: Arc<RwLock<HashMap<PyVersion, String>>> = Arc::new(RwLock::new(HashMap::new()));
|
||||
// }
|
||||
|
||||
let res = self
|
||||
.get_python_inner(job_id, mem_peak, db, worker_name, w_id, occupancy_metrics)
|
||||
.await;
|
||||
|
||||
if let Err(ref e) = res {
|
||||
tracing::error!(
|
||||
"worker_name: {worker_name}, w_id: {w_id}, job_id: {job_id}\n
|
||||
Error while getting python from uv, falling back to system python: {e:?}"
|
||||
);
|
||||
}
|
||||
res
|
||||
}
|
||||
async fn get_python_inner(
|
||||
self,
|
||||
job_id: &Uuid,
|
||||
mem_peak: &mut i32,
|
||||
// canceled_by: &mut Option<CanceledBy>,
|
||||
db: &Pool<Postgres>,
|
||||
worker_name: &str,
|
||||
w_id: &str,
|
||||
occupancy_metrics: &mut Option<&mut OccupancyMetrics>,
|
||||
) -> error::Result<Option<String>> {
|
||||
let py_path = self.find_python().await;
|
||||
|
||||
// Runtime is not installed
|
||||
if py_path.is_err() {
|
||||
// Install it
|
||||
if let Err(err) = self
|
||||
.install_python(job_id, mem_peak, db, worker_name, w_id, occupancy_metrics)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Cannot install python: {err}");
|
||||
return Err(err);
|
||||
} else {
|
||||
// Try to find one more time
|
||||
let py_path = self.find_python().await;
|
||||
|
||||
if let Err(err) = py_path {
|
||||
tracing::error!("Cannot find python version {err}");
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// TODO: Cache the result
|
||||
py_path
|
||||
}
|
||||
} else {
|
||||
py_path
|
||||
}
|
||||
}
|
||||
async fn install_python(
|
||||
self,
|
||||
job_id: &Uuid,
|
||||
mem_peak: &mut i32,
|
||||
// canceled_by: &mut Option<CanceledBy>,
|
||||
db: &Pool<Postgres>,
|
||||
worker_name: &str,
|
||||
w_id: &str,
|
||||
occupancy_metrics: &mut Option<&mut OccupancyMetrics>,
|
||||
) -> error::Result<()> {
|
||||
let v = self.to_string_with_dot();
|
||||
append_logs(job_id, w_id, format!("\nINSTALLING PYTHON ({})", v), db).await;
|
||||
// Create dirs for newly installed python
|
||||
// If we dont do this, NSJAIL will not be able to mount cache
|
||||
// For the default version directory created during startup (main.rs)
|
||||
DirBuilder::new()
|
||||
.recursive(true)
|
||||
.create(self.to_cache_dir())
|
||||
.await
|
||||
.expect("could not create initial worker dir");
|
||||
|
||||
let logs = String::new();
|
||||
|
||||
#[cfg(windows)]
|
||||
let uv_cmd = "uv";
|
||||
|
||||
#[cfg(unix)]
|
||||
let uv_cmd = UV_PATH.as_str();
|
||||
|
||||
let mut child_cmd = Command::new(uv_cmd);
|
||||
child_cmd
|
||||
.args(["python", "install", v, "--python-preference=only-managed"])
|
||||
// TODO: Do we need these?
|
||||
.envs([("UV_PYTHON_INSTALL_DIR", PY_INSTALL_DIR)])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
let child_process = start_child_process(child_cmd, "uv").await?;
|
||||
|
||||
append_logs(&job_id, &w_id, logs, db).await;
|
||||
handle_child(
|
||||
job_id,
|
||||
db,
|
||||
mem_peak,
|
||||
&mut None,
|
||||
child_process,
|
||||
false,
|
||||
worker_name,
|
||||
&w_id,
|
||||
"uv",
|
||||
None,
|
||||
false,
|
||||
occupancy_metrics,
|
||||
)
|
||||
.await
|
||||
}
|
||||
async fn find_python(self) -> error::Result<Option<String>> {
|
||||
#[cfg(windows)]
|
||||
let uv_cmd = "uv";
|
||||
|
||||
#[cfg(unix)]
|
||||
let uv_cmd = UV_PATH.as_str();
|
||||
|
||||
let mut child_cmd = Command::new(uv_cmd);
|
||||
let output = child_cmd
|
||||
// .current_dir(job_dir)
|
||||
.args([
|
||||
"python",
|
||||
"find",
|
||||
self.to_string_with_dot(),
|
||||
"--python-preference=only-managed",
|
||||
])
|
||||
.envs([
|
||||
("UV_PYTHON_INSTALL_DIR", PY_INSTALL_DIR),
|
||||
("UV_PYTHON_PREFERENCE", "only-managed"),
|
||||
])
|
||||
// .stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
// Check if the command was successful
|
||||
if output.status.success() {
|
||||
// Convert the output to a String
|
||||
let stdout =
|
||||
String::from_utf8(output.stdout).expect("Failed to convert output to String");
|
||||
return Ok(Some(stdout.replace('\n', "")));
|
||||
} else {
|
||||
// If the command failed, print the error
|
||||
let stderr =
|
||||
String::from_utf8(output.stderr).expect("Failed to convert error output to String");
|
||||
return Err(error::Error::FindPythonError(stderr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
use crate::SYSTEM_ROOT;
|
||||
|
||||
@@ -120,6 +403,10 @@ pub fn handle_ephemeral_token(x: String) -> String {
|
||||
x
|
||||
}
|
||||
|
||||
// This function only invoked during deployment of script or test run.
|
||||
// And never for already deployed scripts, these have their lockfiles in PostgreSQL
|
||||
// thus this function call is skipped.
|
||||
/// Returns lockfile and python version
|
||||
pub async fn uv_pip_compile(
|
||||
job_id: &Uuid,
|
||||
requirements: &str,
|
||||
@@ -130,14 +417,16 @@ pub async fn uv_pip_compile(
|
||||
worker_name: &str,
|
||||
w_id: &str,
|
||||
occupancy_metrics: &mut Option<&mut OccupancyMetrics>,
|
||||
// Fallback to pip-compile. Will be removed in future
|
||||
mut no_uv: bool,
|
||||
py_version: PyVersion,
|
||||
// Debug-only flag
|
||||
no_cache: bool,
|
||||
// Fallback to pip-compile. Will be removed in future
|
||||
mut no_uv: bool,
|
||||
) -> error::Result<String> {
|
||||
let mut logs = String::new();
|
||||
logs.push_str(&format!("\nresolving dependencies..."));
|
||||
logs.push_str(&format!("\ncontent of requirements:\n{}\n", requirements));
|
||||
|
||||
let requirements = if let Some(pip_local_dependencies) =
|
||||
WORKER_CONFIG.read().await.pip_local_dependencies.as_ref()
|
||||
{
|
||||
@@ -167,6 +456,11 @@ pub async fn uv_pip_compile(
|
||||
requirements.to_string()
|
||||
};
|
||||
|
||||
// Include python version to requirements.in
|
||||
// We need it because same hash based on requirements.in can get calculated even for different python versions
|
||||
// To prevent from overwriting same requirements.in but with different python versions, we include version to hash
|
||||
let requirements = format!("# py{}\n{}", py_version.to_string_no_dot(), requirements);
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
let requirements = replace_pip_secret(db, w_id, &requirements, worker_name, job_id).await?;
|
||||
|
||||
@@ -186,15 +480,21 @@ pub async fn uv_pip_compile(
|
||||
if !no_cache {
|
||||
if let Some(cached) = sqlx::query_scalar!(
|
||||
"SELECT lockfile FROM pip_resolution_cache WHERE hash = $1",
|
||||
// Python version is included in hash,
|
||||
// hash will be the different for every python version
|
||||
req_hash
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
{
|
||||
logs.push_str(&format!("\nfound cached resolution: {req_hash}"));
|
||||
logs.push_str(&format!(
|
||||
"\nFound cached resolution: {req_hash}, on python version: {}",
|
||||
py_version.to_string_with_dot()
|
||||
));
|
||||
return Ok(cached);
|
||||
}
|
||||
}
|
||||
|
||||
let file = "requirements.in";
|
||||
|
||||
write_file(job_dir, file, &requirements)?;
|
||||
@@ -269,6 +569,11 @@ pub async fn uv_pip_compile(
|
||||
.await
|
||||
.map_err(|e| Error::ExecutionErr(format!("Lock file generation failed: {e:?}")))?;
|
||||
} else {
|
||||
// Make sure we have python runtime installed
|
||||
py_version
|
||||
.get_python(job_id, mem_peak, db, worker_name, w_id, occupancy_metrics)
|
||||
.await?;
|
||||
|
||||
let mut args = vec![
|
||||
"pip",
|
||||
"compile",
|
||||
@@ -286,11 +591,15 @@ pub async fn uv_pip_compile(
|
||||
// Target to /tmp/windmill/cache/uv
|
||||
"--cache-dir",
|
||||
UV_CACHE_DIR,
|
||||
// We dont want UV to manage python installations
|
||||
"--python-preference",
|
||||
"only-system",
|
||||
"--no-python-downloads",
|
||||
];
|
||||
|
||||
args.extend([
|
||||
"-p",
|
||||
&py_version.to_string_with_dot(),
|
||||
"--python-preference",
|
||||
"only-managed",
|
||||
]);
|
||||
|
||||
if no_cache {
|
||||
args.extend(["--no-cache"]);
|
||||
}
|
||||
@@ -332,6 +641,7 @@ pub async fn uv_pip_compile(
|
||||
.env_clear()
|
||||
.env("HOME", HOME_ENV.to_string())
|
||||
.env("PATH", PATH_ENV.to_string())
|
||||
.env("UV_PYTHON_INSTALL_DIR", PY_INSTALL_DIR.to_string())
|
||||
.envs(PROXY_ENVS.clone())
|
||||
.args(&args)
|
||||
.stdout(Stdio::piped())
|
||||
@@ -378,17 +688,22 @@ pub async fn uv_pip_compile(
|
||||
let mut file = File::open(path_lock).await?;
|
||||
let mut req_content = "".to_string();
|
||||
file.read_to_string(&mut req_content).await?;
|
||||
let lockfile = req_content
|
||||
.lines()
|
||||
.filter(|x| !x.trim_start().starts_with('#'))
|
||||
.map(|x| x.to_string())
|
||||
.collect::<Vec<String>>()
|
||||
.join("\n");
|
||||
let lockfile = format!(
|
||||
"# py{}\n{}",
|
||||
py_version.to_string_no_dot(),
|
||||
req_content
|
||||
.lines()
|
||||
.filter(|x| !x.trim_start().starts_with('#'))
|
||||
.map(|x| x.to_string())
|
||||
.collect::<Vec<String>>()
|
||||
.join("\n")
|
||||
);
|
||||
sqlx::query!(
|
||||
"INSERT INTO pip_resolution_cache (hash, lockfile, expiration) VALUES ($1, $2, now() + ('3 days')::interval) ON CONFLICT (hash) DO UPDATE SET lockfile = $2",
|
||||
req_hash,
|
||||
lockfile
|
||||
).fetch_optional(db).await?;
|
||||
|
||||
Ok(lockfile)
|
||||
}
|
||||
|
||||
@@ -538,7 +853,8 @@ pub async fn handle_python_job(
|
||||
occupancy_metrics: &mut OccupancyMetrics,
|
||||
) -> windmill_common::error::Result<Box<RawValue>> {
|
||||
let script_path = crate::common::use_flow_root_path(job.script_path());
|
||||
let mut additional_python_paths = handle_python_deps(
|
||||
|
||||
let (py_version, mut additional_python_paths) = handle_python_deps(
|
||||
job_dir,
|
||||
requirements_o,
|
||||
inner_content,
|
||||
@@ -554,23 +870,53 @@ pub async fn handle_python_job(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let PythonAnnotations { no_uv, no_postinstall, .. } = PythonAnnotations::parse(inner_content);
|
||||
tracing::debug!("Finished handling python dependencies");
|
||||
let python_path = if no_uv {
|
||||
PYTHON_PATH.clone()
|
||||
} else if let Some(python_path) = py_version
|
||||
.get_python(
|
||||
&job.id,
|
||||
mem_peak,
|
||||
db,
|
||||
worker_name,
|
||||
&job.workspace_id,
|
||||
&mut Some(occupancy_metrics),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
python_path
|
||||
} else {
|
||||
PYTHON_PATH.clone()
|
||||
};
|
||||
|
||||
if !PythonAnnotations::parse(inner_content).no_postinstall {
|
||||
if !no_postinstall {
|
||||
if let Err(e) = postinstall(&mut additional_python_paths, job_dir, job, db).await {
|
||||
tracing::error!("Postinstall stage has failed. Reason: {e}");
|
||||
}
|
||||
tracing::debug!("Finished deps postinstall stage");
|
||||
}
|
||||
|
||||
append_logs(
|
||||
&job.id,
|
||||
&job.workspace_id,
|
||||
"\n\n--- PYTHON CODE EXECUTION ---\n".to_string(),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
if no_uv {
|
||||
append_logs(
|
||||
&job.id,
|
||||
&job.workspace_id,
|
||||
format!("\n\n--- SYSTEM PYTHON (Fallback) CODE EXECUTION ---\n",),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
append_logs(
|
||||
&job.id,
|
||||
&job.workspace_id,
|
||||
format!(
|
||||
"\n\n--- PYTHON ({}) CODE EXECUTION ---\n",
|
||||
py_version.to_string_with_dot()
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let (
|
||||
import_loader,
|
||||
import_base64,
|
||||
@@ -725,6 +1071,7 @@ mount {{
|
||||
"run.config.proto",
|
||||
&NSJAIL_CONFIG_RUN_PYTHON3_CONTENT
|
||||
.replace("{JOB_DIR}", job_dir)
|
||||
.replace("{PY_INSTALL_DIR}", PY_INSTALL_DIR)
|
||||
.replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string())
|
||||
.replace("{SHARED_MOUNT}", shared_mount)
|
||||
.replace("{SHARED_DEPENDENCIES}", shared_deps.as_str())
|
||||
@@ -743,6 +1090,7 @@ mount {{
|
||||
"started python code execution {}",
|
||||
job.id
|
||||
);
|
||||
|
||||
let child = if !*DISABLE_NSJAIL {
|
||||
let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str());
|
||||
nsjail_cmd
|
||||
@@ -759,7 +1107,7 @@ mount {{
|
||||
"--config",
|
||||
"run.config.proto",
|
||||
"--",
|
||||
PYTHON_PATH.as_str(),
|
||||
&python_path,
|
||||
"-u",
|
||||
"-m",
|
||||
"wrapper",
|
||||
@@ -768,7 +1116,9 @@ mount {{
|
||||
.stderr(Stdio::piped());
|
||||
start_child_process(nsjail_cmd, NSJAIL_PATH.as_str()).await?
|
||||
} else {
|
||||
let mut python_cmd = Command::new(PYTHON_PATH.as_str());
|
||||
let mut python_cmd = Command::new(&python_path);
|
||||
|
||||
let args = vec!["-u", "-m", "wrapper"];
|
||||
python_cmd
|
||||
.current_dir(job_dir)
|
||||
.env_clear()
|
||||
@@ -778,7 +1128,7 @@ mount {{
|
||||
.env("TZ", TZ_ENV.as_str())
|
||||
.env("BASE_INTERNAL_URL", base_internal_url)
|
||||
.env("HOME", HOME_ENV.as_str())
|
||||
.args(vec!["-u", "-m", "wrapper"])
|
||||
.args(args)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
@@ -788,7 +1138,7 @@ mount {{
|
||||
python_cmd.env("USERPROFILE", crate::USERPROFILE_ENV.as_str());
|
||||
}
|
||||
|
||||
start_child_process(python_cmd, PYTHON_PATH.as_str()).await?
|
||||
start_child_process(python_cmd, &python_path).await?
|
||||
};
|
||||
|
||||
handle_child(
|
||||
@@ -1073,7 +1423,7 @@ async fn handle_python_deps(
|
||||
mem_peak: &mut i32,
|
||||
canceled_by: &mut Option<CanceledBy>,
|
||||
occupancy_metrics: &mut Option<&mut OccupancyMetrics>,
|
||||
) -> error::Result<Vec<String>> {
|
||||
) -> error::Result<(PyVersion, Vec<String>)> {
|
||||
create_dependencies_dir(job_dir).await;
|
||||
|
||||
let mut additional_python_paths: Vec<String> = WORKER_CONFIG
|
||||
@@ -1084,8 +1434,12 @@ async fn handle_python_deps(
|
||||
.unwrap_or_else(|| vec![])
|
||||
.clone();
|
||||
|
||||
let annotations = windmill_common::worker::PythonAnnotations::parse(inner_content);
|
||||
let mut requirements;
|
||||
let mut annotated_pyv = None;
|
||||
let mut annotated_pyv_numeric = None;
|
||||
let is_deployed = requirements_o.is_some();
|
||||
let instance_pyv = PyVersion::from_instance_version().await;
|
||||
let annotations = windmill_common::worker::PythonAnnotations::parse(inner_content);
|
||||
let requirements = match requirements_o {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
@@ -1097,9 +1451,13 @@ async fn handle_python_deps(
|
||||
script_path,
|
||||
db,
|
||||
&mut already_visited,
|
||||
&mut annotated_pyv_numeric,
|
||||
)
|
||||
.await?
|
||||
.join("\n");
|
||||
|
||||
annotated_pyv = annotated_pyv_numeric.and_then(|v| PyVersion::from_numeric(v));
|
||||
|
||||
if !requirements.is_empty() {
|
||||
requirements = uv_pip_compile(
|
||||
job_id,
|
||||
@@ -1111,8 +1469,9 @@ async fn handle_python_deps(
|
||||
worker_name,
|
||||
w_id,
|
||||
occupancy_metrics,
|
||||
annotations.no_uv || annotations.no_uv_compile,
|
||||
annotated_pyv.unwrap_or(instance_pyv),
|
||||
annotations.no_cache,
|
||||
annotations.no_uv || annotations.no_uv_compile,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -1123,12 +1482,47 @@ async fn handle_python_deps(
|
||||
}
|
||||
};
|
||||
|
||||
let requirements_lines: Vec<&str> = if requirements.len() > 0 {
|
||||
requirements
|
||||
.split("\n")
|
||||
.filter(|x| !x.starts_with("--") && !x.trim().is_empty())
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
/*
|
||||
For deployed scripts we want to find out version in following order:
|
||||
1. Assigned version (written in lockfile)
|
||||
2. 3.11
|
||||
|
||||
For Previews:
|
||||
1. Annotated version
|
||||
2. Instance version
|
||||
3. Latest Stable
|
||||
*/
|
||||
let final_version = if is_deployed {
|
||||
// If script is deployed we can try to parse first line to get assigned version
|
||||
if let Some(v) = requirements_lines
|
||||
.get(0)
|
||||
.and_then(|line| PyVersion::parse_version(line))
|
||||
{
|
||||
// We have valid assigned version, we use it
|
||||
v
|
||||
} else {
|
||||
// If there is no assigned version in lockfile we automatically fallback to 3.11
|
||||
// In this case we have dependencies, but no associated python version
|
||||
// This is the case for old deployed scripts
|
||||
PyVersion::Py311
|
||||
}
|
||||
} else {
|
||||
// This is not deployed script, meaning we test run it (Preview)
|
||||
annotated_pyv.unwrap_or(instance_pyv)
|
||||
};
|
||||
// If len > 0 it means there is atleast one dependency or assigned python version
|
||||
if requirements.len() > 0 {
|
||||
let mut venv_path = handle_python_reqs(
|
||||
requirements
|
||||
.split("\n")
|
||||
.filter(|x| !x.starts_with("--") && !x.trim().is_empty())
|
||||
.collect(),
|
||||
requirements_lines,
|
||||
job_id,
|
||||
w_id,
|
||||
mem_peak,
|
||||
@@ -1138,13 +1532,14 @@ async fn handle_python_deps(
|
||||
job_dir,
|
||||
worker_dir,
|
||||
occupancy_metrics,
|
||||
final_version,
|
||||
annotations.no_uv || annotations.no_uv_install,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
additional_python_paths.append(&mut venv_path);
|
||||
}
|
||||
Ok(additional_python_paths)
|
||||
|
||||
Ok((final_version, additional_python_paths))
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
@@ -1160,6 +1555,8 @@ async fn spawn_uv_install(
|
||||
venv_p: &str,
|
||||
job_dir: &str,
|
||||
(pip_extra_index_url, pip_index_url): (Option<String>, Option<String>),
|
||||
// If none, it is system python
|
||||
py_path: Option<String>,
|
||||
no_uv_install: bool,
|
||||
) -> Result<tokio::process::Child, Error> {
|
||||
if !*DISABLE_NSJAIL {
|
||||
@@ -1181,7 +1578,14 @@ async fn spawn_uv_install(
|
||||
if let Some(host) = PIP_TRUSTED_HOST.as_ref() {
|
||||
vars.push(("TRUSTED_HOST", host));
|
||||
}
|
||||
|
||||
let _owner;
|
||||
if let Some(py_path) = py_path.as_ref() {
|
||||
_owner = format!(
|
||||
"-p {} --python-preference only-managed",
|
||||
py_path.as_str() //
|
||||
);
|
||||
vars.push(("PY_PATH", &_owner));
|
||||
}
|
||||
vars.push(("REQ", &req));
|
||||
vars.push(("TARGET", venv_p));
|
||||
|
||||
@@ -1231,8 +1635,6 @@ async fn spawn_uv_install(
|
||||
&req,
|
||||
"--no-deps",
|
||||
"--no-color",
|
||||
// "-p",
|
||||
// "3.11",
|
||||
// Prevent uv from discovering configuration files.
|
||||
"--no-config",
|
||||
"--link-mode=copy",
|
||||
@@ -1250,6 +1652,22 @@ async fn spawn_uv_install(
|
||||
]
|
||||
};
|
||||
|
||||
if !no_uv_install {
|
||||
if let Some(py_path) = py_path.as_ref() {
|
||||
command_args.extend([
|
||||
"-p",
|
||||
py_path.as_str(),
|
||||
"--python-preference",
|
||||
"only-managed", //
|
||||
]);
|
||||
} else {
|
||||
command_args.extend([
|
||||
"--python-preference",
|
||||
"only-system", //
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(url) = pip_extra_index_url.as_ref() {
|
||||
url.split(",").for_each(|url| {
|
||||
command_args.extend(["--extra-index-url", url]);
|
||||
@@ -1339,7 +1757,7 @@ fn pad_string(value: &str, total_length: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// pip install, include cached or pull from S3
|
||||
/// uv pip install, include cached or pull from S3
|
||||
pub async fn handle_python_reqs(
|
||||
requirements: Vec<&str>,
|
||||
job_id: &Uuid,
|
||||
@@ -1351,9 +1769,9 @@ pub async fn handle_python_reqs(
|
||||
job_dir: &str,
|
||||
worker_dir: &str,
|
||||
_occupancy_metrics: &mut Option<&mut OccupancyMetrics>,
|
||||
py_version: PyVersion,
|
||||
// TODO: Remove (Deprecated)
|
||||
mut no_uv_install: bool,
|
||||
is_ansible: bool,
|
||||
) -> error::Result<Vec<String>> {
|
||||
let counter_arc = Arc::new(tokio::sync::Mutex::new(0));
|
||||
// Append logs with line like this:
|
||||
@@ -1405,7 +1823,7 @@ pub async fn handle_python_reqs(
|
||||
}
|
||||
no_uv_install |= *USE_PIP_INSTALL;
|
||||
|
||||
if no_uv_install && !is_ansible {
|
||||
if no_uv_install {
|
||||
append_logs(&job_id, w_id, "\nFallback to pip (Deprecated!)\n", db).await;
|
||||
tracing::warn!("Fallback to pip");
|
||||
}
|
||||
@@ -1449,13 +1867,14 @@ pub async fn handle_python_reqs(
|
||||
NSJAIL_CONFIG_DOWNLOAD_PY_CONTENT
|
||||
})
|
||||
.replace("{WORKER_DIR}", &worker_dir)
|
||||
.replace("{PY_INSTALL_DIR}", &PY_INSTALL_DIR)
|
||||
.replace(
|
||||
"{CACHE_DIR}",
|
||||
if no_uv_install {
|
||||
PIP_CACHE_DIR
|
||||
&(if no_uv_install {
|
||||
PIP_CACHE_DIR.to_owned()
|
||||
} else {
|
||||
PY311_CACHE_DIR
|
||||
},
|
||||
py_version.to_cache_dir()
|
||||
}),
|
||||
)
|
||||
.replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()),
|
||||
)?;
|
||||
@@ -1473,11 +1892,10 @@ pub async fn handle_python_reqs(
|
||||
if req.starts_with('#') || req.starts_with('-') || req.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
// TODO: Remove
|
||||
let py_prefix = if no_uv_install {
|
||||
PIP_CACHE_DIR
|
||||
} else {
|
||||
PY311_CACHE_DIR
|
||||
&py_version.to_cache_dir()
|
||||
};
|
||||
|
||||
let venv_p = format!(
|
||||
@@ -1528,7 +1946,7 @@ pub async fn handle_python_reqs(
|
||||
let mut local_mem_peak = 0;
|
||||
for pid_o in pids.lock().await.iter() {
|
||||
if pid_o.is_some(){
|
||||
let mem = get_mem_peak(*pid_o, !*DISABLE_NSJAIL).await;
|
||||
let mem = crate::handle_child::get_mem_peak(*pid_o, !*DISABLE_NSJAIL).await;
|
||||
if mem < 0 {
|
||||
tracing::warn!(
|
||||
workspace_id = %w_id_2,
|
||||
@@ -1659,6 +2077,14 @@ pub async fn handle_python_reqs(
|
||||
let is_not_pro = !matches!(get_license_plan().await, LicensePlan::Pro);
|
||||
|
||||
let total_time = std::time::Instant::now();
|
||||
let py_path = if no_uv_install {
|
||||
None
|
||||
} else {
|
||||
py_version
|
||||
.get_python(job_id, mem_peak, db, _worker_name, w_id, _occupancy_metrics)
|
||||
.await?
|
||||
};
|
||||
|
||||
let has_work = req_with_penv.len() > 0;
|
||||
for ((i, (req, venv_p)), mut kill_rx) in
|
||||
req_with_penv.iter().enumerate().zip(kill_rxs.into_iter())
|
||||
@@ -1688,6 +2114,7 @@ pub async fn handle_python_reqs(
|
||||
let venv_p = venv_p.clone();
|
||||
let counter_arc = counter_arc.clone();
|
||||
let pip_indexes = pip_indexes.clone();
|
||||
let py_path = py_path.clone();
|
||||
let pids = pids.clone();
|
||||
|
||||
handles.push(task::spawn(async move {
|
||||
@@ -1711,7 +2138,7 @@ pub async fn handle_python_reqs(
|
||||
tokio::select! {
|
||||
// Cancel was called on the job
|
||||
_ = kill_rx.recv() => return Err(anyhow::anyhow!("S3 pull was canceled")),
|
||||
pull = pull_from_tar(os, venv_p.clone(), no_uv_install) => {
|
||||
pull = pull_from_tar(os, venv_p.clone(), py_version.to_cache_dir_top_level(), no_uv_install) => {
|
||||
if let Err(e) = pull {
|
||||
tracing::info!(
|
||||
workspace_id = %w_id,
|
||||
@@ -1744,7 +2171,8 @@ pub async fn handle_python_reqs(
|
||||
&venv_p,
|
||||
&job_dir,
|
||||
pip_indexes,
|
||||
no_uv_install,
|
||||
py_path,
|
||||
no_uv_install
|
||||
).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
@@ -1828,7 +2256,6 @@ pub async fn handle_python_reqs(
|
||||
#[cfg(not(all(feature = "enterprise", feature = "parquet", unix)))]
|
||||
let s3_push = false;
|
||||
|
||||
|
||||
print_success(
|
||||
false,
|
||||
s3_push,
|
||||
@@ -1846,7 +2273,7 @@ pub async fn handle_python_reqs(
|
||||
#[cfg(all(feature = "enterprise", feature = "parquet", unix))]
|
||||
if s3_push {
|
||||
if let Some(os) = OBJECT_STORE_CACHE_SETTINGS.read().await.clone() {
|
||||
tokio::spawn(build_tar_and_push(os, venv_p.clone(), no_uv_install));
|
||||
tokio::spawn(build_tar_and_push(os, venv_p.clone(), py_version.to_cache_dir_top_level(), no_uv_install));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1955,7 +2382,7 @@ pub async fn start_worker(
|
||||
.to_vec();
|
||||
|
||||
let context_envs = build_envs_map(context).await;
|
||||
let additional_python_paths = handle_python_deps(
|
||||
let (_, additional_python_paths) = handle_python_deps(
|
||||
job_dir,
|
||||
requirements_o,
|
||||
inner_content,
|
||||
|
||||
@@ -90,12 +90,26 @@ use tokio::{
|
||||
use rand::Rng;
|
||||
|
||||
use crate::{
|
||||
bash_executor::{handle_bash_job, handle_powershell_job}, bun_executor::handle_bun_job, common::{
|
||||
bash_executor::{handle_bash_job, handle_powershell_job},
|
||||
bun_executor::handle_bun_job,
|
||||
common::{
|
||||
build_args_map, cached_result_path, get_cached_resource_value_if_valid,
|
||||
get_reserved_variables, update_worker_ping_for_failed_init_script, OccupancyMetrics,
|
||||
}, csharp_executor::handle_csharp_job, deno_executor::handle_deno_job, go_executor::handle_go_job, graphql_executor::do_graphql, handle_child::SLOW_LOGS, handle_job_error, job_logger::NO_LOGS_AT_ALL, js_eval::{eval_fetch_timeout, transpile_ts}, pg_executor::do_postgresql, result_processor::{process_result, start_background_processor}, worker_flow::{handle_flow, update_flow_status_in_progress}, worker_lockfiles::{
|
||||
},
|
||||
csharp_executor::handle_csharp_job,
|
||||
deno_executor::handle_deno_job,
|
||||
go_executor::handle_go_job,
|
||||
graphql_executor::do_graphql,
|
||||
handle_child::SLOW_LOGS,
|
||||
handle_job_error,
|
||||
job_logger::NO_LOGS_AT_ALL,
|
||||
js_eval::{eval_fetch_timeout, transpile_ts},
|
||||
pg_executor::do_postgresql,
|
||||
result_processor::{process_result, start_background_processor},
|
||||
worker_flow::{handle_flow, update_flow_status_in_progress},
|
||||
worker_lockfiles::{
|
||||
handle_app_dependency_job, handle_dependency_job, handle_flow_dependency_job,
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
#[cfg(feature = "rust")]
|
||||
@@ -105,7 +119,7 @@ use crate::rust_executor::handle_rust_job;
|
||||
use crate::php_executor::handle_php_job;
|
||||
|
||||
#[cfg(feature = "python")]
|
||||
use crate::python_executor::handle_python_job;
|
||||
use crate::python_executor::{handle_python_job, PyVersion};
|
||||
|
||||
#[cfg(feature = "python")]
|
||||
use crate::ansible_executor::handle_ansible_job;
|
||||
@@ -256,14 +270,20 @@ pub const LOCK_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "lock");
|
||||
// Used as fallback now
|
||||
pub const PIP_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "pip");
|
||||
|
||||
// pub const PY310_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "python_310");
|
||||
pub const PY310_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "python_310");
|
||||
pub const PY311_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "python_311");
|
||||
// pub const PY312_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "python_312");
|
||||
// pub const PY313_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "python_313");
|
||||
pub const PY312_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "python_312");
|
||||
pub const PY313_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "python_313");
|
||||
|
||||
pub const TAR_PY310_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "tar/python_310");
|
||||
pub const TAR_PY311_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "tar/python_311");
|
||||
pub const TAR_PY312_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "tar/python_312");
|
||||
pub const TAR_PY313_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "tar/python_313");
|
||||
|
||||
pub const UV_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "uv");
|
||||
pub const PY_INSTALL_DIR: &str = concatcp!(ROOT_CACHE_DIR, "py_runtime");
|
||||
pub const TAR_PYBASE_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "tar");
|
||||
pub const TAR_PIP_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "tar/pip");
|
||||
pub const TAR_PY311_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "tar/python_311");
|
||||
pub const DENO_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "deno");
|
||||
pub const DENO_CACHE_DIR_DEPS: &str = concatcp!(ROOT_CACHE_DIR, "deno/deps");
|
||||
pub const DENO_CACHE_DIR_NPM: &str = concatcp!(ROOT_CACHE_DIR, "deno/npm");
|
||||
@@ -325,7 +345,6 @@ const DOTNET_DEFAULT_PATH: &str = "C:\\Program Files\\dotnet\\dotnet.exe";
|
||||
#[cfg(unix)]
|
||||
const DOTNET_DEFAULT_PATH: &str = "/usr/bin/dotnet";
|
||||
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
|
||||
pub static ref JOB_TOKEN: Option<String> = std::env::var("JOB_TOKEN").ok();
|
||||
@@ -399,6 +418,7 @@ lazy_static::lazy_static! {
|
||||
|
||||
pub static ref PIP_EXTRA_INDEX_URL: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
|
||||
pub static ref PIP_INDEX_URL: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
|
||||
pub static ref INSTANCE_PYTHON_VERSION: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
|
||||
pub static ref JOB_DEFAULT_TIMEOUT: Arc<RwLock<Option<i32>>> = Arc::new(RwLock::new(None));
|
||||
|
||||
static ref MAX_TIMEOUT: u64 = std::env::var("TIMEOUT")
|
||||
@@ -765,6 +785,41 @@ pub async fn run_worker(
|
||||
let worker_dir = format!("{TMP_DIR}/{worker_name}");
|
||||
tracing::debug!(worker = %worker_name, hostname = %hostname, worker_dir = %worker_dir, "Creating worker dir");
|
||||
|
||||
#[cfg(feature = "python")]
|
||||
{
|
||||
let (db, worker_name, hostname, worker_dir) = (
|
||||
db.clone(),
|
||||
worker_name.clone(),
|
||||
hostname.to_owned(),
|
||||
worker_dir.clone(),
|
||||
);
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = PyVersion::from_instance_version()
|
||||
.await
|
||||
.get_python(&Uuid::nil(), &mut 0, &db, &worker_name, "", &mut None)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
worker = %worker_name,
|
||||
hostname = %hostname,
|
||||
worker_dir = %worker_dir,
|
||||
"Cannot preinstall or find Instance Python version to worker: {e}"//
|
||||
);
|
||||
}
|
||||
if let Err(e) = PyVersion::Py311
|
||||
.get_python(&Uuid::nil(), &mut 0, &db, &worker_name, "", &mut None)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
worker = %worker_name,
|
||||
hostname = %hostname,
|
||||
worker_dir = %worker_dir,
|
||||
"Cannot preinstall or find default 311 version to worker: {e}"//
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(ref netrc) = *NETRC {
|
||||
tracing::info!(worker = %worker_name, hostname = %hostname, "Writing netrc at {}/.netrc", HOME_ENV.as_str());
|
||||
write_file(&HOME_ENV, ".netrc", netrc).expect("could not write netrc");
|
||||
|
||||
@@ -39,7 +39,8 @@ use crate::csharp_executor::generate_nuget_lockfile;
|
||||
use crate::php_executor::{composer_install, parse_php_imports};
|
||||
#[cfg(feature = "python")]
|
||||
use crate::python_executor::{
|
||||
create_dependencies_dir, handle_python_reqs, uv_pip_compile, USE_PIP_COMPILE, USE_PIP_INSTALL,
|
||||
create_dependencies_dir, handle_python_reqs, uv_pip_compile, PyVersion, USE_PIP_COMPILE,
|
||||
USE_PIP_INSTALL,
|
||||
};
|
||||
#[cfg(feature = "rust")]
|
||||
use crate::rust_executor::generate_cargo_lockfile;
|
||||
@@ -1595,10 +1596,28 @@ async fn python_dep(
|
||||
w_id: &str,
|
||||
worker_dir: &str,
|
||||
occupancy_metrics: &mut Option<&mut OccupancyMetrics>,
|
||||
annotated_pyv_numeric: Option<u32>,
|
||||
annotations: PythonAnnotations,
|
||||
no_uv_compile: bool,
|
||||
no_uv_install: bool,
|
||||
) -> std::result::Result<String, Error> {
|
||||
create_dependencies_dir(job_dir).await;
|
||||
|
||||
/*
|
||||
Unlike `handle_python_deps` which we use for running scripts (deployed and drafts)
|
||||
This one used specifically for deploying scripts
|
||||
So we can get final_version right away and include in lockfile
|
||||
And the precendence is following:
|
||||
|
||||
1. Annotation version
|
||||
2. Instance version
|
||||
3. Latest Stable
|
||||
*/
|
||||
|
||||
let final_version = annotated_pyv_numeric
|
||||
.and_then(|pyv| PyVersion::from_numeric(pyv))
|
||||
.unwrap_or(PyVersion::from_instance_version().await);
|
||||
|
||||
let req: std::result::Result<String, Error> = uv_pip_compile(
|
||||
job_id,
|
||||
&reqs,
|
||||
@@ -1609,8 +1628,9 @@ async fn python_dep(
|
||||
worker_name,
|
||||
w_id,
|
||||
occupancy_metrics,
|
||||
final_version,
|
||||
annotations.no_cache,
|
||||
no_uv_compile,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
// install the dependencies to pre-fill the cache
|
||||
@@ -1626,8 +1646,8 @@ async fn python_dep(
|
||||
job_dir,
|
||||
worker_dir,
|
||||
occupancy_metrics,
|
||||
final_version,
|
||||
no_uv_install,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1665,10 +1685,18 @@ async fn capture_dependency_job(
|
||||
return Err(Error::InternalErr(
|
||||
"Python requires the python feature to be enabled".to_string(),
|
||||
));
|
||||
|
||||
#[cfg(feature = "python")]
|
||||
{
|
||||
let anns = PythonAnnotations::parse(job_raw_code);
|
||||
let mut annotated_pyv_numeric = None;
|
||||
|
||||
let reqs = if raw_deps {
|
||||
// `wmill script generate-metadata`
|
||||
// should also respect annotated pyversion
|
||||
// can be annotated in script itself
|
||||
// or in requirements.txt if present
|
||||
annotated_pyv_numeric =
|
||||
PyVersion::from_py_annotations(anns).map(|v| v.to_numeric());
|
||||
job_raw_code.to_string()
|
||||
} else {
|
||||
let mut already_visited = vec![];
|
||||
@@ -1679,14 +1707,12 @@ async fn capture_dependency_job(
|
||||
script_path,
|
||||
&db,
|
||||
&mut already_visited,
|
||||
&mut annotated_pyv_numeric,
|
||||
)
|
||||
.await?
|
||||
.join("\n")
|
||||
};
|
||||
|
||||
let PythonAnnotations { no_uv, no_uv_install, no_uv_compile, .. } =
|
||||
PythonAnnotations::parse(job_raw_code);
|
||||
|
||||
let PythonAnnotations { no_uv, no_uv_install, no_uv_compile, .. } = anns;
|
||||
if no_uv || no_uv_install || no_uv_compile || *USE_PIP_COMPILE || *USE_PIP_INSTALL {
|
||||
if let Err(e) = sqlx::query!(
|
||||
r#"
|
||||
@@ -1713,6 +1739,8 @@ async fn capture_dependency_job(
|
||||
w_id,
|
||||
worker_dir,
|
||||
&mut Some(occupancy_metrics),
|
||||
annotated_pyv_numeric,
|
||||
anns,
|
||||
no_uv_compile | no_uv,
|
||||
no_uv_install | no_uv,
|
||||
)
|
||||
@@ -1761,6 +1789,8 @@ async fn capture_dependency_job(
|
||||
w_id,
|
||||
worker_dir,
|
||||
&mut Some(occupancy_metrics),
|
||||
None,
|
||||
PythonAnnotations::default(),
|
||||
false,
|
||||
false,
|
||||
)
|
||||
|
||||
@@ -24,8 +24,10 @@
|
||||
let supabaseWizard = false
|
||||
|
||||
async function isSupabaseAvailable() {
|
||||
supabaseWizard =
|
||||
((await OauthService.listOauthConnects()) ?? {})['supabase_wizard'] != undefined
|
||||
try {
|
||||
supabaseWizard =
|
||||
((await OauthService.listOauthConnects()) ?? {})['supabase_wizard'] != undefined
|
||||
} catch (error) {}
|
||||
}
|
||||
async function loadSchema() {
|
||||
if (!resourceTypeInfo) return
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
|
||||
let scopes: string[] = []
|
||||
let extra_params: [string, string][] = []
|
||||
|
||||
let path: string
|
||||
let description = ''
|
||||
|
||||
|
||||
@@ -481,7 +481,14 @@
|
||||
|
||||
const selectedIdStore = writable<string>(selectedId ?? 'settings-metadata')
|
||||
const selectedTriggerStore = writable<
|
||||
'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes' | 'websockets' | 'scheduledPoll'
|
||||
| 'webhooks'
|
||||
| 'emails'
|
||||
| 'schedules'
|
||||
| 'cli'
|
||||
| 'routes'
|
||||
| 'websockets'
|
||||
| 'postgres'
|
||||
| 'scheduledPoll'
|
||||
>('webhooks')
|
||||
|
||||
export function getSelectedId() {
|
||||
@@ -516,6 +523,7 @@
|
||||
| 'cli'
|
||||
| 'routes'
|
||||
| 'websockets'
|
||||
| 'postgres'
|
||||
| 'scheduledPoll'
|
||||
) {
|
||||
selectedTriggerStore.set(selectedTrigger)
|
||||
|
||||
@@ -27,15 +27,20 @@
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { fade } from 'svelte/transition'
|
||||
import { base } from '$lib/base'
|
||||
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
|
||||
import SimpleEditor from './SimpleEditor.svelte'
|
||||
|
||||
export let setting: Setting
|
||||
export let version: string
|
||||
export let values: Writable<Record<string, any>>
|
||||
export let loading = true
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
if (setting.fieldType == 'select' && $values[setting.key] == undefined){
|
||||
$values[setting.key] = "default";
|
||||
}
|
||||
|
||||
let latestKeyRenewalAttempt: {
|
||||
result: string
|
||||
attempted_at: string
|
||||
@@ -166,6 +171,24 @@
|
||||
EE only {#if setting.ee_only != ''}<Tooltip>{setting.ee_only}</Tooltip>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if setting.fieldType == 'select'}
|
||||
<div>
|
||||
<!-- svelte-ignore a11y-label-has-associated-control -->
|
||||
<label class="block pb-2">
|
||||
<span class="text-primary font-semibold text-sm">{setting.label}</span>
|
||||
{#if setting.description}
|
||||
<span class="text-secondary text-xs">
|
||||
{@html setting.description}
|
||||
</span>
|
||||
{/if}
|
||||
</label>
|
||||
<ToggleButtonGroup bind:selected={$values[setting.key]}>
|
||||
{#each (setting.select_items ?? []) as item }
|
||||
<ToggleButton value={item.value ?? item.label} label={item.label} tooltip={item.tooltip} />
|
||||
{/each}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y-label-has-associated-control -->
|
||||
<label class="block pb-2">
|
||||
<span class="text-primary font-semibold text-sm">{setting.label}</span>
|
||||
@@ -881,8 +904,8 @@
|
||||
bind:seconds={$values[setting.key]}
|
||||
/>
|
||||
</div>
|
||||
{:else if setting.fieldType == 'select'}
|
||||
{/if}
|
||||
|
||||
{#if hasError}
|
||||
<span class="text-red-500 dark:text-red-400 text-sm">
|
||||
{setting.error ?? ''}
|
||||
@@ -892,4 +915,5 @@
|
||||
<input disabled placeholder="Loading..." />
|
||||
{/if}
|
||||
</label>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
VariableService,
|
||||
WebsocketTriggerService,
|
||||
KafkaTriggerService,
|
||||
PostgresTriggerService,
|
||||
NatsTriggerService
|
||||
} from '$lib/gen'
|
||||
import { superadmin, userStore, workspaceStore } from '$lib/stores'
|
||||
@@ -40,6 +41,7 @@
|
||||
| 'http_trigger'
|
||||
| 'websocket_trigger'
|
||||
| 'kafka_trigger'
|
||||
| 'postgres_trigger'
|
||||
| 'nats_trigger'
|
||||
let meta: Meta | undefined = undefined
|
||||
export let fullNamePlaceholder: string | undefined = undefined
|
||||
@@ -234,6 +236,11 @@
|
||||
workspace: $workspaceStore!,
|
||||
path: path
|
||||
})
|
||||
} else if (kind == 'postgres_trigger') {
|
||||
return await PostgresTriggerService.existsPostgresTrigger({
|
||||
workspace: $workspaceStore!,
|
||||
path: path
|
||||
})
|
||||
} else if (kind == 'nats_trigger') {
|
||||
return await NatsTriggerService.existsNatsTrigger({
|
||||
workspace: $workspaceStore!,
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
type NewScriptWithDraft,
|
||||
ScheduleService,
|
||||
type Script,
|
||||
type TriggersCount
|
||||
type TriggersCount,
|
||||
PostgresTriggerService
|
||||
} from '$lib/gen'
|
||||
import { inferArgs } from '$lib/infer'
|
||||
import { initialCode } from '$lib/script_helpers'
|
||||
import { page } from '$app/stores'
|
||||
import { defaultScripts, enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
cleanValueProperties,
|
||||
@@ -230,18 +232,40 @@
|
||||
|
||||
$: !disableHistoryChange &&
|
||||
replaceStateFn('#' + encodeState({ ...script, primarySchedule: $primaryScheduleStore }))
|
||||
|
||||
if (script.content == '') {
|
||||
initContent(script.language, script.kind, template)
|
||||
}
|
||||
|
||||
function initContent(
|
||||
async function isTemplateScript() {
|
||||
let getInitBlockTemplate = $page.url.searchParams.get('id')
|
||||
if (getInitBlockTemplate === null) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
getInitBlockTemplate = await PostgresTriggerService.getTemplateScript({
|
||||
workspace: $workspaceStore!,
|
||||
id: getInitBlockTemplate as string
|
||||
})
|
||||
return getInitBlockTemplate
|
||||
} catch (error) {
|
||||
sendUserToast(
|
||||
'An error occured when trying to load your template script, please try again later',
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function initContent(
|
||||
language: SupportedLanguage,
|
||||
kind: Script['kind'] | undefined,
|
||||
template: 'pgsql' | 'mysql' | 'script' | 'docker' | 'powershell' | 'bunnative'
|
||||
) {
|
||||
scriptEditor?.disableCollaboration()
|
||||
script.content = initialCode(language, kind, template)
|
||||
const templateScript = await isTemplateScript()
|
||||
script.content = initialCode(language, kind, template, templateScript != undefined)
|
||||
if (templateScript) {
|
||||
script.content += '\r\n' + templateScript
|
||||
}
|
||||
scriptEditor?.inferSchema(script.content, language, true)
|
||||
if (script.content != editor?.getCode()) {
|
||||
setCode(script.content)
|
||||
|
||||
@@ -48,7 +48,10 @@
|
||||
}))
|
||||
} else if (itemKind == 'script') {
|
||||
items = (
|
||||
await ScriptService.listScripts({ workspace: $workspaceStore!, kinds: kinds.join(',') })
|
||||
await ScriptService.listScripts({
|
||||
workspace: $workspaceStore!,
|
||||
kinds: kinds.join(','),
|
||||
})
|
||||
).map((script) => ({
|
||||
value: script.path,
|
||||
label: `${script.path}${script.summary ? ` | ${truncate(script.summary, 20)}` : ''}`
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
export let label: string | undefined = undefined
|
||||
export let tooltip: string | undefined = undefined
|
||||
export let documentationLink: string | undefined = undefined
|
||||
export let eeOnly = false
|
||||
export let small: boolean = false
|
||||
|
||||
@@ -38,7 +39,7 @@
|
||||
|
||||
<slot name="header" />
|
||||
{#if tooltip}
|
||||
<Tooltip>{tooltip}</Tooltip>
|
||||
<Tooltip {documentationLink}>{tooltip}</Tooltip>
|
||||
{/if}
|
||||
{#if eeOnly}
|
||||
{#if !$enterpriseLicense}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
| 'cli'
|
||||
| 'routes'
|
||||
| 'websockets'
|
||||
| 'postgres'
|
||||
| 'scheduledPoll'
|
||||
| 'kafka'
|
||||
| 'nats' = 'webhooks'
|
||||
@@ -54,6 +55,7 @@
|
||||
<slot slot="routes" name="routes" />
|
||||
<slot slot="websockets" name="websockets" />
|
||||
<slot slot="kafka" name="kafka" />
|
||||
<slot slot="postgres" name="postgres" />
|
||||
<slot slot="nats" name="nats" />
|
||||
<slot slot="emails" name="emails" />
|
||||
<slot slot="schedules" name="schedules" />
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
| 'cli'
|
||||
| 'routes'
|
||||
| 'websockets'
|
||||
| 'postgres'
|
||||
| 'scheduledPoll'
|
||||
| 'kafka'
|
||||
| 'nats'
|
||||
@@ -64,6 +65,7 @@
|
||||
<slot slot="routes" name="routes" />
|
||||
<slot slot="websockets" name="websockets" />
|
||||
<slot slot="kafka" name="kafka" />
|
||||
<slot slot="postgres" name="postgres" />
|
||||
<slot slot="nats" name="nats" />
|
||||
<slot slot="emails" name="emails" />
|
||||
<slot slot="schedules" name="schedules" />
|
||||
@@ -110,6 +112,7 @@
|
||||
<slot slot="script" name="script" />
|
||||
<slot slot="websockets" name="websockets" />
|
||||
<slot slot="kafka" name="kafka" />
|
||||
<slot slot="postgres" name="postgres" />
|
||||
<slot slot="nats" name="nats" />
|
||||
<slot slot="emails" name="emails" />
|
||||
<slot slot="schedules" name="schedules" />
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
| 'routes'
|
||||
| 'websockets'
|
||||
| 'kafka'
|
||||
| 'postgres'
|
||||
| 'nats'
|
||||
| 'scheduledPoll' = 'webhooks'
|
||||
export let simplfiedPoll: boolean = false
|
||||
@@ -66,6 +67,12 @@
|
||||
Websockets
|
||||
</span>
|
||||
</Tab>
|
||||
<Tab value="postgres">
|
||||
<span class="flex flex-row gap-2 items-center text-xs">
|
||||
<Unplug size={12} />
|
||||
Postgres
|
||||
</span>
|
||||
</Tab>
|
||||
<Tab value="kafka" otherValues={['nats']}>
|
||||
<span class="flex flex-row gap-2 items-center text-xs">
|
||||
<PlugZap size={12} />
|
||||
@@ -97,6 +104,8 @@
|
||||
<slot name="schedules" />
|
||||
{:else if triggerSelected === 'websockets'}
|
||||
<slot name="websockets" />
|
||||
{:else if triggerSelected === 'postgres'}
|
||||
<slot name="postgres" />
|
||||
{:else if triggerSelected === 'kafka' || triggerSelected === 'nats'}
|
||||
<div class="m-1.5">
|
||||
<ToggleButtonGroup bind:selected={eventStreamType}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Calendar, Mail, Webhook, Unplug, PlugZap } from 'lucide-svelte'
|
||||
import { Calendar, Mail, Webhook, Unplug, Database, PlugZap } from 'lucide-svelte'
|
||||
import TriggerButton from './TriggerButton.svelte'
|
||||
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
@@ -29,6 +29,7 @@
|
||||
| 'nats'
|
||||
| 'emails'
|
||||
| 'eventStreams'
|
||||
| 'postgres'
|
||||
)[] = showOnlyWithCount
|
||||
? ['webhooks', 'schedules', 'routes', 'websockets', 'kafka', 'nats', 'emails']
|
||||
: ['webhooks', 'schedules', 'routes', 'websockets', 'eventStreams', 'emails']
|
||||
@@ -61,9 +62,10 @@
|
||||
schedules: { icon: Calendar, countKey: 'schedule_count' },
|
||||
routes: { icon: Route, countKey: 'http_routes_count' },
|
||||
websockets: { icon: Unplug, countKey: 'websocket_count' },
|
||||
postgres: { icon: Database, countKey: 'postgres_count' },
|
||||
kafka: { icon: KafkaIcon, countKey: 'kafka_count' },
|
||||
nats: { icon: NatsIcon, countKey: 'nats_count' },
|
||||
emails: { icon: Mail, countKey: 'email_count' },
|
||||
nats: { icon: NatsIcon, countKey: 'nats_count' },
|
||||
eventStreams: { icon: PlugZap }
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,13 @@ export interface Setting {
|
||||
ee_only?: string
|
||||
tooltip?: string
|
||||
key: string
|
||||
// If value is not specified for first element, it will automatcally use undefined
|
||||
select_items?: {
|
||||
label: string,
|
||||
tooltip?: string,
|
||||
// If not specified, label will be used
|
||||
value?: any,
|
||||
}[],
|
||||
fieldType:
|
||||
| 'text'
|
||||
| 'number'
|
||||
@@ -74,9 +81,9 @@ export const settings: Record<string, Setting[]> = {
|
||||
isValid: (value: string | undefined) =>
|
||||
value
|
||||
? value?.startsWith('http') &&
|
||||
value.includes('://') &&
|
||||
!value?.endsWith('/') &&
|
||||
!value?.endsWith(' ')
|
||||
value.includes('://') &&
|
||||
!value?.endsWith('/') &&
|
||||
!value?.endsWith(' ')
|
||||
: false
|
||||
},
|
||||
{
|
||||
@@ -219,6 +226,35 @@ export const settings: Record<string, Setting[]> = {
|
||||
],
|
||||
'Auth/OAuth': [],
|
||||
Registries: [
|
||||
{
|
||||
label: 'Instance Python Version',
|
||||
description: 'Default python version for newly deployed scripts',
|
||||
key: 'instance_python_version',
|
||||
fieldType: 'select',
|
||||
// To change latest stable version:
|
||||
// 1. Change placeholder in instanceSettings.ts
|
||||
// 2. Change LATEST_STABLE_PY in dockerfile
|
||||
// 3. Change #[default] annotation for PyVersion in backend
|
||||
placeholder: '3.10,3.11,3.12,3.13',
|
||||
select_items: [{
|
||||
label: "Latest Stable",
|
||||
value: "default",
|
||||
tooltip: "python-3.11",
|
||||
},
|
||||
{
|
||||
label: "3.10",
|
||||
},
|
||||
{
|
||||
label: "3.11",
|
||||
},
|
||||
{
|
||||
label: "3.12",
|
||||
},
|
||||
{
|
||||
label: "3.13",
|
||||
}],
|
||||
storage: 'setting',
|
||||
},
|
||||
{
|
||||
label: 'Pip index url',
|
||||
description: 'Add private Pip registry',
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import DataTable from '$lib/components/table/DataTable.svelte'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import Head from '$lib/components/table/Head.svelte'
|
||||
import Cell from '$lib/components/table/Cell.svelte'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { SaveIcon, EyeIcon, EyeOffIcon } from 'lucide-svelte'
|
||||
|
||||
let operatorWorkspaceSettings = {
|
||||
runs: true,
|
||||
schedules: true,
|
||||
resources: true,
|
||||
variables: true,
|
||||
triggers: true,
|
||||
audit_logs: true,
|
||||
groups: true,
|
||||
folders: true,
|
||||
workers: true
|
||||
}
|
||||
|
||||
let originalSettings = { ...operatorWorkspaceSettings }
|
||||
let isChanged = false
|
||||
let currentWorkspace: string | null = null
|
||||
|
||||
async function saveSettings() {
|
||||
try {
|
||||
await WorkspaceService.updateOperatorSettings({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: operatorWorkspaceSettings
|
||||
})
|
||||
originalSettings = { ...operatorWorkspaceSettings }
|
||||
isChanged = false
|
||||
sendUserToast('Operator settings saved successfully!', false)
|
||||
} catch (error) {
|
||||
console.error('Error updating operator settings:', error)
|
||||
sendUserToast('Failed to save operator settings.', true)
|
||||
}
|
||||
}
|
||||
|
||||
const descriptions = {
|
||||
runs: { title: 'Runs', description: 'View runs' },
|
||||
schedules: { title: 'Schedules', description: 'View schedules' },
|
||||
resources: { title: 'Resources', description: 'View resources' },
|
||||
variables: { title: 'Variables', description: 'View variables' },
|
||||
triggers: { title: 'Triggers', description: 'View all triggers (HTTP, Websocket, Kafka)' },
|
||||
audit_logs: { title: 'Audit Logs', description: 'View audit logs' },
|
||||
groups: { title: 'Groups', description: 'View groups and group members' },
|
||||
folders: { title: 'Folders', description: 'View folders' },
|
||||
workers: { title: 'Workers', description: 'View workers and worker groups' }
|
||||
}
|
||||
|
||||
$: if ($workspaceStore && $workspaceStore !== currentWorkspace) {
|
||||
;(async () => {
|
||||
currentWorkspace = $workspaceStore
|
||||
const settings = await WorkspaceService.getSettings({
|
||||
workspace: $workspaceStore
|
||||
})
|
||||
if (settings.operator_settings !== null) {
|
||||
operatorWorkspaceSettings = settings.operator_settings ?? operatorWorkspaceSettings
|
||||
originalSettings = { ...operatorWorkspaceSettings }
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
$: isChanged = JSON.stringify(operatorWorkspaceSettings) !== JSON.stringify(originalSettings)
|
||||
|
||||
$: enableAllState = (() => {
|
||||
const values = Object.values(operatorWorkspaceSettings)
|
||||
if (values.every((v) => v === true)) return true
|
||||
if (values.every((v) => v === false)) return false
|
||||
return null
|
||||
})()
|
||||
|
||||
function toggleAllSettings(event) {
|
||||
const newValue = event.detail === true
|
||||
Object.keys(operatorWorkspaceSettings).forEach((key) => {
|
||||
operatorWorkspaceSettings[key] = newValue
|
||||
})
|
||||
operatorWorkspaceSettings = { ...operatorWorkspaceSettings }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mt-6">
|
||||
<Section
|
||||
label="Operator Settings"
|
||||
collapsable={true}
|
||||
tooltip="Configure the operator visibility settings for your workspace. Toggle the settings you want to enable."
|
||||
>
|
||||
<div class="flex flex-col gap-4 my-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-tertiary text-xs">
|
||||
Configure the operator visibility settings for your workspace. Toggle the settings you
|
||||
want to enable.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mb-2">
|
||||
<div class="flex justify-end" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<DataTable tableFixed={true} size="xs">
|
||||
<Head>
|
||||
<tr>
|
||||
<Cell head first>Section</Cell>
|
||||
<Cell head>Description</Cell>
|
||||
<Cell head last>
|
||||
<ToggleButtonGroup bind:selected={enableAllState} on:selected={toggleAllSettings}>
|
||||
<ToggleButton icon={EyeIcon} small={true} value={true} label="Enable All" />
|
||||
<ToggleButton icon={EyeOffIcon} small={true} value={false} label="Disable All" />
|
||||
</ToggleButtonGroup>
|
||||
</Cell>
|
||||
</tr>
|
||||
</Head>
|
||||
<tbody class="divide-y bg-surface">
|
||||
{#each Object.entries(descriptions) as [key, { title, description }]}
|
||||
<tr>
|
||||
<Cell first>{title}</Cell>
|
||||
<Cell>{description}</Cell>
|
||||
<Cell last class="pl-8">
|
||||
<ToggleButtonGroup bind:selected={operatorWorkspaceSettings[key]}>
|
||||
<ToggleButton icon={EyeIcon} small={true} value={true} label="On" />
|
||||
<ToggleButton icon={EyeOffIcon} small={true} value={false} label="Off" />
|
||||
</ToggleButtonGroup>
|
||||
</Cell>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<Button on:click={saveSettings} startIcon={{ icon: SaveIcon }} disabled={!isChanged}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Badge, Button, Popup, Skeleton } from '$lib/components/common'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
|
||||
import WorkspaceOperatorSettings from '$lib/components/settings/WorkspaceOperatorSettings.svelte'
|
||||
import InviteUser from '$lib/components/InviteUser.svelte'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
|
||||
@@ -441,6 +441,8 @@
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<WorkspaceOperatorSettings />
|
||||
|
||||
{#if showInvites}
|
||||
<PageHeader
|
||||
title="Invites ({invites.length ?? ''})"
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
import Menu from '../common/menu/MenuV2.svelte'
|
||||
|
||||
import MultiplayerMenu from './MultiplayerMenu.svelte'
|
||||
import { enterpriseLicense, superadmin } from '$lib/stores'
|
||||
import { enterpriseLicense, superadmin, userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import MenuButton from './MenuButton.svelte'
|
||||
import { MenuItem } from '@rgossiaux/svelte-headlessui'
|
||||
import MenuLink from './MenuLink.svelte'
|
||||
@@ -37,50 +37,76 @@
|
||||
kind: 'script' | 'flow' | 'app' | 'raw_app'
|
||||
}[]
|
||||
|
||||
const mainMenuLinks = [
|
||||
{ label: 'Home', href: `${base}/`, icon: Home },
|
||||
{ label: 'Runs', href: `${base}/runs`, icon: Play },
|
||||
{ label: 'Schedules', href: `${base}/schedules`, icon: Calendar }
|
||||
]
|
||||
$: mainMenuLinks = [
|
||||
{ label: 'Home', id: 'home', href: `${base}/`, icon: Home },
|
||||
{ label: 'Runs', id: 'runs', href: `${base}/runs`, icon: Play },
|
||||
{ label: 'Schedules', id: 'schedules', href: `${base}/schedules`, icon: Calendar }
|
||||
].filter(
|
||||
(link) =>
|
||||
link.id === 'home' ||
|
||||
($userWorkspaces && $workspaceStore && $userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.[link.id] === true)
|
||||
)
|
||||
|
||||
let secondMenuLinks = [
|
||||
$: secondMenuLinks = [
|
||||
{
|
||||
label: 'Resources',
|
||||
id: 'resources',
|
||||
href: `${base}/resources`
|
||||
},
|
||||
{
|
||||
label: 'Variables',
|
||||
id: 'variables',
|
||||
href: `${base}/variables`
|
||||
},
|
||||
{
|
||||
label: 'Custom HTTP Routes',
|
||||
label: 'Custom HTTP routes',
|
||||
id: 'triggers',
|
||||
href: `${base}/routes`
|
||||
},
|
||||
{
|
||||
label: 'Websocket Triggers',
|
||||
href: `${base}/websockets`
|
||||
label: 'Websocket triggers',
|
||||
id: 'triggers',
|
||||
href: `${base}/websocket_triggers`
|
||||
},
|
||||
{
|
||||
label: 'Kafka Triggers',
|
||||
href: `${base}/kafka`
|
||||
label: 'Postgres triggers',
|
||||
id: 'triggers',
|
||||
href: `${base}/postgres_triggers`
|
||||
},
|
||||
{
|
||||
label: 'Kafka triggers',
|
||||
id: 'triggers',
|
||||
href: `${base}/kafka_triggers`
|
||||
},
|
||||
{
|
||||
label: 'NATS triggers',
|
||||
id: 'triggers',
|
||||
href: `${base}/nats_triggers`
|
||||
},
|
||||
{
|
||||
label: 'Audit logs',
|
||||
id: 'audit_logs',
|
||||
href: `${base}/audit_logs`
|
||||
},
|
||||
{
|
||||
label: 'Groups',
|
||||
id: 'groups',
|
||||
href: `${base}/groups`
|
||||
},
|
||||
{
|
||||
label: 'Folders',
|
||||
id: 'folders',
|
||||
href: `${base}/folders`
|
||||
},
|
||||
{
|
||||
label: 'Workers',
|
||||
id: 'workers',
|
||||
href: `${base}/workers`
|
||||
}
|
||||
]
|
||||
].filter((link) => {
|
||||
if (!$userWorkspaces || !$workspaceStore) return false;
|
||||
return $userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.[link.id] === true
|
||||
})
|
||||
|
||||
let moreOpen = false
|
||||
</script>
|
||||
@@ -200,7 +226,7 @@
|
||||
class="divide-y"
|
||||
role="none"
|
||||
>
|
||||
{#if moreOpen == false}
|
||||
{#if moreOpen == false && secondMenuLinks.length > 0}
|
||||
<div class="px-2 text-tertiary text-2xs">More...</div>
|
||||
{:else}
|
||||
{#each secondMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
UserCog,
|
||||
Plus,
|
||||
Unplug,
|
||||
AlertCircle
|
||||
AlertCircle,
|
||||
Database
|
||||
} from 'lucide-svelte'
|
||||
import Menu from '../common/menu/MenuV2.svelte'
|
||||
import MenuButton from './MenuButton.svelte'
|
||||
@@ -78,7 +79,6 @@
|
||||
(link) => $usedTriggerKinds.includes(link.kind) || $page.url.pathname.includes(link.href)
|
||||
)
|
||||
]
|
||||
|
||||
async function leaveWorkspace() {
|
||||
await WorkspaceService.leaveWorkspace({ workspace: $workspaceStore ?? '' })
|
||||
sendUserToast('You left the workspace')
|
||||
@@ -101,6 +101,13 @@
|
||||
disabled: $userStore?.operator,
|
||||
kind: 'ws'
|
||||
},
|
||||
{
|
||||
label: 'Postgres',
|
||||
href: '/postgres_triggers',
|
||||
icon: Database,
|
||||
disabled: $userStore?.operator,
|
||||
kind: 'postgres'
|
||||
},
|
||||
{
|
||||
label: 'Kafka' + ($enterpriseLicense ? '' : ' (EE)'),
|
||||
href: '/kafka_triggers',
|
||||
@@ -120,7 +127,6 @@
|
||||
$: extraTriggerLinks = defaultExtraTriggerLinks.filter((link) => {
|
||||
return !$page.url.pathname.includes(link.href) && !$usedTriggerKinds.includes(link.kind)
|
||||
})
|
||||
|
||||
$: secondaryMenuLinks = [
|
||||
// {
|
||||
// label: 'Workspace',
|
||||
@@ -342,7 +348,6 @@
|
||||
{#if subItem.icon}
|
||||
<svelte:component this={subItem.icon} size={16} />
|
||||
{/if}
|
||||
|
||||
{subItem.label}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -50,6 +50,7 @@ export type TriggerKind =
|
||||
| 'scheduledPoll'
|
||||
| 'kafka'
|
||||
| 'nats'
|
||||
| 'postgres'
|
||||
export function captureTriggerKindToTriggerKind(kind: CaptureTriggerKind): TriggerKind {
|
||||
switch (kind) {
|
||||
case 'webhook':
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
import { capitalize, isObject, sendUserToast, sleep } from '$lib/utils'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import Alert from '../common/alert/Alert.svelte'
|
||||
import RouteEditorConfigSection from './RouteEditorConfigSection.svelte'
|
||||
import WebsocketEditorConfigSection from './WebsocketEditorConfigSection.svelte'
|
||||
import WebhooksConfigSection from './WebhooksConfigSection.svelte'
|
||||
import RouteEditorConfigSection from './http/RouteEditorConfigSection.svelte'
|
||||
import WebsocketEditorConfigSection from './websocket/WebsocketEditorConfigSection.svelte'
|
||||
import WebhooksConfigSection from './webhook/WebhooksConfigSection.svelte'
|
||||
import EmailTriggerConfigSection from '../details/EmailTriggerConfigSection.svelte'
|
||||
import KafkaTriggersConfigSection from './KafkaTriggersConfigSection.svelte'
|
||||
import KafkaTriggersConfigSection from './kafka/KafkaTriggersConfigSection.svelte'
|
||||
import type { ConnectionInfo } from '../common/alert/ConnectionIndicator.svelte'
|
||||
import type { CaptureInfo } from './CaptureSection.svelte'
|
||||
import CaptureTable from './CaptureTable.svelte'
|
||||
import NatsTriggersConfigSection from './NatsTriggersConfigSection.svelte'
|
||||
import NatsTriggersConfigSection from './nats/NatsTriggersConfigSection.svelte'
|
||||
|
||||
export let isFlow: boolean
|
||||
export let path: string
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
<script lang="ts">
|
||||
import Tab from '$lib/components/common/tabs/Tab.svelte'
|
||||
import { Tabs } from '$lib/components/common'
|
||||
import WebhooksPanel from '$lib/components/triggers/WebhooksPanel.svelte'
|
||||
import WebhooksPanel from '$lib/components/triggers/webhook/WebhooksPanel.svelte'
|
||||
import EmailTriggerPanel from '$lib/components/details/EmailTriggerPanel.svelte'
|
||||
import RoutesPanel from '$lib/components/triggers/RoutesPanel.svelte'
|
||||
import RoutesPanel from '$lib/components/triggers/http/RoutesPanel.svelte'
|
||||
import RunPageSchedules from '$lib/components/RunPageSchedules.svelte'
|
||||
import { canWrite } from '$lib/utils'
|
||||
import { userStore } from '$lib/stores'
|
||||
import FlowCard from '../flows/common/FlowCard.svelte'
|
||||
import { getContext, onDestroy, createEventDispatcher } from 'svelte'
|
||||
import type { TriggerContext } from '$lib/components/triggers'
|
||||
import WebsocketTriggersPanel from './WebsocketTriggersPanel.svelte'
|
||||
import ScheduledPollPanel from './ScheduledPollPanel.svelte'
|
||||
import KafkaTriggersPanel from './KafkaTriggersPanel.svelte'
|
||||
import NatsTriggersPanel from './NatsTriggersPanel.svelte'
|
||||
import ScheduledPollPanel from './scheduled/ScheduledPollPanel.svelte'
|
||||
import WebsocketTriggersPanel from './websocket/WebsocketTriggersPanel.svelte'
|
||||
import PostgresTriggersPanel from './postgres/PostgresTriggersPanel.svelte'
|
||||
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { KafkaIcon, NatsIcon } from '../icons'
|
||||
import KafkaTriggersPanel from './kafka/KafkaTriggersPanel.svelte'
|
||||
import NatsTriggersPanel from './nats/NatsTriggersPanel.svelte'
|
||||
|
||||
export let noEditor: boolean
|
||||
export let newItem = false
|
||||
@@ -53,6 +54,7 @@
|
||||
<Tab value="schedules" selectedClass="text-primary text-sm font-semibold">Schedules</Tab>
|
||||
<Tab value="routes" selectedClass="text-primary text-sm font-semibold">HTTP</Tab>
|
||||
<Tab value="websockets" selectedClass="text-primary text-sm font-semibold">Websockets</Tab>
|
||||
<Tab value="postgres" selectedClass="text-primary text-sm font-semibold">Postgres</Tab>
|
||||
<Tab
|
||||
value="kafka"
|
||||
otherValues={['nats']}
|
||||
@@ -136,6 +138,10 @@
|
||||
{hasPreprocessor}
|
||||
/>
|
||||
</div>
|
||||
{:else if $selectedTrigger === 'postgres'}
|
||||
<div class="p-4">
|
||||
<PostgresTriggersPanel {newItem} path={currentPath} {isFlow} />
|
||||
</div>
|
||||
{:else if $selectedTrigger === 'kafka' || $selectedTrigger === 'nats'}
|
||||
<div class="p-4 flex flex-col gap-2">
|
||||
<ToggleButtonGroup bind:selected={eventStreamType}>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user