Files
windmill/backend/windmill-worker/src/common.rs
T
Ruben FiszelandClaude Opus 5 0139467b01 feat: ingest dbt column lineage and real column schemas from the engine's parquet index (#10977)
* feat: column-level lineage for dbt from the engine's parquet index

`manifest.json` carries no column-to-column edges, which is why decision 14
recorded column lineage as unavailable. The edges live in a different artifact:
`dbt compile --static-analysis strict --write-index` writes `target/index/`,
whose `dbt.column_lineage.parquet` holds them and whose
`dbt.node_columns.parquet` holds every column of every node, typed and ordered
rather than only the ones an author documented.

Strict analysis rejects SQL the default accepts, so this is a separate compile
with its own `--target-path`, opt-in per project via `column_lineage: true`, and
best-effort throughout: a project it cannot analyze keeps exactly the graph it
had, with the engine's own diagnostics in the job log.

Storage mirrors `dbt_edge`: `dbt_column_edge` keyed by (path, version, job) with
the same composite FK to `script` and the same sweeps. The typed column list
lands in `dbt_node.column_schema`, beside `columns` rather than merged into it,
so `columns` stays what the author declared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRtsPQ3Ck69Fu9DNr7bMcJ

* fix: address review findings on the dbt column-lineage pass

- The workspace fork copied every other dbt sidecar table and not this one, so
  a fork lost its column lineage silently and could not recover it: the cloned
  digest covers the column edges, so a dynamic run in the fork matched it and
  stored nothing.
- The parquet was collected whole before the edge cap applied, which is exactly
  the input the cap exists for — a project whose `scan` lineage is quadratic in
  its widest model could take the worker process down. Decoded a row at a time
  with the bound enforced during the decode.
- The pass swallowed every error from the runner, including the job poller's
  cancellation and deadline, so a run that blew its timeout inside an optional
  annotation could still publish a graph and report success. `run_captured`
  now carries the exit status in its value, so only a failed COMPILE is
  downgraded, and the pass may spend at most half the remaining wall clock so
  it cannot starve the build that follows it.
- `scan` edges are stored but no longer served: they are most of a project's
  lineage, nothing renders them, and the graph endpoint is polled by the run
  page. They are also the first thing the storage cap gives up now, rather than
  evicting the direct edges the trace draws.
- `column_schema` and the column edges take the same gate as the model's SQL. A
  column-level view is the shape of what the author wrote, one level finer than
  the `ref()` graph, which is ungated only because it draws relations the
  caller already sees.
- `graph_digest` hashes the new section only when it has edges, so a project
  that never asked for the pass keeps the digest it has instead of
  re-snapshotting on every dynamic run until it is redeployed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRtsPQ3Ck69Fu9DNr7bMcJ

* fix: the editor buffer's column lineage, and three bounds that were wrong

Round-2 review found four defects, all of them introduced by the round-1 fixes.

- The `script_visible` gate on the column edges was copied from the node query
  without its `script_hash IS NULL` arm. `= NULL` is never true, so every
  version-less row was filtered out and an editor buffer's parse rendered its
  typed columns and none of their lineage — the one place the feature is meant
  to be used. Pinned by an assertion in `dbt_pinned_graph.rs`, which is where
  this class of bug already had a home.
- The phase budget was handed to the poller, whose expiry is an `Err`
  indistinguishable from a cancellation or the job's own deadline, so a slow
  but valid analysis aborted the build it exists to annotate. The runner gets
  the full deadline again — those two must still fail the job — and the budget
  is a race around the whole pass, where expiring is this budget and nothing
  else.
- The decode cap counted parquet ROWS, so `scan` and out-of-graph rows could
  spend it before a single drawn edge was read. It now counts what is kept,
  takes direct kinds in a first pass, and is handed the graph's own nodes so
  the budget cannot go on rows that could never be stored.
- Hashing the new digest section conditionally did not preserve old digests,
  because an absent `column_schema` still serialized as `null` inside the
  nodes. It is skipped when absent instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRtsPQ3Ck69Fu9DNr7bMcJ

* refactor: split the lineage pass by error contract, and read it in one query

Round 3's findings were all consequences of round 1 and 2's fixes, clustered in
the same two files, so this reshapes those two seams rather than patching again.

The worker pass was one function being three things at once — a subprocess
runner with job-lifecycle error semantics, a bounded decoder, and a best-effort
degrader — which is why each fix to one perturbed another. It is now
`compile_index`, which owns the JOB's semantics (only a cancellation or the
job's deadline can `Err`; a non-zero exit, the output ceiling and the phase
budget are outcomes), and `read_index`, which owns the ARTIFACT's and knows
nothing about the job. The budget wraps the compile alone, so a decode can no
longer outlive the timeout that reported the build would get the rest. The
output ceiling likewise becomes a value rather than a job error, for the caller
that can carry on without the tail of a compile's stdout.

The column edges were read by a fourth hand-written copy of the `live`/`chosen`
CTEs and the version/editor-buffer join conditions, and copying them is what
dropped the `script_hash IS NULL` arm and hid every buffer parse's lineage. Both
kinds of edge now come from ONE statement over a `UNION ALL`'d edge source, so
those conditions exist once. The union is at the source rather than a join
because column lineage can name a node pair `dbt_edge` has no row for: a model
reading `{{ this }}` gets edges from itself to itself, and `parent_map` has no
self-loop.

The cap on the column half now sits after the scope filter, the visibility
check and the graph joins — the scope moved into SQL via the existing
`ScopePathFilter` — so a row the caller may not read can no longer spend it and
leave an allowed project's trace short.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRtsPQ3Ck69Fu9DNr7bMcJ

* refactor: serve dbt column lineage from its own endpoint

The column edges rode on the folder-wide asset graph, which a run page polls,
while the trace is drawn for one selected relation. That needed a cap, and a cap
has to be applied after every filter that can drop a row.

Keyed to the asset there is no cap: `assets/column_lineage` answers for one
relation, and the caller's `scripts:read` scope and the project's visibility are
decided once, for the script that owns it. Pinning to a run's snapshot or the
editor's parse of its buffer costs the job-read gate, so that form is
`jobs/dbt_column_lineage/{id}` — the same shape `jobs/dbt_graph/{id}` has.

The worker's decode now bounds work and memory separately, and a compile stopped
by the output ceiling reports as truncated rather than complete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: resolve the owning dbt version the way the graph does

The unpinned arm picked the newest live version at the path without narrowing to
dbt, so a path since redeployed in another language answered with no lineage
while the graph beside it still drew that project's stale nodes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: gate pinned column lineage on reading the project, and answer the component

Four things round 5 found, three of them in code this branch rewrote:

- The pinned arm resolved the version from the job and stopped there, so a
  share-link viewer entitled to a run got the project's column names and edges
  while the graph beside it still redacted `raw_code` and `column_schema`.
  Resolving WHICH version answers is not deciding whether the caller may read
  it; the version-less editor buffer keeps its exemption, having no `script` row
  to ask.
- The answer was the whole owning project's edges. The canvas lays out the
  connected component of the selected relation's columns, so the rest was
  unrenderable weight; a recursive walk over both directions returns exactly
  what is drawn, and the project key travels with it so a `unique_id` two
  projects share cannot walk from one graph into the other.
- The decode had no exit but the 4M-row backstop once its buckets were full,
  spending wall clock the build below does not get.
- An unreadable index was reported as a missing one, sending the reader to look
  at their engine rather than at the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: stitch the two column graphs, and walk the component in Rust

Round 6's two findings, both regressions this branch introduced:

- The decode returned `Continue` on the edge that FILLED the direct-edge
  budget, so a `scan`-only tail after it decoded to the 4M-row backstop with
  nowhere to put anything. The read now ends on that edge.
- Seam 3 made the pipeline page choose between the dbt graph and the producer
  one. They share node ids — `// column total <- dbt://wh/analytics/orders.amount`
  mints the same `(dbt, path, column)` node dbt's own lineage does — so choosing
  ended a trace at the boundary in both directions. They are merged again, and
  a ducklake selection asks about the dbt relation its producers name so the
  chain continues past it. The dbt editor gets the same merge.

Also: the component is walked in Rust rather than by a recursive CTE. A CTE has
no index, so the recursive term rescanned the doubled edge set once per level —
1243ms against 59ms for the query alone on a 3000-model project, 11.7M rows in
the plan. Same answers, same tests; end to end 1.48s to 0.73s there and 1.60s to
0.26s on a 1000-deep chain. The client stops re-asking for a component it
already holds, which is most clicks within one project.

The four doc sites that described a whole-project answer are rewritten around
what it now is, rather than edited where they disagreed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: expand every dbt boundary a selection reaches, and only skip what was asked

Round 7's findings, all in the frontend seam this branch added:

- A ducklake selection seeded the dbt fetch from the FIRST boundary relation it
  found, so a table derived from two unconnected dbt relations expanded one and
  left the other a leaf — the same "stops at the boundary" symptom the round-6
  fix removed, one hop further along. Every distinct boundary is fetched now and
  the components merged.
- The component cache skipped a relation merely PRESENT in the graph in hand.
  A relation two projects describe has an owner row in each, and a component
  fetched for one carries it as an endpoint without the other's half, so that
  skipped the request that would have resolved the second owner. Only a relation
  actually asked about under this pin is skipped.
- A comment still called the producer graph gated to ducklake selections after
  it was widened to dbt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: land dbt column lineage as storage and ingest only

The API surface that draws a column trace moves to a follow-up PR, on
`dbt-column-lineage-surface`. It kept generating findings — a client cache
whose premise was wrong for a two-owner relation, then staleness and a lost
retry from tightening it, and a seed walk that stopped at the first boundary —
and the fix for the last of them is a transitive owner expansion, which has to
re-apply the caller's gate to every newly discovered project. That is the same
shape as the leak four reviewers caught in the pinned arm, and it wants its own
review rather than being the fourth fix at the end of this one.

What lands here stands on its own: the analysis pass, `dbt_column_edge`,
`dbt_node.column_schema`, the engine gating and the error-contract split — plus
the one user-visible half, the typed and ordered column list, which rides the
asset graph the details pane already fetches and replaces a panel that could
only show the columns an author had documented.

Also fixes a real bug in the pass, found in review: it compiled without the
build's `--full-refresh`. `is_incremental()` branches on that flag, so an
incremental model reading `{{ this }}` compiles its self-join — and any `ref()`
inside that branch — only when the flag is absent, and the pass was storing
lineage for SQL a full-refresh run never executed. The flag now comes from one
place shared with the build, and a run that overrides it gets its own graph
rather than standing as the version's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: say why direct kinds get the budget without naming a view

The bucketing comments explained the priority by what a trace draws, which is
a forward reference now that the surface moved out. The reason stands on its
own: `copy`/`mod` say the value travelled, `scan` says the column was read to
produce the row and so reaches every output column of its model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: round-9 findings on the descoped PR

- The `full_refresh` helper was inserted between `selection_is_overridden` and
  its doc comment, so thirteen lines about `select`/`exclude` echoes documented
  the wrong function and the one they were written for had none. Moved below it.
- The parse path ran the analysis compile and the parquet decode BEFORE the
  guard that returns when there is no warehouse identity, paying for both and
  dropping the result. Moved after it.
- Three sites still described a `/column_lineage` endpoint this branch no longer
  has, and two user-facing strings promised a column trace it no longer renders:
  the panel's hint and the descriptor template now say what the flag actually
  buys, which is the typed column schema.
- Dropped test scaffolding the removed suite left behind: a `raw_orders` node
  and `dbt_edge` whose only assertion re-tested pre-existing graph behaviour,
  and a second editor-buffer node nothing asserts on.

Documented rather than fixed: an incremental model has two shapes, and which one
the index holds depends on whether the target existed when the pass ran.
`is_incremental()` is false with no target as well as under `--full-refresh`, and
dbt has no mode that emits both — so a version's graph describes the compile that
produced it, and only a re-ingesting run describes its own run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep lineage_kind in the edge key, and one answer for --full-refresh

- Both unique indexes omitted `lineage_kind`, so a column that is projected AND
  used as a predicate for the same output column — an ordinary shape — had its
  `copy` and `scan` edges collapse under `ON CONFLICT DO NOTHING`, while the
  digest counted both. The kind is part of the fact, so it is part of the key.
  Edited in the migration rather than added as a second one: it has not landed.
