mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 16:02:23 +00:00
* feat(dbt): reach any dbt adapter through a dbt_profile resource, and constrain the warehouse picker
The workspace dbt warehouse picker listed every resource in the workspace, so a
slack or github resource was an offerable answer to a field that can only be a
warehouse. Constraining it exposed that the set of resource types that actually
work is both smaller than the docs claim and too small to be useful:
- `render_profile` translates only six adapters from a Windmill resource; the
rest (clickhouse, duckdb, salesforce, mssql, oracle) refused one outright.
- `redshift` and `duckdb` name no resource type anywhere, so two of the
adapters the quickstart advertises were unreachable.
- the `databricks` resource carries `workspace_url`, while the renderer demanded
`host`, so that warehouse could never render at all.
So the picker gets a constraint and dbt gets an escape hatch wide enough to make
it honest. `dbt_profile` is a resource whose value IS a `profiles.yml` target —
`{ type, target }` — passed to dbt unchanged, so any adapter and any key it
documents works.
`DbtAdapter` is now open: it carries dbt's own `type:` spelling plus an optional
`KnownAdapter` (the eleven Windmill has facts about — a field mapping, a pip
package, the license gate). Anything else is carried by name and installed as
`dbt-<name>`, the convention every adapter on PyPI follows, so "whatever dbt
supports" no longer means "whatever this enum lists". The license gate is
unaffected: `sqlserver`/`oracle` still resolve to their `KnownAdapter` and are
still gated. The name is confined to `[a-z0-9_-]` starting alphanumeric because
it reaches a pip requirement and a venv path on the host.
Two adjacent fixes fall out: the project's own `profiles.yml` and the
descriptor's `profile.type` now accept any adapter instead of the closed list,
and a databricks resource renders its `host` from `workspace_url`.
The picker is constrained to `dbt_profile` plus the translated types, so nothing
it offers can fail for want of a mapping.
Fixes WIN-2320
* fix: drop the unused DbtAdapter::from_resource_type wrapper
Nothing calls it: a Windmill resource type maps through
KnownAdapter::from_resource_type, and the executor resolves an adapter from
the resource's own dbt spelling or by inference. CI builds with -D warnings,
so the dead wrapper failed every backend check.
* fix(dbt): make dbt_profile the block itself, and address the review findings
**A `dbt_profile`'s value IS a `profiles.yml` output block**, `type` included.
It was `{ type, output }`, which asked the user to restructure their block
before pasting it — a translation step, in the one type that exists to avoid
translation. The schema now declares no properties, so the resource form renders
a single JSON editor over the value.
That means the value's shape can no longer say what it is: a `dbt_profile` and
Windmill's bigquery resource are both objects with a `type` (the latter says
`type: service_account`). So the warehouse carries its resource's type
(`DbtWarehouseConnection.resource_type`), and detection is exact. It also makes
decision 9's "the resource type name is the authority" true at runtime for the
translated path, which until now resolved its adapter by sniffing fields.
Review findings, all three reviewers:
- **[P0] an author-chosen adapter became an unsandboxed PyPI install.** `dbt-` is
not a reserved prefix, and `provision_core_1x` installs through `run_tool`,
outside the nsjail ordinary dependency installation uses — so `dbt-<name>` from
a script author's `type` could run a PEP 517 build backend as the worker. Now
gated on a list of published adapters plus `DBT_EXTRA_ADAPTERS`, so trust stays
the admin's call. The open set survives: the engines that ship their adapters
install nothing and take any type.
- **[P1] `type: fabric` rendered as `sqlserver`.** dbt's `type:` was resolved
through the resource-type table, where `fabric` is a Windmill alias for SQL
Server — so a Fabric profile installed dbt-sqlserver, was enterprise-gated, and
failed on an ODBC driver without ever naming Fabric. dbt types now have their
own table.
- **[P1] two spellings of one adapter compared unequal.** `PartialEq` covers the
carried name, so `postgres` != `postgresql` even resolving to one adapter, and
the descriptor/resource check rejected valid configs with a message naming the
same adapter twice. The name is normalised to the adapter's dbt spelling.
- **[P2] identity keys.** `database_key` is what a Windmill resource spells it,
and only translated adapters have one; the rest read dbt's `database`.
- **[P2] duplicate `sslrootcert`** when a block carried both a PEM and a path.
Verified with three real dbt builds: a flat `dbt_profile` postgres block, the
same with `type: postgresql` under a `profile.type: postgres` descriptor (the
alias case, which failed before), and trino for the unknown-adapter path.
* docs(dbt): say that installing an adapter is gated, not just using one
The open-adapter text promised every future adapter is installed as dbt-<name>,
which ensure_adapter_installable refuses outside PUBLISHED_ADAPTERS and
DBT_EXTRA_ADAPTERS. Separates the two: rendering, licensing and identity are open
to any adapter, and only the dbt-core 1.x PyPI install is gated, because that is
the step that runs outside the sandbox.
* fix(dbt): keep a dbt_profile's own sslrootcert when Windmill writes none
The previous round skipped the block's sslrootcert unconditionally to avoid
emitting the key twice, which drops a path-only CA reference — a certificate
baked into the image or mounted on the worker, which is the block's own trust
source. Skipped now only when a root_certificate_pem is present, which is when
Windmill writes a replacement.
* fix(frontend): let a resource type declare no properties
A schema without `properties` is a JSON-edited resource type, not a broken one -
`dbt_profile` is a profiles.yml block whose keys belong to its adapter, so there
is nothing for Windmill to declare. Both editors assumed properties exist:
- ResourceEditor threw on Object.keys(undefined) while deriving the field order,
which left the drawer on its loading skeleton forever, so the resource could
not be viewed or edited at all.
- ApiConnectForm caught the same throw and reported the type as missing from the
workspace, offering to sync a type it already had.
Both now fall back to the raw JSON editor, which is what usesRawEditor already
intended for a schema with no properties.
* chore: cut the new comments to AGENTS.md's four-line cap
Each still states its constraint once; the long-form rationale belongs in
docs/dbt-runtime.md and the PR, not beside the code.
* fix(dbt): keep a dbt_profile's empty and nested collections intact
A block with no children reads back as null, so `extensions: []` reached the
adapter as a missing value rather than the empty list dbt was handed, and a
nested array went through the scalar path and arrived as a quoted JSON string.
Both are keys dbt passes to the adapter as it finds them, so the type has to
survive: empty collections are emitted inline, and the value half of an entry
recurses instead of bottoming out at a scalar.
The test parses the rendered YAML back rather than string-matching it, since
what matters is what a YAML reader sees.
Also cuts DbtWarehouseConnection.resource_type's comment to the four-line cap.
825 lines
31 KiB
Rust
825 lines
31 KiB
Rust
//! Provisioning the three dbt engines on a worker.
|
|
//!
|
|
//! No engine ships in a Windmill image. `dbt-core-1x` and `dbt-core-2x` are
|
|
//! Apache 2.0 and an operator may pre-stage either (`DBT_BUNDLED_DIR`); the
|
|
//! Fusion engine is **never bundled**. Its license grants only a
|
|
//! non-transferable, non-sublicensable redistribution right and forbids
|
|
//! interposing on Provider-to-End-User communication, which is exactly what
|
|
//! shipping it inside a job runner would do. The mitigation is that the user's
|
|
//! own instance fetches it from dbt Labs on first use, so Windmill never
|
|
//! redistributes it (docs/dbt-runtime.md, decision 1).
|
|
//!
|
|
//! Everything lands in a worker-global cache keyed by engine and version, so
|
|
//! the fetch happens once per worker rather than once per job.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Stdio;
|
|
|
|
use tokio::process::Command;
|
|
use uuid::Uuid;
|
|
use windmill_common::error::{self, Error};
|
|
use windmill_common::worker::{write_file, Connection, ROOT_CACHE_NOMOUNT_DIR};
|
|
use windmill_parser_yaml::DbtEngine;
|
|
use windmill_queue::append_logs;
|
|
|
|
use crate::dbt_executor::digest;
|
|
use crate::dbt_profiles::DbtAdapter;
|
|
use crate::handle_child::{get_mem_peak, run_future_with_polling_update_job_poller, JobCtx};
|
|
|
|
lazy_static::lazy_static! {
|
|
pub static ref DBT_CACHE_DIR: String = format!("{}dbt", *ROOT_CACHE_NOMOUNT_DIR);
|
|
/// Adapters an operator vouches for beyond `PUBLISHED_ADAPTERS`, comma-separated — so a
|
|
/// brand-new adapter needs an admin's decision, not a Windmill release.
|
|
static ref DBT_EXTRA_ADAPTERS: Vec<String> = std::env::var("DBT_EXTRA_ADAPTERS")
|
|
.unwrap_or_default()
|
|
.split(',')
|
|
.map(|a| a.trim().to_ascii_lowercase())
|
|
.filter(|a| !a.is_empty())
|
|
.collect();
|
|
/// Where an operator may pre-stage an Apache-2.0 engine in a derived image.
|
|
/// A persistent image path, unlike the runtime caches, which are a fresh
|
|
/// volume at start — which is the whole reason it is a separate directory.
|
|
static ref DBT_BUNDLED_DIR: String =
|
|
std::env::var("DBT_BUNDLED_DIR").unwrap_or_else(|_| "/usr/local/dbt".to_string());
|
|
static ref UV_PATH: String =
|
|
std::env::var("UV_PATH").unwrap_or_else(|_| "/usr/local/bin/uv".to_string());
|
|
/// Bounds on the dbt-core the 1.x engine resolves. A RANGE, not a pin: the
|
|
/// adapter decides which core it can take, and several cap below the newest
|
|
/// (dbt-oracle and dbt-databricks below 1.12), so pinning core independently
|
|
/// makes those projects unprovisionable. The floor is the CLI this runtime
|
|
/// invokes -- 1.7 rejects `--target` on `parse`, so resolving down to it
|
|
/// produces a working venv that then fails on flags, which is worse than
|
|
/// not resolving at all.
|
|
///
|
|
/// Every static in this group is env-overridable per instance, so an
|
|
/// operator can move an engine without waiting on a release.
|
|
static ref DBT_CORE_1X_FLOOR: String =
|
|
std::env::var("DBT_CORE_1X_FLOOR").unwrap_or_else(|_| "1.8".to_string());
|
|
static ref DBT_CORE_1X_CEILING: String =
|
|
std::env::var("DBT_CORE_1X_CEILING").unwrap_or_else(|_| "2.0.0".to_string());
|
|
/// Reported as the engine version only when the installed `dist-info` cannot
|
|
/// be read. It pins no install and is not in the cache key -- the resolved
|
|
/// range is -- so it must not be mistaken for the version that gets used.
|
|
static ref DBT_CORE_1X_VERSION: String =
|
|
std::env::var("DBT_CORE_1X_VERSION").unwrap_or_else(|_| "1.12.0".to_string());
|
|
/// Pinned, not ranged: the Rust engine ships its adapters in the binary, so
|
|
/// there is no adapter resolution to accommodate.
|
|
static ref DBT_CORE_2X_VERSION: String =
|
|
std::env::var("DBT_CORE_2X_VERSION").unwrap_or_else(|_| "2.0.0-alpha.5".to_string());
|
|
static ref DBT_PYTHON_VERSION: String =
|
|
std::env::var("DBT_PYTHON_VERSION").unwrap_or_else(|_| "3.12".to_string());
|
|
/// Where the Fusion engine is fetched from. Never a Windmill-hosted mirror:
|
|
/// the point of runtime fetch is that the binary comes from dbt Labs.
|
|
static ref DBT_FUSION_INSTALL_URL: String = std::env::var("DBT_FUSION_INSTALL_URL")
|
|
.unwrap_or_else(|_| "https://public.cdn.getdbt.com/fs/install/install.sh".to_string());
|
|
}
|
|
|
|
pub struct ProvisionedEngine {
|
|
/// Absolute path of the dbt binary to invoke.
|
|
pub bin: PathBuf,
|
|
/// The engine's own directory — the ONLY part of the cache a sandboxed job
|
|
/// may see. Its siblings hold other workspaces' package trees, which are
|
|
/// scoped by cache key, not by permissions.
|
|
pub root: PathBuf,
|
|
pub version: String,
|
|
pub engine: DbtEngine,
|
|
/// The adapter version this venv resolved, for dbt-core 1.x where the
|
|
/// adapter is a separate package that versions independently of core.
|
|
/// `None` for the Rust engines, which ship their adapters in the binary.
|
|
pub adapter_version: Option<String>,
|
|
}
|
|
|
|
/// Ensure the engine is present on this worker and return how to invoke it.
|
|
pub async fn provision_engine(
|
|
engine: DbtEngine,
|
|
adapter: DbtAdapter,
|
|
// What makes the lockfile a lockfile: without it a script silently changes dbt
|
|
// version when the instance upgrades or lands on a different worker. `None` for
|
|
// a deploy, which is what writes the pin.
|
|
pinned_version: Option<&str>,
|
|
pinned_adapter_version: Option<&str>,
|
|
job_id: &Uuid,
|
|
w_id: &str,
|
|
conn: &Connection,
|
|
ctx: &mut JobCtx<'_>,
|
|
) -> error::Result<ProvisionedEngine> {
|
|
let pinned_version = checked_version(pinned_version, "engine_version")?;
|
|
let pinned_adapter_version = checked_version(pinned_adapter_version, "adapter_version")?;
|
|
tokio::fs::create_dir_all(&*DBT_CACHE_DIR).await.ok();
|
|
match engine {
|
|
DbtEngine::DbtCore1x => {
|
|
provision_core_1x(
|
|
adapter,
|
|
pinned_version,
|
|
pinned_adapter_version,
|
|
job_id,
|
|
w_id,
|
|
conn,
|
|
ctx,
|
|
)
|
|
.await
|
|
}
|
|
DbtEngine::DbtCore2x => provision_core_2x(pinned_version, job_id, w_id, conn, ctx).await,
|
|
DbtEngine::Fusion => provision_fusion(pinned_version, job_id, w_id, conn, ctx).await,
|
|
}
|
|
}
|
|
|
|
/// A version out of the lockfile, which is CALLER DATA: a preview run submits
|
|
/// its own `lock` alongside its content, so nothing about this string has been
|
|
/// through a deploy.
|
|
///
|
|
/// It is interpolated into the engine cache path and into a pip requirement,
|
|
/// and provisioning runs on the host rather than inside the dbt jail — a
|
|
/// `../..` in it would download an archive and extract it anywhere the worker
|
|
/// can write. Accepted only as a plain version token, which every version any
|
|
/// of the three engines publishes already is.
|
|
fn checked_version<'a>(v: Option<&'a str>, field: &str) -> error::Result<Option<&'a str>> {
|
|
if let Some(v) = v {
|
|
let plain = v.len() <= 64
|
|
&& v.starts_with(|c: char| c.is_ascii_alphanumeric())
|
|
&& v.chars()
|
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+'));
|
|
if !plain {
|
|
return Err(Error::BadRequest(format!(
|
|
"the lockfile's `{field}` is not a version: expected a token of letters, digits, \
|
|
`.`, `-`, `_` or `+` starting with a letter or digit, got `{v}`"
|
|
)));
|
|
}
|
|
}
|
|
Ok(v)
|
|
}
|
|
|
|
/// dbt adapters whose package this worker installs unasked. A PUBLISHED-PACKAGE list, not a
|
|
/// capability one: an adapter absent from it still renders a profile and still runs under an
|
|
/// engine that already carries it. See `ensure_adapter_installable`.
|
|
const PUBLISHED_ADAPTERS: &[&str] = &[
|
|
"athena", "clickhouse", "databricks", "decodable", "doris", "dremio", "duckdb", "exasol",
|
|
"extrica", "fabric", "fabricspark", "firebolt", "glue", "greenplum", "hive", "ibmdb2",
|
|
"impala", "materialize", "mysql", "oracle", "postgres", "redshift", "risingwave", "rockset",
|
|
"singlestore", "snowflake", "spark", "sqlite", "sqlserver", "starrocks", "synapse", "teradata",
|
|
"tidb", "trino", "vertica", "yellowbrick",
|
|
];
|
|
|
|
/// Refuse to install a package nobody vouched for. `dbt-<name>` comes from an adapter name a
|
|
/// SCRIPT AUTHOR chooses, `dbt-` is not a reserved PyPI prefix, and this install runs outside
|
|
/// the nsjail ordinary dependency installation uses — so an unbounded name would run a PEP
|
|
/// 517 backend as the worker. The admin decides what is trusted, via `DBT_EXTRA_ADAPTERS`.
|
|
fn ensure_adapter_installable(adapter: &DbtAdapter) -> error::Result<()> {
|
|
let name = adapter.name();
|
|
if adapter.known().is_some()
|
|
|| PUBLISHED_ADAPTERS.contains(&name)
|
|
|| DBT_EXTRA_ADAPTERS.iter().any(|a| a == name)
|
|
{
|
|
return Ok(());
|
|
}
|
|
Err(Error::BadRequest(format!(
|
|
"`{name}` is not an adapter this instance installs: the dbt-core 1.x engine would have \
|
|
to fetch `dbt-{name}` from PyPI, and `dbt-` is not a reserved name there. An admin adds \
|
|
it to DBT_EXTRA_ADAPTERS, or use an engine that ships its adapters (`engine: fusion`)"
|
|
)))
|
|
}
|
|
|
|
/// A uv venv per (dbt version, adapter): the adapter is a separate pip package
|
|
/// and installing every adapter into one venv would make their transitive
|
|
/// dependency sets fight.
|
|
async fn provision_core_1x(
|
|
adapter: DbtAdapter,
|
|
pinned_version: Option<&str>,
|
|
pinned_adapter_version: Option<&str>,
|
|
job_id: &Uuid,
|
|
w_id: &str,
|
|
conn: &Connection,
|
|
ctx: &mut JobCtx<'_>,
|
|
) -> error::Result<ProvisionedEngine> {
|
|
ensure_adapter_installable(&adapter)?;
|
|
if adapter.pip_package().is_empty() {
|
|
return Err(Error::BadRequest(format!(
|
|
"the {} adapter has no dbt-core 1.x package: it exists only inside the Fusion \
|
|
engine. Set `engine: fusion` to use it",
|
|
adapter.name()
|
|
)));
|
|
}
|
|
// A RANGE, not an `==`. A locked version still pins exactly, because that
|
|
// one was resolved for this adapter in the first place.
|
|
let version_spec = match pinned_version {
|
|
Some(v) => format!("dbt-core=={v}"),
|
|
None => format!(
|
|
"dbt-core>={},<{}",
|
|
DBT_CORE_1X_FLOOR.as_str(),
|
|
DBT_CORE_1X_CEILING.as_str()
|
|
),
|
|
};
|
|
let version = pinned_version
|
|
.map(str::to_string)
|
|
.unwrap_or_else(|| DBT_CORE_1X_VERSION.clone());
|
|
// The adapter is in the cache key: pinning core alone would let a rebuilt
|
|
// cache resolve a newer adapter than the deploy did, which changes runtime
|
|
// behavior under a lockfile that claims to prevent exactly that.
|
|
let adapter_spec = match pinned_adapter_version {
|
|
Some(v) => format!("{}=={v}", adapter.pip_package()),
|
|
None => adapter.pip_package().to_string(),
|
|
};
|
|
let dir = PathBuf::from(&*DBT_CACHE_DIR).join(format!(
|
|
"core1x-{}-{}",
|
|
digest(&version_spec),
|
|
digest(&adapter_spec)
|
|
));
|
|
let bin = dir.join("bin").join("dbt");
|
|
if bin.exists() {
|
|
let adapter_version = installed_adapter_version(&dir, adapter).await;
|
|
return Ok(ProvisionedEngine {
|
|
root: dir.clone(),
|
|
bin,
|
|
// What the resolver actually chose, so the lock pins the version
|
|
// this adapter can take rather than the one we asked for.
|
|
version: installed_package_version(&dir, "dbt_core")
|
|
.await
|
|
.unwrap_or(version),
|
|
engine: DbtEngine::DbtCore1x,
|
|
adapter_version,
|
|
});
|
|
}
|
|
|
|
append_logs(
|
|
job_id,
|
|
w_id,
|
|
format!(
|
|
"\nProvisioning {version_spec} with {}...\n",
|
|
adapter.pip_package()
|
|
),
|
|
conn,
|
|
)
|
|
.await;
|
|
// Build beside the target and rename, so two jobs racing on one worker cannot
|
|
// observe a half-installed venv through `bin.exists()`. `--relocatable` makes
|
|
// the rename safe: without it uv bakes the staging path into every shebang and
|
|
// the moved venv's `dbt` fails with ENOENT.
|
|
let staging_guard = Scratch::new(staging_path(&dir, job_id));
|
|
let staging = staging_guard.path().to_path_buf();
|
|
tokio::fs::remove_dir_all(&staging).await.ok();
|
|
run_tool(
|
|
Command::new(UV_PATH.as_str())
|
|
// The interpreter goes in Windmill's own cache rather than the
|
|
// worker user's home: a sandboxed job can only see the paths that
|
|
// are mounted into it, and this is one of them.
|
|
.env("UV_PYTHON_INSTALL_DIR", crate::PY_INSTALL_DIR.as_str())
|
|
.args([
|
|
"venv",
|
|
"--relocatable",
|
|
"--python",
|
|
DBT_PYTHON_VERSION.as_str(),
|
|
])
|
|
.arg(&staging),
|
|
"uv venv",
|
|
job_id,
|
|
w_id,
|
|
conn,
|
|
ctx,
|
|
)
|
|
.await?;
|
|
run_tool(
|
|
Command::new(UV_PATH.as_str())
|
|
.env("UV_PYTHON_INSTALL_DIR", crate::PY_INSTALL_DIR.as_str())
|
|
.env("VIRTUAL_ENV", &staging)
|
|
.args(["pip", "install", &version_spec, &adapter_spec]),
|
|
"uv pip install",
|
|
job_id,
|
|
w_id,
|
|
conn,
|
|
ctx,
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
// The common cause is an adapter with no release for this dbt-core
|
|
// range: dbt-mysql, for one, has not shipped past `~=1.7`. uv reports
|
|
// that as a resolver dump, which does not say what to do about it.
|
|
Error::ExecutionErr(format!(
|
|
"{e}\n\ninstalling the {} adapter: it must have a release compatible with \
|
|
{version_spec}, which is the dbt-core CLI this engine invokes. If the adapter has \
|
|
not kept up, use `engine: dbt-core-2x` or `engine: fusion`",
|
|
adapter.name()
|
|
))
|
|
})?;
|
|
match tokio::fs::rename(&staging, &dir).await {
|
|
Ok(()) => staging_guard.keep(),
|
|
// Lost the race: the winner's venv is equivalent, so use it. The guard
|
|
// removes ours.
|
|
Err(_) if bin.exists() => {}
|
|
Err(e) => return Err(Error::internal_err(format!("installing dbt-core: {e}"))),
|
|
}
|
|
let adapter_version = installed_adapter_version(&dir, adapter).await;
|
|
let version = installed_package_version(&dir, "dbt_core")
|
|
.await
|
|
.unwrap_or(version);
|
|
Ok(
|
|
ProvisionedEngine {
|
|
root: dir,
|
|
bin,
|
|
version,
|
|
engine: DbtEngine::DbtCore1x,
|
|
adapter_version,
|
|
},
|
|
)
|
|
}
|
|
|
|
/// The adapter version a venv actually resolved, read from its dist-info so a
|
|
/// deploy can lock it and later runs can ask for the same one.
|
|
async fn installed_adapter_version(dir: &Path, adapter: DbtAdapter) -> Option<String> {
|
|
installed_package_version(dir, &adapter.pip_package().replace('-', "_")).await
|
|
}
|
|
|
|
/// The version of an installed distribution, read from its `.dist-info`.
|
|
async fn installed_package_version(dir: &Path, dist: &str) -> Option<String> {
|
|
let prefix = format!("{dist}-");
|
|
let mut entries = tokio::fs::read_dir(dir.join("lib")).await.ok()?;
|
|
while let Ok(Some(py)) = entries.next_entry().await {
|
|
let mut pkgs = tokio::fs::read_dir(py.path().join("site-packages"))
|
|
.await
|
|
.ok()?;
|
|
while let Ok(Some(e)) = pkgs.next_entry().await {
|
|
let name = e.file_name().to_string_lossy().to_string();
|
|
if let Some(rest) = name.strip_prefix(&prefix) {
|
|
if let Some(v) = rest.strip_suffix(".dist-info") {
|
|
return Some(v.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
async fn provision_core_2x(
|
|
pinned_version: Option<&str>,
|
|
job_id: &Uuid,
|
|
w_id: &str,
|
|
conn: &Connection,
|
|
ctx: &mut JobCtx<'_>,
|
|
) -> error::Result<ProvisionedEngine> {
|
|
let version = pinned_version
|
|
.map(str::to_string)
|
|
.unwrap_or_else(|| DBT_CORE_2X_VERSION.clone());
|
|
// No Windmill image ships an engine, so this is the operator's own
|
|
// pre-stage: `DBT_BUNDLED_DIR` populated in a derived image, for an
|
|
// air-gapped instance or a fleet that should not fetch per worker. Checked
|
|
// before the cache because it is read-only and shared, where the cache is a
|
|
// per-worker volume.
|
|
let bundled = PathBuf::from(&*DBT_BUNDLED_DIR)
|
|
.join(format!("core2x-{version}"))
|
|
.join("dbt-sa-cli");
|
|
if bundled.exists() {
|
|
return Ok(ProvisionedEngine {
|
|
root: bundled.parent().map(Path::to_path_buf).unwrap_or_default(),
|
|
bin: bundled,
|
|
version,
|
|
engine: DbtEngine::DbtCore2x,
|
|
adapter_version: None,
|
|
});
|
|
}
|
|
let dir = PathBuf::from(&*DBT_CACHE_DIR).join(format!("core2x-{version}"));
|
|
let bin = dir.join("dbt-sa-cli");
|
|
if bin.exists() {
|
|
return Ok(ProvisionedEngine {
|
|
root: dir.clone(),
|
|
bin,
|
|
version,
|
|
engine: DbtEngine::DbtCore2x,
|
|
adapter_version: None,
|
|
});
|
|
}
|
|
let target = format!("{}-unknown-linux-gnu", std::env::consts::ARCH);
|
|
let url = format!(
|
|
"https://github.com/dbt-labs/dbt-core/releases/download/v{version}/dbt-core-{version}-{target}.tar.gz"
|
|
);
|
|
append_logs(
|
|
job_id,
|
|
w_id,
|
|
format!("\nFetching dbt-core {version}...\n"),
|
|
conn,
|
|
)
|
|
.await;
|
|
fetch_and_extract(&url, &dir, "dbt-sa-cli", job_id, w_id, conn, ctx).await?;
|
|
Ok(ProvisionedEngine {
|
|
root: dir,
|
|
bin,
|
|
version,
|
|
engine: DbtEngine::DbtCore2x,
|
|
adapter_version: None,
|
|
})
|
|
}
|
|
|
|
/// Fusion is fetched from dbt Labs at runtime and cached — see the module docs
|
|
/// for why it must never be baked into an image.
|
|
async fn provision_fusion(
|
|
pinned_version: Option<&str>,
|
|
job_id: &Uuid,
|
|
w_id: &str,
|
|
conn: &Connection,
|
|
ctx: &mut JobCtx<'_>,
|
|
) -> error::Result<ProvisionedEngine> {
|
|
// Version-keyed like the other two: one shared `fusion` directory means a
|
|
// run landing on a clean worker fetches whatever is current rather than
|
|
// what the deploy locked.
|
|
let dir = PathBuf::from(&*DBT_CACHE_DIR).join(match pinned_version {
|
|
Some(v) => format!("fusion-{v}"),
|
|
None => "fusion".to_string(),
|
|
});
|
|
// The installer places the binary directly in the directory `--to` names.
|
|
let bin = dir.join("dbt");
|
|
if bin.exists() {
|
|
return Ok(ProvisionedEngine {
|
|
root: dir.clone(),
|
|
bin,
|
|
version: fusion_version(&dir).await,
|
|
engine: DbtEngine::Fusion,
|
|
adapter_version: None,
|
|
});
|
|
}
|
|
append_logs(
|
|
job_id,
|
|
w_id,
|
|
"\nFetching the dbt Fusion engine from dbt Labs (not bundled with Windmill; \
|
|
subject to the dbt Fusion engine license agreement)...\n"
|
|
.to_string(),
|
|
conn,
|
|
)
|
|
.await;
|
|
let script = fetch_under_job(
|
|
"the Fusion installer",
|
|
async {
|
|
let net =
|
|
|e: reqwest::Error| Error::internal_err(format!("fetching the installer: {e}"));
|
|
Ok(windmill_common::utils::HTTP_CLIENT
|
|
.get(&*DBT_FUSION_INSTALL_URL)
|
|
.send()
|
|
.await
|
|
.and_then(|r| r.error_for_status())
|
|
.map_err(net)?
|
|
.text()
|
|
.await
|
|
.map_err(net)?)
|
|
},
|
|
job_id,
|
|
w_id,
|
|
conn,
|
|
ctx,
|
|
)
|
|
.await?;
|
|
// Install into a per-job sibling and rename, like the other two engines:
|
|
// pointing the installer straight at the shared cache lets a second job
|
|
// observe `bin/dbt` and execute it while the first is still writing.
|
|
let staging_guard = Scratch::new(staging_path(&dir, job_id));
|
|
let staging = staging_guard.path().to_path_buf();
|
|
tokio::fs::remove_dir_all(&staging).await.ok();
|
|
let script_guard =
|
|
Scratch::new(std::env::temp_dir().join(format!("wm-fusion-install-{job_id}.sh")));
|
|
let tmp = script_guard.path().to_path_buf();
|
|
write_file(
|
|
tmp.parent().unwrap().to_str().unwrap(),
|
|
tmp.file_name().unwrap().to_str().unwrap(),
|
|
&script,
|
|
)?;
|
|
// `--to` and `--version` are the installer's own flags (`install.sh
|
|
// --help`); it takes no positional arguments and ignores unknown ones, so
|
|
// an approximation here fails by silently installing the latest release
|
|
// into the user's $HOME instead of the cache.
|
|
let mut install = Command::new("sh");
|
|
install.arg(&tmp).arg("--to").arg(&staging);
|
|
if let Some(v) = pinned_version {
|
|
install.args(["--version", v]);
|
|
} else {
|
|
install.arg("--update");
|
|
}
|
|
run_tool(&mut install, "fusion install", job_id, w_id, conn, ctx).await?;
|
|
drop(script_guard);
|
|
if !staging.join("dbt").exists() {
|
|
return Err(Error::internal_err(
|
|
"the Fusion installer did not produce a dbt binary".to_string(),
|
|
));
|
|
}
|
|
if tokio::fs::rename(&staging, &dir).await.is_ok() {
|
|
staging_guard.keep();
|
|
} else {
|
|
if !bin.exists() {
|
|
return Err(Error::internal_err(
|
|
"could not install the Fusion engine".to_string(),
|
|
));
|
|
}
|
|
}
|
|
Ok(ProvisionedEngine {
|
|
root: dir.clone(),
|
|
bin,
|
|
version: fusion_version(&dir).await,
|
|
engine: DbtEngine::Fusion,
|
|
adapter_version: None,
|
|
})
|
|
}
|
|
|
|
/// From the binary itself: the installer writes no version file, and a pinned
|
|
/// directory name would only echo back what was asked for rather than what was
|
|
/// installed.
|
|
async fn fusion_version(dir: &Path) -> String {
|
|
Command::new(dir.join("dbt"))
|
|
.arg("--version")
|
|
.output()
|
|
.await
|
|
.ok()
|
|
.filter(|o| o.status.success())
|
|
.and_then(|o| {
|
|
String::from_utf8_lossy(&o.stdout)
|
|
.split_whitespace()
|
|
.nth(1)
|
|
.map(|v| v.to_string())
|
|
})
|
|
.unwrap_or_else(|| "unknown".to_string())
|
|
}
|
|
|
|
/// A sibling of `dir` to build in before renaming into place. Appended to the
|
|
/// whole file name rather than via `with_extension`, which would eat everything
|
|
/// after the version's last dot.
|
|
/// A per-job path removed on drop unless the install claimed it.
|
|
///
|
|
/// Provisioning is a sequence of fallible awaits — a download, an installer, an
|
|
/// extraction — and each one can also be cancelled. Cleaning up after the `?`
|
|
/// only covers the paths someone remembered, so a run of failed or cancelled
|
|
/// first-use installs accumulates venvs and tarballs until the worker's disk is
|
|
/// gone. Dropping is the one exit every path takes.
|
|
struct Scratch(Option<PathBuf>);
|
|
|
|
impl Scratch {
|
|
fn new(path: PathBuf) -> Self {
|
|
Scratch(Some(path))
|
|
}
|
|
|
|
fn path(&self) -> &Path {
|
|
self.0.as_deref().unwrap_or(Path::new(""))
|
|
}
|
|
|
|
/// The install succeeded and moved it: stop owning it.
|
|
fn keep(mut self) {
|
|
self.0 = None;
|
|
}
|
|
}
|
|
|
|
impl Drop for Scratch {
|
|
fn drop(&mut self) {
|
|
let Some(p) = self.0.take() else {
|
|
return;
|
|
};
|
|
let remove = move || {
|
|
let _ = std::fs::remove_dir_all(&p);
|
|
let _ = std::fs::remove_file(&p);
|
|
};
|
|
// Off the runtime thread: this removes thousands of files and `Drop` runs
|
|
// inside the job future, so doing it synchronously stalls every other job
|
|
// on that thread. Detached because a `Drop` cannot await.
|
|
match tokio::runtime::Handle::try_current() {
|
|
Ok(handle) => {
|
|
handle.spawn_blocking(remove);
|
|
}
|
|
// No runtime to hand it to (a test, or shutdown): do it here rather
|
|
// than leave the directory behind.
|
|
Err(_) => remove(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn staging_path(dir: &Path, job_id: &Uuid) -> PathBuf {
|
|
let name = dir.file_name().unwrap_or_default().to_string_lossy();
|
|
dir.with_file_name(format!("{name}.staging-{job_id}"))
|
|
}
|
|
|
|
/// Fetch under the job's cancellation and timeout. The shared `HTTP_CLIENT`
|
|
/// sets no request timeout, so a stalled release download would otherwise hold
|
|
/// the worker for as long as the connection stays open.
|
|
async fn fetch_under_job<T, F>(
|
|
what: &str,
|
|
fetch: F,
|
|
job_id: &Uuid,
|
|
w_id: &str,
|
|
conn: &Connection,
|
|
ctx: &mut JobCtx<'_>,
|
|
) -> error::Result<T>
|
|
where
|
|
F: std::future::Future<Output = error::Result<T>>,
|
|
{
|
|
run_future_with_polling_update_job_poller(
|
|
*job_id,
|
|
ctx.timeout(),
|
|
conn,
|
|
ctx.mem_peak,
|
|
ctx.canceled_by,
|
|
async move {
|
|
fetch
|
|
.await
|
|
.map_err(|e| Error::internal_err(format!("fetching {what}: {e}")))
|
|
},
|
|
ctx.worker_name,
|
|
w_id,
|
|
&mut Some(ctx.occupancy_metrics),
|
|
Box::pin(futures::stream::once(async { 0 })),
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn fetch_and_extract(
|
|
url: &str,
|
|
dir: &Path,
|
|
expected_bin: &str,
|
|
job_id: &Uuid,
|
|
w_id: &str,
|
|
conn: &Connection,
|
|
ctx: &mut JobCtx<'_>,
|
|
) -> error::Result<()> {
|
|
let tarball_guard = Scratch::new(std::env::temp_dir().join(format!("wm-dbt-{job_id}.tar.gz")));
|
|
let tarball = tarball_guard.path().to_path_buf();
|
|
// STREAMED to disk, never held whole: an engine archive is ~290 MB, this
|
|
// runs in the shared worker process rather than the job's subprocess, and
|
|
// several cold jobs provision at once — buffering would multiply that until
|
|
// the worker dies, taking every job on it along.
|
|
fetch_under_job(
|
|
url,
|
|
async {
|
|
use futures::StreamExt;
|
|
use tokio::io::AsyncWriteExt;
|
|
let net = |e: reqwest::Error| Error::internal_err(format!("fetching {url}: {e}"));
|
|
let mut resp = windmill_common::utils::HTTP_CLIENT
|
|
.get(url)
|
|
.send()
|
|
.await
|
|
.and_then(|r| r.error_for_status())
|
|
.map_err(net)?
|
|
.bytes_stream();
|
|
let mut file = tokio::fs::File::create(&tarball)
|
|
.await
|
|
.map_err(|e| Error::internal_err(format!("writing {url}: {e}")))?;
|
|
while let Some(chunk) = resp.next().await {
|
|
let chunk = chunk.map_err(net)?;
|
|
file.write_all(&chunk)
|
|
.await
|
|
.map_err(|e| Error::internal_err(format!("writing {url}: {e}")))?;
|
|
}
|
|
file.flush()
|
|
.await
|
|
.map_err(|e| Error::internal_err(format!("writing {url}: {e}")))?;
|
|
Ok(())
|
|
},
|
|
job_id,
|
|
w_id,
|
|
conn,
|
|
ctx,
|
|
)
|
|
.await?;
|
|
let staging_guard = Scratch::new(staging_path(dir, job_id));
|
|
let staging = staging_guard.path().to_path_buf();
|
|
tokio::fs::create_dir_all(&staging)
|
|
.await
|
|
.map_err(|e| Error::internal_err(format!("creating {staging:?}: {e}")))?;
|
|
run_tool(
|
|
Command::new("tar")
|
|
.arg("xzf")
|
|
.arg(&tarball)
|
|
.arg("-C")
|
|
.arg(&staging)
|
|
// The release tarballs wrap the binary in a versioned directory.
|
|
.args(["--strip-components", "1"]),
|
|
"tar",
|
|
job_id,
|
|
w_id,
|
|
conn,
|
|
ctx,
|
|
)
|
|
.await?;
|
|
drop(tarball_guard);
|
|
if !staging.join(expected_bin).exists() {
|
|
return Err(Error::internal_err(format!(
|
|
"{url} did not contain the expected `{expected_bin}` binary"
|
|
)));
|
|
}
|
|
if tokio::fs::rename(&staging, dir).await.is_ok() {
|
|
staging_guard.keep();
|
|
} else {
|
|
if !dir.join(expected_bin).exists() {
|
|
return Err(Error::internal_err(format!("could not install {url}")));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Run a provisioning command to completion. Provisioning happens inside the
|
|
/// job that needs the engine, so it runs under that job's cancellation and
|
|
/// timeout: a cold `uv pip install` or Fusion download is the longest thing a
|
|
/// dbt job does, and a cancel that could not reach it would hold the worker
|
|
/// slot for the rest of the install. Its output is not streamed to the job log
|
|
/// — these are worker-level setup steps with no node to attribute progress to,
|
|
/// and failures surface with the tool's own stderr.
|
|
async fn run_tool(
|
|
cmd: &mut Command,
|
|
name: &str,
|
|
job_id: &Uuid,
|
|
w_id: &str,
|
|
conn: &Connection,
|
|
ctx: &mut JobCtx<'_>,
|
|
) -> error::Result<()> {
|
|
let child = cmd
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
// The wait future owns the child, so cancellation dropping that future
|
|
// is what terminates the install — dropping it alone would not.
|
|
.kill_on_drop(true)
|
|
.spawn()
|
|
.map_err(|e| Error::internal_err(format!("{name} could not be started: {e}")))?;
|
|
let pid = child.id();
|
|
let out = run_future_with_polling_update_job_poller(
|
|
*job_id,
|
|
ctx.timeout(),
|
|
conn,
|
|
ctx.mem_peak,
|
|
ctx.canceled_by,
|
|
async move {
|
|
child
|
|
.wait_with_output()
|
|
.await
|
|
.map_err(|e| Error::internal_err(format!("{name} failed: {e}")))
|
|
},
|
|
ctx.worker_name,
|
|
w_id,
|
|
&mut Some(ctx.occupancy_metrics),
|
|
Box::pin(futures::stream::unfold((), move |_| async move {
|
|
Some((get_mem_peak(pid, false).await, ()))
|
|
})),
|
|
)
|
|
.await?;
|
|
if !out.status.success() {
|
|
return Err(Error::internal_err(format!(
|
|
"{name} failed: {}",
|
|
String::from_utf8_lossy(&out.stderr)
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod core1x_tests {
|
|
use super::*;
|
|
use crate::dbt_profiles::KnownAdapter;
|
|
|
|
// Several adapters cap dbt-core below what this runtime would ask for
|
|
// (dbt-mysql ~=1.7, dbt-oracle and dbt-databricks below 1.12) and
|
|
// dbt-salesforce has no 1.x package at all, so pinning core independently made
|
|
// those projects fail at provisioning. The install names a ceiling instead.
|
|
#[test]
|
|
fn every_adapter_either_names_a_package_or_is_fusion_only() {
|
|
for a in KnownAdapter::ALL {
|
|
if matches!(a, KnownAdapter::Salesforce) {
|
|
continue;
|
|
}
|
|
assert!(
|
|
!a.pip_package().is_empty(),
|
|
"{} must name a pip package for dbt-core 1.x",
|
|
a.name()
|
|
);
|
|
}
|
|
// Fusion has it built in, and there is no package to install.
|
|
assert!(KnownAdapter::Salesforce.pip_package().is_empty());
|
|
assert_eq!(KnownAdapter::Salesforce.name(), "salesforce");
|
|
}
|
|
|
|
// `dbt-` is not a reserved prefix on PyPI and this install is not sandboxed,
|
|
// so the name a script author picks decides which package runs its build
|
|
// backend as the worker.
|
|
#[test]
|
|
fn an_unvouched_adapter_is_not_installed() {
|
|
let known = DbtAdapter::from_dbt_type("postgres").unwrap();
|
|
assert!(ensure_adapter_installable(&known).is_ok());
|
|
let published = DbtAdapter::from_dbt_type("trino").unwrap();
|
|
assert!(ensure_adapter_installable(&published).is_ok());
|
|
let squatted = DbtAdapter::from_dbt_type("totally-legit-adapter").unwrap();
|
|
assert!(ensure_adapter_installable(&squatted).is_err());
|
|
}
|
|
|
|
/// A preview submits its own lockfile, so this string reaches a path join
|
|
/// and a pip requirement straight from the caller, on the host and outside
|
|
/// the jail.
|
|
#[test]
|
|
fn a_lockfile_version_cannot_leave_the_cache_directory() {
|
|
for v in ["1.12.0", "2.0.0-alpha.5", "2.0.0-preview.202"] {
|
|
assert_eq!(checked_version(Some(v), "engine_version").unwrap(), Some(v));
|
|
}
|
|
for v in [
|
|
"../../../../etc",
|
|
"1.0/../..",
|
|
"..",
|
|
".ssh",
|
|
"-rf",
|
|
"1.0 --index-url=http://x",
|
|
"1.0\n",
|
|
] {
|
|
assert!(
|
|
checked_version(Some(v), "engine_version").is_err(),
|
|
"`{v}` must be refused before it reaches a path"
|
|
);
|
|
}
|
|
assert!(checked_version(None, "engine_version").unwrap().is_none());
|
|
}
|
|
}
|