* fix: restrict filesystem workspace storage to debug builds Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7p2VbtYqaXHGaAskgwVk5 * chore: update ee-repo-ref to b58ad414b098d3d7787001a352bfbb13e43a335f This commit updates the EE repository reference after PR #747 was merged in windmill-ee-private. Previous ee-repo-ref: 1b4dada77a8fe2224579c643550c63b1ac2616de New ee-repo-ref: b58ad414b098d3d7787001a352bfbb13e43a335f Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
14 KiB
Backend (Rust)
- Coding patterns: MUST use the
rust-backendskill when writing Rust code - Validation:
docs/validation.md— whichcargo checkflags to use - Enterprise:
docs/enterprise.md— EE file conventions and PR workflow - DB schema:
backend/summarized_schema.txt - API routes entry point:
windmill-api/src/lib.rs - OpenAPI spec:
windmill-api/openapi.yaml - DuckDB local jobs: build the dynamic FFI library before running DuckDB scripts locally:
Re-run after clean builds or when
cd backend/windmill-duckdb-ffi-internal && ./build_dev.shtarget/debug/libwindmill_duckdb_ffi_internal.*is missing. The bundled DuckDB compile (~2min) is cached in a per-user dir shared across worktrees (keyed by the crate'sCargo.lock), so a fresh worktree reuses it and the build is near-instant — you don't pay the full compile per worktree. Editing the FFI crate's own source falls back to an isolated per-worktree./target. The engine is a patched fork of duckdb-rs, not the crates.io crate — readdocs/duckdb-isolation.mdbefore bumping it or touching the isolation transform. - Running data pipelines (DuckLake) from source: see the section below — a plain build
advertises the
duckdbtag but cannot execute DuckDB scripts and has no working S3 proxy.
Cargo features & running the dev backend
The dev backend runs under cargo watch and is launched by default with only
--features quickjs (see the tmux backend pane). That baseline compiles fast but
deliberately omits most functionality — notably S3/object storage, the S3 proxy, all
EE code, MCP, and every non-JS language runtime. A running server never gains a feature you
didn't compile in: feature-gated routes 404 or return a "requires <feature>" stub. So if
you touch code behind a feature gate, or need to exercise such a feature at runtime, you
MUST restart the backend with the appropriate features for what you're working on.
Restarting the dev backend with the right features
Restart in the same pane, so the relaunch inherits that pane's DATABASE_URL, BACKEND_PORT
and the rest of runtime.env. Scope every kill to this worktree — never
pkill -f target/debug/windmill, which kills every sibling worktree's backend.
-
Find the backend pane by what it is running, not by index. The index depends on the webmux profile: pane 1 is the backend under
full, but the frontend underfrontendOnly.WIN=$(tmux display-message -p -t "$TMUX_PANE" '#{window_id}') tmux list-panes -t "$WIN" -F '#{pane_index} #{pane_current_command} #{pane_pid}' -
Read the feature set it is actually running.
CARGO_FEATURESinruntime.envis only what the pane started with, and goes stale the first time anyone restarts by hand:ps --ppid <pane_pid> -o args= # /home/hugo/.cargo/bin/cargo-watch watch -x run --features quickjs -
Stop it and relaunch with the extended set.
PORTin the pane shell can be stale, so pass it explicitly:tmux send-keys -t "$WIN.<idx>" C-c tmux send-keys -t "$WIN.<idx>" 'PORT=$BACKEND_PORT cargo watch -x "run --features quickjs,private,parquet"' EnterCarry over every feature the old command had unless you mean to drop one — rebuilding the list from memory is how a backend silently loses
quickjs. -
Persist the new set so a recreated pane starts with it: set
CARGO_FEATURESin the worktree's.env.local, whichscripts/post-create.shwrites and webmux reads. Do not editruntime.envfor this — webmux regenerates it from metadata and.env.localevery time the worktree is opened, so an edit there is lost on the next reopen. Either way the change only affects a future pane; step 3 is what takes effect now. -
Re-capture the pane until
health check completedappears before hitting the API. A cold rebuild takes ~60s, and the previous run's success line is still in the scrollback, so a capture taken too early reads as ready when it isn't.
cargo-watch only re-runs on a file change, so after an idle/failed run touch README.md (from
backend/, where the watch runs) is a cheap retrigger (touching a .rs forces a full rebuild).
An orphaned backend is holding the port
If the pane's cargo watch looks alive but the API never answers, or the build ends in an
address-already-in-use error, a backend from an earlier run is probably still bound to the port.
It gets reparented to systemd --user when its shell dies, so it survives everything that looks
like a cleanup.
Confirm all three before killing anything — a dozen sibling worktrees run their own backend, and
pkill -f windmill (or -f target/debug/windmill) kills every one of them:
ss -ltnp | grep ":$BACKEND_PORT" # 1. which pid holds the port
readlink /proc/<pid>/cwd # 2. must be THIS worktree's backend/
ps -o ppid= -p <pid> # 3. parent is systemd/pid 1, not your pane's cargo-watch
Only when the port owner is this worktree's backend and it is orphaned, kill that single pid
(kill <pid>, then kill -9 if it does not exit). Ask first when there is a human in the loop;
unattended, the three checks are what make it safe. Then touch README.md to retrigger the
watch.
What each feature gate does (the ones you'll actually toggle)
backend/Cargo.toml [features] is the source of truth; this is the practical dev map. Combine
only what you need — build time scales with the set.
| Feature | Enables | Need it for |
|---|---|---|
quickjs |
Embedded JS engine for inline JS eval (the default dev baseline). | Keep in every dev set. |
private |
Compiles the *_ee.rs files (symlinked from windmill-ee-private). Gates all EE code, including the real S3 helpers, the S3 proxy, and advanced S3 permission checks. |
Any EE code path, S3/object storage. |
enterprise |
EE business logic (autoscaling, SAML hooks, advanced S3 rule enforcement, WAP, forks, …). Pulls in license. |
Running EE features. Advanced S3 permission rules only take effect with this. |
license |
License-key/plan plumbing (LICENSE_KEY). Pulled in by enterprise. Having the feature compiled does not require a license key at runtime — CE defaults to a free plan and most EE paths still run keyless. |
License-gated behavior. |
parquet |
S3/object-storage support: the job_helpers/* and apps_u/* S3 endpoints, parquet/CSV preview, workspace large-file storage. Without it those routes return "requires parquet". |
Anything touching S3/object storage or datasets. |
duckdb |
DuckDB script executor (also needs the FFI dylib — see above). | DuckDB scripts, DuckLake. |
python rust php java ruby csharp nu deno_core mysql mssql bigquery snowflake oracledb rlang |
Each enables that language/DB runtime for job execution. | Running jobs in that language. |
mcp |
MCP gateway routes (baseline quickjs does NOT include it → MCP routes 404). |
MCP work. |
websocket http_trigger kafka nats mqtt_trigger sqs_trigger gcp_trigger azure_trigger postgres_trigger native_trigger |
Each native trigger kind; none on by default (creating one 404s without its feature). | Working on / exercising that trigger. |
no_auth |
Treats every request as an admin superadmin (CLOUD_HOSTED-guarded). |
Local auth-free experiments only. |
Convenience bundles (ce, ee, oss, …) exist in [features] but are heavy — prefer the
minimal explicit set for dev.
Common combinations (run from backend/):
| Goal | --features |
|---|---|
| Plain dev baseline (JS eval only) | quickjs |
| S3 / object storage / datasets (CE) | quickjs,private,parquet |
| S3 + EE (advanced S3 rules, on-behalf app reads, WAP, forks) | quickjs,enterprise,private,parquet |
| DuckLake / DuckDB (CE) | quickjs,duckdb,parquet,private (+ build the FFI) |
| + Python jobs | append ,python |
Workspace object storage in dev — use the local filesystem
For a dev workspace you don't need MinIO/S3: use the built-in FilesystemStorage large-file
storage (a root path on local disk). It is a debug-build affordance only — every site that
builds a filesystem object store calls ensure_filesystem_storage_allowed, so release builds
refuse it, and the settings UI never offers it — so set it via the API on a cargo run/cargo test binary. Requires the backend built with parquet (+ private for the real S3 helpers,
enterpriseif you want advanced permission rules enforced):
curl -X POST "$BASE/api/w/<ws>/workspaces/edit_large_file_storage_config" \
-H "Authorization: Bearer <admin-token>" -H "Content-Type: application/json" \
-d '{"large_file_storage":{"type":"FilesystemStorage","root_path":"/abs/writable/dir",
"public_resource":false,"advanced_permissions":null,"secondary_storage":{}}}'
Optional advanced_permissions (EE) is a list of {"pattern":"<glob>","allow":"read[,write,delete,list]"}
rules: admins bypass them, non-admins are confined to matching grants. Uploads/reads then flow
through the normal job_helpers/* (viewer-scoped) and apps_u/* (app-author on-behalf) S3
endpoints. Caveat: direct DuckDB access rejects filesystem stores ("Filesystem is not supported in DuckDB") — DuckLake/datatable go through the S3 proxy instead, which works.
Running data pipelines (DuckLake) from source
DuckLake pipelines need both the right cargo features and the prebuilt DuckDB FFI. A
plain cargo run (or cargo run --features quickjs) does not suffice, and the failure modes
are silent-ish, so agents lose time. Verify feature names against backend/Cargo.toml [features].
Feature sets (run from backend/):
| Goal | Command |
|---|---|
| CE DuckLake (DuckDB scripts + S3 proxy) | cargo run --features quickjs,duckdb,parquet,private |
| + Python scripts | add ,python |
| EE features (WAP, partitioning, forks, …) | add ,enterprise,license |
enterprise already pulls in license, but list both when you want the license-gated paths.
quickjs is for JS eval, not DuckLake per se — keep it if your baseline build had it.
Before running any DuckDB script, build the FFI (see the bullet above):
cd backend/windmill-duckdb-ffi-internal && ./build_dev.sh.
Two gotchas that a wrong feature set produces:
duckdbtag advertised, feature missing. Theduckdbworker tag is in the unconditional default tag list (windmill-common/src/worker.rs,DEFAULT_TAGS), so a worker advertises it even without theduckdbfeature. Jobs then dispatch but fail at execution with"Duck DB requires the duckdb feature to be enabled"(windmill-worker/src/worker.rs). Fix: compile with--features duckdb.- DuckLake writes 404 (no S3 proxy). The workspace S3 proxy (
/w/{ws}/s3_proxy/*) that DuckLake uses for reads/writes only mounts the real service under#[cfg(all(feature = "private", feature = "parquet"))](windmill-api/src/s3_proxy_oss.rs); otherwise it's an empty router and every proxied request 404s. Fix: compile with bothprivateandparquet.
Cloud vs self-hosted gating
The cloud cargo feature is compiled into all EE builds, so #[cfg(feature = "cloud")] is not a "cloud-only" runtime gate — it only means the code is present. The real gate for behavior specific to the managed cloud (app.windmill.dev) is the runtime flag *CLOUD_HOSTED (windmill_common::worker::CLOUD_HOSTED, from the CLOUD_HOSTED env var; note it's loaded from .env via dotenv, so it won't show in /proc/<pid>/environ — check the running behavior, not the exec env).
Cloud-only logic must be behind if *CLOUD_HOSTED { ... }: feature-gate the helper so it compiles, then runtime-gate the call. #[cfg(feature = "cloud")] on its own is only sufficient for:
- pure helper/struct definitions (they only run when a gated caller invokes them),
- code already inside an
if *CLOUD_HOSTED { ... }block, - handlers that early-return on
!*CLOUD_HOSTED, - idempotent no-ops that are harmless off-cloud (e.g. cache invalidation).
Verifying Backend Changes
cargo check and the unit tests do not exercise a worker code path. If you changed how
a job runs — an executor, handle_child, anything spawning or reading from a
subprocess — run an actual job of that kind and confirm it completed, then say so.
Whole classes of defect compile and unit-test clean:
- Stack overflow from a large buffer in an async block. An array declared across an
.awaitis baked into the future's state; once that future is boxed a few layers deep by the job poller, two 16 KB arrays abort the worker process (thread 'tokio-runtime-worker' has overflowed its stack). Heap-allocate read buffers (vec![0u8; N], not[0u8; N]). - Deadlocks from draining only one of a child's pipes, missed cancellation or timeout propagation, and anything depending on the real engine's output format.
A crash like this takes down every job on that worker, not just yours, so check the backend log after the run rather than only the job's own status. If you cannot run one, say which path went unexercised instead of implying it was verified.
How many jobs a worker runs at once
NUM_WORKERS > 1 falls back to 1 outside native mode (backend/src/main.rs, unless
I_ACK_NUM_WORKERS_IS_UNSAFE), so a worker serving script tags — go, python3,
dependency, flow, … — runs one job at a time: a per-job resource budget (memory, CPU,
temp space) shares the worker with the worker process alone.
Native mode is the exception, and budgets for its tags must divide by its concurrency:
it forces 8 workers, and NATIVE_TAGS includes executors that already claim a per-job
share of the worker's memory (postgresql and mysql through MAX_SQL_RESULT_SIZE), so
up to 8 of those run against the same limit at once.