- `full_refresh` was shared between the build and the analysis pass without the
  `command != "test"` condition that sat at the build's call site, so the two
  disagreed for exactly the runs that build nothing. The condition moved inside
  the function, which is the point of sharing it, and the command is threaded to
  the pass.
- The "what a trace draws" rewrite missed the copy in `dbt_manifest.rs`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: drop the unreachable full_refresh threading, test the uniqueness key

`DBT_COMMANDS` is `["build", "retry", "show", "parse"]` and `default_command`
returns `build` in every arm, so `command == "test"` cannot happen — the guard
the last commit moved into `full_refresh` was already inert where it came from.
Threading the command through five signatures to preserve it bought nothing, so
it is gone; the build and the pass call one function of the descriptor and the
invocation, which is what the sharing was for.

The uniqueness-key fix now has a test: a column projected AND used as a
predicate for the same output column stores both its `copy` and its `scan` row.
Verified against the old key, where it returns 1 instead of 2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: restore the dbt test --full-refresh guard I removed on a wrong premise

The previous commit removed it after reading `DBT_COMMANDS` and concluding
`"test"` was unreachable. That is only true of the command a CALLER can name:
`run_dbt` is invoked with `"test"` directly for the `after_all` test phase, so
an `after_all` project with `full_refresh: true` reached it — and dbt rejects
`--full-refresh` on `test`, failing the phase. Both reviewers caught it.

The guard is back inside the shared function, where the build and the pass get
one answer, and its doc now records why reading the allowlist alone is
misleading. The test covering the `test` case is restored with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: notice a job that ended during the decode, and name truncation as the cause

- The parquet decode runs on a blocking thread with no poller watching it, so a
  cancellation or an expired deadline during it was invisible: `dbt_dep` went on
  to publish the graph and the job returned success. The job's state is checked
  once the decode returns, before the caller publishes anything, and an ended
  job `Err`s — which this module may always do for the job's own semantics.
- A compile stopped by the output ceiling could leave no artifact, and the log
  then blamed the engine's capability, sending the reader to check their adapter
  rather than the ceiling. Truncation now names itself in the missing and
  unreadable branches too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: read cancellation from the DB after the decode, not from a poller's field

`ctx.canceled_by` is only ever written by a poller, and no poller runs during
the blocking decode — which is the exact window the check was added for. So the
guard caught only a cancellation already observed before it, and the comment
beside it claimed more than it did. It now queries `v2_job_queue` directly, the
same probe `worker_lockfiles` uses before it overwrites a flow.

A failed probe answers "still running": this decides whether to discard work
already done, so an unreachable database must not be the reason a healthy deploy
loses its graph.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: reuse job_is_canceled rather than a second copy of it

The probe added last round was `job_is_canceled` from the same file, retyped —
same query, same `Connection::Http` behaviour. Reused instead.

Its doc said a non-database connection was "a failed probe", which reads as an
error path. It is not: it is the agent worker, and on one there is no database
to ask, so only the deadline answers and a cancel issued during the decode is
not observable. The retry path avoids that by refusing to run on an agent worker
at all — which an optional annotation has no business doing — so the gap is
recorded at both ends instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: close the agent-worker cancellation gap instead of documenting it

The previous commit said a cancel issued during the decode is not observable on
an agent worker. It is: `ping_job_status` returns `canceled_by` over both
connection kinds, and is how the poller itself notices one there. So the check
asks through the ping rather than querying `v2_job_queue` directly, and holds on
an agent worker, where a direct query reaches no database at all.

`job_is_canceled` goes back to private and its doc to what it said before — the
retry that calls it still refuses to run on an agent worker for its own reasons.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: decode the index under the job poller instead of checking after it

Two findings with one cause: the decode was the only phase of this pass with no
subprocess behind it, so nothing heartbeated while it ran. A large index left
the worker silent for as long as it took, which the zombie sweep reads as a dead
job and restarts — and the cancellation check bolted on afterwards could only
ever report what had already happened, while dropping the ping's
`already_completed`, so a force-cancelled deploy still published its graph.

Running it under `run_future_with_polling_update_job_poller` answers all of it:
the poller pings throughout, and ends the phase with an `Err` on cancellation,
`AlreadyCompleted` or the phase timeout. The bespoke probe is gone with it.

Verified on a live deploy: 32 edges and 4 typed schemas ingested through the
polled decode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: stop a cancelled decode, and say what the read phase can now do

Putting the decode under the poller heartbeats it and ends the phase when the
job does, but dropping a `JoinHandle` detaches a blocking task rather than
cancelling it — so a cancelled job left a thread decoding up to four million
rows for a job that was over. The row loop reads an abandonment flag that a drop
guard on the awaiting future sets, so the decode stops at its next row.

That same change made the read phase able to `Err`, and three places still said
it could not — decision 14 in as many words. The distinction that holds is
narrower: nothing the ARTIFACT does or fails to do can fail a job, so absent,
unreadable and partial are all values; the JOB can still end the phase the read
runs in. Stated that way in the module doc, the `Artifact` doc, `MAX_INDEX_ROWS`
and the decision.

Verified on a live deploy: 32 edges and 4 typed schemas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: share AbortOnDrop, and stop citing a hazard that is now handled

`Abandon` was `ansible_executor`'s `AbortOnDrop` retyped — same struct, same
reason, same `spawn_blocking` shape. Moved to `common` and used from both.

The paragraph explaining why the phase budget wraps the compile alone gave as
its reason "a decode still running on a blocking thread", which is exactly what
the abandonment flag now prevents. The reason that survives is the one that was
always the point: the budget exists to leave the build its share of the clock,
and only the compile can spend that share unboundedly. The decode's end is the
job's, through the poller it runs under.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: put both doc comments back on the items they describe

Moving AbortOnDrop orphaned a doc at each end: it landed between
`raw_to_string`'s doc and `raw_to_string`, and the doc of the struct it replaced
stayed behind to prefix `fetch_repo_archive`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: name the binding the row loop actually reads

`Abandoned` was neither the type nor the binding; the flag is `abandoned`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 09:58:38 +02:00

2450 lines
87 KiB
Rust

