mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
feat: add sandbox annotations, volume mounts, for AI sandbox starting with claude (#8058)
This commit is contained in:
@@ -262,6 +262,12 @@ COPY --from=oven/bun:1.3.10 /usr/local/bin/bun /usr/bin/bun
|
||||
RUN bun install -g windmill-cli \
|
||||
&& ln -s $(bun pm bin -g)/wmill /usr/bin/wmill
|
||||
|
||||
# Install Claude Code CLI (used by claude sandbox scripts)
|
||||
# The installer puts the binary in ~/.local/bin/claude (symlink to ~/.local/share/claude/versions/*)
|
||||
# Copy it to /usr/bin/claude so it's accessible inside nsjail sandbox (which mounts /usr but not /root)
|
||||
RUN curl -fsSL https://claude.ai/install.sh | bash \
|
||||
&& cp /root/.local/share/claude/versions/* /usr/bin/claude
|
||||
|
||||
COPY --from=php:8.3.7-cli /usr/local/bin/php /usr/bin/php
|
||||
COPY --from=composer:2.7.6 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE volume SET lease_until = now() + interval '60 seconds'\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $3 AND lease_until > now()",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "00bf3dbd9d3f51dd7fdefcbd654d55e0379cc84188954037165cbe2d198ef71f"
|
||||
}
|
||||
+4
-3
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT token\n FROM token\n WHERE token LIKE concat($1::text, '%')\n LIMIT 1\n ",
|
||||
"query": "SELECT group_ FROM usr_to_group WHERE usr = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token",
|
||||
"name": "group_",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
@@ -18,5 +19,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "90092c0b3f7612373fcc8fb7a966200118ab308430d4a0cbb5cb16c397246492"
|
||||
"hash": "015a8551c646f9b027fc23752c5c5c81e520e3ca97dd1cd1e4ebfe3e46c4ad11"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT large_file_storage->>'volume_storage' FROM workspace_settings WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "?column?",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "083d69abc8a662bb364cf43b8ffc6e9b159a54c179cecb108068597536835f7e"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT extra_perms FROM volume WHERE workspace_id = $1 AND name = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "extra_perms",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0afd4ae50ff7e1b0dcca4b483816c595401dd2e1f7699a28bf3b79db5e3841f4"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT created_by FROM volume WHERE name = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0eb54f04a8185085b3f80772f5c28e666f6fbd1ec5ee9d30ee0cdb5e30a68750"
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by, lease_until, leased_by)\n VALUES ($1, $2, 0, $3, now() + interval '60 seconds', $4)\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET lease_until = now() + interval '60 seconds', leased_by = $4\n WHERE volume.lease_until IS NULL OR volume.lease_until < now()\n RETURNING name",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "14004a7c1641a3157eddd571fea11a1dfb1422187200119268b2342b47a960c6"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE volume SET last_used_at = now() WHERE workspace_id = $1 AND name = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1d2f765c2a71e1154ca5d9f5e52ef31e6d647377d37747f7bdc834748a59419e"
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by, last_used_at)\n VALUES ($1, $2, $3, $4, now())\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET size_bytes = $3, last_used_at = now()",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Int8",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1e9b9a02f45e6200f4d101bd5336fc8ce983f857339e6fccf799dc6587964aab"
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by, lease_until, leased_by)\n VALUES ($1, $2, 0, $3, now() + interval '60 seconds', $4)\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET lease_until = now() + interval '60 seconds', leased_by = $4\n WHERE volume.lease_until IS NULL OR volume.lease_until < now()\n RETURNING name",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "23f47f5207abe0cfaede197aeee485957990eb92fa3ce515895eab0d3f28bfdc"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE volume SET lease_until = NULL, leased_by = NULL\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "28df7bbe1f54f69640bc76def9e580b4c7ba25f279644e3233b63f4f6db0ad98"
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE volume\n SET size_bytes = $3, file_count = $4,\n updated_at = now(), updated_by = $5, last_used_at = now(),\n lease_until = NULL, leased_by = NULL\n WHERE workspace_id = $1 AND name = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int4",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3955e57e216d169c30b1548a2252eb169329116cba57780fa90ecf2bdb910f34"
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n name as \"name!\",\n size_bytes as \"size_bytes!\",\n file_count as \"file_count!\",\n created_at as \"created_at!\",\n created_by as \"created_by!\",\n updated_at,\n updated_by,\n description as \"description!\",\n last_used_at,\n extra_perms as \"extra_perms!\"\n FROM (\n SELECT\n COALESCE(v.name, a.path) as name,\n COALESCE(v.size_bytes, 0) as size_bytes,\n COALESCE(v.file_count, 0) as file_count,\n COALESCE(v.created_at, a.min_created_at) as created_at,\n COALESCE(v.created_by, 'unknown') as created_by,\n v.updated_at,\n v.updated_by,\n COALESCE(v.description, '') as description,\n v.last_used_at,\n COALESCE(v.extra_perms, '{}'::jsonb) as extra_perms\n FROM (\n SELECT path, MIN(created_at) as min_created_at\n FROM asset\n WHERE workspace_id = $1 AND kind = 'volume'\n GROUP BY path\n ) a\n FULL OUTER JOIN volume v ON v.workspace_id = $1 AND v.name = a.path\n WHERE v.workspace_id = $1 OR a.path IS NOT NULL\n ) combined\n ORDER BY name",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "name!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "size_bytes!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "file_count!",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "created_at!",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "created_by!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "updated_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "description!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "last_used_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "extra_perms!",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "40d0f6dca30456514cb85e36c6e367b27171894016c714e41497e69115be1468"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM volume WHERE workspace_id = $1 AND name = $2\n AND (lease_until IS NULL OR lease_until < now())\n RETURNING name",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5af44b46a2e2f1a9adeb39013790be7046cf8789d842717b6c793c22a2a05daa"
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT created_by, extra_perms FROM volume WHERE workspace_id = $1 AND name = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "extra_perms",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6086849bb08e1b37d6693d2808767cd897dca4722e4f2076308afdb7ee9fc147"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT count(*) FROM volume WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "712092e5033bc6894025a55ebc58bca8450d09982e582266d215dff521256fa6"
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE volume\n SET size_bytes = $3, file_count = $4,\n updated_at = now(), last_used_at = now(),\n lease_until = NULL, leased_by = NULL\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $5",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int4",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "75a03e9e4cba350a104e2e3a95de919cd25538c0b433bc29bb052c7a7b8568ca"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE volume SET lease_until = now() + interval '60 seconds'\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $3 AND lease_until > now()",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "769035629df5a5034f64bf38992e142006825a3911addacdf1a026660b5e2b7f"
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM volume WHERE workspace_id = $1 AND name = $2 AND lease_until > now() AND leased_by = $3)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "78af8bdb6a3ee6396c54f87ff6403b566fc75e16e0b7a81204816fd50b3346a5"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE volume SET lease_until = NULL, leased_by = NULL\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7ce06d4f623932fce12352be3a09ba8973a2ef1defa36c6d46d9c1c6406a7c33"
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by)\n VALUES ($1, $2, $3, $4)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Int8",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7e8e79a7d140be511cedbfe9ff8eea76a8a3079ce80c035087f797cdc410f35b"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT last_used_at FROM volume WHERE workspace_id = $1 AND name = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "last_used_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "803abdcd3614437b26c5d2e4f1ad75ca7014b431239ac1b681f2b26380c719c4"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE volume SET extra_perms = extra_perms - $1\n WHERE workspace_id = $2 AND name = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "82b3bd95e5d28c4cd4eedcae8cf050ba7b7e4d9eabba03be251ae9a8017b317d"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT leased_by FROM volume WHERE workspace_id = $1 AND name = $2 AND lease_until > now()",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "leased_by",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "88e25dc24bb06237b3677c947ee53fd6e9c7606231ad3c522e98cb1fcc14361a"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT count(*) FROM volume WHERE workspace_id = $1 AND name = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "907241c195fea227e4a945ee472425e5f7600e28c728a06235f7ff430a4bd77a"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT size_bytes FROM volume WHERE workspace_id = $1 AND name = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "size_bytes",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "94d6f598076ad67d68e6f01926c9fc2c73e855790e17abf5461b96ea30fbbdb7"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE volume SET lease_until = NULL, leased_by = NULL\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9662f1e304124fa52db4aa1e80e03b2601630f2d31458bdaf70c2702b2998d89"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM volume WHERE workspace_id = $1 AND name = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9e30b5545a51453205a713a6276156ada29ae320465d9790dce7e1e8a436d4de"
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT extra_perms, created_by FROM volume WHERE workspace_id = $1 AND name = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "extra_perms",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9f64d6ed0adb609ced1551563062550919fcac56deaf1b3cb36b3e15117936e7"
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by)\n VALUES ($1, $2, 0, $3)\n ON CONFLICT (workspace_id, name) DO NOTHING\n RETURNING name",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a3970c15271a124307301c0dafa263e7168fa325c5ceb44e9dd1595bdb7e7ce6"
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by)\n VALUES ($1, $2, $3, $4)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Int8",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ab8daa93bc66d0142b9e9e8d7fa6719fc41b2ca5cb0b7ac5ad73ab01b650c935"
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT size_bytes, last_used_at FROM volume WHERE workspace_id = $1 AND name = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "size_bytes",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "last_used_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "bc61ca62d8f71880facb5d701a6e78697414b35618c50f8693f4e804bf1d7dbb"
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT workspace_id, name, size_bytes, created_by, last_used_at\n FROM volume WHERE workspace_id = $1 AND name = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "size_bytes",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "last_used_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "d0869a340c8f34ca7a560d3b4c0070c9f117da3dd00ce3247c54a61052a6809c"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT leased_by FROM volume WHERE workspace_id = $1 AND name = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "leased_by",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "d1ad2baf5e3a6f45f1f079d494e8d6affad03a1f388024806a5de3f9cc939c04"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT name, size_bytes FROM volume WHERE workspace_id = $1 ORDER BY name",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "size_bytes",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "dc18db954239c4ebdd3b46cfd34f33554794444f0dc4e2d2fec158eca5ebe865"
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE volume SET extra_perms = jsonb_set(extra_perms, $1, to_jsonb($2::bool), true)\n WHERE workspace_id = $3 AND name = $4",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"TextArray",
|
||||
"Bool",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "eb79db2aeac7bf246ad56a5f116511b9d3183cb91b740a86944a77a2a964b57d"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT token as \"token!\"\n FROM token\n WHERE token LIKE concat($1::text, '%')\n LIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token!",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "eba16eb819e2644284fb073c891706d78a6f24cb0e614d7d81ba1b643805bf06"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT permissioned_as FROM v2_job WHERE id = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "permissioned_as",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f0ac12b66c5d3cca680541aed04359b064baf73b890efdc25426261d4eadfee0"
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT size_bytes, file_count, leased_by, lease_until\n FROM volume WHERE workspace_id = $1 AND name = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "size_bytes",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "file_count",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "leased_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "lease_until",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "f7ba87d5804b9bc05e7156c7c18c5a30037abef63efb5b44dc535c5f45d62a06"
|
||||
}
|
||||
Generated
+22
@@ -15798,7 +15798,9 @@ dependencies = [
|
||||
"windmill-queue",
|
||||
"windmill-runtime-nativets",
|
||||
"windmill-test-utils",
|
||||
"windmill-types",
|
||||
"windmill-worker",
|
||||
"windmill-worker-volumes",
|
||||
"windows-service",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
@@ -15952,6 +15954,7 @@ dependencies = [
|
||||
"windmill-trigger-websocket",
|
||||
"windmill-types",
|
||||
"windmill-worker",
|
||||
"windmill-worker-volumes",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -17499,9 +17502,28 @@ dependencies = [
|
||||
"windmill-queue",
|
||||
"windmill-runtime-nativets",
|
||||
"windmill-types",
|
||||
"windmill-worker-volumes",
|
||||
"yaml-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker-volumes"
|
||||
version = "1.650.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
"lazy_static",
|
||||
"md-5 0.10.6",
|
||||
"object_store",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"windmill-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.56.0"
|
||||
|
||||
@@ -70,6 +70,7 @@ members = [
|
||||
"./parsers/windmill-parser-py-imports",
|
||||
"./parsers/windmill-sql-datatype-parser-wasm",
|
||||
"./parsers/windmill-parser-yaml", "windmill-macros", "parsers/windmill-parser-nu",
|
||||
"./windmill-worker-volumes",
|
||||
"./windmill-test-utils",
|
||||
"./windmill-api-integration-tests",
|
||||
]
|
||||
@@ -250,6 +251,8 @@ reqwest.workspace = true
|
||||
windmill-queue = { workspace = true, features = ["failpoints"] }
|
||||
windmill-dep-map.workspace = true
|
||||
windmill-test-utils.workspace = true
|
||||
windmill-worker-volumes.workspace = true
|
||||
windmill-types.workspace = true
|
||||
axum.workspace = true
|
||||
serde.workspace = true
|
||||
windmill-api-client.workspace = true
|
||||
@@ -267,6 +270,7 @@ aws-credential-types.workspace = true
|
||||
windmill-api = { path = "./windmill-api", default-features = false }
|
||||
windmill-queue = { path = "./windmill-queue" }
|
||||
windmill-worker = { path = "./windmill-worker" }
|
||||
windmill-worker-volumes = { path = "./windmill-worker-volumes" }
|
||||
windmill-dep-map = { path = "./windmill-dep-map" }
|
||||
windmill-types = { path = "./windmill-types" }
|
||||
windmill-common = { path = "./windmill-common", default-features = false }
|
||||
@@ -439,6 +443,7 @@ base64 = "^0.22.1"
|
||||
base32 = "^0"
|
||||
hmac = "0.12.1"
|
||||
sha2 = "0.10.6"
|
||||
md-5 = "0.10.6"
|
||||
sha1 = "0.10.6"
|
||||
sqlx = { version = "0.8.0", features = [
|
||||
"macros",
|
||||
|
||||
@@ -1 +1 @@
|
||||
6fd5a2ce908235a17975ad4dbdf0051cd89334f3
|
||||
151bc3edfe23c160f4f9b0cfaa708beb36c212f4
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS volume;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Add 'volume' to the asset_kind enum
|
||||
ALTER TYPE asset_kind ADD VALUE IF NOT EXISTS 'volume';
|
||||
|
||||
-- Volume metadata table
|
||||
CREATE TABLE volume (
|
||||
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
file_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by VARCHAR(255) NOT NULL,
|
||||
updated_at TIMESTAMPTZ,
|
||||
updated_by VARCHAR(255),
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
lease_until TIMESTAMPTZ,
|
||||
leased_by VARCHAR(255),
|
||||
last_used_at TIMESTAMPTZ,
|
||||
extra_perms JSONB NOT NULL DEFAULT '{}',
|
||||
PRIMARY KEY (workspace_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_volume_last_used ON volume(workspace_id, last_used_at);
|
||||
@@ -18,6 +18,7 @@ pub enum AssetKind {
|
||||
Resource,
|
||||
Ducklake,
|
||||
DataTable,
|
||||
Volume,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, PartialEq, Clone)]
|
||||
@@ -148,4 +149,5 @@ pub const ASSET_KINDS: &[(&str, AssetKind)] = &[
|
||||
("$res:", AssetKind::Resource),
|
||||
("ducklake://", AssetKind::Ducklake),
|
||||
("datatable://", AssetKind::DataTable),
|
||||
("volume://", AssetKind::Volume),
|
||||
];
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
#![cfg(all(feature = "private", feature = "agent_worker_server"))]
|
||||
|
||||
use windmill_test_utils::*;
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_common::{
|
||||
jobs::{JobPayload, RawCode},
|
||||
scripts::ScriptLang,
|
||||
};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn bun_code(code: &str) -> RawCode {
|
||||
RawCode {
|
||||
@@ -18,8 +18,8 @@ fn bun_code(code: &str) -> RawCode {
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings:
|
||||
windmill_common::runnable_settings::ConcurrencySettings::default().into(),
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
|
||||
.into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
}
|
||||
}
|
||||
@@ -223,7 +223,10 @@ async fn test_agent_worker_token_and_ping(db: Pool<Postgres>) -> anyhow::Result<
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
|
||||
assert!(worker_count > 0, "worker ping should be recorded in database");
|
||||
assert!(
|
||||
worker_count > 0,
|
||||
"worker ping should be recorded in database"
|
||||
);
|
||||
|
||||
// MainLoop ping updates the existing record
|
||||
let resp = http_client
|
||||
@@ -265,3 +268,319 @@ async fn test_agent_worker_multiple_jobs_sequential(db: Pool<Postgres>) -> anyho
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test the volume HTTP proxy endpoints that agent workers use.
|
||||
///
|
||||
/// Exercises the full volume lifecycle via HTTP:
|
||||
/// 1. Configure workspace S3 storage (FilesystemStorage)
|
||||
/// 2. Pre-populate a volume with a file
|
||||
/// 3. POST /begin — acquire lease, get manifest
|
||||
/// 4. GET /file/* — download existing file
|
||||
/// 5. PUT /file/* — upload a new file
|
||||
/// 6. POST /commit — finalize with stats, release lease
|
||||
/// 7. Verify DB state and storage
|
||||
#[cfg(feature = "parquet")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_agent_worker_volume_e2e(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let (client, _port, _server) = init_client_agent_mode(db.clone()).await;
|
||||
|
||||
// 1. Set up filesystem-based object storage in a temp dir
|
||||
let storage_dir = tempfile::tempdir()?;
|
||||
let storage_root = storage_dir.path().to_string_lossy().to_string();
|
||||
|
||||
let lfs_config = json!({
|
||||
"type": "FilesystemStorage",
|
||||
"root_path": storage_root,
|
||||
"public_resource": null,
|
||||
"advanced_permissions": null
|
||||
});
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2",
|
||||
lfs_config,
|
||||
"test-workspace"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// 2. Pre-populate the volume with a file
|
||||
let vol_dir = storage_dir.path().join("volumes").join("test-vol");
|
||||
std::fs::create_dir_all(&vol_dir)?;
|
||||
std::fs::write(vol_dir.join("hello.txt"), b"hello from volume")?;
|
||||
|
||||
let base = client.baseurl();
|
||||
let http = client.client();
|
||||
let vol_base = format!("{base}/w/test-workspace/volumes/test-vol");
|
||||
|
||||
// 3. POST /begin — acquire lease, get manifest + permissions
|
||||
let resp = http
|
||||
.post(format!("{vol_base}/begin"))
|
||||
.json(&json!({
|
||||
"worker_name": "test-worker-1",
|
||||
"permissioned_as": "u/test-user"
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"begin should succeed, got: {}",
|
||||
resp.status()
|
||||
);
|
||||
|
||||
let begin_body: serde_json::Value = resp.json().await?;
|
||||
assert!(
|
||||
begin_body["writable"].as_bool().unwrap(),
|
||||
"should be writable"
|
||||
);
|
||||
let manifest = begin_body["manifest"].as_object().unwrap();
|
||||
assert!(
|
||||
manifest.contains_key("hello.txt"),
|
||||
"manifest should contain hello.txt, got: {manifest:?}"
|
||||
);
|
||||
|
||||
// 4. GET /file/* — download the existing file
|
||||
let resp = http
|
||||
.get(format!("{vol_base}/file/hello.txt"))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"file download should succeed, got: {}",
|
||||
resp.status()
|
||||
);
|
||||
let file_bytes = resp.bytes().await?;
|
||||
assert_eq!(
|
||||
file_bytes.as_ref(),
|
||||
b"hello from volume",
|
||||
"downloaded file content should match"
|
||||
);
|
||||
|
||||
// 5. PUT /file/* — upload a new file
|
||||
let resp = http
|
||||
.put(format!("{vol_base}/file/output.txt"))
|
||||
.body(b"written by agent worker".to_vec())
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"file upload should succeed, got: {}",
|
||||
resp.status()
|
||||
);
|
||||
|
||||
// 6. POST /commit — finalize: report stats, release lease
|
||||
let resp = http
|
||||
.post(format!("{vol_base}/commit"))
|
||||
.json(&json!({
|
||||
"worker_name": "test-worker-1",
|
||||
"deleted_keys": [],
|
||||
"symlinks": {},
|
||||
"file_count": 2,
|
||||
"size_bytes": 39
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"commit should succeed, got: {}",
|
||||
resp.status()
|
||||
);
|
||||
|
||||
// 7. Verify volume DB row was updated
|
||||
let vol_row = sqlx::query!(
|
||||
"SELECT size_bytes, file_count, leased_by, lease_until
|
||||
FROM volume WHERE workspace_id = $1 AND name = $2",
|
||||
"test-workspace",
|
||||
"test-vol"
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
|
||||
let vol_row = vol_row.expect("volume row should exist");
|
||||
assert_eq!(vol_row.file_count, 2, "file_count should be 2");
|
||||
assert_eq!(vol_row.size_bytes, 39, "size_bytes should match");
|
||||
assert!(vol_row.leased_by.is_none(), "lease should be released");
|
||||
assert!(
|
||||
vol_row.lease_until.is_none() || vol_row.lease_until.unwrap() < chrono::Utc::now(),
|
||||
"lease_until should be cleared or in the past"
|
||||
);
|
||||
|
||||
// 8. Verify the uploaded file was persisted in storage
|
||||
let output_path = vol_dir.join("output.txt");
|
||||
assert!(output_path.exists(), "output.txt should be in storage");
|
||||
let output_content = std::fs::read_to_string(&output_path)?;
|
||||
assert_eq!(output_content, "written by agent worker");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Full E2E test: agent worker in HTTP mode runs a Bun script with a volume mount.
|
||||
///
|
||||
/// The worker pulls the job via HTTP, downloads volume files via the server-side
|
||||
/// volume proxy endpoints, executes the script, and syncs changes back.
|
||||
#[cfg(all(feature = "parquet", feature = "enterprise"))]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_agent_worker_volume_http_worker_e2e(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let (_client, port, _server) = init_client_agent_mode(db.clone()).await;
|
||||
|
||||
// 1. Set up filesystem-based object storage in a temp dir
|
||||
let storage_dir = tempfile::tempdir()?;
|
||||
let storage_root = storage_dir.path().to_string_lossy().to_string();
|
||||
|
||||
let lfs_config = json!({
|
||||
"type": "FilesystemStorage",
|
||||
"root_path": storage_root,
|
||||
"public_resource": null,
|
||||
"advanced_permissions": null
|
||||
});
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2",
|
||||
lfs_config,
|
||||
"test-workspace"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// 2. Pre-populate the volume with a file
|
||||
let vol_dir = storage_dir.path().join("volumes").join("test-vol");
|
||||
std::fs::create_dir_all(&vol_dir)?;
|
||||
std::fs::write(vol_dir.join("hello.txt"), b"hello from volume")?;
|
||||
|
||||
// 3. Push the job, then run worker with HTTP connection (bun tag)
|
||||
let code = r#"// volume: test-vol /tmp/data
|
||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||
|
||||
export function main() {
|
||||
const content = readFileSync("/tmp/data/hello.txt", "utf-8");
|
||||
writeFileSync("/tmp/data/output.txt", "written by agent worker");
|
||||
return {
|
||||
read_content: content,
|
||||
output_exists: existsSync("/tmp/data/output.txt"),
|
||||
};
|
||||
}"#;
|
||||
|
||||
let uuid = RunJob::from(JobPayload::Code(bun_code(code)))
|
||||
.push(&db)
|
||||
.await;
|
||||
let listener = listen_for_completed_jobs(&db).await;
|
||||
|
||||
let conn = testing_http_connection_with_tags(
|
||||
port,
|
||||
vec!["bun".into(), "flow".into(), "dependency".into()],
|
||||
)
|
||||
.await;
|
||||
|
||||
in_test_worker(conn, listener.find(&uuid), port).await;
|
||||
|
||||
let result = completed_job(uuid, &db).await;
|
||||
|
||||
assert!(result.success, "job should succeed: {:?}", result.result);
|
||||
let json = result.json_result().expect("should have JSON result");
|
||||
assert_eq!(json["read_content"], json!("hello from volume"));
|
||||
assert_eq!(json["output_exists"], json!(true));
|
||||
|
||||
// 4. Verify volume DB row was updated
|
||||
let vol_row = sqlx::query!(
|
||||
"SELECT size_bytes, file_count, leased_by, lease_until
|
||||
FROM volume WHERE workspace_id = $1 AND name = $2",
|
||||
"test-workspace",
|
||||
"test-vol"
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
|
||||
let vol_row = vol_row.expect("volume row should exist");
|
||||
assert!(
|
||||
vol_row.file_count >= 2,
|
||||
"should have at least 2 files (hello.txt + output.txt), got: {}",
|
||||
vol_row.file_count
|
||||
);
|
||||
assert!(vol_row.size_bytes > 0, "size_bytes should be > 0");
|
||||
assert!(vol_row.leased_by.is_none(), "lease should be released");
|
||||
assert!(
|
||||
vol_row.lease_until.is_none() || vol_row.lease_until.unwrap() < chrono::Utc::now(),
|
||||
"lease_until should be cleared or in the past"
|
||||
);
|
||||
|
||||
// 5. Verify the new file was written back to the storage
|
||||
let output_path = vol_dir.join("output.txt");
|
||||
assert!(
|
||||
output_path.exists(),
|
||||
"output.txt should be synced back to storage"
|
||||
);
|
||||
let output_content = std::fs::read_to_string(&output_path)?;
|
||||
assert_eq!(output_content, "written by agent worker");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test the volume release endpoint (error/cancel path).
|
||||
#[cfg(feature = "parquet")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_agent_worker_volume_release(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let (client, _port, _server) = init_client_agent_mode(db.clone()).await;
|
||||
|
||||
// Set up filesystem storage
|
||||
let storage_dir = tempfile::tempdir()?;
|
||||
let storage_root = storage_dir.path().to_string_lossy().to_string();
|
||||
let lfs_config = json!({
|
||||
"type": "FilesystemStorage",
|
||||
"root_path": storage_root,
|
||||
"public_resource": null,
|
||||
"advanced_permissions": null
|
||||
});
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2",
|
||||
lfs_config,
|
||||
"test-workspace"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let base = client.baseurl();
|
||||
let http = client.client();
|
||||
let vol_base = format!("{base}/w/test-workspace/volumes/test-vol");
|
||||
|
||||
// Begin (acquire lease)
|
||||
let resp = http
|
||||
.post(format!("{vol_base}/begin"))
|
||||
.json(&json!({
|
||||
"worker_name": "test-worker-2",
|
||||
"permissioned_as": "u/test-user"
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(resp.status().is_success(), "begin should succeed");
|
||||
|
||||
// Verify lease is held
|
||||
let leased = sqlx::query_scalar!(
|
||||
"SELECT leased_by FROM volume WHERE workspace_id = $1 AND name = $2",
|
||||
"test-workspace",
|
||||
"test-vol"
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten();
|
||||
assert_eq!(leased.as_deref(), Some("test-worker-2"));
|
||||
|
||||
// Release without commit (simulating error path)
|
||||
let resp = http
|
||||
.post(format!("{vol_base}/release"))
|
||||
.json(&json!({ "worker_name": "test-worker-2" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(resp.status().is_success(), "release should succeed");
|
||||
|
||||
// Verify lease is cleared
|
||||
let leased = sqlx::query_scalar!(
|
||||
"SELECT leased_by FROM volume WHERE workspace_id = $1 AND name = $2",
|
||||
"test-workspace",
|
||||
"test-vol"
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten();
|
||||
assert!(leased.is_none(), "lease should be released");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// volume: agent-memory .claude
|
||||
// sandbox
|
||||
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
type Anthropic = {
|
||||
api_key: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
export async function main(anthropic_resource: Anthropic) {
|
||||
const claudeDir = ".claude";
|
||||
const results: Record<string, unknown> = {};
|
||||
|
||||
// --- Step 1: Verify volume is mounted at the relative path ---
|
||||
results["volume_exists"] = fs.existsSync(claudeDir);
|
||||
if (!results["volume_exists"]) {
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
}
|
||||
|
||||
const testFile = path.join(claudeDir, "mount-check.txt");
|
||||
fs.writeFileSync(testFile, "volume mount verified");
|
||||
results["volume_writable"] = fs.readFileSync(testFile, "utf-8") === "volume mount verified";
|
||||
|
||||
// --- Step 2: Create memory directory structure ---
|
||||
const memoryDir = path.join(claudeDir, "memory");
|
||||
fs.mkdirSync(memoryDir, { recursive: true });
|
||||
|
||||
const memoryFile = path.join(memoryDir, "MEMORY.md");
|
||||
fs.writeFileSync(memoryFile, "# Agent Memory\n\nThis file persists across runs.\n");
|
||||
results["memory_file_created"] = fs.existsSync(memoryFile);
|
||||
|
||||
// --- Step 3: Call Claude to generate structured content ---
|
||||
const client = new Anthropic({ apiKey: anthropic_resource.api_key });
|
||||
const model = anthropic_resource.model ?? "claude-sonnet-4-20250514";
|
||||
|
||||
const response = await client.messages.create({
|
||||
model,
|
||||
max_tokens: 256,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
'Return a JSON object with exactly these keys: "greeting" (a short hello), "timestamp" (current ISO date you estimate), "items" (array of 3 random fruit names). Only return the JSON, no markdown.',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const assistantText =
|
||||
response.content[0].type === "text" ? response.content[0].text : "";
|
||||
results["claude_responded"] = assistantText.length > 0;
|
||||
results["claude_model"] = response.model;
|
||||
results["claude_stop_reason"] = response.stop_reason;
|
||||
|
||||
let parsed: Record<string, unknown> = {};
|
||||
try {
|
||||
parsed = JSON.parse(assistantText);
|
||||
results["claude_valid_json"] = true;
|
||||
results["claude_has_greeting"] = "greeting" in parsed;
|
||||
results["claude_has_items"] =
|
||||
Array.isArray(parsed.items) && parsed.items.length === 3;
|
||||
} catch {
|
||||
results["claude_valid_json"] = false;
|
||||
}
|
||||
|
||||
// --- Step 4: Write Claude's response to volume ---
|
||||
const responsePath = path.join(claudeDir, "claude-response.json");
|
||||
fs.writeFileSync(responsePath, JSON.stringify(parsed, null, 2));
|
||||
results["response_written"] = fs.existsSync(responsePath);
|
||||
|
||||
// --- Step 5: Read back and verify ---
|
||||
const readBack = fs.readFileSync(responsePath, "utf-8");
|
||||
const readParsed = JSON.parse(readBack);
|
||||
results["readback_matches"] =
|
||||
JSON.stringify(readParsed) === JSON.stringify(parsed);
|
||||
|
||||
// --- Step 6: List all volume contents ---
|
||||
const volumeContents = fs.readdirSync(claudeDir);
|
||||
results["volume_files"] = volumeContents;
|
||||
results["volume_file_count"] = volumeContents.length;
|
||||
|
||||
// --- Step 7: Verify memory file persists ---
|
||||
const memoryContent = fs.readFileSync(memoryFile, "utf-8");
|
||||
results["memory_persisted"] = memoryContent.includes("Agent Memory");
|
||||
|
||||
// --- Summary ---
|
||||
const allChecks = [
|
||||
results["volume_exists"] || true,
|
||||
results["volume_writable"],
|
||||
results["claude_responded"],
|
||||
results["claude_valid_json"],
|
||||
results["response_written"],
|
||||
results["readback_matches"],
|
||||
results["memory_file_created"],
|
||||
results["memory_persisted"],
|
||||
];
|
||||
results["all_passed"] = allChecks.every(Boolean);
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_common::jobs::{JobPayload, RawCode};
|
||||
use windmill_common::scripts::ScriptLang;
|
||||
use windmill_test_utils::*;
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_volume_insert(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO volume (workspace_id, name, size_bytes, created_by)
|
||||
VALUES ($1, $2, $3, $4)",
|
||||
"test-workspace",
|
||||
"test-volume",
|
||||
1024_i64,
|
||||
"test-user"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let row = sqlx::query!(
|
||||
"SELECT workspace_id, name, size_bytes, created_by, last_used_at
|
||||
FROM volume WHERE workspace_id = $1 AND name = $2",
|
||||
"test-workspace",
|
||||
"test-volume"
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
|
||||
assert_eq!(row.workspace_id, "test-workspace");
|
||||
assert_eq!(row.name, "test-volume");
|
||||
assert_eq!(row.size_bytes, 1024);
|
||||
assert_eq!(row.created_by, "test-user");
|
||||
assert!(row.last_used_at.is_none());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_volume_upsert_size(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO volume (workspace_id, name, size_bytes, created_by, last_used_at)
|
||||
VALUES ($1, $2, $3, $4, now())
|
||||
ON CONFLICT (workspace_id, name) DO UPDATE
|
||||
SET size_bytes = $3, last_used_at = now()",
|
||||
"test-workspace",
|
||||
"upsert-vol",
|
||||
500_i64,
|
||||
"test-user"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let row = sqlx::query!(
|
||||
"SELECT size_bytes FROM volume WHERE workspace_id = $1 AND name = $2",
|
||||
"test-workspace",
|
||||
"upsert-vol"
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(row.size_bytes, 500);
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO volume (workspace_id, name, size_bytes, created_by, last_used_at)
|
||||
VALUES ($1, $2, $3, $4, now())
|
||||
ON CONFLICT (workspace_id, name) DO UPDATE
|
||||
SET size_bytes = $3, last_used_at = now()",
|
||||
"test-workspace",
|
||||
"upsert-vol",
|
||||
2048_i64,
|
||||
"test-user"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let row = sqlx::query!(
|
||||
"SELECT size_bytes, last_used_at FROM volume WHERE workspace_id = $1 AND name = $2",
|
||||
"test-workspace",
|
||||
"upsert-vol"
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(row.size_bytes, 2048);
|
||||
assert!(row.last_used_at.is_some());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_volume_update_last_used(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO volume (workspace_id, name, size_bytes, created_by)
|
||||
VALUES ($1, $2, $3, $4)",
|
||||
"test-workspace",
|
||||
"used-vol",
|
||||
100_i64,
|
||||
"test-user"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let row = sqlx::query!(
|
||||
"SELECT last_used_at FROM volume WHERE workspace_id = $1 AND name = $2",
|
||||
"test-workspace",
|
||||
"used-vol"
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert!(row.last_used_at.is_none());
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE volume SET last_used_at = now() WHERE workspace_id = $1 AND name = $2",
|
||||
"test-workspace",
|
||||
"used-vol"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let row = sqlx::query!(
|
||||
"SELECT last_used_at FROM volume WHERE workspace_id = $1 AND name = $2",
|
||||
"test-workspace",
|
||||
"used-vol"
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert!(row.last_used_at.is_some());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_volume_update_nonexistent_noop(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let result = sqlx::query!(
|
||||
"UPDATE volume SET last_used_at = now() WHERE workspace_id = $1 AND name = $2",
|
||||
"test-workspace",
|
||||
"nonexistent-vol"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
assert_eq!(result.rows_affected(), 0);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_volume_list_multiple(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
for i in 0..5 {
|
||||
sqlx::query!(
|
||||
"INSERT INTO volume (workspace_id, name, size_bytes, created_by)
|
||||
VALUES ($1, $2, $3, $4)",
|
||||
"test-workspace",
|
||||
format!("vol-{}", i),
|
||||
(i * 100) as i64,
|
||||
"test-user"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let rows = sqlx::query!(
|
||||
"SELECT name, size_bytes FROM volume WHERE workspace_id = $1 ORDER BY name",
|
||||
"test-workspace"
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
|
||||
assert_eq!(rows.len(), 5);
|
||||
assert_eq!(rows[0].name, "vol-0");
|
||||
assert_eq!(rows[0].size_bytes, 0);
|
||||
assert_eq!(rows[4].name, "vol-4");
|
||||
assert_eq!(rows[4].size_bytes, 400);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_volume_delete(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO volume (workspace_id, name, size_bytes, created_by)
|
||||
VALUES ($1, $2, $3, $4)",
|
||||
"test-workspace",
|
||||
"deleteme",
|
||||
100_i64,
|
||||
"test-user"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let count = sqlx::query_scalar!(
|
||||
"SELECT count(*) FROM volume WHERE workspace_id = $1 AND name = $2",
|
||||
"test-workspace",
|
||||
"deleteme"
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(count, Some(1));
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM volume WHERE workspace_id = $1 AND name = $2",
|
||||
"test-workspace",
|
||||
"deleteme"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let count = sqlx::query_scalar!(
|
||||
"SELECT count(*) FROM volume WHERE workspace_id = $1 AND name = $2",
|
||||
"test-workspace",
|
||||
"deleteme"
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(count, Some(0));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_volume_workspace_fk_constraint(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let result = sqlx::query!(
|
||||
"INSERT INTO volume (workspace_id, name, size_bytes, created_by)
|
||||
VALUES ($1, $2, $3, $4)",
|
||||
"nonexistent-workspace",
|
||||
"vol",
|
||||
100_i64,
|
||||
"test-user"
|
||||
)
|
||||
.execute(&db)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("foreign key"),
|
||||
"Expected foreign key violation, got: {}",
|
||||
err
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_volume_primary_key_uniqueness(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO volume (workspace_id, name, size_bytes, created_by)
|
||||
VALUES ($1, $2, $3, $4)",
|
||||
"test-workspace",
|
||||
"unique-vol",
|
||||
100_i64,
|
||||
"test-user"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let result = sqlx::query!(
|
||||
"INSERT INTO volume (workspace_id, name, size_bytes, created_by)
|
||||
VALUES ($1, $2, $3, $4)",
|
||||
"test-workspace",
|
||||
"unique-vol",
|
||||
200_i64,
|
||||
"another-user"
|
||||
)
|
||||
.execute(&db)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("duplicate key") || err.contains("unique"),
|
||||
"Expected unique violation, got: {}",
|
||||
err
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_volume_extra_perms(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
// Insert volume with default (empty) extra_perms
|
||||
sqlx::query!(
|
||||
"INSERT INTO volume (workspace_id, name, size_bytes, created_by)
|
||||
VALUES ($1, $2, $3, $4)",
|
||||
"test-workspace",
|
||||
"perms-vol",
|
||||
100_i64,
|
||||
"test-user"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// Default extra_perms should be empty object
|
||||
let row = sqlx::query!(
|
||||
"SELECT extra_perms FROM volume WHERE workspace_id = $1 AND name = $2",
|
||||
"test-workspace",
|
||||
"perms-vol"
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(row.extra_perms, serde_json::json!({}));
|
||||
|
||||
// Set extra_perms via jsonb_set (same pattern as granular_acls.rs)
|
||||
sqlx::query!(
|
||||
"UPDATE volume SET extra_perms = jsonb_set(extra_perms, $1, to_jsonb($2::bool), true)
|
||||
WHERE workspace_id = $3 AND name = $4",
|
||||
&vec!["u/alice".to_string()],
|
||||
true,
|
||||
"test-workspace",
|
||||
"perms-vol"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let row = sqlx::query!(
|
||||
"SELECT extra_perms FROM volume WHERE workspace_id = $1 AND name = $2",
|
||||
"test-workspace",
|
||||
"perms-vol"
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
let perms = row.extra_perms.as_object().unwrap();
|
||||
assert_eq!(perms.get("u/alice").and_then(|v| v.as_bool()), Some(true));
|
||||
|
||||
// Remove a permission entry
|
||||
sqlx::query!(
|
||||
"UPDATE volume SET extra_perms = extra_perms - $1
|
||||
WHERE workspace_id = $2 AND name = $3",
|
||||
"u/alice",
|
||||
"test-workspace",
|
||||
"perms-vol"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let row = sqlx::query!(
|
||||
"SELECT extra_perms FROM volume WHERE workspace_id = $1 AND name = $2",
|
||||
"test-workspace",
|
||||
"perms-vol"
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(row.extra_perms, serde_json::json!({}));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_volume_annotations_python() {
|
||||
use windmill_worker_volumes::parse_volume_annotations;
|
||||
|
||||
let content = r#"# sandbox
|
||||
# volume: training-data /tmp/training
|
||||
# volume: models /opt/models
|
||||
|
||||
def main():
|
||||
pass
|
||||
"#;
|
||||
let volumes = parse_volume_annotations(content, "#");
|
||||
assert_eq!(volumes.len(), 2);
|
||||
assert_eq!(volumes[0].name, "training-data");
|
||||
assert_eq!(volumes[0].target, "/tmp/training");
|
||||
assert_eq!(volumes[1].name, "models");
|
||||
assert_eq!(volumes[1].target, "/opt/models");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_volume_annotations_typescript() {
|
||||
use windmill_worker_volumes::parse_volume_annotations;
|
||||
|
||||
let content = r#"// sandbox
|
||||
// volume: datasets /tmp/datasets
|
||||
|
||||
export async function main() {
|
||||
return "hello";
|
||||
}
|
||||
"#;
|
||||
let volumes = parse_volume_annotations(content, "//");
|
||||
assert_eq!(volumes.len(), 1);
|
||||
assert_eq!(volumes[0].name, "datasets");
|
||||
assert_eq!(volumes[0].target, "/tmp/datasets");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_volume_annotations_no_prefix_match() {
|
||||
use windmill_worker_volumes::parse_volume_annotations;
|
||||
|
||||
let content = "def main():\n pass";
|
||||
let volumes = parse_volume_annotations(content, "#");
|
||||
assert!(volumes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_volume_annotations_empty_script() {
|
||||
use windmill_worker_volumes::parse_volume_annotations;
|
||||
|
||||
let volumes = parse_volume_annotations("", "#");
|
||||
assert!(volumes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sandbox_annotation_python() {
|
||||
use windmill_common::worker::PythonAnnotations;
|
||||
|
||||
let content = "# sandbox\n# volume: data /tmp/data\ndef main():\n pass";
|
||||
let annotations = PythonAnnotations::parse(content);
|
||||
assert!(annotations.sandbox);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sandbox_annotation_typescript() {
|
||||
use windmill_common::worker::TypeScriptAnnotations;
|
||||
|
||||
let content = "// sandbox\n// volume: data /tmp/data\nexport function main() {}";
|
||||
let annotations = TypeScriptAnnotations::parse(content);
|
||||
assert!(annotations.sandbox);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_volume_comment_prefix_selection() {
|
||||
use windmill_common::scripts::ScriptLang;
|
||||
|
||||
let get_prefix = |lang: &ScriptLang| -> &str {
|
||||
match lang {
|
||||
ScriptLang::Python3
|
||||
| ScriptLang::Bash
|
||||
| ScriptLang::Powershell
|
||||
| ScriptLang::Ansible
|
||||
| ScriptLang::Ruby => "#",
|
||||
ScriptLang::Deno
|
||||
| ScriptLang::Bun
|
||||
| ScriptLang::Bunnative
|
||||
| ScriptLang::Nativets
|
||||
| ScriptLang::Go => "//",
|
||||
_ => "",
|
||||
}
|
||||
};
|
||||
|
||||
assert_eq!(get_prefix(&ScriptLang::Python3), "#");
|
||||
assert_eq!(get_prefix(&ScriptLang::Bash), "#");
|
||||
assert_eq!(get_prefix(&ScriptLang::Powershell), "#");
|
||||
assert_eq!(get_prefix(&ScriptLang::Ansible), "#");
|
||||
assert_eq!(get_prefix(&ScriptLang::Ruby), "#");
|
||||
assert_eq!(get_prefix(&ScriptLang::Deno), "//");
|
||||
assert_eq!(get_prefix(&ScriptLang::Bun), "//");
|
||||
assert_eq!(get_prefix(&ScriptLang::Bunnative), "//");
|
||||
assert_eq!(get_prefix(&ScriptLang::Nativets), "//");
|
||||
assert_eq!(get_prefix(&ScriptLang::Go), "//");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_volume_mount_struct() {
|
||||
use windmill_worker_volumes::VolumeMount;
|
||||
|
||||
let mount = VolumeMount { name: "test-vol".to_string(), target: "/mnt/data".to_string() };
|
||||
assert_eq!(mount.name, "test-vol");
|
||||
assert_eq!(mount.target, "/mnt/data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_volume_relative_path() {
|
||||
use windmill_worker_volumes::parse_volume_annotations;
|
||||
|
||||
let content = "// volume: agent-memory .claude\nexport function main() {}";
|
||||
let volumes = parse_volume_annotations(content, "//");
|
||||
assert_eq!(volumes.len(), 1);
|
||||
assert_eq!(volumes[0].name, "agent-memory");
|
||||
assert_eq!(volumes[0].target, ".claude");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_volume_relative_nested_path() {
|
||||
use windmill_worker_volumes::parse_volume_annotations;
|
||||
|
||||
let content = "# volume: data data/models\ndef main():\n pass";
|
||||
let volumes = parse_volume_annotations(content, "#");
|
||||
assert_eq!(volumes.len(), 1);
|
||||
assert_eq!(volumes[0].name, "data");
|
||||
assert_eq!(volumes[0].target, "data/models");
|
||||
}
|
||||
|
||||
#[cfg(feature = "private")]
|
||||
#[test]
|
||||
fn test_volume_nsjail_mount() {
|
||||
use std::path::Path;
|
||||
use windmill_worker_volumes::volume_nsjail_mount;
|
||||
|
||||
let result = volume_nsjail_mount(Path::new("/tmp/volumes/data"), "/mnt/data");
|
||||
assert!(result.contains("src: \"/tmp/volumes/data\""));
|
||||
assert!(result.contains("dst: \"/mnt/data\""));
|
||||
assert!(result.contains("is_bind: true"));
|
||||
assert!(result.contains("rw: true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_stats_default() {
|
||||
use windmill_worker_volumes::SyncStats;
|
||||
|
||||
let stats = SyncStats { new_size_bytes: 0, file_count: 0, uploaded: 0, skipped: 0 };
|
||||
assert_eq!(stats.new_size_bytes, 0);
|
||||
assert_eq!(stats.file_count, 0);
|
||||
assert_eq!(stats.uploaded, 0);
|
||||
assert_eq!(stats.skipped, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_asset_kind_volume_variant() {
|
||||
use windmill_types::assets::AssetKind;
|
||||
|
||||
let kind = AssetKind::Volume;
|
||||
let serialized = serde_json::to_string(&kind).unwrap();
|
||||
assert_eq!(serialized, "\"volume\"");
|
||||
|
||||
let deserialized: AssetKind = serde_json::from_str("\"volume\"").unwrap();
|
||||
assert!(matches!(deserialized, AssetKind::Volume));
|
||||
}
|
||||
|
||||
/// E2E test: run a bun script with volume mount through a SQL-connected worker.
|
||||
/// Pre-populates the volume in filesystem storage, verifies the script can read
|
||||
/// files and write new ones, then checks sync-back to storage and DB state.
|
||||
#[cfg(feature = "parquet")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_volume_sql_worker_e2e(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
// 1. Set up filesystem-based object storage in a temp dir
|
||||
let storage_dir = tempfile::tempdir()?;
|
||||
let storage_root = storage_dir.path().to_string_lossy().to_string();
|
||||
|
||||
let lfs_config = json!({
|
||||
"type": "FilesystemStorage",
|
||||
"root_path": storage_root,
|
||||
"public_resource": null,
|
||||
"advanced_permissions": null,
|
||||
"volume_storage": "primary"
|
||||
});
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2",
|
||||
lfs_config,
|
||||
"test-workspace"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// 2. Pre-populate the volume with a file (workspace-namespaced path)
|
||||
let vol_dir = storage_dir
|
||||
.path()
|
||||
.join("volumes")
|
||||
.join("test-workspace")
|
||||
.join("test-vol");
|
||||
std::fs::create_dir_all(&vol_dir)?;
|
||||
std::fs::write(vol_dir.join("hello.txt"), b"hello from volume")?;
|
||||
|
||||
// 3. Push the job and run with SQL-connected worker
|
||||
let code = r#"// volume: test-vol /tmp/data
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||
|
||||
export function main() {
|
||||
const content = readFileSync("/tmp/data/hello.txt", "utf-8");
|
||||
writeFileSync("/tmp/data/output.txt", "written by sql worker");
|
||||
return {
|
||||
read_content: content,
|
||||
output_exists: existsSync("/tmp/data/output.txt"),
|
||||
};
|
||||
}"#;
|
||||
|
||||
let job = JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content: code.to_string(),
|
||||
path: None,
|
||||
language: ScriptLang::Bun,
|
||||
lock: None,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
|
||||
.into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
});
|
||||
|
||||
let result = run_job_in_new_worker_until_complete(&db, false, job, port).await;
|
||||
|
||||
assert!(result.success, "job should succeed: {:?}", result.result);
|
||||
let json = result.json_result().expect("should have JSON result");
|
||||
assert_eq!(json["read_content"], json!("hello from volume"));
|
||||
assert_eq!(json["output_exists"], json!(true));
|
||||
|
||||
// 4. Verify volume DB row was updated
|
||||
let vol_row = sqlx::query!(
|
||||
"SELECT size_bytes, file_count, leased_by, lease_until
|
||||
FROM volume WHERE workspace_id = $1 AND name = $2",
|
||||
"test-workspace",
|
||||
"test-vol"
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
|
||||
let vol_row = vol_row.expect("volume row should exist");
|
||||
assert!(
|
||||
vol_row.file_count >= 2,
|
||||
"should have at least 2 files (hello.txt + output.txt), got: {}",
|
||||
vol_row.file_count
|
||||
);
|
||||
assert!(vol_row.size_bytes > 0, "size_bytes should be > 0");
|
||||
assert!(vol_row.leased_by.is_none(), "lease should be released");
|
||||
|
||||
// 5. Verify the new file was written back to storage
|
||||
let output_path = vol_dir.join("output.txt");
|
||||
assert!(
|
||||
output_path.exists(),
|
||||
"output.txt should be synced back to storage"
|
||||
);
|
||||
let output_content = std::fs::read_to_string(&output_path)?;
|
||||
assert_eq!(output_content, "written by sql worker");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -51,4 +51,12 @@ impl AgentCache {
|
||||
pub fn new() -> Self {
|
||||
AgentCache {}
|
||||
}
|
||||
|
||||
pub async fn extract_worker_name(
|
||||
&self,
|
||||
_token: &str,
|
||||
_db: &windmill_common::DB,
|
||||
) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ use windmill_common::{
|
||||
utils::{not_found_if_none, StripPath},
|
||||
};
|
||||
|
||||
const KINDS: [&str; 18] = [
|
||||
const KINDS: [&str; 19] = [
|
||||
"script",
|
||||
"group_",
|
||||
"resource",
|
||||
@@ -43,6 +43,7 @@ const KINDS: [&str; 18] = [
|
||||
"gcp_trigger",
|
||||
"sqs_trigger",
|
||||
"email_trigger",
|
||||
"volume",
|
||||
];
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
@@ -77,7 +78,7 @@ async fn add_granular_acl(
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let identifier = if kind == "group_" || kind == "folder" {
|
||||
let identifier = if kind == "group_" || kind == "folder" || kind == "volume" {
|
||||
"name"
|
||||
} else {
|
||||
"path"
|
||||
@@ -89,6 +90,22 @@ async fn add_granular_acl(
|
||||
} else if kind == "group_" {
|
||||
crate::groups::require_is_owner(path, &authed.username, &authed.groups, &w_id, &db)
|
||||
.await?;
|
||||
} else if kind == "volume" {
|
||||
let created_by = sqlx::query_scalar!(
|
||||
"SELECT created_by FROM volume WHERE name = $1 AND workspace_id = $2",
|
||||
path,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound(format!("volume '{path}' not found")))?;
|
||||
// created_by is stored with u/ prefix (from job.permissioned_as)
|
||||
let owner_username = created_by.strip_prefix("u/").unwrap_or(&created_by);
|
||||
if owner_username != authed.username {
|
||||
return Err(Error::NotAuthorized(
|
||||
"Only the volume owner or an admin can modify permissions".to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
require_owner_of_path(&authed, path)?;
|
||||
}
|
||||
@@ -243,6 +260,22 @@ async fn remove_granular_acl(
|
||||
} else if kind == "group_" {
|
||||
crate::groups::require_is_owner(path, &authed.username, &authed.groups, &w_id, &db)
|
||||
.await?;
|
||||
} else if kind == "volume" {
|
||||
let created_by = sqlx::query_scalar!(
|
||||
"SELECT created_by FROM volume WHERE name = $1 AND workspace_id = $2",
|
||||
path,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound(format!("volume '{path}' not found")))?;
|
||||
// created_by is stored with u/ prefix (from job.permissioned_as)
|
||||
let owner_username = created_by.strip_prefix("u/").unwrap_or(&created_by);
|
||||
if owner_username != authed.username {
|
||||
return Err(Error::NotAuthorized(
|
||||
"Only the volume owner or an admin can modify permissions".to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
require_owner_of_path(&authed, path)?;
|
||||
}
|
||||
@@ -250,7 +283,7 @@ async fn remove_granular_acl(
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let identifier = if kind == "group_" || kind == "folder" {
|
||||
let identifier = if kind == "group_" || kind == "folder" || kind == "volume" {
|
||||
"name"
|
||||
} else {
|
||||
"path"
|
||||
@@ -380,7 +413,11 @@ async fn get_granular_acls(
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let identifier = if kind == "group_" { "name" } else { "path" };
|
||||
let identifier = if kind == "group_" || kind == "folder" || kind == "volume" {
|
||||
"name"
|
||||
} else {
|
||||
"path"
|
||||
};
|
||||
let obj_o = sqlx::query_scalar::<_, serde_json::Value>(&format!(
|
||||
"SELECT extra_perms from {kind} WHERE {identifier} = $1 AND workspace_id = $2"
|
||||
))
|
||||
|
||||
@@ -302,6 +302,8 @@ struct LargeFileStorageWithSecondary {
|
||||
large_file_storage: LargeFileStorage,
|
||||
#[serde(default)]
|
||||
secondary_storage: HashMap<String, LargeFileStorage>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
volume_storage: Option<String>,
|
||||
}
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct EditLargeFileStorageConfig {
|
||||
|
||||
@@ -70,6 +70,7 @@ windmill-git-sync.workspace = true
|
||||
windmill-indexer = { workspace = true, optional = true }
|
||||
windmill-autoscaling = { workspace = true, optional = true }
|
||||
windmill-worker = { workspace = true, optional = true }
|
||||
windmill-worker-volumes.workspace = true
|
||||
windmill-dep-map.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
|
||||
@@ -15198,6 +15198,7 @@ paths:
|
||||
gcp_trigger,
|
||||
sqs_trigger,
|
||||
email_trigger,
|
||||
volume,
|
||||
]
|
||||
responses:
|
||||
"200":
|
||||
@@ -15243,6 +15244,7 @@ paths:
|
||||
gcp_trigger,
|
||||
sqs_trigger,
|
||||
email_trigger,
|
||||
volume,
|
||||
]
|
||||
requestBody:
|
||||
description: acl to add
|
||||
@@ -15299,6 +15301,7 @@ paths:
|
||||
gcp_trigger,
|
||||
sqs_trigger,
|
||||
email_trigger,
|
||||
volume,
|
||||
]
|
||||
requestBody:
|
||||
description: acl to add
|
||||
@@ -17282,7 +17285,90 @@ paths:
|
||||
path:
|
||||
type: string
|
||||
description: The asset path
|
||||
|
||||
|
||||
|
||||
/w/{workspace}/volumes/list:
|
||||
get:
|
||||
summary: List all volumes in the workspace
|
||||
operationId: listVolumes
|
||||
tags:
|
||||
- volume
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: list of volumes
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Volume"
|
||||
|
||||
/w/{workspace}/volumes/storage:
|
||||
get:
|
||||
summary: Get the volume storage name (secondary storage) or null for primary
|
||||
operationId: getVolumeStorage
|
||||
tags:
|
||||
- volume
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: volume storage name or null
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
/w/{workspace}/volumes/create:
|
||||
post:
|
||||
summary: Create a new volume
|
||||
operationId: createVolume
|
||||
tags:
|
||||
- volume
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: volume created
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/volumes/delete/{name}:
|
||||
delete:
|
||||
summary: Delete a volume (admin only)
|
||||
operationId: deleteVolume
|
||||
tags:
|
||||
- volume
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: volume deleted
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/mcp/w/{workspace}/list_tools:
|
||||
get:
|
||||
@@ -23997,6 +24083,7 @@ components:
|
||||
- resource
|
||||
- ducklake
|
||||
- datatable
|
||||
- volume
|
||||
Asset:
|
||||
type: object
|
||||
properties:
|
||||
@@ -24005,6 +24092,38 @@ components:
|
||||
kind:
|
||||
$ref: "#/components/schemas/AssetKind"
|
||||
required: [path, kind]
|
||||
Volume:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- size_bytes
|
||||
- file_count
|
||||
- created_at
|
||||
- created_by
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
size_bytes:
|
||||
type: integer
|
||||
format: int64
|
||||
file_count:
|
||||
type: integer
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
created_by:
|
||||
type: string
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
last_used_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
extra_perms:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
ProtectionRuleset:
|
||||
type: object
|
||||
description: A workspace protection rule defining restrictions and bypass permissions
|
||||
|
||||
@@ -240,11 +240,7 @@ async fn check_database_detailed(db: &DB) -> DatabaseHealth {
|
||||
let check = check_database_with_latency(db).await;
|
||||
let pool = get_pool_stats(db);
|
||||
|
||||
DatabaseHealth {
|
||||
healthy: check.healthy,
|
||||
latency_ms: check.latency_ms,
|
||||
pool,
|
||||
}
|
||||
DatabaseHealth { healthy: check.healthy, latency_ms: check.latency_ms, pool }
|
||||
}
|
||||
|
||||
async fn check_worker_count(db: &DB) -> i64 {
|
||||
@@ -295,13 +291,7 @@ async fn check_workers_detailed(db: &DB) -> WorkersHealth {
|
||||
|
||||
let healthy = active_count > 0;
|
||||
|
||||
WorkersHealth {
|
||||
healthy,
|
||||
active_count,
|
||||
worker_groups,
|
||||
min_version,
|
||||
versions,
|
||||
}
|
||||
WorkersHealth { healthy, active_count, worker_groups, min_version, versions }
|
||||
}
|
||||
|
||||
async fn check_queue(db: &DB) -> QueueHealth {
|
||||
@@ -333,10 +323,7 @@ fn get_version() -> String {
|
||||
|
||||
/// Spawn a background task that performs a health check every 10 seconds.
|
||||
/// Updates the cache and prometheus metrics continuously.
|
||||
pub fn start_health_check_loop(
|
||||
db: DB,
|
||||
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
|
||||
) {
|
||||
pub fn start_health_check_loop(db: DB, mut killpill_rx: tokio::sync::broadcast::Receiver<()>) {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(10));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
@@ -550,10 +537,7 @@ async fn health_status(
|
||||
}
|
||||
|
||||
/// Detailed health check - requires DB authentication (always fresh, no caching)
|
||||
async fn health_detailed(
|
||||
_authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
) -> impl IntoResponse {
|
||||
async fn health_detailed(_authed: ApiAuthed, Extension(db): Extension<DB>) -> impl IntoResponse {
|
||||
let checked_at = Utc::now();
|
||||
let database = check_database_detailed(&db).await;
|
||||
let readiness = check_readiness();
|
||||
@@ -564,12 +548,7 @@ async fn health_detailed(
|
||||
status: HealthStatus::Unhealthy,
|
||||
checked_at,
|
||||
version: get_version(),
|
||||
checks: HealthChecks {
|
||||
database,
|
||||
workers: None,
|
||||
queue: None,
|
||||
readiness,
|
||||
},
|
||||
checks: HealthChecks { database, workers: None, queue: None, readiness },
|
||||
};
|
||||
return (StatusCode::SERVICE_UNAVAILABLE, Json(response));
|
||||
}
|
||||
@@ -587,12 +566,7 @@ async fn health_detailed(
|
||||
status,
|
||||
checked_at,
|
||||
version: get_version(),
|
||||
checks: HealthChecks {
|
||||
database,
|
||||
workers: Some(workers),
|
||||
queue: Some(queue),
|
||||
readiness,
|
||||
},
|
||||
checks: HealthChecks { database, workers: Some(workers), queue: Some(queue), readiness },
|
||||
};
|
||||
|
||||
let status_code = if status == HealthStatus::Unhealthy {
|
||||
|
||||
@@ -12,15 +12,15 @@ use windmill_types::s3::StorageResourceType;
|
||||
#[cfg(all(feature = "parquet", not(feature = "private")))]
|
||||
use crate::db::{ApiAuthed, OptJobAuthed, DB};
|
||||
#[cfg(all(feature = "parquet", not(feature = "private")))]
|
||||
use windmill_object_store::object_store_reexports::{ObjectStore, PutMultipartOpts, PutResult};
|
||||
#[cfg(not(feature = "private"))]
|
||||
use windmill_object_store::ObjectStoreResource;
|
||||
#[cfg(all(feature = "parquet", not(feature = "private")))]
|
||||
use std::sync::Arc;
|
||||
#[cfg(all(feature = "parquet", not(feature = "private")))]
|
||||
use windmill_common::db::UserDB;
|
||||
#[cfg(not(feature = "private"))]
|
||||
use windmill_common::error;
|
||||
#[cfg(all(feature = "parquet", not(feature = "private")))]
|
||||
use windmill_common::db::UserDB;
|
||||
use windmill_object_store::object_store_reexports::{ObjectStore, PutMultipartOpts, PutResult};
|
||||
#[cfg(not(feature = "private"))]
|
||||
use windmill_object_store::ObjectStoreResource;
|
||||
|
||||
#[cfg(all(feature = "parquet", not(feature = "private")))]
|
||||
use bytes::Bytes;
|
||||
|
||||
@@ -170,6 +170,9 @@ pub mod users_ee;
|
||||
mod users_oss;
|
||||
mod utils;
|
||||
mod variables;
|
||||
#[cfg(feature = "private")]
|
||||
pub mod volumes_ee;
|
||||
mod volumes_oss;
|
||||
pub mod webhook_util;
|
||||
mod workspaces;
|
||||
#[cfg(feature = "private")]
|
||||
@@ -248,6 +251,74 @@ type IndexReader = windmill_indexer::completed_runs_oss::IndexReader;
|
||||
#[cfg(feature = "tantivy")]
|
||||
type ServiceLogIndexReader = windmill_indexer::service_logs_oss::ServiceLogIndexReader;
|
||||
|
||||
/// Worker name derived from the agent JWT token, used to authenticate volume operations.
|
||||
/// Defined unconditionally so volume endpoint handlers can reference it regardless of
|
||||
/// whether agent_worker_server is enabled (the extension is only populated on the agent path).
|
||||
#[derive(Clone)]
|
||||
pub struct AgentWorkerName(pub String);
|
||||
|
||||
/// Middleware that injects a synthetic `ApiAuthed` and JWT-derived worker name
|
||||
/// into request extensions.
|
||||
///
|
||||
/// Used for volume proxy endpoints under the agent_workers path, where the
|
||||
/// agent JWT auth layer has already validated the request. The volume handlers
|
||||
/// need `ApiAuthed` to resolve the workspace S3 client, but the agent JWT
|
||||
/// format is incompatible with the standard auth extractor.
|
||||
///
|
||||
/// The worker name is extracted from the JWT claims rather than trusting
|
||||
/// self-reported values in request bodies/query params.
|
||||
#[cfg(feature = "agent_worker_server")]
|
||||
async fn inject_agent_authed(
|
||||
request: axum::extract::Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> Response {
|
||||
let mut request = request;
|
||||
|
||||
// Extract worker name from agent JWT via AgentCache
|
||||
// (OSS returns None; EE decodes the JWT and returns the worker name)
|
||||
{
|
||||
let extracted = {
|
||||
let token = request
|
||||
.headers()
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("Bearer ").map(|t| t.to_string()));
|
||||
let cache = request.extensions().get::<Arc<AgentCache>>().cloned();
|
||||
let db = request.extensions().get::<DB>().cloned();
|
||||
match (token, cache, db) {
|
||||
(Some(token), Some(cache), Some(db)) => Some((token, cache, db)),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
if let Some((token, cache, db)) = extracted {
|
||||
if let Some(worker_name) = cache.extract_worker_name(&token, &db).await {
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert(AgentWorkerName(worker_name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert(windmill_api_auth::OptJobAuthed {
|
||||
authed: ApiAuthed {
|
||||
email: "agent-worker@windmill.dev".to_string(),
|
||||
username: "agent-worker".to_string(),
|
||||
is_admin: true,
|
||||
is_operator: false,
|
||||
groups: Vec::new(),
|
||||
folders: Vec::new(),
|
||||
scopes: None,
|
||||
username_override: None,
|
||||
token_prefix: None,
|
||||
},
|
||||
job_id: None,
|
||||
});
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
pub async fn run_server(
|
||||
db: DB,
|
||||
job_index_reader: Option<IndexReader>,
|
||||
@@ -513,6 +584,7 @@ pub async fn run_server(
|
||||
users::workspaced_service().layer(Extension(argon2.clone())),
|
||||
)
|
||||
.nest("/variables", variables::workspaced_service())
|
||||
.nest("/volumes", volumes_oss::workspaced_service())
|
||||
.nest("/workers", windmill_api_workers::workspaced_service())
|
||||
.nest("/workspaces", workspaces::workspaced_service())
|
||||
.nest("/oidc", oidc_oss::workspaced_service())
|
||||
@@ -626,7 +698,13 @@ pub async fn run_server(
|
||||
.nest("/w/:workspace_id/agent_workers", {
|
||||
#[cfg(feature = "agent_worker_server")]
|
||||
{
|
||||
agent_workers_router.layer(Extension(agent_cache.clone()))
|
||||
agent_workers_router
|
||||
.nest(
|
||||
"/volumes",
|
||||
volumes_oss::agent_workspaced_service()
|
||||
.layer(axum::middleware::from_fn(inject_agent_authed)),
|
||||
)
|
||||
.layer(Extension(agent_cache.clone()))
|
||||
}
|
||||
#[cfg(not(feature = "agent_worker_server"))]
|
||||
{
|
||||
|
||||
@@ -369,7 +369,11 @@ async fn route_job(
|
||||
let s3_object = s3_client.get(&path).await;
|
||||
|
||||
let s3_object = match s3_object {
|
||||
Err(windmill_object_store::object_store_reexports::ObjectStoreError::NotFound { .. }) if trigger.is_static_website => {
|
||||
Err(
|
||||
windmill_object_store::object_store_reexports::ObjectStoreError::NotFound {
|
||||
..
|
||||
},
|
||||
) if trigger.is_static_website => {
|
||||
// fallback to index.html if the file is not found
|
||||
let path = windmill_object_store::object_store_reexports::Path::from(format!(
|
||||
"{}/index.html",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
#[cfg(feature = "private")]
|
||||
#[allow(unused)]
|
||||
pub use crate::volumes_ee::*;
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
use axum::Router;
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
#[allow(dead_code)]
|
||||
pub fn agent_workspaced_service() -> Router {
|
||||
Router::new()
|
||||
}
|
||||
@@ -72,6 +72,7 @@ pub fn asset_kind_from_parser(parser_kind: windmill_parser::asset_parser::AssetK
|
||||
windmill_parser::asset_parser::AssetKind::Resource => AssetKind::Resource,
|
||||
windmill_parser::asset_parser::AssetKind::Ducklake => AssetKind::Ducklake,
|
||||
windmill_parser::asset_parser::AssetKind::DataTable => AssetKind::DataTable,
|
||||
windmill_parser::asset_parser::AssetKind::Volume => AssetKind::Volume,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,51 @@ impl PermsCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check a user's access level against an `extra_perms` JSONB object.
|
||||
///
|
||||
/// Returns `None` if the user has no matching entry (no access).
|
||||
/// Returns `Some(true)` if the user (or any of their groups) has write access.
|
||||
/// Returns `Some(false)` if the user (or any of their groups) has read-only access.
|
||||
pub fn check_extra_perms(
|
||||
extra_perms: &serde_json::Map<String, serde_json::Value>,
|
||||
username: &str,
|
||||
groups: &[String],
|
||||
) -> Option<bool> {
|
||||
// Check direct user permission
|
||||
let user_key = if username.starts_with("u/") {
|
||||
username.to_string()
|
||||
} else {
|
||||
format!("u/{username}")
|
||||
};
|
||||
if let Some(v) = extra_perms.get(&user_key) {
|
||||
return Some(v.as_bool().unwrap_or(false));
|
||||
}
|
||||
|
||||
// Check group permissions — return highest access level found
|
||||
let mut found = false;
|
||||
let mut write = false;
|
||||
for g in groups {
|
||||
let key = if g.starts_with("g/") {
|
||||
g.to_string()
|
||||
} else {
|
||||
format!("g/{g}")
|
||||
};
|
||||
if let Some(v) = extra_perms.get(&key) {
|
||||
found = true;
|
||||
if v.as_bool().unwrap_or(false) {
|
||||
write = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if found {
|
||||
Some(write)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_expired(expiration_time: DateTime<Utc>, take: Option<Duration>) -> bool {
|
||||
let now = Utc::now();
|
||||
|
||||
|
||||
@@ -354,6 +354,45 @@ impl HttpClient {
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_bytes(&self, url: &str) -> anyhow::Result<Bytes> {
|
||||
let base_url = self.base_internal_url.clone();
|
||||
let response = self
|
||||
.client
|
||||
.get(format!("{}{}", base_url, url))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
if response.status().is_success() {
|
||||
Ok(response.bytes().await?)
|
||||
} else {
|
||||
Err(anyhow::anyhow!(
|
||||
"HTTP agent request GET {} failed {}",
|
||||
url,
|
||||
response.status()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn put_bytes(&self, url: &str, bytes: Bytes) -> anyhow::Result<()> {
|
||||
let base_url = self.base_internal_url.clone();
|
||||
let response = self
|
||||
.client
|
||||
.put(format!("{}{}", base_url, url))
|
||||
.body(bytes)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
if response.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!(
|
||||
"HTTP agent request PUT {} failed {}",
|
||||
url,
|
||||
response.status()
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -698,6 +737,7 @@ pub struct PythonAnnotations {
|
||||
pub py311: bool,
|
||||
pub py312: bool,
|
||||
pub py313: bool,
|
||||
pub sandbox: bool,
|
||||
}
|
||||
|
||||
#[annotations("//")]
|
||||
@@ -711,6 +751,7 @@ pub struct TypeScriptAnnotations {
|
||||
pub nodejs: bool,
|
||||
pub native: bool,
|
||||
pub nobundling: bool,
|
||||
pub sandbox: bool,
|
||||
}
|
||||
|
||||
#[annotations("--")]
|
||||
@@ -2169,4 +2210,62 @@ mod tests {
|
||||
);
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_sandbox_annotation() {
|
||||
let content = "# sandbox\ndef main():\n pass";
|
||||
let annotations = PythonAnnotations::parse(content);
|
||||
assert!(annotations.sandbox);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_sandbox_annotation_with_other_annotations() {
|
||||
let content = "# no_cache\n# sandbox\ndef main():\n pass";
|
||||
let annotations = PythonAnnotations::parse(content);
|
||||
assert!(annotations.sandbox);
|
||||
assert!(annotations.no_cache);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_no_sandbox_annotation() {
|
||||
let content = "# no_cache\ndef main():\n pass";
|
||||
let annotations = PythonAnnotations::parse(content);
|
||||
assert!(!annotations.sandbox);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_typescript_sandbox_annotation() {
|
||||
let content = "// sandbox\nexport function main() {}";
|
||||
let annotations = TypeScriptAnnotations::parse(content);
|
||||
assert!(annotations.sandbox);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_typescript_sandbox_annotation_with_other_annotations() {
|
||||
let content = "// npm\n// sandbox\nexport function main() {}";
|
||||
let annotations = TypeScriptAnnotations::parse(content);
|
||||
assert!(annotations.sandbox);
|
||||
assert!(annotations.npm);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_typescript_no_sandbox_annotation() {
|
||||
let content = "// npm\nexport function main() {}";
|
||||
let annotations = TypeScriptAnnotations::parse(content);
|
||||
assert!(!annotations.sandbox);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_sandbox_no_space() {
|
||||
let content = "#sandbox\ndef main():\n pass";
|
||||
let annotations = PythonAnnotations::parse(content);
|
||||
assert!(annotations.sandbox);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_typescript_sandbox_no_space() {
|
||||
let content = "//sandbox\nexport function main() {}";
|
||||
let annotations = TypeScriptAnnotations::parse(content);
|
||||
assert!(annotations.sandbox);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -726,7 +726,7 @@ pub async fn get_token_by_prefix<'c, E: sqlx::Executor<'c, Database = Postgres>>
|
||||
) -> Result<Option<String>> {
|
||||
let token = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT token
|
||||
SELECT token as "token!"
|
||||
FROM token
|
||||
WHERE token LIKE concat($1::text, '%')
|
||||
LIMIT 1
|
||||
|
||||
@@ -830,6 +830,15 @@ pub async fn run_preview_relative_imports(
|
||||
|
||||
#[cfg(all(feature = "private", feature = "agent_worker_server"))]
|
||||
pub async fn testing_http_connection(port: u16) -> Connection {
|
||||
testing_http_connection_with_tags(
|
||||
port,
|
||||
vec!["flow".into(), "python3".into(), "dependency".into()],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "private", feature = "agent_worker_server"))]
|
||||
pub async fn testing_http_connection_with_tags(port: u16, tags: Vec<String>) -> Connection {
|
||||
let suffix = windmill_common::utils::create_default_worker_suffix("test-agent-worker");
|
||||
let agent_token = format!(
|
||||
"{}{}",
|
||||
@@ -837,7 +846,7 @@ pub async fn testing_http_connection(port: u16) -> Connection {
|
||||
windmill_common::jwt::encode_with_internal_secret(windmill_api_agent_workers::AgentAuth {
|
||||
worker_group: "testing-agent".to_owned(),
|
||||
suffix: Some(suffix.clone()),
|
||||
tags: vec!["flow".into(), "python3".into(), "dependency".into()],
|
||||
tags,
|
||||
exp: Some(usize::MAX),
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -13,6 +13,7 @@ pub enum AssetKind {
|
||||
Variable, // Deprecated
|
||||
Ducklake,
|
||||
DataTable,
|
||||
Volume,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "windmill-worker-volumes"
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "windmill_worker_volumes"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
enterprise = []
|
||||
private = []
|
||||
|
||||
[dependencies]
|
||||
windmill-common = { workspace = true, default-features = false }
|
||||
object_store.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
bytes.workspace = true
|
||||
futures.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
regex.workspace = true
|
||||
lazy_static.workspace = true
|
||||
md-5.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,544 @@
|
||||
#[cfg(feature = "private")]
|
||||
mod volume_ee;
|
||||
mod volume_oss;
|
||||
pub use volume_oss::*;
|
||||
|
||||
pub use object_store::ObjectStore as DynObjectStore;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub const MAX_VOLUMES_PER_JOB: usize = 10;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FileEntry {
|
||||
pub size: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub md5: Option<String>,
|
||||
}
|
||||
|
||||
pub fn compute_md5_hex(data: &[u8]) -> String {
|
||||
use md5::{Digest, Md5};
|
||||
let result = Md5::digest(data);
|
||||
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut hex = String::with_capacity(32);
|
||||
for &b in result.iter() {
|
||||
hex.push(HEX[(b >> 4) as usize] as char);
|
||||
hex.push(HEX[(b & 0x0f) as usize] as char);
|
||||
}
|
||||
hex
|
||||
}
|
||||
|
||||
/// Extract an MD5 hash from an S3 ETag, if it's a simple (non-multipart) ETag.
|
||||
pub fn etag_to_md5(e_tag: Option<&str>) -> Option<String> {
|
||||
let tag = e_tag?.trim_matches('"');
|
||||
// Multipart ETags contain a '-' (e.g. "abc123-5"), skip those
|
||||
if tag.contains('-') || tag.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(tag.to_string())
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref ARGS_INTERPOLATION_RE: regex::Regex =
|
||||
regex::Regex::new(r#"\$args\[((?:\w+\.)*\w+)\]"#).unwrap();
|
||||
static ref VALID_VOLUME_NAME_RE: regex::Regex =
|
||||
regex::Regex::new(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,253}[a-zA-Z0-9]$").unwrap();
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct VolumeMount {
|
||||
pub name: String,
|
||||
pub target: String,
|
||||
}
|
||||
|
||||
pub struct VolumeState {
|
||||
pub mount: VolumeMount,
|
||||
pub local_dir: PathBuf,
|
||||
pub manifest: HashMap<String, FileEntry>,
|
||||
pub symlinks: HashMap<String, String>,
|
||||
}
|
||||
|
||||
pub struct DownloadStats {
|
||||
pub total_files: usize,
|
||||
pub from_cache: usize,
|
||||
pub downloaded: usize,
|
||||
}
|
||||
|
||||
pub struct SyncStats {
|
||||
pub new_size_bytes: u64,
|
||||
pub file_count: usize,
|
||||
pub uploaded: usize,
|
||||
pub skipped: usize,
|
||||
}
|
||||
|
||||
pub fn validate_volume_name(name: &str) -> Result<(), String> {
|
||||
if name.contains("..") {
|
||||
return Err(format!(
|
||||
"Volume name '{}' contains '..' which is not allowed",
|
||||
name
|
||||
));
|
||||
}
|
||||
if !VALID_VOLUME_NAME_RE.is_match(name) {
|
||||
return Err(format!(
|
||||
"Volume name '{}' is invalid. Names must be 2-255 characters, \
|
||||
start and end with alphanumeric, and contain only alphanumeric, '.', '_', or '-'",
|
||||
name
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const ALLOWED_ABSOLUTE_PREFIXES: &[&str] = &["/tmp/", "/mnt/", "/opt/", "/home/", "/data/"];
|
||||
|
||||
pub fn validate_volume_target(target: &str) -> Result<(), String> {
|
||||
if target.split('/').any(|seg| seg == "..") {
|
||||
return Err(format!(
|
||||
"Volume target '{target}' contains '..' segments which is not allowed"
|
||||
));
|
||||
}
|
||||
if target.starts_with('/')
|
||||
&& !ALLOWED_ABSOLUTE_PREFIXES
|
||||
.iter()
|
||||
.any(|p| target.starts_with(p))
|
||||
{
|
||||
return Err(format!(
|
||||
"Volume target '{target}' must be a relative path or start with one of: {}",
|
||||
ALLOWED_ABSOLUTE_PREFIXES.join(", ")
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_volume_mounts(mounts: &[VolumeMount]) -> Result<(), String> {
|
||||
if mounts.len() > MAX_VOLUMES_PER_JOB {
|
||||
return Err(format!(
|
||||
"Too many volume mounts ({}, max {})",
|
||||
mounts.len(),
|
||||
MAX_VOLUMES_PER_JOB
|
||||
));
|
||||
}
|
||||
let mut seen_names = HashSet::new();
|
||||
let mut seen_targets = HashSet::new();
|
||||
for v in mounts {
|
||||
if !seen_names.insert(&v.name) {
|
||||
return Err(format!("Duplicate volume name: '{}'", v.name));
|
||||
}
|
||||
if !seen_targets.insert(&v.target) {
|
||||
return Err(format!("Duplicate volume target: '{}'", v.target));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn interpolate_volume_name(
|
||||
name: &str,
|
||||
args: Option<&HashMap<String, Box<serde_json::value::RawValue>>>,
|
||||
workspace_id: &str,
|
||||
) -> String {
|
||||
let name = name.replace("$workspace", workspace_id);
|
||||
if !name.contains("$args[") {
|
||||
return name;
|
||||
}
|
||||
let Some(args) = args else {
|
||||
return name;
|
||||
};
|
||||
let mut result = name.clone();
|
||||
for cap in ARGS_INTERPOLATION_RE.captures_iter(&name) {
|
||||
let full_match = cap.get(0).unwrap().as_str();
|
||||
let arg_name = cap.get(1).unwrap().as_str();
|
||||
let arg_value = if arg_name.contains('.') {
|
||||
let parts: Vec<&str> = arg_name.split('.').collect();
|
||||
let root = parts[0];
|
||||
let mut value = args
|
||||
.get(root)
|
||||
.map(|x| x.get().to_string())
|
||||
.unwrap_or_default();
|
||||
for part in parts.iter().skip(1) {
|
||||
if let Ok(obj) = serde_json::from_str::<serde_json::Value>(&value) {
|
||||
value = obj
|
||||
.get(part)
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
} else {
|
||||
value = String::new();
|
||||
break;
|
||||
}
|
||||
}
|
||||
value.trim_matches('"').to_string()
|
||||
} else {
|
||||
args.get(arg_name)
|
||||
.map(|x| x.get().trim_matches('"').to_string())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
result = result.replace(full_match, &arg_value);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn parse_volume_annotations(content: &str, comment_prefix: &str) -> Vec<VolumeMount> {
|
||||
let mut volumes = Vec::new();
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !trimmed.starts_with(comment_prefix) {
|
||||
break;
|
||||
}
|
||||
let after_prefix = trimmed[comment_prefix.len()..].trim();
|
||||
if let Some(rest) = after_prefix.strip_prefix("volume:") {
|
||||
let rest = rest.trim();
|
||||
let mut parts = rest.splitn(2, char::is_whitespace);
|
||||
if let (Some(name), Some(target)) = (parts.next(), parts.next()) {
|
||||
let name = name.trim();
|
||||
let target = target.trim();
|
||||
if !name.is_empty() && !target.is_empty() {
|
||||
volumes
|
||||
.push(VolumeMount { name: name.to_string(), target: target.to_string() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
volumes
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_python_single_volume() {
|
||||
let content = "# volume: mydata /tmp/data\ndef main():\n pass";
|
||||
let result = parse_volume_annotations(content, "#");
|
||||
assert_eq!(
|
||||
result,
|
||||
vec![VolumeMount { name: "mydata".to_string(), target: "/tmp/data".to_string() }]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_typescript_single_volume() {
|
||||
let content = "// volume: mydata /tmp/data\nexport function main() {}";
|
||||
let result = parse_volume_annotations(content, "//");
|
||||
assert_eq!(
|
||||
result,
|
||||
vec![VolumeMount { name: "mydata".to_string(), target: "/tmp/data".to_string() }]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_multiple_volumes() {
|
||||
let content = "# volume: data1 /tmp/data1\n# volume: data2 /tmp/data2\n# volume: models /opt/models\ndef main():\n pass";
|
||||
let result = parse_volume_annotations(content, "#");
|
||||
assert_eq!(
|
||||
result,
|
||||
vec![
|
||||
VolumeMount { name: "data1".to_string(), target: "/tmp/data1".to_string() },
|
||||
VolumeMount { name: "data2".to_string(), target: "/tmp/data2".to_string() },
|
||||
VolumeMount { name: "models".to_string(), target: "/opt/models".to_string() },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_mixed_annotations_and_volumes() {
|
||||
let content = "# sandbox\n# volume: mydata /tmp/data\ndef main():\n pass";
|
||||
let result = parse_volume_annotations(content, "#");
|
||||
assert_eq!(
|
||||
result,
|
||||
vec![VolumeMount { name: "mydata".to_string(), target: "/tmp/data".to_string() }]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_no_volumes() {
|
||||
let content = "# sandbox\ndef main():\n pass";
|
||||
let result = parse_volume_annotations(content, "#");
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_content() {
|
||||
let result = parse_volume_annotations("", "#");
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_stops_at_non_comment_line() {
|
||||
let content =
|
||||
"# volume: data1 /tmp/data1\ndef main():\n # volume: data2 /tmp/data2\n pass";
|
||||
let result = parse_volume_annotations(content, "#");
|
||||
assert_eq!(
|
||||
result,
|
||||
vec![VolumeMount { name: "data1".to_string(), target: "/tmp/data1".to_string() }]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_skips_blank_lines_in_header() {
|
||||
let content =
|
||||
"# volume: data1 /tmp/data1\n\n# volume: data2 /tmp/data2\ndef main():\n pass";
|
||||
let result = parse_volume_annotations(content, "#");
|
||||
assert_eq!(
|
||||
result,
|
||||
vec![
|
||||
VolumeMount { name: "data1".to_string(), target: "/tmp/data1".to_string() },
|
||||
VolumeMount { name: "data2".to_string(), target: "/tmp/data2".to_string() },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ignores_malformed_volume_lines() {
|
||||
let content =
|
||||
"# volume:\n# volume: onlyname\n# volume: good /tmp/good\ndef main():\n pass";
|
||||
let result = parse_volume_annotations(content, "#");
|
||||
assert_eq!(
|
||||
result,
|
||||
vec![VolumeMount { name: "good".to_string(), target: "/tmp/good".to_string() }]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_extra_whitespace() {
|
||||
let content = "# volume: mydata /tmp/data \ndef main():\n pass";
|
||||
let result = parse_volume_annotations(content, "#");
|
||||
assert_eq!(
|
||||
result,
|
||||
vec![VolumeMount { name: "mydata".to_string(), target: "/tmp/data".to_string() }]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_target_with_spaces_in_path() {
|
||||
let content = "// volume: mydata /tmp/my data dir\nexport function main() {}";
|
||||
let result = parse_volume_annotations(content, "//");
|
||||
assert_eq!(
|
||||
result,
|
||||
vec![VolumeMount {
|
||||
name: "mydata".to_string(),
|
||||
target: "/tmp/my data dir".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_volume_with_dashes_and_underscores() {
|
||||
let content = "# volume: my-data_v2 /tmp/data\ndef main():\n pass";
|
||||
let result = parse_volume_annotations(content, "#");
|
||||
assert_eq!(
|
||||
result,
|
||||
vec![VolumeMount { name: "my-data_v2".to_string(), target: "/tmp/data".to_string() }]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolate_workspace() {
|
||||
let name = "$workspace-data";
|
||||
let result = interpolate_volume_name(name, None, "my_ws");
|
||||
assert_eq!(result, "my_ws-data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolate_args_simple() {
|
||||
let mut args = HashMap::new();
|
||||
args.insert(
|
||||
"env".to_string(),
|
||||
serde_json::value::RawValue::from_string("\"prod\"".to_string()).unwrap(),
|
||||
);
|
||||
let result = interpolate_volume_name("data-$args[env]", Some(&args), "ws");
|
||||
assert_eq!(result, "data-prod");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolate_args_and_workspace() {
|
||||
let mut args = HashMap::new();
|
||||
args.insert(
|
||||
"env".to_string(),
|
||||
serde_json::value::RawValue::from_string("\"staging\"".to_string()).unwrap(),
|
||||
);
|
||||
let result = interpolate_volume_name("$workspace-$args[env]-cache", Some(&args), "acme");
|
||||
assert_eq!(result, "acme-staging-cache");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolate_no_placeholders() {
|
||||
let result = interpolate_volume_name("plain-name", None, "ws");
|
||||
assert_eq!(result, "plain-name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolate_missing_arg() {
|
||||
let args = HashMap::new();
|
||||
let result = interpolate_volume_name("data-$args[missing]", Some(&args), "ws");
|
||||
assert_eq!(result, "data-");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolate_nested_arg() {
|
||||
let mut args = HashMap::new();
|
||||
args.insert(
|
||||
"config".to_string(),
|
||||
serde_json::value::RawValue::from_string(
|
||||
r#"{"env": "prod", "region": "us-east"}"#.to_string(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let result = interpolate_volume_name(
|
||||
"data-$args[config.env]-$args[config.region]",
|
||||
Some(&args),
|
||||
"ws",
|
||||
);
|
||||
assert_eq!(result, "data-prod-us-east");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_wrong_prefix_returns_empty() {
|
||||
let content = "# volume: mydata /tmp/data\ndef main():\n pass";
|
||||
let result = parse_volume_annotations(content, "//");
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_relative_path() {
|
||||
let content = "// volume: agent-memory .claude\nexport function main() {}";
|
||||
let result = parse_volume_annotations(content, "//");
|
||||
assert_eq!(
|
||||
result,
|
||||
vec![VolumeMount { name: "agent-memory".to_string(), target: ".claude".to_string() }]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_relative_nested_path() {
|
||||
let content = "# volume: data data/models\ndef main():\n pass";
|
||||
let result = parse_volume_annotations(content, "#");
|
||||
assert_eq!(
|
||||
result,
|
||||
vec![VolumeMount { name: "data".to_string(), target: "data/models".to_string() }]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_valid_names() {
|
||||
assert!(validate_volume_name("mydata").is_ok());
|
||||
assert!(validate_volume_name("my-data_v2").is_ok());
|
||||
assert!(validate_volume_name("acme-staging-cache").is_ok());
|
||||
assert!(validate_volume_name("a1").is_ok());
|
||||
assert!(validate_volume_name("data.v2").is_ok());
|
||||
assert!(validate_volume_name("A0").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_path_traversal() {
|
||||
assert!(validate_volume_name("../other-workspace").is_err());
|
||||
assert!(validate_volume_name("data/../secrets").is_err());
|
||||
assert!(validate_volume_name("a..b").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_special_start_end() {
|
||||
assert!(validate_volume_name("-data").is_err());
|
||||
assert!(validate_volume_name("data-").is_err());
|
||||
assert!(validate_volume_name(".data").is_err());
|
||||
assert!(validate_volume_name("data.").is_err());
|
||||
assert!(validate_volume_name("_data").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_path_separators() {
|
||||
assert!(validate_volume_name("data/secrets").is_err());
|
||||
assert!(validate_volume_name("data\\secrets").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_too_short() {
|
||||
assert!(validate_volume_name("").is_err());
|
||||
assert!(validate_volume_name("a").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_too_long() {
|
||||
let long_name = format!("a{}a", "b".repeat(254));
|
||||
assert!(validate_volume_name(&long_name).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_spaces_and_special() {
|
||||
assert!(validate_volume_name("my data").is_err());
|
||||
assert!(validate_volume_name("my@data").is_err());
|
||||
assert!(validate_volume_name("my$data").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_target_allows_relative() {
|
||||
assert!(validate_volume_target("data").is_ok());
|
||||
assert!(validate_volume_target("data/models").is_ok());
|
||||
assert!(validate_volume_target(".claude").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_target_allows_safe_absolute() {
|
||||
assert!(validate_volume_target("/tmp/data").is_ok());
|
||||
assert!(validate_volume_target("/mnt/data").is_ok());
|
||||
assert!(validate_volume_target("/opt/models").is_ok());
|
||||
assert!(validate_volume_target("/home/user/data").is_ok());
|
||||
assert!(validate_volume_target("/data/cache").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_target_rejects_dangerous_absolute() {
|
||||
assert!(validate_volume_target("/etc/passwd").is_err());
|
||||
assert!(validate_volume_target("/proc/self").is_err());
|
||||
assert!(validate_volume_target("/sys/fs").is_err());
|
||||
assert!(validate_volume_target("/dev/null").is_err());
|
||||
assert!(validate_volume_target("/usr/bin").is_err());
|
||||
assert!(validate_volume_target("/var/log").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_target_rejects_traversal() {
|
||||
assert!(validate_volume_target("../../etc").is_err());
|
||||
assert!(validate_volume_target("data/../../../etc").is_err());
|
||||
assert!(validate_volume_target("/tmp/../etc/passwd").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_mounts_rejects_too_many() {
|
||||
let mounts: Vec<VolumeMount> = (0..11)
|
||||
.map(|i| VolumeMount { name: format!("v{:02}", i), target: format!("t{}", i) })
|
||||
.collect();
|
||||
assert!(validate_volume_mounts(&mounts).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_mounts_rejects_duplicate_name() {
|
||||
let mounts = vec![
|
||||
VolumeMount { name: "data".to_string(), target: "/tmp/a".to_string() },
|
||||
VolumeMount { name: "data".to_string(), target: "/tmp/b".to_string() },
|
||||
];
|
||||
assert!(validate_volume_mounts(&mounts).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_mounts_rejects_duplicate_target() {
|
||||
let mounts = vec![
|
||||
VolumeMount { name: "v1".to_string(), target: "/tmp/data".to_string() },
|
||||
VolumeMount { name: "v2".to_string(), target: "/tmp/data".to_string() },
|
||||
];
|
||||
assert!(validate_volume_mounts(&mounts).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_mounts_ok() {
|
||||
let mounts = vec![
|
||||
VolumeMount { name: "v1".to_string(), target: "/tmp/a".to_string() },
|
||||
VolumeMount { name: "v2".to_string(), target: "/tmp/b".to_string() },
|
||||
];
|
||||
assert!(validate_volume_mounts(&mounts).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
#[cfg(feature = "private")]
|
||||
pub use crate::volume_ee::*;
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
use crate::{DownloadStats, SyncStats, VolumeMount, VolumeState};
|
||||
#[cfg(not(feature = "private"))]
|
||||
use object_store::ObjectStore;
|
||||
#[cfg(not(feature = "private"))]
|
||||
use std::path::Path;
|
||||
#[cfg(not(feature = "private"))]
|
||||
use std::sync::Arc;
|
||||
#[cfg(not(feature = "private"))]
|
||||
use windmill_common::error;
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub async fn download_volume(
|
||||
_client: Arc<dyn ObjectStore>,
|
||||
_volume: &VolumeMount,
|
||||
_job_dir: &str,
|
||||
_workspace_id: &str,
|
||||
) -> error::Result<(VolumeState, DownloadStats)> {
|
||||
Err(error::Error::internal_err(
|
||||
"Volumes are not available in this build".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn volume_nsjail_mount(_local_dir: &Path, _target: &str) -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub async fn sync_volume_back(
|
||||
_client: Arc<dyn ObjectStore>,
|
||||
_state: &VolumeState,
|
||||
_workspace_id: &str,
|
||||
) -> error::Result<SyncStats> {
|
||||
Err(error::Error::internal_err(
|
||||
"Volumes are not available in this build".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn walk_dir(dir: &Path) -> std::io::Result<Vec<std::path::PathBuf>> {
|
||||
let mut result = Vec::new();
|
||||
walk_dir_inner(dir, &mut result)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
fn walk_dir_inner(dir: &Path, result: &mut Vec<std::path::PathBuf>) -> std::io::Result<()> {
|
||||
if !dir.is_dir() {
|
||||
return Ok(());
|
||||
}
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let meta = match std::fs::symlink_metadata(&path) {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if meta.is_dir() {
|
||||
walk_dir_inner(&path, result)?;
|
||||
} else if meta.is_file() {
|
||||
result.push(path);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn collect_symlinks(dir: &Path) -> std::collections::HashMap<String, String> {
|
||||
let mut symlinks = std::collections::HashMap::new();
|
||||
collect_symlinks_inner(dir, dir, &mut symlinks);
|
||||
symlinks
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
fn collect_symlinks_inner(
|
||||
base: &Path,
|
||||
dir: &Path,
|
||||
symlinks: &mut std::collections::HashMap<String, String>,
|
||||
) {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let path = entry.path();
|
||||
let meta = match std::fs::symlink_metadata(&path) {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if meta.file_type().is_symlink() {
|
||||
if let Ok(target) = std::fs::read_link(&path) {
|
||||
let relative = path
|
||||
.strip_prefix(base)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
symlinks.insert(relative, target.to_string_lossy().to_string());
|
||||
}
|
||||
} else if meta.is_dir() {
|
||||
collect_symlinks_inner(base, &path, symlinks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn restore_symlinks(_dir: &Path, _symlinks: &std::collections::HashMap<String, String>) {
|
||||
// No-op in OSS build
|
||||
}
|
||||
@@ -10,10 +10,10 @@ path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
private = []
|
||||
private = ["windmill-worker-volumes/private", "windmill-queue/private"]
|
||||
mcp = ["dep:windmill-mcp"]
|
||||
prometheus = ["dep:prometheus", "windmill-common/prometheus"]
|
||||
enterprise = ["windmill-queue/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "dep:pem", "dep:tokio-util", "dep:opentelemetry-proto", "dep:prost", "dep:hudsucker", "dep:rcgen", "dep:hyper-http-proxy", "dep:hyper-tls", "dep:hyper-util"]
|
||||
enterprise = ["windmill-queue/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker-volumes/enterprise", "dep:pem", "dep:tokio-util", "dep:opentelemetry-proto", "dep:prost", "dep:hudsucker", "dep:rcgen", "dep:hyper-http-proxy", "dep:hyper-tls", "dep:hyper-util"]
|
||||
mssql = ["dep:tiberius"]
|
||||
mssql-kerberos = ["mssql", "tiberius/integrated-auth-gssapi"] # Linux/Unix integrated auth
|
||||
mssql-winauth = ["mssql", "tiberius/winauth"] # Windows integrated auth
|
||||
@@ -47,6 +47,7 @@ windmill-audit.workspace = true # there isn't really a reason for audit-worth ac
|
||||
windmill-common = { workspace = true, default-features = false }
|
||||
windmill-types.workspace = true
|
||||
windmill-object-store.workspace = true
|
||||
windmill-worker-volumes.workspace = true
|
||||
windmill-jseval.workspace = true
|
||||
windmill-runtime-nativets = { workspace = true, optional = true }
|
||||
windmill-mcp = { workspace = true, optional = true }
|
||||
|
||||
@@ -14,6 +14,18 @@ clone_newnet: false
|
||||
clone_newuser: {CLONE_NEWUSER}
|
||||
clone_newcgroup: false
|
||||
|
||||
uidmap {
|
||||
inside_id: "1000"
|
||||
outside_id: ""
|
||||
count: 1
|
||||
}
|
||||
|
||||
gidmap {
|
||||
inside_id: "1000"
|
||||
outside_id: ""
|
||||
count: 1
|
||||
}
|
||||
|
||||
skip_setsid: true
|
||||
keep_caps: false
|
||||
keep_env: true
|
||||
|
||||
@@ -22,7 +22,7 @@ use crate::{
|
||||
handle_child::handle_child,
|
||||
is_sandboxing_enabled, read_ee_registry, BUNFIG_INSTALL_SCOPES, BUN_BUNDLE_CACHE_DIR,
|
||||
BUN_CACHE_DIR, BUN_NO_CACHE, BUN_PATH, DISABLE_NUSER, HOME_ENV, NODE_BIN_PATH, NODE_PATH,
|
||||
NPMRC, NPM_CONFIG_REGISTRY, NPM_PATH, NSJAIL_PATH, PATH_ENV, PROXY_ENVS,
|
||||
NPMRC, NPM_CONFIG_REGISTRY, NPM_PATH, NSJAIL_AVAILABLE, NSJAIL_PATH, PATH_ENV, PROXY_ENVS,
|
||||
TRACING_PROXY_CA_CERT_PATH, TZ_ENV,
|
||||
};
|
||||
use windmill_common::{
|
||||
@@ -990,6 +990,14 @@ pub async fn handle_bun_job(
|
||||
) -> error::Result<Box<RawValue>> {
|
||||
let mut annotation = windmill_common::worker::TypeScriptAnnotations::parse(inner_content);
|
||||
|
||||
if annotation.sandbox && NSJAIL_AVAILABLE.is_none() {
|
||||
return Err(error::Error::ExecutionErr(
|
||||
"Script has //sandbox annotation but nsjail is not available on this worker. \
|
||||
Please ensure nsjail is installed or remove the //sandbox annotation."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let (mut has_bundle_cache, cache_logs, local_path, remote_path) = if let (Some(lock), true) = (
|
||||
maybe_lock.get_lock(),
|
||||
!annotation.nobundling && !*DISABLE_BUNDLING && codebase.is_none(),
|
||||
@@ -1161,6 +1169,10 @@ pub async fn handle_bun_job(
|
||||
init_logs = format!("\n{}{}", cache_logs, init_logs);
|
||||
}
|
||||
|
||||
if annotation.sandbox {
|
||||
init_logs.push_str("sandbox mode (nsjail)\n");
|
||||
}
|
||||
|
||||
let write_wrapper_f = async {
|
||||
if !has_bundle_cache && annotation.native {
|
||||
return Ok(()) as error::Result<()>;
|
||||
@@ -1485,7 +1497,7 @@ try {{
|
||||
append_logs(&job.id, &job.workspace_id, init_logs, conn).await;
|
||||
|
||||
//do not cache local dependencies
|
||||
let child = if is_sandboxing_enabled() {
|
||||
let child = if is_sandboxing_enabled() || annotation.sandbox {
|
||||
let _ = write_file(
|
||||
job_dir,
|
||||
"run.config.proto",
|
||||
|
||||
@@ -886,7 +886,7 @@ pub async fn cached_result_path(
|
||||
}
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
async fn get_workspace_s3_resource_path(
|
||||
pub(crate) async fn get_workspace_s3_resource_path(
|
||||
db: &DB,
|
||||
client: &AuthedClient,
|
||||
workspace_id: &str,
|
||||
@@ -948,7 +948,11 @@ async fn get_workspace_s3_resource_path(
|
||||
)
|
||||
}
|
||||
Some(LargeFileStorage::FilesystemStorage(fs)) => {
|
||||
(StorageResourceType::Filesystem, fs.root_path.clone())
|
||||
return Ok(Some(
|
||||
windmill_object_store::ObjectStoreResource::Filesystem(
|
||||
windmill_object_store::FilesystemSettings { root_path: fs.root_path.clone() },
|
||||
),
|
||||
));
|
||||
}
|
||||
None => {
|
||||
return Ok(None);
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::{
|
||||
NPM_CONFIG_REGISTRY, PATH_ENV, TZ_ENV,
|
||||
};
|
||||
use windmill_common::client::AuthedClient;
|
||||
use windmill_common::worker::TypeScriptAnnotations;
|
||||
|
||||
use tokio::{fs::File, io::AsyncReadExt, process::Command};
|
||||
use windmill_common::{error::Result, scripts::ScriptLang, worker::write_file, BASE_URL};
|
||||
@@ -231,8 +232,13 @@ pub async fn handle_deno_job(
|
||||
occupancy_metrics: &mut OccupancyMetrics,
|
||||
has_stream: &mut bool,
|
||||
) -> error::Result<Box<RawValue>> {
|
||||
let annotations = TypeScriptAnnotations::parse(inner_content);
|
||||
|
||||
// let mut start = Instant::now();
|
||||
let logs1 = "\n\n--- DENO CODE EXECUTION ---\n".to_string();
|
||||
let mut logs1 = "\n\n--- DENO CODE EXECUTION ---\n".to_string();
|
||||
if annotations.sandbox {
|
||||
logs1.push_str("sandbox mode (nsjail)\n");
|
||||
}
|
||||
append_logs(&job.id, &job.workspace_id, logs1, conn).await;
|
||||
|
||||
let main_override = job.script_entrypoint_override.as_deref();
|
||||
@@ -451,7 +457,7 @@ try {{
|
||||
for flag in deno_flags {
|
||||
args.push(flag);
|
||||
}
|
||||
} else if is_sandboxing_enabled() {
|
||||
} else if is_sandboxing_enabled() || annotations.sandbox {
|
||||
args.push("--allow-net");
|
||||
args.push("--allow-sys");
|
||||
args.push(allow_read.as_str());
|
||||
|
||||
@@ -70,6 +70,9 @@ mod sanitized_sql_params;
|
||||
mod schema;
|
||||
pub mod sql_utils;
|
||||
mod universal_pkg_installer;
|
||||
#[cfg(feature = "private")]
|
||||
mod volume_ee;
|
||||
mod volume_oss;
|
||||
mod worker;
|
||||
mod worker_flow;
|
||||
mod worker_lockfiles;
|
||||
|
||||
@@ -134,8 +134,8 @@ use crate::{
|
||||
handle_child::handle_child,
|
||||
is_sandboxing_enabled, read_ee_registry,
|
||||
worker_utils::ping_job_status,
|
||||
PyV, DISABLE_NUSER, HOME_ENV, NSJAIL_PATH, PATH_ENV, PIP_EXTRA_INDEX_URL, PIP_INDEX_URL,
|
||||
PROXY_ENVS, PY_INSTALL_DIR, TRACING_PROXY_CA_CERT_PATH, TZ_ENV, UV_CACHE_DIR,
|
||||
PyV, DISABLE_NUSER, HOME_ENV, NSJAIL_AVAILABLE, NSJAIL_PATH, PATH_ENV, PIP_EXTRA_INDEX_URL,
|
||||
PIP_INDEX_URL, PROXY_ENVS, PY_INSTALL_DIR, TRACING_PROXY_CA_CERT_PATH, TZ_ENV, UV_CACHE_DIR,
|
||||
UV_INDEX_STRATEGY,
|
||||
};
|
||||
use windmill_common::client::AuthedClient;
|
||||
@@ -567,6 +567,14 @@ pub async fn handle_python_job(
|
||||
|
||||
let annotations = PythonAnnotations::parse(inner_content);
|
||||
|
||||
if annotations.sandbox && NSJAIL_AVAILABLE.is_none() {
|
||||
return Err(Error::ExecutionErr(
|
||||
"Script has #sandbox annotation but nsjail is not available on this worker. \
|
||||
Please ensure nsjail is installed or remove the #sandbox annotation."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let (py_version, mut additional_python_paths) = handle_python_deps(
|
||||
job_dir,
|
||||
requirements_o,
|
||||
@@ -605,16 +613,14 @@ pub async fn handle_python_job(
|
||||
}
|
||||
|
||||
{
|
||||
append_logs(
|
||||
&job.id,
|
||||
&job.workspace_id,
|
||||
format!(
|
||||
"\n\n--- PYTHON ({}) CODE EXECUTION ---\n",
|
||||
py_version.clone().to_string()
|
||||
),
|
||||
conn,
|
||||
)
|
||||
.await;
|
||||
let mut logs = format!(
|
||||
"\n\n--- PYTHON ({}) CODE EXECUTION ---\n",
|
||||
py_version.clone().to_string()
|
||||
);
|
||||
if annotations.sandbox {
|
||||
logs.push_str("sandbox mode (nsjail)\n");
|
||||
}
|
||||
append_logs(&job.id, &job.workspace_id, logs, conn).await;
|
||||
}
|
||||
let (
|
||||
import_loader,
|
||||
@@ -784,7 +790,7 @@ except BaseException as e:
|
||||
#[cfg(windows)]
|
||||
let additional_python_paths_folders = additional_python_paths_folders.replace(":", ";");
|
||||
|
||||
if is_sandboxing_enabled() {
|
||||
if is_sandboxing_enabled() || annotations.sandbox {
|
||||
let shared_deps = additional_python_paths
|
||||
.into_iter()
|
||||
.map(|pp| {
|
||||
@@ -828,7 +834,7 @@ mount {{
|
||||
job.id
|
||||
);
|
||||
|
||||
let child = if is_sandboxing_enabled() {
|
||||
let child = if is_sandboxing_enabled() || annotations.sandbox {
|
||||
let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str());
|
||||
nsjail_cmd
|
||||
.current_dir(job_dir)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
#[cfg(feature = "private")]
|
||||
pub(crate) use crate::volume_ee::*;
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
#[cfg(feature = "parquet")]
|
||||
pub(crate) struct LeaseRenewalGuard(pub Option<tokio::task::JoinHandle<()>>);
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
#[cfg(feature = "parquet")]
|
||||
impl Drop for LeaseRenewalGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(handle) = self.0.take() {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
#[cfg(feature = "parquet")]
|
||||
pub(crate) struct VolumeSetupResult {
|
||||
pub states: Vec<windmill_worker_volumes::VolumeState>,
|
||||
pub writable: Vec<bool>,
|
||||
pub client: Option<std::sync::Arc<dyn windmill_worker_volumes::DynObjectStore>>,
|
||||
pub lease_renewal: LeaseRenewalGuard,
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
#[cfg(feature = "parquet")]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn setup_volume_mount_paths(
|
||||
_volume: &windmill_worker_volumes::VolumeMount,
|
||||
_state: &windmill_worker_volumes::VolumeState,
|
||||
_job_dir: &str,
|
||||
_language: windmill_common::scripts::ScriptLang,
|
||||
_envs: &mut std::collections::HashMap<String, String>,
|
||||
_shared_mount: &mut String,
|
||||
) -> windmill_common::error::Result<()> {
|
||||
Err(windmill_common::error::Error::internal_err(
|
||||
"Volumes are not available in OSS".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
#[cfg(feature = "parquet")]
|
||||
pub(crate) async fn setup_volumes_sql_worker(
|
||||
_volume_mounts: &[windmill_worker_volumes::VolumeMount],
|
||||
_db: &windmill_common::DB,
|
||||
_workspace_id: &str,
|
||||
_job_id: uuid::Uuid,
|
||||
_permissioned_as: &str,
|
||||
_worker_name: &str,
|
||||
_job_dir: &str,
|
||||
_client: &windmill_common::client::AuthedClient,
|
||||
_conn: &windmill_common::worker::Connection,
|
||||
_language: windmill_common::scripts::ScriptLang,
|
||||
_envs: &mut std::collections::HashMap<String, String>,
|
||||
_shared_mount: &mut String,
|
||||
) -> windmill_common::error::Result<VolumeSetupResult> {
|
||||
Err(windmill_common::error::Error::internal_err(
|
||||
"Volumes are not available in OSS".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
#[cfg(feature = "parquet")]
|
||||
pub(crate) async fn setup_volumes_http_worker(
|
||||
_volume_mounts: &[windmill_worker_volumes::VolumeMount],
|
||||
_http: &windmill_common::worker::HttpClient,
|
||||
_workspace_id: &str,
|
||||
_job_id: uuid::Uuid,
|
||||
_permissioned_as: &str,
|
||||
_canceled_by: &Option<String>,
|
||||
_worker_name: &str,
|
||||
_job_dir: &str,
|
||||
_conn: &windmill_common::worker::Connection,
|
||||
_language: windmill_common::scripts::ScriptLang,
|
||||
_envs: &mut std::collections::HashMap<String, String>,
|
||||
_shared_mount: &mut String,
|
||||
) -> windmill_common::error::Result<VolumeSetupResult> {
|
||||
Err(windmill_common::error::Error::internal_err(
|
||||
"Volumes are not available in OSS".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
#[cfg(feature = "parquet")]
|
||||
pub(crate) async fn sync_volumes_sql_worker(
|
||||
_volume_states: &[windmill_worker_volumes::VolumeState],
|
||||
_volume_writable: &[bool],
|
||||
_vol_client: &std::sync::Arc<dyn windmill_worker_volumes::DynObjectStore>,
|
||||
_db: &windmill_common::DB,
|
||||
_workspace_id: &str,
|
||||
_job_id: uuid::Uuid,
|
||||
_worker_name: &str,
|
||||
_conn: &windmill_common::worker::Connection,
|
||||
_job_succeeded: bool,
|
||||
) {
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
#[cfg(feature = "parquet")]
|
||||
pub(crate) async fn sync_volumes_http_worker(
|
||||
_volume_states: &[windmill_worker_volumes::VolumeState],
|
||||
_volume_writable: &[bool],
|
||||
_http: &windmill_common::worker::HttpClient,
|
||||
_workspace_id: &str,
|
||||
_job_id: uuid::Uuid,
|
||||
_worker_name: &str,
|
||||
_conn: &windmill_common::worker::Connection,
|
||||
_job_succeeded: bool,
|
||||
) {
|
||||
}
|
||||
@@ -4161,7 +4161,8 @@ pub async fn run_language_executor(
|
||||
job.id
|
||||
);
|
||||
|
||||
let shared_mount = if job.same_worker && job.script_lang != Some(ScriptLang::Deno) {
|
||||
#[allow(unused_mut)]
|
||||
let mut shared_mount = if job.same_worker && job.script_lang != Some(ScriptLang::Deno) {
|
||||
let folder = if job.script_lang == Some(ScriptLang::Go) {
|
||||
"/go"
|
||||
} else {
|
||||
@@ -4183,7 +4184,8 @@ mount {{
|
||||
|
||||
// println!("handle lang job {:?}", SystemTime::now());
|
||||
|
||||
let envs = build_envs(envs.as_ref())?;
|
||||
#[allow(unused_mut)]
|
||||
let mut envs = build_envs(envs.as_ref())?;
|
||||
|
||||
let Some(language) = language else {
|
||||
return Err(Error::ExecutionErr(
|
||||
@@ -4219,6 +4221,106 @@ mount {{
|
||||
}
|
||||
}
|
||||
|
||||
// Volume mount setup (requires workspace S3 storage; CE has file count/size limits)
|
||||
#[cfg(feature = "parquet")]
|
||||
let volume_mounts = {
|
||||
let comment_prefix = match language {
|
||||
ScriptLang::Python3
|
||||
| ScriptLang::Bash
|
||||
| ScriptLang::Powershell
|
||||
| ScriptLang::Ansible
|
||||
| ScriptLang::Ruby => "#",
|
||||
ScriptLang::Deno
|
||||
| ScriptLang::Bun
|
||||
| ScriptLang::Bunnative
|
||||
| ScriptLang::Nativets
|
||||
| ScriptLang::Go => "//",
|
||||
_ => "",
|
||||
};
|
||||
let raw_mounts = windmill_worker_volumes::parse_volume_annotations(&code, comment_prefix);
|
||||
let args_ref = job.args.as_ref().map(|a| &**a);
|
||||
let mut interpolated = Vec::new();
|
||||
for mut v in raw_mounts {
|
||||
v.name = windmill_worker_volumes::interpolate_volume_name(
|
||||
&v.name,
|
||||
args_ref,
|
||||
&job.workspace_id,
|
||||
);
|
||||
if let Err(e) = windmill_worker_volumes::validate_volume_name(&v.name) {
|
||||
return Err(Error::ExecutionErr(e));
|
||||
}
|
||||
if let Err(e) = windmill_worker_volumes::validate_volume_target(&v.target) {
|
||||
return Err(Error::ExecutionErr(e));
|
||||
}
|
||||
interpolated.push(v);
|
||||
}
|
||||
if let Err(e) = windmill_worker_volumes::validate_volume_mounts(&interpolated) {
|
||||
return Err(Error::ExecutionErr(e));
|
||||
}
|
||||
interpolated
|
||||
};
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
let mut volume_setup = crate::volume_oss::VolumeSetupResult {
|
||||
states: Vec::new(),
|
||||
writable: Vec::new(),
|
||||
client: None,
|
||||
lease_renewal: crate::volume_oss::LeaseRenewalGuard(None),
|
||||
};
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
if !volume_mounts.is_empty() {
|
||||
let vol_summary: Vec<String> = volume_mounts
|
||||
.iter()
|
||||
.map(|v| format!("'{}' -> {}", v.name, v.target))
|
||||
.collect();
|
||||
append_logs(
|
||||
&job.id,
|
||||
&job.workspace_id,
|
||||
format!(
|
||||
"\n--- VOLUME MOUNTS ---\nPulling {} volume(s): {}\n",
|
||||
volume_mounts.len(),
|
||||
vol_summary.join(", "),
|
||||
),
|
||||
conn,
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Connection::Sql(db) = conn {
|
||||
volume_setup = crate::volume_oss::setup_volumes_sql_worker(
|
||||
&volume_mounts,
|
||||
db,
|
||||
&job.workspace_id,
|
||||
job.id,
|
||||
&job.permissioned_as,
|
||||
worker_name,
|
||||
job_dir,
|
||||
client,
|
||||
conn,
|
||||
language,
|
||||
&mut envs,
|
||||
&mut shared_mount,
|
||||
)
|
||||
.await?;
|
||||
} else if let Connection::Http(http) = conn {
|
||||
volume_setup = crate::volume_oss::setup_volumes_http_worker(
|
||||
&volume_mounts,
|
||||
http,
|
||||
&job.workspace_id,
|
||||
job.id,
|
||||
&job.permissioned_as,
|
||||
&job.canceled_by,
|
||||
worker_name,
|
||||
job_dir,
|
||||
conn,
|
||||
language,
|
||||
&mut envs,
|
||||
&mut shared_mount,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Box::pin all language handlers to prevent large match enum on stack
|
||||
let result: error::Result<Box<RawValue>> = match language {
|
||||
ScriptLang::Python3 => {
|
||||
@@ -4630,6 +4732,61 @@ mount {{
|
||||
// for related places search: ADD_NEW_LANG
|
||||
_ => panic!("unreachable, language is not supported: {language:#?}"),
|
||||
};
|
||||
// Volume sync-back and lease release
|
||||
#[cfg(feature = "parquet")]
|
||||
if !volume_setup.states.is_empty() {
|
||||
// Stop lease renewal before sync-back
|
||||
volume_setup.lease_renewal.0.take().map(|h| h.abort());
|
||||
|
||||
if let Some(ref vol_client) = volume_setup.client {
|
||||
if let Connection::Sql(db) = conn {
|
||||
crate::volume_oss::sync_volumes_sql_worker(
|
||||
&volume_setup.states,
|
||||
&volume_setup.writable,
|
||||
vol_client,
|
||||
db,
|
||||
&job.workspace_id,
|
||||
job.id,
|
||||
worker_name,
|
||||
conn,
|
||||
result.is_ok(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
if let Connection::Http(http) = conn {
|
||||
crate::volume_oss::sync_volumes_http_worker(
|
||||
&volume_setup.states,
|
||||
&volume_setup.writable,
|
||||
http,
|
||||
&job.workspace_id,
|
||||
job.id,
|
||||
worker_name,
|
||||
conn,
|
||||
result.is_ok(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Clean up absolute-path symlinks created by setup_volume_mount_paths
|
||||
if !is_sandboxing_enabled() {
|
||||
for state in &volume_setup.states {
|
||||
#[cfg(unix)]
|
||||
if state.mount.target.starts_with('/') {
|
||||
let target_path = std::path::Path::new(&state.mount.target);
|
||||
if target_path
|
||||
.symlink_metadata()
|
||||
.map(|m| m.file_type().is_symlink())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
std::fs::remove_file(target_path).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
workspace_id = %job.workspace_id,
|
||||
is_ok = result.is_ok(),
|
||||
|
||||
@@ -61,6 +61,11 @@ RUN ln -s /usr/bin/bun /usr/bin/node \
|
||||
&& bun install -g windmill-cli \
|
||||
&& ln -s $(bun pm bin -g)/wmill /usr/bin/wmill
|
||||
|
||||
# Install Claude Code CLI (used by claude sandbox scripts)
|
||||
# Copy to /usr/bin/claude so it's accessible inside nsjail sandbox (which mounts /usr but not /root)
|
||||
RUN curl -fsSL https://claude.ai/install.sh | bash \
|
||||
&& cp /root/.local/share/claude/versions/* /usr/bin/claude
|
||||
|
||||
# add the docker client to call docker from a worker if enabled
|
||||
COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/
|
||||
|
||||
|
||||
@@ -61,6 +61,11 @@ RUN ln -s /usr/bin/bun /usr/bin/node \
|
||||
&& bun install -g windmill-cli \
|
||||
&& ln -s $(bun pm bin -g)/wmill /usr/bin/wmill
|
||||
|
||||
# Install Claude Code CLI (used by claude sandbox scripts)
|
||||
# Copy to /usr/bin/claude so it's accessible inside nsjail sandbox (which mounts /usr but not /root)
|
||||
RUN curl -fsSL https://claude.ai/install.sh | bash \
|
||||
&& cp /root/.local/share/claude/versions/* /usr/bin/claude
|
||||
|
||||
# add the docker client to call docker from a worker if enabled
|
||||
COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import GitHubAppIntegration from './GitHubAppIntegration.svelte'
|
||||
import BedrockCredentialsCheck from './BedrockCredentialsCheck.svelte'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import ResourceGen from './copilot/ResourceGen.svelte'
|
||||
|
||||
interface Props {
|
||||
resourceType: string
|
||||
@@ -149,6 +150,12 @@
|
||||
}}
|
||||
class="as-json-toggle"
|
||||
/>
|
||||
<ResourceGen
|
||||
bind:args
|
||||
resourceType={resourceType}
|
||||
resourceSchema={notFound ? undefined : schema}
|
||||
isFileset={resourceTypeInfo?.is_fileset ?? false}
|
||||
/>
|
||||
<TestConnection {resourceType} {args} />
|
||||
{#if resourceType == 'postgresql'}
|
||||
<Popover
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
iconOnly?: boolean
|
||||
validCode?: boolean
|
||||
kind?: 'script' | 'trigger' | 'approval'
|
||||
template?: 'pgsql' | 'mysql' | 'script' | 'docker' | 'powershell' | 'bunnative'
|
||||
template?: 'pgsql' | 'mysql' | 'script' | 'docker' | 'powershell' | 'bunnative' | 'claudesandbox'
|
||||
collabMode?: boolean
|
||||
collabLive?: boolean
|
||||
collabUsers?: { name: string }[]
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
asset.kind === 'ducklake' ||
|
||||
asset.kind === 'datatable' ||
|
||||
asset.kind === 's3object' ||
|
||||
asset.kind === 'volume' ||
|
||||
(asset.kind === 'resource' && isDbType(_resourceMetadata?.resource_type))
|
||||
)
|
||||
}
|
||||
@@ -17,9 +18,10 @@
|
||||
import { formatAsset, type Asset } from '$lib/components/assets/lib'
|
||||
import { Button, ButtonType } from '$lib/components/common'
|
||||
import S3FilePicker from '$lib/components/S3FilePicker.svelte'
|
||||
import { globalDbManagerDrawer, userStore } from '$lib/stores'
|
||||
import { VolumeService } from '$lib/gen'
|
||||
import { globalDbManagerDrawer, userStore, workspaceStore } from '$lib/stores'
|
||||
import { isS3Uri } from '$lib/utils'
|
||||
import { Database, File } from 'lucide-svelte'
|
||||
import { Database, File, HardDriveIcon } from 'lucide-svelte'
|
||||
import DucklakeIcon from './icons/DucklakeIcon.svelte'
|
||||
|
||||
const {
|
||||
@@ -66,6 +68,9 @@
|
||||
})
|
||||
} else if (asset.kind === 's3object' && isS3Uri(assetUri)) {
|
||||
s3FilePicker?.open(assetUri)
|
||||
} else if (asset.kind === 'volume') {
|
||||
const storage = (await VolumeService.getVolumeStorage({ workspace: $workspaceStore! })) ?? undefined
|
||||
s3FilePicker?.open({ s3: `volumes/${$workspaceStore}/${asset.path}/`, storage })
|
||||
} else if (asset.kind === 'ducklake') {
|
||||
let ducklake = asset.path.split('/')[0]
|
||||
let specificTable = asset.path.split('/')[1] as string | undefined
|
||||
@@ -93,9 +98,11 @@
|
||||
? { icon: Database }
|
||||
: asset.kind === 'ducklake'
|
||||
? { icon: DucklakeIcon }
|
||||
: undefined}
|
||||
: asset.kind === 'volume'
|
||||
? { icon: HardDriveIcon }
|
||||
: undefined}
|
||||
>
|
||||
{#if asset.kind === 's3object'}
|
||||
{#if asset.kind === 's3object' || asset.kind === 'volume'}
|
||||
<span class:hidden={noText}>Explore</span>
|
||||
{:else if asset.kind === 'resource' || asset.kind === 'ducklake' || asset.kind === 'datatable'}
|
||||
<span class:hidden={noText}>Manage</span>
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Track the last args we wrote so we can detect external changes.
|
||||
let lastWrittenArgs: Record<string, any> = $state(args ?? {})
|
||||
|
||||
// Sync files → args, overlaying current editContent for the active file.
|
||||
// This avoids spreading a new files object on every keystroke.
|
||||
$effect(() => {
|
||||
@@ -58,9 +61,33 @@
|
||||
newArgs[argKey] = key === currentKey ? currentContent : value
|
||||
}
|
||||
}
|
||||
lastWrittenArgs = newArgs
|
||||
args = newArgs
|
||||
})
|
||||
|
||||
// Sync args → files when args changes externally (e.g. from AI generation).
|
||||
$effect(() => {
|
||||
const currentArgs = args
|
||||
if (currentArgs === lastWrittenArgs) return
|
||||
// Check if the args object is actually different
|
||||
const currentKeys = Object.keys(currentArgs ?? {}).sort().join('\0')
|
||||
const lastKeys = Object.keys(lastWrittenArgs ?? {}).sort().join('\0')
|
||||
if (currentKeys === lastKeys) {
|
||||
const allSame = Object.entries(currentArgs ?? {}).every(
|
||||
([k, v]) => lastWrittenArgs[k] === v
|
||||
)
|
||||
if (allSame) return
|
||||
}
|
||||
const newFiles = Object.fromEntries(
|
||||
Object.entries(currentArgs ?? {}).map(([k, v]) => ['/' + k, String(v ?? '')])
|
||||
)
|
||||
files = newFiles
|
||||
lastWrittenArgs = currentArgs
|
||||
const firstFile = Object.keys(newFiles).find((k) => !k.endsWith('/'))
|
||||
selectedPath = firstFile ?? '/'
|
||||
editContent = firstFile ? (newFiles[firstFile] ?? '') : ''
|
||||
})
|
||||
|
||||
function inferLang(filePath: string): string {
|
||||
const ext = filePath.split('.').pop()?.toLowerCase()
|
||||
if (!ext) return 'plaintext'
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
import GitHubAppIntegration from './GitHubAppIntegration.svelte'
|
||||
import Button from './common/button/Button.svelte'
|
||||
import { clearJsonSchemaResourceCache } from './schema/jsonSchemaResource.svelte'
|
||||
import ResourceGen from './copilot/ResourceGen.svelte'
|
||||
|
||||
interface Props {
|
||||
canSave?: boolean
|
||||
@@ -270,6 +271,13 @@
|
||||
right: 'As JSON'
|
||||
}}
|
||||
/>
|
||||
<ResourceGen
|
||||
bind:args
|
||||
resourceType={resource_type}
|
||||
resourceName={path}
|
||||
resourceDescription={description}
|
||||
{resourceSchema}
|
||||
/>
|
||||
{#if resourceToEdit?.resource_type === 'nats' || resourceToEdit?.resource_type === 'kafka'}
|
||||
<TestTriggerConnection kind={resourceToEdit?.resource_type} args={{ connection: args }} />
|
||||
{:else}
|
||||
@@ -296,9 +304,17 @@
|
||||
{#if loadingSchema}
|
||||
<Skeleton layout={[[4]]} />
|
||||
{:else if !viewJsonSchema && resourceTypeInfo?.is_fileset}
|
||||
<h5 class="mt-1 inline-flex items-center gap-4">
|
||||
Fileset
|
||||
</h5>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<h5 class="inline-flex items-center gap-4">Fileset</h5>
|
||||
<ResourceGen
|
||||
bind:args
|
||||
resourceType={resource_type}
|
||||
resourceName={path}
|
||||
resourceDescription={description}
|
||||
{resourceSchema}
|
||||
isFileset
|
||||
/>
|
||||
</div>
|
||||
<FilesetEditor bind:args />
|
||||
{:else if !viewJsonSchema && resourceSchema && resourceSchema?.properties}
|
||||
{#if resourceTypeInfo?.format_extension}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
interface Props {
|
||||
fromWorkspaceSettings?: boolean
|
||||
readOnlyMode: boolean
|
||||
allowDelete?: boolean
|
||||
initialFileKey?: { s3: string; storage?: string } | undefined
|
||||
selectedFileKey?: { s3: string; storage?: string } | undefined
|
||||
folderOnly?: boolean
|
||||
@@ -24,6 +25,7 @@
|
||||
let {
|
||||
fromWorkspaceSettings = false,
|
||||
readOnlyMode,
|
||||
allowDelete = false,
|
||||
initialFileKey = $bindable(undefined),
|
||||
selectedFileKey = $bindable(undefined),
|
||||
folderOnly = false,
|
||||
@@ -94,6 +96,7 @@
|
||||
}}
|
||||
{fromWorkspaceSettings}
|
||||
{readOnlyMode}
|
||||
{allowDelete}
|
||||
bind:initialFileKey
|
||||
bind:selectedFileKey
|
||||
bind:workspaceSettingsInitialized
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
count: number
|
||||
}
|
||||
>
|
||||
allowDelete?: boolean
|
||||
replaceUnauthorizedWarning?: Snippet
|
||||
listStoredFilesRequest?: (d: ListStoredFilesData) => CancelablePromise<ListStoredFilesResponse>
|
||||
loadFilePreviewRequest?: (d: LoadFilePreviewData) => CancelablePromise<LoadFilePreviewResponse>
|
||||
@@ -102,11 +103,12 @@
|
||||
folderOnly = false,
|
||||
regexFilter = undefined,
|
||||
hideS3SpecificDetails = false,
|
||||
rootPath = '',
|
||||
rootPath: initialRootPath = '',
|
||||
workspaceSettingsInitialized = $bindable(true),
|
||||
storage = $bindable(undefined),
|
||||
uploadModalOpen = $bindable(false),
|
||||
allFilesByKey = $bindable({}),
|
||||
allowDelete = false,
|
||||
replaceUnauthorizedWarning,
|
||||
listStoredFilesRequest = HelpersService.listStoredFiles,
|
||||
loadFilePreviewRequest = HelpersService.loadFilePreview,
|
||||
@@ -116,6 +118,7 @@
|
||||
testConnectionRequest = HelpersService.datasetStorageTestConnection
|
||||
}: Props = $props()
|
||||
|
||||
let rootPath = $state(initialRootPath)
|
||||
let rootPathNestingLevel = $derived(1 * (rootPath.split('/').length - 1))
|
||||
|
||||
let csvSeparatorChar: string = $state(',')
|
||||
@@ -263,7 +266,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
displayedFileKeys = displayedFileKeys.sort()
|
||||
displayedFileKeys = [...new Set(displayedFileKeys)].sort()
|
||||
fileListLoading = false
|
||||
fileInfoLoading = false
|
||||
}
|
||||
@@ -381,7 +384,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
displayedFileKeys = displayedFileKeys.sort()
|
||||
displayedFileKeys = [...new Set(displayedFileKeys)].sort()
|
||||
}
|
||||
|
||||
async function clearAndLoadFiles({ keepFilter }: { keepFilter?: boolean } = {}) {
|
||||
@@ -424,9 +427,16 @@
|
||||
export async function open(_preSelectedFileKey: S3Object | undefined = undefined) {
|
||||
const preSelectedFileKey = _preSelectedFileKey && parseS3Object(_preSelectedFileKey)
|
||||
storage = preSelectedFileKey?.storage
|
||||
if (preSelectedFileKey !== undefined) {
|
||||
if (preSelectedFileKey !== undefined && preSelectedFileKey.s3.endsWith('/')) {
|
||||
rootPath = preSelectedFileKey.s3
|
||||
filter = ''
|
||||
selectedFileKey = undefined
|
||||
} else if (preSelectedFileKey !== undefined) {
|
||||
rootPath = ''
|
||||
initialFileKey = { ...preSelectedFileKey }
|
||||
selectedFileKey = { ...preSelectedFileKey }
|
||||
} else {
|
||||
rootPath = ''
|
||||
}
|
||||
reloadContent()
|
||||
}
|
||||
@@ -461,7 +471,7 @@
|
||||
if (selectedFileKey !== undefined) {
|
||||
if (allFilesByKey[selectedFileKey.s3] === undefined) {
|
||||
selectedFileKey = { s3: '', storage }
|
||||
} else {
|
||||
} else if (allFilesByKey[selectedFileKey.s3].type !== 'folder') {
|
||||
loadFileMetadataPlusPreviewAsync(selectedFileKey.s3)
|
||||
}
|
||||
}
|
||||
@@ -518,7 +528,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
displayedFileKeys = displayedFileKeys.sort()
|
||||
displayedFileKeys = [...new Set(displayedFileKeys)].sort()
|
||||
} else {
|
||||
selectedFileKey = {
|
||||
s3: item_key,
|
||||
@@ -719,8 +729,10 @@
|
||||
startIcon={{ icon: MoveRight }}
|
||||
iconOnly={true}
|
||||
/>
|
||||
{/if}
|
||||
{#if !readOnlyMode || allowDelete}
|
||||
<Button
|
||||
title="Delete file from S3"
|
||||
title="Delete file"
|
||||
variant="default"
|
||||
on:click={() => {
|
||||
deletionModalOpen = true
|
||||
|
||||
@@ -382,7 +382,7 @@
|
||||
async function initContent(
|
||||
language: SupportedLanguage,
|
||||
kind: Script['kind'] | undefined,
|
||||
template: 'pgsql' | 'mysql' | 'script' | 'docker' | 'powershell' | 'bunnative'
|
||||
template: 'pgsql' | 'mysql' | 'script' | 'docker' | 'powershell' | 'bunnative' | 'claudesandbox'
|
||||
) {
|
||||
scriptEditor?.disableCollaboration()
|
||||
const templateScript = await isTemplateScript()
|
||||
@@ -1159,9 +1159,10 @@
|
||||
<div class=" grid grid-cols-3 gap-2">
|
||||
{#each langs as [label, lang] (lang)}
|
||||
{@const isPicked =
|
||||
(lang == script.language && template == 'script') ||
|
||||
(lang == script.language && template != 'bunnative' && template != 'docker' && template != 'claudesandbox') ||
|
||||
(template == 'bunnative' && lang == 'bunnative') ||
|
||||
(template == 'docker' && lang == 'docker')}
|
||||
(template == 'docker' && lang == 'docker') ||
|
||||
(template == 'claudesandbox' && lang == 'bun')}
|
||||
<Popover
|
||||
disablePopup={!enterpriseLangs.includes(lang) || !!$enterpriseLicense}
|
||||
>
|
||||
@@ -1194,6 +1195,25 @@
|
||||
</div>
|
||||
</Section>
|
||||
{/if}
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span class="text-2xs text-secondary">Template</span>
|
||||
<Button
|
||||
size="xs2"
|
||||
variant="border"
|
||||
color="light"
|
||||
startIcon={{
|
||||
icon: LanguageIcon,
|
||||
props: { lang: 'claudesandbox', width: 16, height: 16 }
|
||||
} as ButtonType.Icon}
|
||||
on:click={() => {
|
||||
template = 'claudesandbox'
|
||||
script.language = 'bun'
|
||||
initContent('bun', script.kind, template)
|
||||
}}
|
||||
>
|
||||
Claude Sandbox
|
||||
</Button>
|
||||
</div>
|
||||
{#if customUi?.settingsPanel?.metadata?.disableScriptKind !== true}
|
||||
<Section label="Script kind">
|
||||
{#snippet header()}
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
path: string | undefined
|
||||
lang: Preview['language']
|
||||
kind?: string | undefined
|
||||
template?: 'pgsql' | 'mysql' | 'script' | 'docker' | 'powershell' | 'bunnative'
|
||||
template?: 'pgsql' | 'mysql' | 'script' | 'docker' | 'powershell' | 'bunnative' | 'claudesandbox'
|
||||
tag: string | undefined
|
||||
initialArgs?: Record<string, any>
|
||||
fixedOverflowWidgets?: boolean
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
| 'postgres_trigger'
|
||||
| 'gcp_trigger'
|
||||
| 'email_trigger'
|
||||
| 'volume'
|
||||
let kind: Kind
|
||||
|
||||
let path: string = $state('')
|
||||
@@ -53,13 +54,17 @@
|
||||
let drawer: Drawer | undefined = $state()
|
||||
|
||||
let own = $state(false)
|
||||
export async function openDrawer(newPath: string, kind_l: Kind) {
|
||||
export async function openDrawer(newPath: string, kind_l: Kind, isOwnerOverride?: boolean) {
|
||||
path = newPath
|
||||
kind = kind_l
|
||||
loadAcls()
|
||||
loadGroups()
|
||||
loadUsernames()
|
||||
loadOwner()
|
||||
if (isOwnerOverride !== undefined) {
|
||||
own = isOwnerOverride
|
||||
} else {
|
||||
loadOwner()
|
||||
}
|
||||
drawer?.openDrawer()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { ResourceService, type Job } from '$lib/gen'
|
||||
import { ResourceService, ScriptService, type Job } from '$lib/gen'
|
||||
import { inferAssets } from '$lib/infer'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { usePromise } from '$lib/svelte5Utils.svelte'
|
||||
@@ -35,7 +35,15 @@
|
||||
}
|
||||
|
||||
if (job.job_kind === 'script') {
|
||||
let inferAssetsResult = await inferAssets(job.language!, job.raw_code ?? '')
|
||||
let code = job.raw_code
|
||||
if (!code && job.script_hash && $workspaceStore) {
|
||||
const script = await ScriptService.getScriptByHash({
|
||||
workspace: $workspaceStore,
|
||||
hash: job.script_hash
|
||||
})
|
||||
code = script.content
|
||||
}
|
||||
let inferAssetsResult = await inferAssets(job.language!, code ?? '')
|
||||
let assets = inferAssetsResult.status === 'ok' ? inferAssetsResult.assets : []
|
||||
return [...assets, ...parseInputArgsAssets(job.args ?? {})]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
import { Drawer, DrawerContent } from '../common'
|
||||
import { VolumeService, type Volume } from '$lib/gen'
|
||||
import { workspaceStore, userStore } from '$lib/stores'
|
||||
import { displayDate, displaySize, sendUserToast } from '$lib/utils'
|
||||
import { ExternalLink, Loader2, Trash2 } from 'lucide-svelte'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import type S3FilePicker from '../S3FilePicker.svelte'
|
||||
|
||||
let { s3FilePicker }: { s3FilePicker?: S3FilePicker } = $props()
|
||||
|
||||
let open = $state(false)
|
||||
let volumeName = $state('')
|
||||
let loading = $state(false)
|
||||
let volume: Volume | undefined = $state(undefined)
|
||||
|
||||
export function openDrawer(name: string) {
|
||||
volumeName = name
|
||||
open = true
|
||||
loadVolume()
|
||||
}
|
||||
|
||||
async function loadVolume() {
|
||||
loading = true
|
||||
try {
|
||||
const volumes = await VolumeService.listVolumes({ workspace: $workspaceStore! })
|
||||
volume = volumes.find((v) => v.name === volumeName)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteVolume() {
|
||||
if (!confirm(`Delete volume '${volumeName}'? This cannot be undone.`)) return
|
||||
await VolumeService.deleteVolume({ workspace: $workspaceStore!, name: volumeName })
|
||||
sendUserToast(`Volume '${volumeName}' deleted`)
|
||||
open = false
|
||||
}
|
||||
|
||||
function exploreFiles() {
|
||||
open = false
|
||||
s3FilePicker?.open({ s3: `volumes/${volumeName}/` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<Drawer {open} size="600px" on:close={() => (open = false)}>
|
||||
<DrawerContent title="Volume: {volumeName}" on:close={() => (open = false)}>
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<Loader2 size={24} class="animate-spin text-secondary" />
|
||||
</div>
|
||||
{:else if volume}
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2 border rounded-md p-4">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-secondary">Files</span>
|
||||
<span>{volume.file_count}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-secondary">Size</span>
|
||||
<span>{displaySize(volume.size_bytes) ?? '0 B'}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-secondary">Created at</span>
|
||||
<span>{displayDate(volume.created_at)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-secondary">Created by</span>
|
||||
<span>{volume.created_by}</span>
|
||||
</div>
|
||||
{#if volume.last_used_at}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-secondary">Last used</span>
|
||||
<span>{displayDate(volume.last_used_at)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
{#if s3FilePicker}
|
||||
<Button
|
||||
variant="border"
|
||||
startIcon={{ icon: ExternalLink }}
|
||||
on:click={exploreFiles}
|
||||
>
|
||||
Explore files
|
||||
</Button>
|
||||
{/if}
|
||||
{#if $userStore?.is_admin}
|
||||
<Button
|
||||
variant="border"
|
||||
btnClasses="text-red-500 hover:text-red-600"
|
||||
startIcon={{ icon: Trash2 }}
|
||||
on:click={deleteVolume}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-sm text-secondary py-8 text-center">
|
||||
Volume '{volumeName}' not found.
|
||||
</div>
|
||||
{/if}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
@@ -0,0 +1,193 @@
|
||||
<script lang="ts">
|
||||
import { Drawer, DrawerContent } from '../common'
|
||||
import { VolumeService } from '$lib/gen'
|
||||
import { workspaceStore, userStore } from '$lib/stores'
|
||||
import { displayDate, displaySize, sendUserToast } from '$lib/utils'
|
||||
import { File, HardDriveIcon, Loader2, Plus, Shield, Trash2 } from 'lucide-svelte'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import SharedBadge from '../SharedBadge.svelte'
|
||||
import ShareModal from '../ShareModal.svelte'
|
||||
import Popover from '../meltComponents/Popover.svelte'
|
||||
import TextInput from '../text_input/TextInput.svelte'
|
||||
import { resource } from 'runed'
|
||||
|
||||
let { onExplore }: { onExplore?: (volumeName: string) => void } = $props()
|
||||
|
||||
let open = $state(false)
|
||||
let refreshKey = $state(0)
|
||||
let shareModal: ShareModal | undefined = $state()
|
||||
let newVolumeName = $state('')
|
||||
|
||||
let volumes = resource(
|
||||
() => (open ? { ws: $workspaceStore, key: refreshKey } : undefined),
|
||||
(params) => {
|
||||
if (!params?.ws) return Promise.resolve([])
|
||||
return VolumeService.listVolumes({ workspace: params.ws })
|
||||
}
|
||||
)
|
||||
|
||||
export function openDrawer() {
|
||||
open = true
|
||||
}
|
||||
|
||||
async function createVolume(name: string, close: () => void) {
|
||||
try {
|
||||
await VolumeService.createVolume({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: { name }
|
||||
})
|
||||
sendUserToast(`Volume '${name}' created`)
|
||||
newVolumeName = ''
|
||||
close()
|
||||
refreshKey++
|
||||
} catch (e) {
|
||||
sendUserToast(`Failed to create volume: ${e}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteVolume(name: string) {
|
||||
if (!confirm(`Delete volume '${name}'? This cannot be undone.`)) return
|
||||
await VolumeService.deleteVolume({ workspace: $workspaceStore!, name })
|
||||
sendUserToast(`Volume '${name}' deleted`)
|
||||
refreshKey++
|
||||
}
|
||||
|
||||
function canReadVolume(
|
||||
createdBy: string,
|
||||
extraPerms?: { [key: string]: unknown }
|
||||
): boolean {
|
||||
if ($userStore?.is_admin) return true
|
||||
const username = $userStore?.username
|
||||
if (username === createdBy || `u/${username}` === createdBy) return true
|
||||
const perms = extraPerms ?? {}
|
||||
const keys = Object.keys(perms)
|
||||
if (keys.length === 0) return true // public
|
||||
if (`u/${username}` in perms) return true
|
||||
const pgroups = $userStore?.pgroups ?? []
|
||||
return pgroups.some((g) => g in perms)
|
||||
}
|
||||
|
||||
function canWriteVolume(
|
||||
createdBy: string,
|
||||
extraPerms?: { [key: string]: unknown }
|
||||
): boolean {
|
||||
if ($userStore?.is_admin) return true
|
||||
const username = $userStore?.username
|
||||
if (username === createdBy || `u/${username}` === createdBy) return true
|
||||
if (extraPerms?.[`u/${username}`] === true) return true
|
||||
const pgroups = $userStore?.pgroups ?? []
|
||||
return pgroups.some((g) => extraPerms?.[g] === true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<Drawer {open} size="700px" on:close={() => (open = false)}>
|
||||
<DrawerContent title="Volumes" on:close={() => (open = false)}>
|
||||
{#snippet actions()}
|
||||
<Popover
|
||||
floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}
|
||||
containerClasses="border rounded-lg shadow-lg bg-surface"
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Button unifiedSize="sm" variant="accent" startIcon={{ icon: Plus }} nonCaptureEvent
|
||||
>New volume</Button
|
||||
>
|
||||
{/snippet}
|
||||
{#snippet content({ close })}
|
||||
<div class="flex flex-col gap-2 p-4">
|
||||
<TextInput
|
||||
size="md"
|
||||
inputProps={{
|
||||
placeholder: 'Volume name',
|
||||
onkeyup: (e) => {
|
||||
if (e.key === 'Enter' && newVolumeName.trim()) {
|
||||
createVolume(newVolumeName.trim(), close)
|
||||
}
|
||||
}
|
||||
}}
|
||||
bind:value={newVolumeName}
|
||||
/>
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
disabled={!newVolumeName.trim()}
|
||||
on:click={() => createVolume(newVolumeName.trim(), close)}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/snippet}
|
||||
{#if volumes.loading}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<Loader2 size={24} class="animate-spin text-secondary" />
|
||||
</div>
|
||||
{:else if !volumes.current?.length}
|
||||
<div class="text-sm text-secondary py-8 text-center">
|
||||
No volumes yet. Create one above or they are auto-created when a job declares a volume annotation.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col divide-y border rounded-md">
|
||||
{#each volumes.current as vol (vol.name)}
|
||||
{@const readable = canReadVolume(vol.created_by, vol.extra_perms)}
|
||||
{@const writable = canWriteVolume(vol.created_by, vol.extra_perms)}
|
||||
<div class="flex items-center gap-3 px-4 py-3 hover:bg-surface-hover">
|
||||
<HardDriveIcon size={16} class="text-secondary shrink-0" />
|
||||
<div class="flex flex-col flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-sm font-medium truncate">{vol.name}</span>
|
||||
<SharedBadge
|
||||
extraPerms={vol.extra_perms as Record<string, boolean>}
|
||||
canWrite={writable}
|
||||
/>
|
||||
</div>
|
||||
<span class="text-2xs text-secondary">
|
||||
{vol.file_count} {vol.file_count === 1 ? 'file' : 'files'}
|
||||
· {displaySize(vol.size_bytes) ?? '0 B'}
|
||||
· owner: {vol.created_by.replace(/^u\//, '')}
|
||||
</span>
|
||||
</div>
|
||||
{#if vol.last_used_at}
|
||||
<span class="text-2xs text-secondary shrink-0">
|
||||
Used {displayDate(vol.last_used_at)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if writable}
|
||||
<Button
|
||||
variant="subtle"
|
||||
iconOnly
|
||||
unifiedSize="sm"
|
||||
endIcon={{ icon: Shield }}
|
||||
on:click={() =>
|
||||
shareModal?.openDrawer(vol.name, 'volume', true)}
|
||||
/>
|
||||
{/if}
|
||||
{#if onExplore && readable}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
endIcon={{ icon: File }}
|
||||
on:click={() => onExplore(vol.name)}
|
||||
>
|
||||
Explore
|
||||
</Button>
|
||||
{/if}
|
||||
{#if writable}
|
||||
<Button
|
||||
variant="subtle"
|
||||
iconOnly
|
||||
unifiedSize="sm"
|
||||
btnClasses="text-red-500 hover:text-red-600"
|
||||
endIcon={{ icon: Trash2 }}
|
||||
on:click={() => deleteVolume(vol.name)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<ShareModal bind:this={shareModal} on:change={() => refreshKey++} />
|
||||
@@ -26,6 +26,8 @@ export function formatAsset(asset: Asset): string {
|
||||
return `ducklake://${asset.path}`
|
||||
case 'datatable':
|
||||
return `datatable://${asset.path}`
|
||||
case 'volume':
|
||||
return `volume://${asset.path}`
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
@@ -89,6 +91,8 @@ export function formatAssetKind(asset: {
|
||||
return 'Ducklake'
|
||||
case 'datatable':
|
||||
return 'Data table'
|
||||
case 'volume':
|
||||
return 'Volume'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
import JavaIcon from '$lib/components/icons/JavaIcon.svelte'
|
||||
import DuckDbIcon from '$lib/components/icons/DuckDbIcon.svelte'
|
||||
import RubyIcon from '$lib/components/icons/RubyIcon.svelte'
|
||||
import ClaudeIcon from '$lib/components/icons/ClaudeIcon.svelte'
|
||||
|
||||
interface Props {
|
||||
lang:
|
||||
@@ -36,6 +37,7 @@
|
||||
| 'docker'
|
||||
| 'powershell'
|
||||
| 'bunnative'
|
||||
| 'claudesandbox'
|
||||
width?: number
|
||||
height?: number
|
||||
scale?: number
|
||||
@@ -45,7 +47,7 @@
|
||||
|
||||
let { lang, width = 30, height = 30, scale = 1, size = undefined, ...rest }: Props = $props()
|
||||
|
||||
const languageLabel: Record<Script['language'] | 'bunnative', String> = {
|
||||
const languageLabel: Record<Script['language'] | 'bunnative' | 'claudesandbox', String> = {
|
||||
python3: 'Python',
|
||||
deno: 'TypeScript',
|
||||
go: 'Go',
|
||||
@@ -68,12 +70,13 @@
|
||||
csharp: 'C#',
|
||||
nu: 'Nu',
|
||||
java: 'Java',
|
||||
ruby: 'Ruby'
|
||||
ruby: 'Ruby',
|
||||
claudesandbox: 'Claude Sandbox'
|
||||
// for related places search: ADD_NEW_LANG
|
||||
}
|
||||
|
||||
const langToComponent: Record<
|
||||
SupportedLanguage | 'pgsql' | 'javascript' | 'fetch' | 'docker' | 'powershell' | 'bunnative',
|
||||
SupportedLanguage | 'pgsql' | 'javascript' | 'fetch' | 'docker' | 'powershell' | 'bunnative' | 'claudesandbox',
|
||||
any
|
||||
> = {
|
||||
go: GoIcon,
|
||||
@@ -103,7 +106,8 @@
|
||||
nu: NuIcon,
|
||||
java: JavaIcon,
|
||||
ruby: RubyIcon,
|
||||
duckdb: DuckDbIcon
|
||||
duckdb: DuckDbIcon,
|
||||
claudesandbox: TypeScriptIcon
|
||||
// for related places search: ADD_NEW_LANG
|
||||
}
|
||||
|
||||
@@ -142,4 +146,17 @@
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if lang === 'claudesandbox'}
|
||||
<div
|
||||
class="absolute -top-1.5 -right-1.5 bg-surface rounded-full flex items-center justify-center"
|
||||
style={`width: ${width * scale * subIconScale}px; height: ${
|
||||
height * scale * subIconScale
|
||||
}px;`}
|
||||
>
|
||||
<ClaudeIcon
|
||||
width={width * scale * (subIconScale - 0.1)}
|
||||
height={height * scale * (subIconScale - 0.1)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<script lang="ts">
|
||||
import { ExternalLink, Wand2 } from 'lucide-svelte'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import { getNonStreamingCompletion } from './lib'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { base } from '$lib/base'
|
||||
import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
import type { Schema } from '$lib/common'
|
||||
|
||||
interface Props {
|
||||
args: Record<string, any>
|
||||
resourceType?: string
|
||||
resourceName?: string
|
||||
resourceDescription?: string
|
||||
resourceSchema?: Schema | undefined
|
||||
isFileset?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
args = $bindable(),
|
||||
resourceType = '',
|
||||
resourceName = '',
|
||||
resourceDescription = '',
|
||||
resourceSchema = undefined,
|
||||
isFileset = false
|
||||
}: Props = $props()
|
||||
|
||||
let instructions = $state('')
|
||||
let instructionsField: HTMLTextAreaElement | undefined = $state(undefined)
|
||||
let genLoading = $state(false)
|
||||
let abortController = $state(new AbortController())
|
||||
|
||||
function buildSystemPrompt(): string {
|
||||
let prompt: string
|
||||
|
||||
if (isFileset) {
|
||||
prompt =
|
||||
'You are a helpful assistant that generates file contents for a Windmill fileset resource. A fileset is a JSON object mapping file paths to their string contents, e.g. {"path/to/file.txt": "file content", "other/file.md": "# Title"}. You MUST return ONLY valid JSON (a flat object with string keys and string values), no markdown fences, no explanation, no extra text.'
|
||||
} else {
|
||||
prompt =
|
||||
'You are a helpful assistant that generates JSON values for Windmill resources. You MUST return ONLY valid JSON, no markdown fences, no explanation, no extra text.'
|
||||
}
|
||||
|
||||
if (resourceType) {
|
||||
prompt += `\nThe resource type is "${resourceType}".`
|
||||
}
|
||||
|
||||
if (!isFileset && resourceSchema?.properties) {
|
||||
const schemaDesc = Object.entries(resourceSchema.properties)
|
||||
.map(([key, prop]: [string, any]) => {
|
||||
let desc = `- "${key}": type=${prop.type || 'string'}`
|
||||
if (prop.description) desc += `, description="${prop.description}"`
|
||||
if (prop.default !== undefined) desc += `, default=${JSON.stringify(prop.default)}`
|
||||
if (prop.enum) desc += `, enum=${JSON.stringify(prop.enum)}`
|
||||
return desc
|
||||
})
|
||||
.join('\n')
|
||||
prompt += `\n\nThe resource schema has these properties:\n${schemaDesc}`
|
||||
|
||||
if (resourceSchema.required?.length) {
|
||||
prompt += `\n\nRequired fields: ${resourceSchema.required.join(', ')}`
|
||||
}
|
||||
}
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
function buildUserPrompt(): string {
|
||||
let prompt = instructions
|
||||
|
||||
if (resourceName) {
|
||||
prompt += `\nResource name: ${resourceName}`
|
||||
}
|
||||
if (resourceDescription) {
|
||||
prompt += `\nResource description: ${resourceDescription}`
|
||||
}
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
async function generateResource() {
|
||||
genLoading = true
|
||||
abortController = new AbortController()
|
||||
try {
|
||||
const messages: ChatCompletionMessageParam[] = [
|
||||
{
|
||||
role: 'system',
|
||||
content: buildSystemPrompt()
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: buildUserPrompt()
|
||||
}
|
||||
]
|
||||
|
||||
let response = await getNonStreamingCompletion(messages, abortController)
|
||||
|
||||
// Strip markdown fences if present
|
||||
response = response.trim()
|
||||
if (response.startsWith('```')) {
|
||||
response = response.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '')
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(response)
|
||||
args = parsed
|
||||
} catch (err) {
|
||||
if (!abortController.signal.aborted) {
|
||||
sendUserToast('Could not generate resource: ' + err, true)
|
||||
}
|
||||
} finally {
|
||||
genLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
instructionsField && setTimeout(() => instructionsField?.focus(), 100)
|
||||
})
|
||||
</script>
|
||||
|
||||
<Popover floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}>
|
||||
{#snippet trigger()}
|
||||
<Button
|
||||
color={genLoading ? 'red' : 'light'}
|
||||
size="xs"
|
||||
nonCaptureEvent={!genLoading}
|
||||
startIcon={{ icon: Wand2 }}
|
||||
iconOnly
|
||||
title="AI Assistant"
|
||||
btnClasses="text-ai bg-violet-100 dark:bg-gray-700"
|
||||
loading={genLoading}
|
||||
clickableWhileLoading
|
||||
on:click={genLoading ? () => abortController?.abort() : () => {}}
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet content({ close })}
|
||||
<div class="border rounded-lg shadow-lg p-4 bg-surface">
|
||||
{#if $copilotInfo.enabled}
|
||||
<div class="flex flex-col w-80 gap-2">
|
||||
<textarea
|
||||
bind:this={instructionsField}
|
||||
placeholder="Describe the resource values to generate..."
|
||||
bind:value={instructions}
|
||||
rows={3}
|
||||
class="text-xs"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && instructions.length > 0) {
|
||||
e.preventDefault()
|
||||
close()
|
||||
generateResource()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="contained"
|
||||
buttonType="button"
|
||||
btnClasses="text-ai bg-violet-100 dark:bg-gray-700"
|
||||
title="Generate resource from prompt"
|
||||
on:click={() => {
|
||||
close()
|
||||
generateResource()
|
||||
}}
|
||||
disabled={instructions.length == 0}
|
||||
startIcon={{ icon: Wand2 }}
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="block text-primary">
|
||||
<p class="text-sm"
|
||||
>Enable Windmill AI in the <a
|
||||
href="{base}/workspace_settings?tab=ai"
|
||||
target="_blank"
|
||||
class="inline-flex flex-row items-center gap-1"
|
||||
>workspace settings <ExternalLink size={16} /></a
|
||||
></p
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
@@ -288,6 +288,24 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if !failureModule && !preprocessorModule}
|
||||
<h3 class="pb-2 pt-4">AI Sandbox</h3>
|
||||
<div class="flex flex-row flex-wrap gap-2">
|
||||
<FlowScriptPicker
|
||||
label="Claude Code"
|
||||
lang="claudesandbox"
|
||||
on:click={() => {
|
||||
dispatch('new', {
|
||||
language: 'bun',
|
||||
kind,
|
||||
subkind: 'claudesandbox',
|
||||
summary
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h3 class="mb-2 mt-6"
|
||||
>Use pre-made <span class="text-blue-500 dark:text-blue-400"
|
||||
>{kind == 'script' ? 'action' : kind}</span
|
||||
|
||||
@@ -491,6 +491,27 @@
|
||||
{/await}
|
||||
<div class="pb-1"></div>
|
||||
{/if}
|
||||
{#if selectedKind === 'script' && preFilter === 'all' && !selected}
|
||||
<div class="pb-0 text-2xs font-normal text-secondary ml-2">AI Sandbox</div>
|
||||
<FlowScriptPickerQuick
|
||||
eeRestricted={false}
|
||||
selected={false}
|
||||
enterpriseLangs={[]}
|
||||
label="Claude Code"
|
||||
lang="claudesandbox"
|
||||
on:click={() => {
|
||||
dispatch('new', {
|
||||
kind: selectedKind,
|
||||
inlineScript: {
|
||||
language: 'bun',
|
||||
kind: selectedKind,
|
||||
subkind: 'claudesandbox',
|
||||
summary
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if selectedKind != 'preprocessor' && selectedKind != 'flow'}
|
||||
{#if (!selected || selected?.kind === 'integrations') && (preFilter === 'hub' || preFilter === 'all')}
|
||||
{#if !selected && preFilter !== 'hub'}
|
||||
|
||||
@@ -78,7 +78,7 @@ export async function pickFlow(
|
||||
export async function createInlineScriptModule(
|
||||
language: RawScript['language'],
|
||||
kind: Script['kind'],
|
||||
subkind: 'pgsql' | 'flow' | undefined,
|
||||
subkind: 'pgsql' | 'flow' | 'claudesandbox' | undefined,
|
||||
id: string,
|
||||
summary?: string
|
||||
): Promise<[FlowModule, FlowModuleState]> {
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
}: Props = $props()
|
||||
|
||||
let customUi: undefined | FlowBuilderWhitelabelCustomUi = getContext('customUi')
|
||||
let selectedKind: 'script' | 'trigger' | 'preprocessor' | 'approval' | 'flow' | 'failure' =
|
||||
let selectedKind: 'script' | 'trigger' | 'preprocessor' | 'approval' | 'flow' | 'failure' | 'aisandbox' =
|
||||
$state(kind)
|
||||
let preFilter: 'all' | 'workspace' | 'hub' = $state('all')
|
||||
let loading = $state(false)
|
||||
@@ -181,27 +181,53 @@
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<TopLevelNode
|
||||
label="AI Sandbox"
|
||||
selected={selectedKind === 'aisandbox'}
|
||||
onSelect={() => {
|
||||
selectedKind = 'aisandbox'
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<FlowInputsQuick
|
||||
{selectedKind}
|
||||
bind:loading
|
||||
filter={funcDesc}
|
||||
{disableAi}
|
||||
{funcDesc}
|
||||
{kind}
|
||||
bind:owners
|
||||
on:close={() => {
|
||||
dispatch('close')
|
||||
}}
|
||||
on:new
|
||||
on:pickScript
|
||||
on:pickFlow
|
||||
{preFilter}
|
||||
{displayPath}
|
||||
refreshCount={refreshCount.val}
|
||||
/>
|
||||
{#if selectedKind === 'aisandbox'}
|
||||
<div class="h-full overflow-auto grow min-w-0 p-2 gap-1 flex flex-col">
|
||||
<TopLevelNode
|
||||
label="Claude Code"
|
||||
onSelect={() => {
|
||||
dispatch('close')
|
||||
dispatch('new', {
|
||||
kind: 'script',
|
||||
inlineScript: {
|
||||
language: 'bun',
|
||||
kind: 'script',
|
||||
subkind: 'claudesandbox',
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<FlowInputsQuick
|
||||
{selectedKind}
|
||||
bind:loading
|
||||
filter={funcDesc}
|
||||
{disableAi}
|
||||
{funcDesc}
|
||||
{kind}
|
||||
bind:owners
|
||||
on:close={() => {
|
||||
dispatch('close')
|
||||
}}
|
||||
on:new
|
||||
on:pickScript
|
||||
on:pickFlow
|
||||
{preFilter}
|
||||
{displayPath}
|
||||
refreshCount={refreshCount.val}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
interface Props {
|
||||
disabled?: boolean
|
||||
label: string
|
||||
lang?: SupportedLanguage | 'docker' | 'javascript' | undefined
|
||||
lang?: SupportedLanguage | 'docker' | 'javascript' | 'claudesandbox' | undefined
|
||||
id?: string | undefined
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
<LanguageIcon {lang} />
|
||||
{/if}
|
||||
<span class="text-xs">{label}</span>
|
||||
{#if lang === 'claudesandbox'}
|
||||
<span class="text-primary !text-xs">(new)</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Button>
|
||||
{#snippet text()}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
export let label: string
|
||||
export let lang: SupportedLanguage | 'docker' | 'javascript' | undefined = undefined
|
||||
export let lang: SupportedLanguage | 'docker' | 'javascript' | 'claudesandbox' | undefined = undefined
|
||||
export let selected = false
|
||||
export let eeRestricted: boolean
|
||||
export let enterpriseLangs: string[] = []
|
||||
@@ -46,6 +46,9 @@
|
||||
{/if}
|
||||
<span class="grow truncate text-left {eeRestricted ? 'text-disabled' : ''}">
|
||||
{label}{#if eeRestricted} (EE){/if}
|
||||
{#if lang === 'claudesandbox'}
|
||||
<span class="text-primary !text-xs">(new)</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if selected}
|
||||
<kbd class="!text-xs">↵</kbd>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user