use async_recursion::async_recursion;
use itertools::Itertools;
use lazy_static::lazy_static;
use process_wrap::tokio::TokioChildWrapper;
use regex::Regex;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use serde_json::{json, Value};
use sha2::Digest;
use sqlx::types::Json;
use sqlx::{Pool, Postgres};
use tokio::process::Command;
use tokio::{fs::File, io::AsyncReadExt};
use windmill_common::flows::Step;
use windmill_common::global_settings::NSJAIL_TMP_BACKING_DISK;
use windmill_common::variables::{build_crypt_with_key_suffix, decrypt};
use windmill_common::worker::{
max_job_duration_secs, to_raw_value, update_ping_for_failed_init_script_query, write_file,
Connection, Ping, PingType, CLOUD_HOSTED, ROOT_CACHE_DIR, WORKER_CONFIG,
};
use windmill_common::workspace_dependencies::WorkspaceDependenciesPrefetched;
use windmill_common::{
cache::{Cache, RawData},
error::{self, Error},
scripts::ScriptHash,
utils::configure_client,
variables::ContextualVariable,
};
#[cfg(feature = "parquet")]
use windmill_object_store::get_etag_or_empty;
#[cfg(feature = "parquet")]
use windmill_types::s3::{LargeFileStorage, ObjectStoreResource, S3Object};
use anyhow::{anyhow, Result};
use windmill_parser_sql::{s3_mode_extension, S3ModeArgs, S3ModeFormat};
use windmill_queue::{MiniCompletedJob, MiniPulledJob};
use std::collections::HashSet;
use std::path::Path;
use std::{collections::HashMap, sync::Arc, time::Duration};
use uuid::Uuid;
use windmill_common::{variables, DB};
use tokio::{io::AsyncWriteExt, time::Instant};
use crate::agent_workers::UPDATE_PING_URL;
use crate::{
JOB_DEFAULT_TIMEOUT, MAX_RESULT_SIZE, NSJAIL_TMPFS_SIZE_MB, NSJAIL_TMP_BACKING, PATH_ENV,
};
use windmill_common::client::AuthedClient;
/// Additional nsjail config for development. Currently used for nix flake.
#[cfg(debug_assertions)]
pub const DEV_CONF_NSJAIL: &str = r#"
mount {
src: "/nix/store"
dst: "/nix/store"
is_bind: true
mandatory: false
}
"#;
#[cfg(not(debug_assertions))]
pub const DEV_CONF_NSJAIL: &str = "";
/// Tells a `spawn_blocking` task to stop when the future awaiting it goes away.
///
/// Dropping a `JoinHandle` detaches the task rather than cancelling it, so a
/// cancelled or timed-out phase otherwise leaves the blocking pool working on an
/// answer nobody will read. Hold one of these beside the handle and have the
/// blocking loop check the flag.
pub(crate) struct AbortOnDrop(pub(crate) std::sync::Arc<std::sync::atomic::AtomicBool>);
impl Drop for AbortOnDrop {
fn drop(&mut self) {
self.0.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
/// Turn a JSON value into the string a shell/CLI arg should receive: a JSON string
/// becomes its inner value, anything else is re-serialized compactly.
pub(crate) fn raw_to_string(x: &str) -> String {
match serde_json::from_str::<serde_json::Value>(x) {
Ok(serde_json::Value::String(x)) => x,
Ok(x) => serde_json::to_string(&x).unwrap_or_else(|_| String::new()),
_ => String::new(),
}
}
pub async fn build_args_map<'a>(
job: &'a MiniPulledJob,
client: &AuthedClient,
conn: &Connection,
) -> error::Result<Option<HashMap<String, Box<RawValue>>>> {
if let Some(args) = &job.args {
return transform_json(client, &job.workspace_id, &args.0, &job, conn).await;
}
return Ok(None);
}
pub fn check_executor_binary_exists(
executor: &str,
executor_path: &str,
language: &str,
) -> Result<(), Error> {
if !Path::new(executor_path).exists() {
#[cfg(feature = "enterprise")]
let msg = format!("Couldn't find {executor} at {}. This probably means that you are not using the windmill-ee-full image. Please use the image `ghcr.io/windmill-labs/windmill-ee-full` for your instance in order to run {language} jobs.", executor_path);
#[cfg(not(feature = "enterprise"))]
let msg = format!("Couldn't find {executor} at {}. This probably means that you are not using the windmill-full image. Please use the image `ghcr.io/windmill-labs/windmill-full` for your instance in order to run {language} jobs.", executor_path);
return Err(Error::NotFound(msg));
}
Ok(())
}
pub async fn build_args_values(
job: &MiniPulledJob,
client: &AuthedClient,
conn: &Connection,
) -> error::Result<HashMap<String, serde_json::Value>> {
if let Some(args) = &job.args {
transform_json_as_values(client, &job.workspace_id, &args.0, job, conn).await
} else {
Ok(HashMap::new())
}
}
#[tracing::instrument(level = "trace", skip_all)]
pub async fn create_args_and_out_file(
client: &AuthedClient,
job: &MiniPulledJob,
job_dir: &str,
conn: &Connection,
) -> Result<(), Error> {
if let Some(args) = job.args.as_ref() {
if let Some(mut x) = transform_json(client, &job.workspace_id, &args.0, job, conn).await? {
x.remove("_MODULES");
x.remove("_TEMP_SCRIPT_REFS");
write_file(
job_dir,
"args.json",
&serde_json::to_string(&x).unwrap_or_else(|_| "{}".to_string()),
)?;
} else {
let mut filtered = args.0.clone();
filtered.remove("_MODULES");
filtered.remove("_TEMP_SCRIPT_REFS");
write_file(
job_dir,
"args.json",
&serde_json::to_string(&filtered).unwrap_or_else(|_| "{}".to_string()),
)?;
}
} else {
write_file(job_dir, "args.json", "{}")?;
};
write_file(job_dir, "result.json", "")?;
Ok(())
}
pub async fn write_file_binary(dir: &str, path: &str, content: &[u8]) -> error::Result<File> {
let path = format!("{}/{}", dir, path);
let mut file = File::create(&path).await?;
file.write_all(content).await?;
file.flush().await?;
Ok(file)
}
lazy_static::lazy_static! {
static ref RE_RES_VAR: Regex = Regex::new(r#"\$(?:var|jsonvar|res|encrypted)\:"#).unwrap();
}
/// Returns true if any value in `vs` contains a `$var:`/`$jsonvar:`/`$res:`/`$encrypted:`
/// reference that would need interpolation by `transform_json`. Cheap pre-check that
/// callers can use to skip the DB roundtrip + clone path when nothing requires resolution.
pub(crate) fn map_needs_resolution(vs: &HashMap<String, Box<RawValue>>) -> bool {
vs.values().any(|v| (*RE_RES_VAR).is_match(v.get()))
}
pub async fn transform_json<'a>(
client: &AuthedClient,
workspace: &str,
vs: &'a HashMap<String, Box<RawValue>>,
job: &MiniPulledJob,
db: &Connection,
) -> error::Result<Option<HashMap<String, Box<RawValue>>>> {
let mut has_match = false;
for (_, v) in vs {
let inner_vs = v.get();
if (*RE_RES_VAR).is_match(inner_vs) {
has_match = true;
break;
}
}
if !has_match {
return Ok(None);
}
let mut r = HashMap::new();
for (k, v) in vs {
let inner_vs = v.get();
if (*RE_RES_VAR).is_match(inner_vs) {
let value = serde_json::from_str(inner_vs).map_err(|e| {
error::Error::internal_err(format!("Error while parsing inner arg: {e:#}"))
})?;
let transformed =
transform_json_value(&k, &client, workspace, value, job, db, 0).await?;
let as_raw = serde_json::from_value(transformed).map_err(|e| {
error::Error::internal_err(format!("Error while parsing inner arg: {e:#}"))
})?;
r.insert(k.to_string(), as_raw);
} else {
r.insert(k.to_string(), v.to_owned());
}
}
Ok(Some(r))
}
pub async fn transform_json_as_values<'a>(
client: &AuthedClient,
workspace: &str,
vs: &'a HashMap<String, Box<RawValue>>,
job: &MiniPulledJob,
db: &Connection,
) -> error::Result<HashMap<String, serde_json::Value>> {
let mut r: HashMap<String, serde_json::Value> = HashMap::new();
for (k, v) in vs {
let inner_vs = v.get();
if (*RE_RES_VAR).is_match(inner_vs) {
let value = serde_json::from_str(inner_vs).map_err(|e| {
error::Error::internal_err(format!("Error while parsing inner arg: {e:#}"))
})?;
let transformed =
transform_json_value(&k, &client, workspace, value, job, db, 0).await?;
let as_raw = serde_json::from_value(transformed).map_err(|e| {
error::Error::internal_err(format!("Error while parsing inner arg: {e:#}"))
})?;
r.insert(k.to_string(), as_raw);
} else {
r.insert(
k.to_string(),
serde_json::from_str(v.get()).unwrap_or_else(|_| serde_json::Value::Null),
);
}
}
Ok(r)
}
pub fn parse_npm_config(s: &str) -> (String, Option<String>) {
let (url, token_opt) = if s.contains(":_authToken=") {
let split_url = s.split(":_authToken=").collect::<Vec<&str>>();
let url = split_url
.get(0)
.map(|u| u.to_string())
.unwrap_or("".to_string());
let token = split_url
.get(1)
.map(|t| t.to_string())
.unwrap_or("".to_string());
(url, Some(token))
} else {
(s.to_owned(), None)
};
return (url, token_opt);
}
// Defense-in-depth bound on worker-side interpolation recursion. `$res:`
// resolution is delegated to the API (which enforces its own
// MAX_RESOURCE_INTERPOLATION_DEPTH), so this only bounds nested
// object/array structure here, but a finite cap guards against pathological
// inputs regardless of the API-side guard.
const MAX_INTERPOLATION_DEPTH: u8 = 50;
#[async_recursion]
pub async fn transform_json_value(
name: &str,
client: &AuthedClient,
workspace: &str,
v: Value,
job: &MiniPulledJob,
conn: &Connection,
depth: u8,
) -> error::Result<Value> {
if depth >= MAX_INTERPOLATION_DEPTH {
return Err(Error::internal_err(format!(
"Maximum resource/variable interpolation depth ({MAX_INTERPOLATION_DEPTH}) exceeded for `{name}`; this usually indicates a circular `$res:` or `$var:` reference"
)));
}
match v {
Value::String(y) if y.starts_with("$var:") => {
let path = y.strip_prefix("$var:").unwrap();
client
.get_variable_value(path)
.await
.map(|x| json!(x))
.map_err(|e| {
Error::NotFound(format!("Variable {path} not found for `{name}`: {e:#}"))
})
}
Value::String(y) if y.starts_with("$jsonvar:") => {
let path = y.strip_prefix("$jsonvar:").unwrap();
let v = client.get_variable_value(path).await.map_err(|e| {
Error::NotFound(format!("Variable {path} not found for `{name}`: {e:#}"))
})?;
serde_json::from_str::<serde_json::Value>(&v).map_err(|e| {
Error::internal_err(format!("Failed to parse $jsonvar value as JSON: {e}"))
})
}
Value::String(y) if y.starts_with("$res:") => {
let path = y.strip_prefix("$res:").unwrap();
if path.split("/").count() < 2 && !path.starts_with("CUSTOM_INSTANCE_DB/") {
return Err(Error::internal_err(format!(
"Argument `{name}` is an invalid resource path: {path}",
)));
}
client
.get_resource_value_interpolated::<serde_json::Value>(
path,
Some(job.id.to_string()),
)
.await
.map_err(|e| {
Error::NotFound(format!("Resource {path} not found for `{name}`: {e:#}"))
})
}
Value::String(y) if y.starts_with("$encrypted:") => {
match conn {
Connection::Sql(db) => {
let encrypted = y.strip_prefix("$encrypted:").unwrap();
let root_job_id = get_root_job_id(&job);
let mc = build_crypt_with_key_suffix(
&db,
&job.workspace_id,
&root_job_id.to_string(),
)
.await?;
decrypt(&mc, encrypted.to_string()).and_then(|x| {
// Register the raw decrypted string for log masking.
// This covers both string values and their JSON representations
// (numbers, objects, etc.) that could appear in logs.
windmill_common::sensitive_log_masks::register_secret_for_job(job.id, &x);
if let serde_json::Value::String(ref s) =
serde_json::from_str::<serde_json::Value>(&x).unwrap_or_default()
{
// Also register the inner string value (without JSON quotes)
windmill_common::sensitive_log_masks::register_secret_for_job(
job.id, s,
);
}
serde_json::from_str(&x).map_err(|e| {
Error::internal_err(format!(
"Failed to decrypt '$encrypted:' value: {e}"
))
})
})
}
Connection::Http(_) => {
Err(Error::NotFound("Http connection not supported".to_string()))
}
}
// let path = y.strip_prefix("$res:").unwrap();
}
Value::String(y) if y.starts_with("$") => {
let variables = get_reserved_variables(job, &client.token, conn, None).await?;
let name = y.strip_prefix("$").unwrap();
let value = variables
.iter()
.find(|x| x.0 == name)
.map(|x| x.1.clone())
.unwrap_or_else(|| y);
Ok(json!(value))
}
Value::Array(mut arr) if depth <= 2 && arr.len() <= 1000 => {
for i in 0..arr.len() {
let val = std::mem::take(&mut arr[i]);
arr[i] = transform_json_value(name, client, workspace, val, job, conn, depth + 1)
.await?;
}
Ok(Value::Array(arr))
}
Value::Array(arr) => {
if arr.len() > 1000 {
tracing::warn!(
"Array with {} items exceeds 1000 item limit for variable/resource resolution, skipping",
arr.len()
);
}
Ok(Value::Array(arr))
}
Value::Object(mut m) => {
for (a, b) in m.clone().into_iter() {
m.insert(
a.clone(),
transform_json_value(&a, client, workspace, b, job, conn, depth + 1).await?,
);
}
Ok(Value::Object(m))
}
a @ _ => Ok(a),
}
}
pub async fn read_file_content(path: &str) -> error::Result<String> {
let mut file = File::open(path).await?;
let mut content = "".to_string();
file.read_to_string(&mut content).await?;
Ok(content)
}
pub async fn read_file_bytes(path: &str) -> error::Result<Vec<u8>> {
let mut file = File::open(path).await?;
let mut content = Vec::new();
file.read_to_end(&mut content).await?;
Ok(content)
}
pub use windmill_common::utils::unsafe_raw;
fn check_result_too_big(size: usize) -> error::Result<()> {
if *CLOUD_HOSTED && size > MAX_RESULT_SIZE {
return Err(error::Error::ExecutionErr("Result is too large for the cloud app (limit 2MB).
We highly recommend using object to store and pass heavy data (https://www.windmill.dev/docs/core_concepts/object_storage_in_windmill#read-a-file-from-s3-within-a-script)
Alternatively, if using this script as part of a flow, activate shared folder and use the shared folder to pass heavy data between steps.".to_owned()));
};
Ok(())
}
/// This function assumes that the file contains valid json and will result in UB if it isn't. If
/// the file is user generated or otherwise not guaranteed to be valid used the
/// `read_and_check_file` instead
pub async fn read_file(path: &str) -> error::Result<Box<RawValue>> {
let content = read_file_content(path).await?;
check_result_too_big(content.len())?;
let r = unsafe_raw(content);
return Ok(r);
}
pub async fn merge_result_stream(
result: error::Result<Box<RawValue>>,
result_stream: Option<String>,
) -> error::Result<Box<RawValue>> {
if let Some(result_stream) = result_stream {
result.and_then(|x| {
let mut value: Value = serde_json::from_str(x.get())?;
// Insert the string at the "wm_stream" field
if let Value::Object(ref mut map) = value {
map.insert("wm_stream".to_string(), Value::String(result_stream));
} else if value.is_null() {
// return Ok(unsafe_raw(json))
return Ok(to_raw_value(&json!(result_stream)));
} else {
return Ok(x);
}
// Convert back to RawValue
let json_string = serde_json::to_string(&value)?;
Ok(RawValue::from_string(json_string)?)
})
} else {
result
}
}
/// Read the `result.json` file. This function assumes that the file contains valid json and will
/// result in undefined behaviour if it isn't. If the result.json is user generated or otherwise
/// not guaranteed to be valid, use `read_and_check_result`
pub async fn read_result(
job_dir: &str,
result_stream: Option<String>,
) -> error::Result<Box<RawValue>> {
let rf = read_file(&format!("{job_dir}/result.json")).await;
merge_result_stream(rf, result_stream).await
}
pub async fn read_and_check_file(path: &str) -> error::Result<Box<RawValue>> {
let content = read_file_content(path).await?;
check_result_too_big(content.len())?;
let raw_value: Box<RawValue> =
serde_json::from_str(&content).map_err(|e| anyhow!("{} is not valid json: {}", path, e))?;
Ok(raw_value)
}
/// Use this to read `result.json` that were user-generated
pub async fn read_and_check_result(job_dir: &str) -> error::Result<Box<RawValue>> {
let result_path = format!("{job_dir}/result.json");
if let Ok(metadata) = tokio::fs::metadata(&result_path).await {
if metadata.len() > 0 {
return read_and_check_file(&result_path)
.await
.map_err(|e| anyhow!("Failed to read result: {}", e).into());
}
}
Ok(to_raw_value(&json!("null")))
}
pub fn capitalize(s: &str) -> String {
let mut c = s.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
}
}
#[tracing::instrument(level = "trace", skip_all)]
pub async fn get_reserved_variables(
job: &MiniPulledJob,
token: &str,
db: &Connection,
parent_runnable_path: Option<String>,
) -> Result<HashMap<String, String>, Error> {
let flow_path = if parent_runnable_path.is_some() {
parent_runnable_path
} else if let Some(uuid) = job.parent_job {
match db {
Connection::Sql(db) => {
sqlx::query_scalar!("SELECT runnable_path FROM v2_job WHERE id = $1", uuid)
.fetch_optional(db)
.await?
.flatten()
}
Connection::Http(_) => None,
}
} else {
None
};
let tested_runnable = match (&job.trigger_kind, &job.trigger) {
(Some(k), Some(t)) if k.is(windmill_common::jobs::JobTriggerKind::CiTest) => {
Some(t.clone())
}
_ => None,
};
let variables = variables::get_reserved_variables(
db,
&job.workspace_id,
token,
&job.permissioned_as_email,
&job.created_by,
&job.id.to_string(),
&job.permissioned_as,
job.runnable_path.clone(),
job.parent_job.map(|x| x.to_string()),
flow_path,
job.schedule_path(),
job.flow_step_id.clone(),
job.flow_innermost_root_job.clone().map(|x| x.to_string()),
Some(get_root_job_id(job).to_string()),
Some(job.scheduled_for.clone()),
job.runnable_id,
job.permissioned_as_end_user_email.clone(),
tested_runnable,
)
.await
.to_vec();
Ok(build_envs_map(variables).await)
}
pub async fn build_envs_map(context: Vec<ContextualVariable>) -> HashMap<String, String> {
let mut r: HashMap<String, String> =
context.into_iter().map(|rv| (rv.name, rv.value)).collect();
let envs = (**WORKER_CONFIG.load()).clone().env_vars;
for env in envs {
r.insert(env.0.clone(), env.1.clone());
}
r
}
pub fn sizeof_val(v: &serde_json::Value) -> usize {
std::mem::size_of::<serde_json::Value>()
+ match v {
serde_json::Value::Null => 0,
serde_json::Value::Bool(_) => 0,
serde_json::Value::Number(_) => 4, // Incorrect if arbitrary_precision is enabled. oh well
serde_json::Value::String(s) => s.capacity(),
serde_json::Value::Array(a) => a.iter().map(sizeof_val).sum(),
serde_json::Value::Object(o) => o
.iter()
.map(|(k, v)| {
std::mem::size_of::<String>()
+ k.capacity()
+ sizeof_val(v)
+ std::mem::size_of::<usize>() * 3
})
.sum(),
}
}
pub async fn update_worker_ping_for_failed_init_script(
conn: &Connection,
worker_name: &str,
last_job_id: Uuid,
) {
match conn {
Connection::Sql(db) => {
if let Err(e) =
update_ping_for_failed_init_script_query(worker_name, last_job_id, db).await
{
tracing::error!("Error updating worker ping for failed init script: {e:?}");
}
}
Connection::Http(client) => {
if let Err(e) = client
.post::<_, ()>(
UPDATE_PING_URL,
None,
&Ping {
last_job_executed: Some(last_job_id),
last_job_workspace_id: None,
worker_instance: None,
ip: None,
tags: None,
dw: None,
dws: None,
jobs_executed: None,
occupancy_rate: None,
occupancy_rate_15s: None,
occupancy_rate_5m: None,
occupancy_rate_30m: None,
version: None,
vcpus: None,
memory: None,
memory_usage: None,
wm_memory_usage: None,
job_isolation: None,
native_mode: None,
ping_type: PingType::InitScript,
},
)
.await
{
tracing::error!("Error updating worker ping for failed init script: {e:?}");
}
}
}
}
#[derive(Clone)]
pub struct OccupancyMetrics {
pub running_job_started_at: Option<Instant>,
pub total_duration_of_running_jobs: f32,
pub worker_occupancy_rate_history: Vec<(f32, f32)>,
pub start_time: Instant,
}
pub struct OccupancyResult {
pub occupancy_rate: f32,
pub occupancy_rate_15s: Option<f32>,
pub occupancy_rate_5m: Option<f32>,
pub occupancy_rate_30m: Option<f32>,
}
impl OccupancyMetrics {
pub fn new(start_time: Instant) -> Self {
OccupancyMetrics {
running_job_started_at: None,
total_duration_of_running_jobs: 0.0,
worker_occupancy_rate_history: Vec::new(),
start_time,
}
}
pub fn update_occupancy_metrics(&mut self) -> OccupancyResult {
let metrics = self;
let current_occupied_duration = metrics
.running_job_started_at
.map(|started_at| started_at.elapsed().as_secs_f32())
.unwrap_or(0.0);
let total_occupation = metrics.total_duration_of_running_jobs + current_occupied_duration;
let elapsed = metrics.start_time.elapsed().as_secs_f32();
let (occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m) =
if !metrics.worker_occupancy_rate_history.is_empty() {
let mut total_occupation_15s = 0.0;
let mut total_occupation_5m = 0.0;
let mut total_occupation_30m = 0.0;
let mut index30m = 0;
for (i, (past_total_occupation, time)) in
metrics.worker_occupancy_rate_history.iter().enumerate()
{
let diff = elapsed - time;
if diff < 1800.0 && total_occupation_30m == 0.0 {
total_occupation_30m = (total_occupation - past_total_occupation) / diff;
index30m = i;
}
if diff < 300.0 && total_occupation_5m == 0.0 {
total_occupation_5m = (total_occupation - past_total_occupation) / diff;
}
if diff < 15.0 {
total_occupation_15s = (total_occupation - past_total_occupation) / diff;
break;
}
}
//drop all elements before the oldest one in 30m windows
metrics.worker_occupancy_rate_history.drain(..index30m);
// Only report occupancy for a window if the worker has been running
// long enough to have meaningful data for that window. Otherwise,
// short-lived workers would report misleadingly high occupancy rates.
(
if elapsed >= 15.0 {
Some(total_occupation_15s)
} else {
None
},
if elapsed >= 300.0 {
Some(total_occupation_5m)
} else {
None
},
if elapsed >= 1800.0 {
Some(total_occupation_30m)
} else {
None
},
)
} else {
(None, None, None)
};
let occupancy_rate = total_occupation / elapsed;
//push the current occupancy rate and the timestamp
metrics
.worker_occupancy_rate_history
.push((total_occupation, elapsed));
OccupancyResult {
occupancy_rate,
occupancy_rate_15s,
occupancy_rate_5m,
occupancy_rate_30m,
}
}
}
lazy_static! {
static ref DISABLE_PROCESS_GROUP: bool = std::env::var("DISABLE_PROCESS_GROUP").is_ok();
}
/// 2 GB memory limit in bytes for LIMIT_WINDOWS_TO_1CU
#[cfg(windows)]
const MEMORY_LIMIT_1CU: usize = 2 * 1024 * 1024 * 1024;
/// Wrapper that holds a Windows Job Object handle alongside the child process.
/// The job object carries KILL_ON_JOB_CLOSE and/or a memory limit. With
/// KILL_ON_JOB_CLOSE, the worker process dying closes the handle and the OS reaps the
/// whole child tree — preventing orphans when the worker is force-killed (e.g. a second
/// CTRL_BREAK_EVENT or Nomad's kill_timeout) before a job has drained.
#[cfg(windows)]
struct WindowsJobChild {
inner: Box<dyn TokioChildWrapper>,
_job_handle: Win32JobHandle,
}
#[cfg(windows)]
impl std::fmt::Debug for WindowsJobChild {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WindowsJobChild").finish()
}
}
/// RAII wrapper for a raw Win32 HANDLE that closes it on drop.
#[cfg(windows)]
struct Win32JobHandle(windows::Win32::Foundation::HANDLE);
// SAFETY: Win32 HANDLEs are plain pointer-sized values with no thread affinity;
// the kernel ref-counts the underlying object, so sending/sharing the handle is safe.
#[cfg(windows)]
unsafe impl Send for Win32JobHandle {}
#[cfg(windows)]
unsafe impl Sync for Win32JobHandle {}
#[cfg(windows)]
impl Drop for Win32JobHandle {
fn drop(&mut self) {
let _ = unsafe { windows::Win32::Foundation::CloseHandle(self.0) };
}
}
#[cfg(windows)]
impl process_wrap::tokio::TokioChildWrapper for WindowsJobChild {
fn inner(&self) -> &tokio::process::Child {
self.inner.inner()
}
fn inner_mut(&mut self) -> &mut tokio::process::Child {
self.inner.inner_mut()
}
fn into_inner(self: Box<Self>) -> tokio::process::Child {
// Leak the job handle: with KILL_ON_JOB_CLOSE, closing the last handle reaps
// the (still-running) child, so extracting the inner Child must not close it.
let WindowsJobChild { inner, _job_handle } = *self;
std::mem::forget(_job_handle);
inner.into_inner()
}
fn start_kill(&mut self) -> std::io::Result<()> {
self.inner.start_kill()
}
fn wait(
&mut self,
) -> Box<dyn std::future::Future<Output = std::io::Result<std::process::ExitStatus>> + Send + '_>
{
self.inner.wait()
}
fn try_wait(&mut self) -> std::io::Result<Option<std::process::ExitStatus>> {
self.inner.try_wait()
}
}
/// Resume all threads of a process created with CREATE_SUSPENDED. We create the child
/// suspended so it can be assigned to its job object before running any code; otherwise
/// a child that forks a helper at startup could create it before the assignment and
/// leave it outside the job (escaping KILL_ON_JOB_CLOSE reaping / the memory cap).
/// (Ported from process-wrap's resume_threads.)
#[cfg(windows)]
fn resume_process(pid: u32) -> Result<(), std::io::Error> {
use windows::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32,
};
use windows::Win32::System::Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME};
unsafe {
let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::Other,
format!("CreateToolhelp32Snapshot: {e}"),
)
})?;
let mut entry = THREADENTRY32 {
dwSize: std::mem::size_of::<THREADENTRY32>() as u32,
cntUsage: 0,
th32ThreadID: 0,
th32OwnerProcessID: 0,
tpBasePri: 0,
tpDeltaPri: 0,
dwFlags: 0,
};
let mut res = Thread32First(snapshot, &mut entry);
while res.is_ok() {
if entry.th32OwnerProcessID == pid {
if let Ok(thread) = OpenThread(THREAD_SUSPEND_RESUME, false, entry.th32ThreadID) {
ResumeThread(thread);
let _ = windows::Win32::Foundation::CloseHandle(thread);
}
}
res = Thread32Next(snapshot, &mut entry);
}
let _ = windows::Win32::Foundation::CloseHandle(snapshot);
}
Ok(())
}
/// Create a Windows Job Object and assign the process to it, optionally with
/// KILL_ON_JOB_CLOSE (so the child tree is reaped when the worker drops the handle /
/// dies) and/or a memory limit. Assigning post-spawn (rather than via process-wrap's
/// suspend/resume JobObject wrap) keeps the dotnet-safe behavior that csharp relies on.
#[cfg(windows)]
fn assign_job_object(
pid: u32,
memory_limit: Option<usize>,
kill_on_close: bool,
) -> Result<Win32JobHandle, std::io::Error> {
use windows::Win32::System::JobObjects::*;
use windows::Win32::System::Threading::{OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE};
unsafe {
let job = CreateJobObjectW(None, None).map_err(|e| {
std::io::Error::new(std::io::ErrorKind::Other, format!("CreateJobObjectW: {e}"))
})?;
let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
if kill_on_close {
info.BasicLimitInformation.LimitFlags |= JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
}
if let Some(memory_limit) = memory_limit {
info.BasicLimitInformation.LimitFlags |= JOB_OBJECT_LIMIT_JOB_MEMORY;
info.JobMemoryLimit = memory_limit;
}
SetInformationJobObject(
job,
JobObjectExtendedLimitInformation,
&info as *const _ as _,
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
)
.map_err(|e| {
let _ = windows::Win32::Foundation::CloseHandle(job);
std::io::Error::new(
std::io::ErrorKind::Other,
format!("SetInformationJobObject: {e}"),
)
})?;
let process_handle = OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, false, pid)
.map_err(|e| {
let _ = windows::Win32::Foundation::CloseHandle(job);
std::io::Error::new(
std::io::ErrorKind::Other,
format!("OpenProcess({pid}): {e}"),
)
})?;
let assign_result = AssignProcessToJobObject(job, process_handle);
let _ = windows::Win32::Foundation::CloseHandle(process_handle);
assign_result.map_err(|e| {
let _ = windows::Win32::Foundation::CloseHandle(job);
std::io::Error::new(
std::io::ErrorKind::Other,
format!("AssignProcessToJobObject: {e}"),
)
})?;
Ok(Win32JobHandle(job))
}
}
pub fn build_command_with_isolation(program: &str, args: &[&str]) -> Command {
use tokio::process::Command;
if crate::is_unshare_enabled() {
if let Some(unshare_path) = crate::UNSHARE_PATH.as_ref() {
let mut cmd = Command::new(unshare_path);
let flags = crate::UNSHARE_ISOLATION_FLAGS.as_str();
for flag in flags.split_whitespace() {
cmd.arg(flag);
}
// If tini is available, use it for proper PID 1 signal handling
// (especially OOM exit codes which return 137 instead of sigprocmask errors).
// Note: --fork should already be in the flags for proper namespace setup.
if let Some(tini_path) = crate::TINI_AVAILABLE.as_ref() {
cmd.arg("--");
cmd.arg(tini_path);
cmd.arg("-s");
cmd.arg("--");
} else {
// Without tini, just run the command directly (--fork is in flags)
cmd.arg("--");
}
cmd.arg(program);
cmd.args(args);
cmd
} else {
tracing::error!(
"unshare isolation is enabled but UNSHARE_PATH is not available. \
Running job without isolation. Check Instance Settings > Job Isolation."
);
let mut cmd = Command::new(program);
cmd.args(args);
cmd
}
} else {
let mut cmd = Command::new(program);
cmd.args(args);
cmd
}
}
pub async fn start_child_process(
cmd: Command,
executable: &str,
disable_process_group: bool,
) -> Result<Box<dyn TokioChildWrapper>, Error> {
use process_wrap::tokio::*;
let mut cmd = TokioCommandWrap::from(cmd);
// On Windows, put the child in its own console process group so a CTRL_BREAK_EVENT
// sent to the worker's group (e.g. by Nomad on scale-in) is not delivered to it.
// Otherwise the whole child tree dies instantly with STATUS_CONTROL_C_EXIT
// (0xC000013A) before the worker can drain running jobs.
//
// This deliberately does NOT use process-wrap's JobObject wrap: its pre_spawn calls
// command.creation_flags(CREATE_SUSPENDED | get_wrap::<CreationFlags>()), but
// get_wrap returns None mid-spawn (process-wrap 8.2.1 takes the wrappers out of
// `self` before running pre_spawn), so it silently overwrites and drops
// CREATE_NEW_PROCESS_GROUP. Job-object grouping is done post-spawn below instead,
// which also matches the dotnet-safe assignment csharp already relies on.
#[cfg(windows)]
if !*DISABLE_PROCESS_GROUP {
use process_wrap::tokio::CreationFlags;
use windows::Win32::System::Threading::{CREATE_NEW_PROCESS_GROUP, CREATE_SUSPENDED};
// Also create the child suspended so it can be placed in its job object (below)
// before it runs any code; otherwise a child that forks a helper at startup could
// create it before the assignment and leave it outside the job. Resumed right
// after the job assignment.
cmd.wrap(CreationFlags(CREATE_NEW_PROCESS_GROUP | CREATE_SUSPENDED));
}
if !*DISABLE_PROCESS_GROUP && !disable_process_group {
#[cfg(unix)]
{
use process_wrap::tokio::ProcessGroup;
cmd.wrap(ProcessGroup::leader());
}
}
let child: Box<dyn TokioChildWrapper> = cmd
.spawn()
.map_err(|err| tentatively_improve_error(err.into(), executable))?;
// On Windows, assign the child to a job object. KILL_ON_JOB_CLOSE makes the OS reap
// the whole child tree when the worker drops the handle or dies, so jobs aren't
// orphaned if the worker is force-killed (second CTRL_BREAK_EVENT, or Nomad exceeding
// kill_timeout) before they drain; it is gated on the DISABLE_PROCESS_GROUP escape
// hatch alongside CREATE_NEW_PROCESS_GROUP above. The LIMIT_WINDOWS_TO_1CU memory cap
// is independent of that hatch (it's its own opt-in), so it's applied whenever set.
// Assigning post-spawn does not touch the creation flags (so it composes with
// CREATE_NEW_PROCESS_GROUP) and is the dotnet-safe path csharp already uses.
#[cfg(windows)]
{
let kill_on_close = !*DISABLE_PROCESS_GROUP;
let memory_limit =
(*windmill_common::worker::LIMIT_WINDOWS_TO_1CU).then_some(MEMORY_LIMIT_1CU);
if kill_on_close || memory_limit.is_some() {
if let Some(pid) = child.inner().id() {
let assigned = assign_job_object(pid, memory_limit, kill_on_close);
// When kill_on_close, the child was created suspended (CREATE_SUSPENDED
// above) so it could be placed in the job before running. Resume it now —
// unconditionally of assign success, so a failed assign never leaves it hung.
if kill_on_close {
if let Err(e) = resume_process(pid) {
tracing::error!("Failed to resume child process {pid}: {e}");
}
}
match assigned {
Ok(job_handle) => {
if memory_limit.is_some() {
tracing::info!(
"Applied 2GB memory limit (LIMIT_WINDOWS_TO_1CU) to child process {pid}"
);
}
return Ok(Box::new(WindowsJobChild {
inner: child,
_job_handle: job_handle,
}));
}
Err(e) => {
tracing::warn!("Failed to assign child process {pid} to job object: {e}");
}
}
}
}
}
Ok(child)
}
pub async fn resolve_job_timeout(
_conn: &Connection,
_w_id: &str,
_job_id: Uuid,
custom_timeout_secs: Option<i32>,
) -> (Duration, Option<String>, bool) {
let mut warn_msg: Option<String> = None;
#[cfg(feature = "cloud")]
let cloud_premium_workspace =
*CLOUD_HOSTED
&& windmill_common::workspaces::get_team_plan_status(
_conn.as_sql().expect("cloud cannot use http connection"),
_w_id,
)
.await
.inspect_err(|err| tracing::error!("Failed to get team plan status to resolve job timeout for workspace {_w_id}: {err:#}"))
.map(|s| s.premium)
.unwrap_or(true);
#[cfg(not(feature = "cloud"))]
let cloud_premium_workspace = false;
let global_max_timeout_duration =
Duration::from_secs(max_job_duration_secs(cloud_premium_workspace));
// A `custom_timeout_secs <= 0` is not a 0-second limit but "unset": fall through to the
// default/global-max timeout instead of killing the job immediately.
match windmill_common::runnable_settings::none_if_non_positive(custom_timeout_secs) {
Some(timeout_secs)
if Duration::from_secs(timeout_secs as u64) < global_max_timeout_duration =>
{
(Duration::from_secs(timeout_secs as u64), warn_msg, true)
}
Some(timeout_secs) => {
warn_msg = Some(format!("WARNING: Custom job timeout of {timeout_secs} seconds was greater than the maximum timeout. It will be ignored and the max timeout will be used instead"));
tracing::warn!(warn_msg);
(global_max_timeout_duration, warn_msg, false)
}
None => {
// fallback to default timeout or max if not set
let default_timeout = match JOB_DEFAULT_TIMEOUT.read().await.clone() {
None => global_max_timeout_duration,
Some(default_timeout_secs)
if Duration::from_secs(default_timeout_secs as u64)
< global_max_timeout_duration =>
{
Duration::from_secs(default_timeout_secs as u64)
}
Some(default_timeout_secs) => {
warn_msg = Some(format!("WARNING: Default job timeout of {default_timeout_secs} seconds was greater than the maximum timeout. It will be ignored and the global max timeout will be used instead"));
tracing::warn!(warn_msg);
global_max_timeout_duration
}
};
(default_timeout, warn_msg, false)
}
}
}
/// Compute the nsjail timeout (in seconds) with a 15s buffer so handle_child fires first.
pub async fn resolve_nsjail_timeout(
conn: &Connection,
w_id: &str,
job_id: Uuid,
custom_timeout: Option<i32>,
) -> String {
let (duration, _, _) = resolve_job_timeout(conn, w_id, job_id, custom_timeout).await;
(duration.as_secs() + 15).to_string()
}
/// Render the `rlimit_as` line for an nsjail run config, honoring a per-language
/// env-var override.
///
/// nsjail caps a jailed job's virtual address space at `rlimit_as` MiB. JIT
/// runtimes (Bun/JavaScriptCore, the JVM) reserve large virtual ranges up front,
/// so a subprocess spawned from a jailed Python/Ansible job can crash against this
/// cap even when its physical memory use is modest. Lifting it lets operators run
/// such workloads on a dedicated worker pool (set the env var only there) without
/// giving up the mount/PID/user-namespace isolation that provides the real
/// security boundary. Only the address-space limit is affected; the other rlimits
/// (cpu/fsize/nofile) in the proto are untouched.
///
/// `env_override` is the raw value of the language's `NSJAIL_*_RLIMIT_AS_MB` env var:
/// - unset/empty -> historical default (`rlimit_as: {default_mb}`)
/// - `unlimited`/`none`/`inf`/`0` -> `rlimit_as_type: INF` (address space uncapped)
/// - a positive integer (MiB) -> `rlimit_as: {n}`
pub fn render_nsjail_rlimit_as(env_override: Option<&str>, default_mb: u32) -> String {
match env_override.map(str::trim) {
None | Some("") => format!("rlimit_as: {default_mb}"),
Some(v)
if v.eq_ignore_ascii_case("unlimited")
|| v.eq_ignore_ascii_case("none")
|| v.eq_ignore_ascii_case("inf")
|| v == "0" =>
{
"rlimit_as_type: INF".to_string()
}
Some(v) => match v.parse::<u32>() {
Ok(mb) => format!("rlimit_as: {mb}"),
Err(_) => {
tracing::warn!(
"Invalid nsjail rlimit_as override {v:?}, using default {default_mb}MiB"
);
format!("rlimit_as: {default_mb}")
}
},
}
}
/// Default size (in bytes) of the `/tmp` tmpfs mount inside nsjail sandboxes,
/// used when the `nsjail_tmpfs_size_mb` instance setting is unset.
pub const DEFAULT_NSJAIL_TMPFS_SIZE_BYTES: u64 = 800_000_000;
/// Resolve the tmpfs `size=` value (in bytes, formatted for the nsjail proto)
/// for the `/tmp` tmpfs mount. When the `nsjail_tmpfs_size_mb` instance setting
/// is `None`, `Some(0)`, or negative, falls back to
/// [`DEFAULT_NSJAIL_TMPFS_SIZE_BYTES`].
pub async fn resolve_nsjail_tmpfs_size_bytes() -> String {
match *NSJAIL_TMPFS_SIZE_MB.read().await {
Some(mb) if mb > 0 => ((mb as u64).saturating_mul(1_000_000)).to_string(),
_ => DEFAULT_NSJAIL_TMPFS_SIZE_BYTES.to_string(),
}
}
/// Sub-directory inside each job dir used as the disk-backed `/tmp` when
/// `nsjail_tmp_disk_backed` is enabled. Kept under `{JOB_DIR}` so existing
/// job-dir cleanup removes it for free.
const NSJAIL_TMP_BIND_SUBDIR: &str = "jail_tmp";
fn tmpfs_mount_block(size_bytes: &str) -> String {
format!(
"mount {{\n dst: \"/tmp\"\n fstype: \"tmpfs\"\n rw: true\n options: \"size={size_bytes}\"\n}}"
)
}
fn bind_mount_block(jail_tmp: &str) -> String {
format!(
"mount {{\n src: \"{jail_tmp}\"\n dst: \"/tmp\"\n is_bind: true\n rw: true\n}}"
)
}
/// Build the nsjail `mount { ... }` block that backs `/tmp` inside the
/// sandbox.
///
/// **Caller contract**: `job_dir` must be a trusted, worker-allocated job
/// directory (typically `{worker_dir}/{job_id}`). In disk-backed mode this
/// function creates `{job_dir}/jail_tmp` and bind-mounts it as `/tmp` with
/// `rw: true`. Callers must not pass user-controlled paths.
///
/// Some executors (e.g. the bun codebase path) extract user-supplied archives
/// into `job_dir` before this resolver runs, so the resolver actively refuses
/// any pre-existing entry at `{job_dir}/jail_tmp` (including symlinks) to
/// avoid bind-mounting an attacker-controlled host directory as `/tmp`.
///
/// When the `nsjail_tmp_backing` instance setting is `"disk"`, returns a
/// disk-backed bind mount of `{job_dir}/jail_tmp` after creating the
/// directory. If creation or the pre-existence check fails, logs an error and
/// falls back to the historical tmpfs block so the job can still start. For
/// any other value (including unset, `"tmpfs"`, or unrecognized), returns the
/// historical RAM-backed tmpfs mount sized via `nsjail_tmpfs_size_mb`.
pub(crate) async fn resolve_nsjail_tmp_mount_block(job_dir: &str) -> String {
let disk_backed = NSJAIL_TMP_BACKING
.read()
.await
.as_deref()
.map(|v| v.eq_ignore_ascii_case(NSJAIL_TMP_BACKING_DISK))
.unwrap_or(false);
let size_bytes = resolve_nsjail_tmpfs_size_bytes().await;
if !disk_backed {
return tmpfs_mount_block(&size_bytes);
}
let jail_tmp = format!("{job_dir}/{NSJAIL_TMP_BIND_SUBDIR}");
// SECURITY: never bind-mount a symlinked (or otherwise non-directory)
// entry at jail_tmp. `symlink_metadata` returns the link's own metadata
// without following it, so `is_dir()` is true only for a real directory.
// User-controlled archives extracted into job_dir could otherwise plant
// `jail_tmp` as a symlink to an arbitrary host directory, which nsjail
// would then expose as a writable /tmp.
//
// A pre-existing real directory at this path is legitimate: several
// executors (python_executor, ruby_executor, rust_executor) invoke nsjail
// more than once per job_dir (e.g. dep install, then run), and the first
// invocation will have created it via the `create_dir` below.
match tokio::fs::symlink_metadata(&jail_tmp).await {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if let Err(e) = tokio::fs::create_dir(&jail_tmp).await {
tracing::error!(
"Failed to create nsjail disk-backed /tmp at {jail_tmp}: {e:?}; \
falling back to tmpfs for this job."
);
return tmpfs_mount_block(&size_bytes);
}
}
Ok(meta) if meta.is_dir() => {
// Real directory left over from an earlier nsjail invocation in
// this same job_dir — safe to reuse.
}
Ok(_) => {
tracing::error!(
"Refusing to bind-mount nsjail disk-backed /tmp: {jail_tmp} \
exists but is not a regular directory (possibly a symlink \
planted by a user-controlled archive). Falling back to \
RAM-backed tmpfs for this job."
);
return tmpfs_mount_block(&size_bytes);
}
Err(e) => {
tracing::error!(
"Failed to stat nsjail disk-backed /tmp at {jail_tmp}: {e:?}; \
falling back to tmpfs for this job."
);
return tmpfs_mount_block(&size_bytes);
}
}
bind_mount_block(&jail_tmp)
}
#[cfg(test)]
mod nsjail_rlimit_as_tests {
use super::render_nsjail_rlimit_as;
#[test]
fn unset_uses_default() {
assert_eq!(render_nsjail_rlimit_as(None, 4096), "rlimit_as: 4096");
assert_eq!(render_nsjail_rlimit_as(Some(" "), 4096), "rlimit_as: 4096");
}
#[test]
fn numeric_override_is_used() {
assert_eq!(
render_nsjail_rlimit_as(Some("16384"), 4096),
"rlimit_as: 16384"
);
assert_eq!(
render_nsjail_rlimit_as(Some(" 8192 "), 4096),
"rlimit_as: 8192"
);
}
#[test]
fn unlimited_keywords_emit_inf() {
for v in ["unlimited", "UNLIMITED", "none", "inf", "0"] {
assert_eq!(
render_nsjail_rlimit_as(Some(v), 4096),
"rlimit_as_type: INF",
"value {v:?}"
);
}
}
#[test]
fn invalid_falls_back_to_default() {
assert_eq!(
render_nsjail_rlimit_as(Some("abc"), 4096),
"rlimit_as: 4096"
);
assert_eq!(render_nsjail_rlimit_as(Some("-1"), 4096), "rlimit_as: 4096");
}
}
#[cfg(test)]
mod nsjail_tmp_mount_tests {
use super::*;
#[test]
fn tmpfs_block_renders_size() {
let block = tmpfs_mount_block("800000000");
assert!(block.contains("dst: \"/tmp\""));
assert!(block.contains("fstype: \"tmpfs\""));
assert!(block.contains("options: \"size=800000000\""));
assert!(!block.contains("is_bind"));
}
#[test]
fn bind_block_renders_source_path() {
let block = bind_mount_block("/var/lib/windmill/jobs/abc/jail_tmp");
assert!(block.contains("src: \"/var/lib/windmill/jobs/abc/jail_tmp\""));
assert!(block.contains("dst: \"/tmp\""));
assert!(block.contains("is_bind: true"));
assert!(block.contains("rw: true"));
assert!(!block.contains("fstype"));
}
/// Serializes tests that mutate the process-global `NSJAIL_TMP_BACKING`
/// so they don't race when cargo runs them in parallel.
static SETTING_GUARD: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
async fn with_tmp_backing<F, Fut, T>(value: Option<String>, f: F) -> T
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = T>,
{
let _serial = SETTING_GUARD.lock().await;
let prev = NSJAIL_TMP_BACKING.read().await.clone();
*NSJAIL_TMP_BACKING.write().await = value;
let res = f().await;
*NSJAIL_TMP_BACKING.write().await = prev;
res
}
#[tokio::test]
async fn tmpfs_mode_returns_tmpfs_block_for_any_job_dir() {
let block = with_tmp_backing(Some("tmpfs".to_string()), || async {
resolve_nsjail_tmp_mount_block("/anything").await
})
.await;
assert!(block.contains("fstype: \"tmpfs\""));
assert!(block.contains("options: \"size="));
assert!(!block.contains("is_bind"));
}
#[tokio::test]
async fn unset_defaults_to_tmpfs() {
let block = with_tmp_backing(None, || async {
resolve_nsjail_tmp_mount_block("/anything").await
})
.await;
assert!(block.contains("fstype: \"tmpfs\""));
assert!(!block.contains("is_bind"));
}
/// Disk-backed branch: the resolver must create `{job_dir}/jail_tmp` and
/// emit a bind block pointing at it.
#[tokio::test]
async fn disk_backed_creates_jail_tmp_and_returns_bind_block() {
let tmp = tempfile::tempdir().expect("tempdir");
let job_dir = tmp.path().to_str().expect("utf8 path").to_string();
let block = with_tmp_backing(Some("disk".to_string()), || async {
resolve_nsjail_tmp_mount_block(&job_dir).await
})
.await;
let expected_dir = format!("{job_dir}/{NSJAIL_TMP_BIND_SUBDIR}");
assert!(
std::path::Path::new(&expected_dir).is_dir(),
"jail_tmp dir should have been created at {expected_dir}"
);
assert!(block.contains("is_bind: true"));
assert!(block.contains(&format!("src: \"{expected_dir}\"")));
}
/// Disk-backed branch fallback: if `create_dir_all` fails, we must emit
/// the tmpfs block instead of returning an invalid bind config.
#[tokio::test]
async fn disk_backed_falls_back_to_tmpfs_on_mkdir_error() {
// /proc is a kernel filesystem that disallows directory creation,
// so create_dir_all on a subpath returns EPERM/EACCES.
let job_dir = "/proc/win1967_should_not_exist";
let block = with_tmp_backing(Some("disk".to_string()), || async {
resolve_nsjail_tmp_mount_block(job_dir).await
})
.await;
assert!(block.contains("fstype: \"tmpfs\""));
assert!(!block.contains("is_bind"));
}
/// Unknown values fall through to the tmpfs branch instead of crashing.
#[tokio::test]
async fn unknown_value_defaults_to_tmpfs() {
let block = with_tmp_backing(Some("bogus".to_string()), || async {
resolve_nsjail_tmp_mount_block("/anything").await
})
.await;
assert!(block.contains("fstype: \"tmpfs\""));
assert!(!block.contains("is_bind"));
}
/// Security regression: if a pre-existing symlink sits at the jail_tmp
/// path (e.g. planted by a user-controlled tarball extracted into
/// `job_dir` before the resolver runs), the resolver must refuse the
/// bind mount and fall back to tmpfs — never bind-mount the symlink
/// target into the sandbox as /tmp.
#[cfg(unix)]
#[tokio::test]
async fn disk_backed_refuses_preexisting_symlink_at_jail_tmp() {
let tmp = tempfile::tempdir().expect("tempdir");
let job_dir = tmp.path().to_str().expect("utf8 path").to_string();
// Plant a symlink at {job_dir}/jail_tmp pointing at an arbitrary host
// path. Target doesn't have to exist — what matters is that the
// resolver doesn't follow it.
let jail_tmp_path = format!("{job_dir}/{NSJAIL_TMP_BIND_SUBDIR}");
std::os::unix::fs::symlink("/etc", &jail_tmp_path).expect("plant symlink");
assert!(std::path::Path::new(&jail_tmp_path).is_symlink());
let block = with_tmp_backing(Some("disk".to_string()), || async {
resolve_nsjail_tmp_mount_block(&job_dir).await
})
.await;
// Fell back to tmpfs — no bind-mount of the attacker-controlled path.
assert!(
block.contains("fstype: \"tmpfs\""),
"expected tmpfs fallback, got: {block}"
);
assert!(
!block.contains("is_bind"),
"must not emit bind block, got: {block}"
);
assert!(
!block.contains("/etc"),
"must not leak the symlink target into the proto, got: {block}"
);
}
/// Sequential resolver calls in the same `job_dir` (e.g. Python uv install
/// → Python run, Ruby install → run, Rust build → run) must keep using
/// the bind mount instead of silently falling back to tmpfs on the
/// second call. The first call creates `jail_tmp`; subsequent calls see
/// it as a pre-existing real directory and must accept it.
#[tokio::test]
async fn disk_backed_reuses_jail_tmp_across_sequential_calls() {
let tmp = tempfile::tempdir().expect("tempdir");
let job_dir = tmp.path().to_str().expect("utf8 path").to_string();
let expected_dir = format!("{job_dir}/{NSJAIL_TMP_BIND_SUBDIR}");
let (first, second) = with_tmp_backing(Some("disk".to_string()), || async {
let first = resolve_nsjail_tmp_mount_block(&job_dir).await;
// Simulate an executor that completes its first nsjail invocation
// (e.g. uv install) leaving jail_tmp on disk, then invokes nsjail
// again for the main run.
assert!(std::path::Path::new(&expected_dir).is_dir());
let second = resolve_nsjail_tmp_mount_block(&job_dir).await;
(first, second)
})
.await;
assert!(first.contains("is_bind: true"), "first call: {first}");
assert!(
second.contains("is_bind: true"),
"second call regressed to tmpfs: {second}"
);
assert!(second.contains(&format!("src: \"{expected_dir}\"")));
}
}
async fn hash_args(
#[allow(unused)] db: &DB,
#[allow(unused)] client: &AuthedClient,
#[allow(unused)] workspace_id: &str,
v: &Option<Json<HashMap<String, Box<RawValue>>>>,
hasher: &mut sha2::Sha256,
#[allow(unused)] job_id: &Uuid,
#[allow(unused)] ignore_s3_path: bool,
) {
if let Some(Json(hm)) = v {
for k in hm.keys().sorted() {
hasher.update(k.as_bytes());
let arg_value = hm.get(k).unwrap();
#[cfg(feature = "parquet")]
let etag = match serde_json::from_str::<S3Object>(arg_value.get()).ok() {
Some(s3_object) => {
let s3_resource = get_workspace_s3_resource_path(
db,
client,
workspace_id,
s3_object.storage.as_ref(),
job_id,
)
.await
.ok()
.flatten();
match s3_resource {
Some(s3_resource) => get_etag_or_empty(&s3_resource, s3_object).await,
None => None,
}
}
None => None,
};
#[cfg(feature = "parquet")]
if let Some(etag) = etag {
hasher.update(etag.as_bytes());
if ignore_s3_path {
continue;
}
}
hasher.update(arg_value.get().as_bytes());
}
}
}
pub async fn cached_result_path(
db: &DB,
client: &AuthedClient,
job: &MiniPulledJob,
raw_data: Option<&RawData>,
) -> String {
let mut hasher = sha2::Sha256::new();
hasher.update(&[job.kind as u8]);
if let Some(ScriptHash(hash)) = job.runnable_id {
hasher.update(&hash.to_le_bytes())
} else {
job.runnable_path
.as_ref()
.inspect(|x| hasher.update(x.as_bytes()));
match raw_data {
Some(RawData::Flow(data)) => hasher.update(data.raw_flow.get()),
Some(RawData::Script(data)) => hasher.update(&data.code),
_ => {}
}
}
hash_args(
db,
client,
&job.workspace_id,
&job.args,
&mut hasher,
&job.id,
job.cache_ignore_s3_path.unwrap_or(false),
)
.await;
format!("g/results/{:064x}", hasher.finalize())
}
#[cfg(feature = "parquet")]
pub(crate) async fn get_workspace_s3_resource_path(
db: &DB,
client: &AuthedClient,
workspace_id: &str,
storage: Option<&String>,
job_id: &Uuid,
) -> windmill_common::error::Result<Option<ObjectStoreResource>> {
use windmill_object_store::job_s3_helpers_oss::get_s3_resource_internal;
use windmill_types::s3::StorageResourceType;
// `_default_` names the primary storage, never a secondary one (see `DEFAULT_STORAGE`), so
// it resolves below rather than being looked up among storages that cannot carry the name.
let raw_lfs_opt = if let Some(storage) =
storage.filter(|s| s.as_str() != windmill_types::s3::DEFAULT_STORAGE)
{
sqlx::query_scalar!(
"SELECT large_file_storage->'secondary_storage'->$2 FROM workspace_settings WHERE workspace_id = $1",
workspace_id,
storage
)
.fetch_optional(db)
.await?
.flatten()
} else {
sqlx::query_scalar!(
"SELECT large_file_storage FROM workspace_settings WHERE workspace_id = $1",
workspace_id
)
.fetch_optional(db)
.await?
.flatten()
};
let raw_lfs_opt = raw_lfs_opt
.map(|val| serde_json::from_value::<LargeFileStorage>(val).ok())
.flatten();
let (rt, path) = match raw_lfs_opt {
Some(LargeFileStorage::S3Storage(s3_storage)) => {
let resource_path = s3_storage.s3_resource_path.trim_start_matches("$res:");
(StorageResourceType::S3, resource_path.to_string())
}
Some(LargeFileStorage::AzureBlobStorage(azure_blob_storage)) => {
let resource_path = azure_blob_storage
.azure_blob_resource_path
.trim_start_matches("$res:");
(StorageResourceType::AzureBlob, resource_path.to_string())
}
Some(LargeFileStorage::S3AwsOidc(s3_aws_oidc)) => {
let resource_path = s3_aws_oidc.s3_resource_path.trim_start_matches("$res:");
(StorageResourceType::S3AwsOidc, resource_path.to_string())
}
Some(LargeFileStorage::AzureWorkloadIdentity(azure)) => {
let resource_path = azure.azure_blob_resource_path.trim_start_matches("$res:");
(
StorageResourceType::AzureWorkloadIdentity,
resource_path.to_string(),
)
}
Some(LargeFileStorage::GoogleCloudStorage(gcs)) => {
let resource_path = gcs.gcs_resource_path.trim_start_matches("$res:");
(
StorageResourceType::GoogleCloudStorage,
resource_path.to_string(),
)
}
Some(LargeFileStorage::FilesystemStorage(fs)) => {
windmill_common::workspaces::ensure_filesystem_storage_allowed()?;
return Ok(Some(
windmill_object_store::ObjectStoreResource::Filesystem(
windmill_object_store::FilesystemSettings { root_path: fs.root_path.clone() },
),
));
}
None => {
return Ok(None);
}
};
let s3_resource_value_raw = client
.get_resource_value_interpolated::<serde_json::Value>(
path.as_str(),
Some(job_id.to_string()),
)
.await?;
let object_store_resource = get_s3_resource_internal(
rt,
s3_resource_value_raw,
windmill_object_store::job_s3_helpers_oss::TokenGenerator::AsClient(client),
db,
)
.await?;
// Check bucket workspace restrictions
use windmill_object_store::ObjectStoreResource;
let bucket_name = match &object_store_resource {
ObjectStoreResource::S3(s3_resource) => Some(&s3_resource.bucket),
ObjectStoreResource::Azure(azure_resource) => Some(&azure_resource.container_name),
ObjectStoreResource::Gcs(gcs_resource) => Some(&gcs_resource.bucket),
ObjectStoreResource::Filesystem(_) => None,
};
if let Some(bucket) = bucket_name {
windmill_object_store::check_bucket_workspace_restriction(bucket, workspace_id)?;
}
// Check Azure account name workspace restrictions
if let ObjectStoreResource::Azure(azure_resource) = &object_store_resource {
windmill_object_store::check_az_account_name_workspace_restriction(
&azure_resource.account_name,
workspace_id,
)?;
}
Ok(Some(object_store_resource))
}
#[derive(Deserialize, Serialize)]
struct CachedResource {
expire: i64,
value: Arc<Box<RawValue>>,
}
impl CachedResource {
fn expired(&self) -> bool {
self.expire <= chrono::Utc::now().timestamp()
}
}
lazy_static! {
/// In-memory cache for resources.
static ref CACHED_RESULTS: Cache<String, Arc<CachedResource>> = Cache::new(1000);
}
pub async fn get_cached_resource_value_if_valid(
_db: &DB,
client: &AuthedClient,
_workspace_id: &str,
cached_res_path: &str,
) -> Option<Arc<Box<RawValue>>> {
let resource = match CACHED_RESULTS
.get_value_or_guard_async(cached_res_path)
.await
{
// check for cache expiration.
Ok(resource) if resource.expired() => {
let _ = CACHED_RESULTS.remove(cached_res_path);
return None;
}
// resource is in cache and not expired, return it.
Ok(resource) => resource,
// resource is not in cache, fetch it from the database.
Err(entry) => match client
.get_resource_value::<CachedResource>(cached_res_path)
.await
.ok()
{
None => return None,
// check for cache expiration.
Some(resource) if resource.expired() => return None,
Some(resource) => {
let resource = Arc::new(resource);
let _ = entry.insert(resource.clone());
resource
}
},
};
Some(resource.value.clone())
}
pub async fn save_in_cache(
db: &Pool<Postgres>,
_client: &AuthedClient,
job: &MiniCompletedJob,
cached_path: String,
r: Arc<Box<RawValue>>,
) {
let expire = chrono::Utc::now().timestamp() + job.cache_ttl.unwrap() as i64;
let store_cache_resource = CachedResource { expire, value: r };
let raw_json = Json(&store_cache_resource);
if let Err(e) = sqlx::query!(
"INSERT INTO resource
(workspace_id, path, value, resource_type, created_by, edited_at)
VALUES ($1, $2, $3, $4, $5, now()) ON CONFLICT (workspace_id, path)
DO UPDATE SET value = EXCLUDED.value, edited_at = now()",
job.workspace_id,
&cached_path,
raw_json as Json<&CachedResource>,
"cache",
job.created_by
)
.execute(db)
.await
{
tracing::error!("Error creating cache resource {e:#}")
}
// Cache result in-memory.
CACHED_RESULTS.insert(cached_path, Arc::new(store_cache_resource));
}
fn tentatively_improve_error(err: Error, executable: &str) -> Error {
#[cfg(unix)]
let err_msgs = vec!["os error 2", "os error 3", "No such file or directory"];
#[cfg(windows)]
let err_msgs = vec!["program not found", "os error 2", "os error 3"];
if err_msgs.iter().any(|msg| err.to_string().contains(msg)) {
return Error::internal_err(format!(
"Executable {executable} not found on worker. PATH: {}",
*PATH_ENV
));
}
return Error::ExecutionErr(format!("Error executing {executable}: {err:#}"));
}
pub async fn clean_cache() -> error::Result<()> {
tracing::info!("Started cleaning cache");
tokio::fs::remove_dir_all(&*ROOT_CACHE_DIR).await?;
tracing::info!("Finished cleaning cache");
Ok(())
}
lazy_static::lazy_static! {
static ref RE_FLOW_ROOT: Regex = Regex::new(r"(?i)(.*?)(?:/branchone-\d+/|/branchall-\d+/|/loop-\d+|/forloop-\d+/)").unwrap();
}
pub fn use_flow_root_path(flow_path: &str) -> String {
if let Some(captures) = RE_FLOW_ROOT.captures(flow_path) {
return captures
.get(1)
.map(|m| format!("{}/flow", m.as_str()))
.unwrap_or_else(|| flow_path.to_string());
} else {
return flow_path.to_string();
}
}
/// Extract the flow root path by stripping /branchone-N/, /branchall-N/, /forloop-N/, /loop-N/
/// and everything after. Returns None if no nesting segments are found.
pub fn extract_flow_root(runnable_path: &str) -> Option<&str> {
RE_FLOW_ROOT
.captures(runnable_path)
.and_then(|c| c.get(1).map(|m| m.as_str()))
}
pub fn build_http_client(timeout_duration: std::time::Duration) -> error::Result<Client> {
configure_client(
reqwest::ClientBuilder::new()
.user_agent("windmill/beta")
.timeout(timeout_duration)
.connect_timeout(std::time::Duration::from_secs(10)),
)
.build()
.map_err(|e| Error::internal_err(format!("Error building http client: {e:#}")))
}
pub fn get_root_job_id(job: &MiniPulledJob) -> uuid::Uuid {
job.root_job
.or(job.flow_innermost_root_job)
.or(job.parent_job)
.unwrap_or(job.id)
}
#[derive(Clone)]
pub struct StreamNotifier {
db: DB,
job_id: uuid::Uuid,
parent_job: uuid::Uuid,
root_job: uuid::Uuid,
flow_step_id: Option<String>,
}
// Helper struct to hold parent job information
#[derive(Debug)]
struct JobInfo {
flow_step_id: Option<String>,
step: Option<i32>,
len: Option<i32>,
is_branch_one: Option<bool>,
next_parent: Option<Uuid>,
}
// Shared helper function to fetch parent job info with flow status
async fn get_job_info(db: &DB, job_id: Uuid) -> error::Result<JobInfo> {
sqlx::query_as!(
JobInfo,
r#"SELECT
flow_step_id,
(flow_status->'step')::integer as step,
jsonb_array_length(flow_status->'modules') as len,
runnable_path ~ '/branchone-\d+$' as is_branch_one,
parent_job as next_parent
FROM v2_job
LEFT JOIN v2_job_status USING (id)
WHERE v2_job.id = $1"#,
job_id
)
.fetch_one(db)
.await
.map_err(|e| Error::internal_err(format!("fetching parent job info: {e:#}")))
}
async fn check_if_last_step(
db: &DB,
mut next_parent: Option<Uuid>,
root_job: Uuid,
) -> error::Result<bool> {
let mut visited = HashSet::new();
loop {
// Get parent of current job
let Some(parent_job) = next_parent else {
return Ok(false);
};
// Check for cycles
if !visited.insert(parent_job) {
return Ok(false);
}
let parent_info = get_job_info(db, parent_job).await?;
if let Some(step) = parent_info.step {
let step = Step::from_i32_and_len(step, parent_info.len.unwrap_or(0) as usize);
if step.is_last_step() {
if parent_job == root_job {
return Ok(true);
} else if parent_info.is_branch_one.unwrap_or(false) {
next_parent = parent_info.next_parent;
continue;
}
}
}
return Ok(false);
}
}
// Iterative implementation to avoid stack overflow from async_recursion
// Checks if we're at last step in nested branches AND if any parent's flow_step_id matches early_return_id
async fn check_if_early_return_or_last_in_early_return_parent(
db: &DB,
mut next_parent: Option<Uuid>,
mut step_id: Option<String>,
early_return_id: &str,
root_job: Uuid,
) -> error::Result<bool> {
let mut visited = HashSet::new();
loop {
if step_id.is_none() {
return Ok(false);
}
let Some(parent_job) = next_parent else {
return Ok(false);
};
// Check for cycles
if !visited.insert(parent_job) {
return Ok(false);
}
// If the parent's flow_step_id matches early_return_id, we found it!
if step_id.as_deref() == Some(early_return_id) && parent_job == root_job {
return Ok(true);
} else {
let parent_info = get_job_info(db, parent_job).await?;
if let Some(step) = parent_info.step {
let step = Step::from_i32_and_len(step, parent_info.len.unwrap_or(0) as usize);
// we only continue if we are at the last step and the parent is a branch one
if step.is_last_step() && parent_info.is_branch_one.unwrap_or(false) {
next_parent = parent_info.next_parent;
step_id = parent_info.flow_step_id;
continue;
}
}
return Ok(false);
}
}
}
impl StreamNotifier {
pub fn new(conn: &Connection, job: &MiniPulledJob) -> Option<Self> {
let root_job = get_root_job_id(job);
if job.is_flow_step() && job.parent_job.is_some() {
match conn {
Connection::Sql(db) => Some(Self {
db: db.clone(),
parent_job: job.parent_job.unwrap(),
job_id: job.id,
root_job,
flow_step_id: job.flow_step_id.clone(),
}),
Connection::Http(_) => {
tracing::warn!(
"Flow job streaming is only supported for workers connected to a database"
);
None
}
}
} else {
None
}
}
async fn update_flow_status_with_stream_job_inner(
db: DB,
parent_job: Uuid,
job_id: Uuid,
root_job: Uuid,
flow_step_id: Option<String>,
) -> Result<(), Error> {
// Check if early_return is set at the flow level
let early_return_node_id = sqlx::query_scalar!(
r#"
SELECT fv.value->>'early_return' as "early_return"
FROM v2_job j
INNER JOIN flow_version fv ON fv.id = j.runnable_id
WHERE j.id = $1
"#,
root_job
)
.fetch_optional(&db)
.await?
.flatten();
let should_set_stream_job = if let Some(ref early_return_id) = early_return_node_id {
check_if_early_return_or_last_in_early_return_parent(
&db,
Some(parent_job),
flow_step_id,
early_return_id,
root_job,
)
.await?
} else {
check_if_last_step(&db, Some(parent_job), root_job).await?
};
if should_set_stream_job {
sqlx::query!(r#"
UPDATE v2_job_status
SET flow_status = jsonb_set(flow_status, array['stream_job'], to_jsonb($1::UUID::TEXT))
WHERE id = $2"#,
job_id,
root_job
)
.execute(&db)
.await?;
}
Ok(())
}
pub fn update_flow_status_with_stream_job(&self) -> () {
let db = self.db.clone();
let parent_job = self.parent_job;
let job_id = self.job_id;
let root_job = self.root_job;
let flow_step_id = self.flow_step_id.clone();
tokio::spawn(async move {
if let Err(err) = Self::update_flow_status_with_stream_job_inner(
db,
parent_job,
job_id,
root_job,
flow_step_id,
)
.await
{
tracing::error!("Could not notify about stream job {}: {err:#?}", parent_job);
}
});
}
}
#[derive(Clone)]
pub struct S3ModeWorkerData {
pub client: AuthedClient,
pub object_key: String,
pub format: S3ModeFormat,
pub storage: Option<String>,
pub workspace_id: String,
}
impl S3ModeWorkerData {
pub async fn upload<S>(&self, stream: S) -> anyhow::Result<()>
where
S: futures::stream::TryStream + Send + 'static,
S::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
bytes::Bytes: From<S::Ok>,
{
self.client
.upload_s3_file(
self.workspace_id.as_str(),
self.object_key.clone(),
self.storage.clone(),
stream,
)
.await
}
pub fn to_return_s3_obj(&self) -> windmill_types::s3::S3Object {
windmill_types::s3::S3Object {
s3: self.object_key.clone(),
storage: self.storage.clone(),
..Default::default()
}
}
}
/// Stream rows to S3 (via `convert_json_line_stream` + `s3.upload`) while appending
/// periodic progress, ingest-done, and upload-done logs to the job output.
///
/// `db_name` is a human-readable prefix for the log lines (e.g. "MSSQL", "PostgreSQL").
pub async fn s3_stream_and_upload_with_logs<S, V, E>(
db_name: &str,
rows_stream: S,
s3: &S3ModeWorkerData,
job_id: Uuid,
workspace_id: &str,
conn: &Connection,
) -> anyhow::Result<()>
where
S: futures::TryStreamExt<Item = Result<V, E>> + Unpin,
V: serde::Serialize,
E: Into<anyhow::Error>,
{
let s3_format = s3.format;
let (progress_tx, mut progress_rx) =
tokio::sync::mpsc::channel::<windmill_object_store::IngestStats>(8);
let progress_conn = conn.clone();
let progress_workspace = workspace_id.to_string();
let progress_db_name = db_name.to_string();
let progress_task = tokio::spawn(async move {
while let Some(stats) = progress_rx.recv().await {
let logs = format!(
"\n{} s3 stream progress: {} rows, {:.1} MB | elapsed {:.1}s (db-fetch {:.1}s, write {:.1}s)",
progress_db_name,
stats.rows,
stats.bytes as f64 / 1_048_576.0,
stats.elapsed.as_secs_f64(),
stats.fetch_wait.as_secs_f64(),
stats.write_time.as_secs_f64(),
);
windmill_queue::append_logs(&job_id, &progress_workspace, logs, &progress_conn).await;
}
});
let (stream, ingest_stats) =
windmill_object_store::convert_json_line_stream(rows_stream, s3_format, Some(progress_tx))
.await?;
let _ = progress_task.await;
let ingest_log = format!(
"\n{} s3 stream ingest done ({:?}): {} rows, {:.1} MB in {:.2}s (db-fetch {:.2}s, write {:.2}s, first row after {:.2}s)",
db_name,
s3_format,
ingest_stats.rows,
ingest_stats.bytes as f64 / 1_048_576.0,
ingest_stats.elapsed.as_secs_f64(),
ingest_stats.fetch_wait.as_secs_f64(),
ingest_stats.write_time.as_secs_f64(),
ingest_stats.first_row_latency.unwrap_or_default().as_secs_f64(),
);
windmill_queue::append_logs(&job_id, workspace_id, ingest_log, conn).await;
let upload_start = std::time::Instant::now();
s3.upload(stream).await?;
let upload_elapsed = upload_start.elapsed();
let final_log = format!(
"\n{} s3 stream upload+transcode done: {:.2}s | total {:.2}s",
db_name,
upload_elapsed.as_secs_f64(),
(ingest_stats.elapsed + upload_elapsed).as_secs_f64(),
);
windmill_queue::append_logs(&job_id, workspace_id, final_log, conn).await;
Ok(())
}
pub fn s3_mode_args_to_worker_data(
s3: S3ModeArgs,
client: AuthedClient,
job: &MiniPulledJob,
) -> S3ModeWorkerData {
S3ModeWorkerData {
client,
storage: s3.storage,
format: s3.format,
object_key: format!(
"{}/{}.{}",
s3.prefix.unwrap_or_else(|| format!(
"wmill_datalake/{}",
job.runnable_path
.as_ref()
.map(|s| s.as_str())
.unwrap_or("unknown_script")
)),
job.id,
s3_mode_extension(s3.format)
),
workspace_id: job.workspace_id.clone(),
}
}
#[derive(Debug)]
pub enum MaybeLock {
/// Deployed Scripts
Resolved { lock: String },
/// Previews
Unresolved { workspace_dependencies: WorkspaceDependenciesPrefetched },
}
impl MaybeLock {
pub fn map_unresolved<B, F>(&self, mut f: F) -> Option<B>
where
Self: Sized,
F: FnMut(&WorkspaceDependenciesPrefetched) -> B,
{
self.get_workspace_dependencies().map(|wd| f(wd))
}
pub fn get_workspace_dependencies(&self) -> Option<&WorkspaceDependenciesPrefetched> {
match self {
MaybeLock::Resolved { .. } => None,
MaybeLock::Unresolved { ref workspace_dependencies } => Some(workspace_dependencies),
}
}
pub fn get_lock(&self) -> Option<&String> {
match self {
MaybeLock::Resolved { ref lock } => Some(lock),
MaybeLock::Unresolved { .. } => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use windmill_common::client::AuthedClient;
fn test_client() -> AuthedClient {
AuthedClient::new(
"http://localhost:0".to_string(),
"test".to_string(),
"test-token".to_string(),
None,
)
}
fn test_job() -> MiniPulledJob {
MiniPulledJob {
workspace_id: "test".to_string(),
id: uuid::Uuid::nil(),
args: None,
parent_job: None,
created_by: "test".to_string(),
scheduled_for: chrono::Utc::now(),
started_at: None,
runnable_path: None,
kind: windmill_common::jobs::JobKind::Noop,
runnable_id: None,
canceled_reason: None,
canceled_by: None,
permissioned_as: "test".to_string(),
permissioned_as_email: "test@test.com".to_string(),
flow_status: None,
tag: "test".to_string(),
script_lang: None,
same_worker: false,
pre_run_error: None,
concurrent_limit: None,
concurrency_time_window_s: None,
flow_innermost_root_job: None,
root_job: None,
timeout: None,
flow_step_id: None,
cache_ttl: None,
cache_ignore_s3_path: None,
priority: None,
preprocessed: None,
script_entrypoint_override: None,
trigger: None,
trigger_kind: None,
visible_to_owner: false,
permissioned_as_end_user_email: None,
runnable_settings_handle: None,
}
}
#[tokio::test]
async fn test_transform_array_over_1000_passthrough() {
let db_url = std::env::var("DATABASE_URL")
.unwrap_or("postgres://postgres:changeme@localhost:5432/windmill".to_string());
let pool = sqlx::PgPool::connect(&db_url).await.unwrap();
let conn = Connection::Sql(pool);
let client = test_client();
let job = test_job();
let arr: Vec<Value> = (0..1001).map(|i| json!(format!("$var:x/{i}"))).collect();
let input = Value::Array(arr.clone());
let result = transform_json_value("test", &client, "test", input, &job, &conn, 0)
.await
.unwrap();
assert_eq!(result, Value::Array(arr));
}
#[tokio::test]
async fn test_transform_array_non_matching_strings_passthrough() {
let db_url = std::env::var("DATABASE_URL")
.unwrap_or("postgres://postgres:changeme@localhost:5432/windmill".to_string());
let pool = sqlx::PgPool::connect(&db_url).await.unwrap();
let conn = Connection::Sql(pool);
let client = test_client();
let job = test_job();
let input = json!(["hello", "world", 42, true, null, {"key": "val"}]);
let result = transform_json_value("test", &client, "test", input.clone(), &job, &conn, 0)
.await
.unwrap();
assert_eq!(result, input);
}
#[tokio::test]
async fn test_transform_array_resolved_inside_object() {
let db_url = std::env::var("DATABASE_URL")
.unwrap_or("postgres://postgres:changeme@localhost:5432/windmill".to_string());
let pool = sqlx::PgPool::connect(&db_url).await.unwrap();
let conn = Connection::Sql(pool);
let client = test_client();
let job = test_job();
let input = json!({"urls": ["$var:u/test/nonexistent", "plain"]});
let result = transform_json_value("test", &client, "test", input, &job, &conn, 0).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_transform_array_attempts_matching_items() {
let db_url = std::env::var("DATABASE_URL")
.unwrap_or("postgres://postgres:changeme@localhost:5432/windmill".to_string());
let pool = sqlx::PgPool::connect(&db_url).await.unwrap();
let conn = Connection::Sql(pool);
let client = test_client();
let job = test_job();
let input = json!(["$var:u/test/nonexistent", "plain"]);
let result = transform_json_value("test", &client, "test", input, &job, &conn, 0).await;
assert!(result.is_err());
}
}
lazy_static::lazy_static! {
static ref TEMPLATE_RE: regex::Regex =
regex::Regex::new(r"\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}").unwrap();
}
/// Substitute `{{ arg_name }}` placeholders with values from `args`.
/// Strings are used raw; numbers/bools are stringified. Other types are rejected.
pub(crate) fn interpolate_template(
template: &str,
args: Option<&HashMap<String, Box<RawValue>>>,
field_name: &str,
) -> error::Result<String> {
let mut last_err: Option<error::Error> = None;
let result = TEMPLATE_RE.replace_all(template, |caps: &regex::Captures| {
let name = &caps[1];
let raw = args.and_then(|a| a.get(name));
let Some(raw) = raw else {
last_err = Some(error::Error::BadRequest(format!(
"`{}` references `{{{{ {} }}}}` but no such argument was provided",
field_name, name
)));
return String::new();
};
let json: serde_json::Value = match serde_json::from_str(raw.get()) {
Ok(v) => v,
Err(e) => {
last_err = Some(error::Error::BadRequest(format!(
"`{}` could not parse argument `{}` as JSON: {e}",
field_name, name
)));
return String::new();
}
};
match json {
serde_json::Value::String(s) => s,
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Null => {
last_err = Some(error::Error::BadRequest(format!(
"`{}` references `{{{{ {} }}}}` but the argument is null",
field_name, name
)));
String::new()
}
_ => {
last_err = Some(error::Error::BadRequest(format!(
"`{}` references `{{{{ {} }}}}` but the argument is not a primitive (string/number/bool)",
field_name, name
)));
String::new()
}
}
});
if let Some(e) = last_err {
return Err(e);
}
Ok(result.into_owned())
}
/// Reject absolute paths and `..` segments to prevent escaping the cloned repo directory.
pub(crate) fn validate_relative_path(path: &str, field_name: &str) -> error::Result<()> {
let trimmed = path.trim();
if trimmed.is_empty() {
return Err(error::Error::BadRequest(format!(
"`{}` resolved to an empty path",
field_name
)));
}
let p = std::path::Path::new(trimmed);
for component in p.components() {
match component {
// RootDir catches leading `/` or `\`; Prefix catches Windows drive
// letters and UNC paths. `Path::is_absolute()` alone misses
// RootDir-only paths on Windows (e.g. `/etc/passwd`).
std::path::Component::RootDir | std::path::Component::Prefix(_) => {
return Err(error::Error::BadRequest(format!(
"`{}` must be a relative path inside the cloned repo, got: {}",
field_name, trimmed
)));
}
std::path::Component::ParentDir => {
return Err(error::Error::BadRequest(format!(
"`{}` must not contain `..` segments, got: {}",
field_name, trimmed
)));
}
_ => {}
}
}
Ok(())
}