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.
228 lines
9.2 KiB
Rust
228 lines
9.2 KiB
Rust
use axum::{
|
|
extract::Path,
|
|
routing::{get, post},
|
|
Extension, Json, Router,
|
|
};
|
|
use windmill_common::{
|
|
db::UserDB,
|
|
error::{Error, Result},
|
|
workspaces::{dbt_warehouse_resource, DbtWarehouseConnection},
|
|
DB,
|
|
};
|
|
|
|
use crate::db::{ApiAuthed, OptJobAuthed};
|
|
use windmill_api_auth::{is_no_auth, Tokened};
|
|
|
|
pub fn workspaced_service() -> Router {
|
|
Router::new()
|
|
.route("/warehouse/{name}", get(get_warehouse))
|
|
.route("/warehouse_exists/{name}", get(warehouse_exists))
|
|
.route("/run_progress", post(record_run_progress))
|
|
}
|
|
|
|
async fn get_warehouse(
|
|
OptJobAuthed { job_id, authed }: OptJobAuthed,
|
|
Tokened { token }: Tokened,
|
|
Extension(db): Extension<DB>,
|
|
Extension(_user_db): Extension<UserDB>,
|
|
Path((w_id, name)): Path<(String, String)>,
|
|
) -> Result<Json<DbtWarehouseConnection>> {
|
|
// Scoped to a running DBT job, and the reason it must stay that way: the
|
|
// response carries the warehouse's credentials. A dbt job already holds them
|
|
// in its rendered `profiles.yml`, so serving them changes nothing for it —
|
|
// but every script job's token carries a job id too, and any other language
|
|
// asking for them would be reading a credential it was never given.
|
|
// In no-auth mode every request is the synthetic superadmin and carries no
|
|
// job, so the scoping below has nothing to check. Refusing there would make
|
|
// dbt unusable on an instance that has deliberately turned auth off, and
|
|
// there is no credential boundary left to protect.
|
|
let Some(job_id) = job_id else {
|
|
if is_no_auth() {
|
|
// Validated here too, so all three paths agree on what a name may be
|
|
// rather than one of them accepting whatever the URL carried.
|
|
windmill_common::workspaces::validate_dbt_warehouse_name(&name)?;
|
|
let (resource_path, target) = dbt_warehouse_resource(&db, &w_id, &name).await?;
|
|
let value = windmill_store::resources::get_resource_value_interpolated_internal(
|
|
&windmill_common::db::DbWithOptAuthed::<ApiAuthed>::from_authed(
|
|
&authed,
|
|
db.clone(),
|
|
None,
|
|
),
|
|
&w_id,
|
|
&resource_path,
|
|
None,
|
|
Some(&token),
|
|
false,
|
|
)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
Error::NotFound(format!(
|
|
"the dbt warehouse `{name}` points at `{resource_path}`, which does not exist"
|
|
))
|
|
})?;
|
|
let resource_type = warehouse_resource_type(&db, &w_id, &resource_path).await?;
|
|
return Ok(Json(DbtWarehouseConnection { value, target, resource_type }));
|
|
}
|
|
return Err(Error::BadRequest(
|
|
"this route resolves a dbt warehouse for a running job and needs a job token"
|
|
.to_string(),
|
|
));
|
|
};
|
|
let is_dbt = sqlx::query_scalar!(
|
|
"SELECT script_lang = 'dbt' FROM v2_job WHERE id = $1 AND workspace_id = $2",
|
|
job_id,
|
|
&w_id
|
|
)
|
|
.fetch_optional(&db)
|
|
.await?
|
|
.flatten()
|
|
.unwrap_or(false);
|
|
if !is_dbt {
|
|
return Err(Error::NotAuthorized(
|
|
"only a dbt job may resolve a dbt warehouse".to_string(),
|
|
));
|
|
}
|
|
windmill_common::workspaces::validate_dbt_warehouse_name(&name)?;
|
|
let (resource_path, target) = dbt_warehouse_resource(&db, &w_id, &name).await?;
|
|
// Interpolated AGAINST THE JOB, so a warehouse whose resource carries
|
|
// `$WM_TOKEN` or another `$WM_*` renders what the job would see rather than
|
|
// the literal placeholder. Unchecked (no `user_db`) because dbt warehouses
|
|
// are unpermissioned by design; uncached because a job-context value must
|
|
// not be served to the next job.
|
|
let value = windmill_store::resources::get_resource_value_interpolated_internal(
|
|
&windmill_common::db::DbWithOptAuthed::<ApiAuthed>::from_authed(&authed, db.clone(), None),
|
|
&w_id,
|
|
&resource_path,
|
|
Some(job_id),
|
|
Some(&token),
|
|
false,
|
|
)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
Error::NotFound(format!(
|
|
"the dbt warehouse `{name}` points at `{resource_path}`, which does not exist"
|
|
))
|
|
})?;
|
|
let resource_type = warehouse_resource_type(&db, &w_id, &resource_path).await?;
|
|
Ok(Json(DbtWarehouseConnection { value, target, resource_type }))
|
|
}
|
|
|
|
/// A warehouse resource's type, which decides whether its value is translated
|
|
/// into a `profiles.yml` target or taken as one. Read separately from the value
|
|
/// because the interpolating loader returns the value alone.
|
|
async fn warehouse_resource_type(db: &DB, w_id: &str, path: &str) -> Result<String> {
|
|
sqlx::query_scalar!(
|
|
"SELECT resource_type FROM resource WHERE workspace_id = $1 AND path = $2",
|
|
w_id,
|
|
path
|
|
)
|
|
.fetch_optional(db)
|
|
.await?
|
|
.ok_or_else(|| Error::NotFound(format!("the dbt warehouse points at `{path}`, which does not exist")))
|
|
}
|
|
|
|
/// A settled node's state, for a worker that cannot write the database.
|
|
///
|
|
/// The job is taken from the TOKEN, never the body: a job may report its own
|
|
/// progress and no one else's.
|
|
///
|
|
/// NO no-auth branch, unlike its two siblings: they need only the warehouse
|
|
/// NAME, which the URL carries, while this needs the job — and the job lives in
|
|
/// the token's JWT claims, which the no-auth path never decodes. So on a
|
|
/// no-auth instance an agent worker's per-model rows do not appear. The run is
|
|
/// unaffected: the worker treats a failed post as a display problem and builds
|
|
/// the models regardless. Taking the job from the body instead would hand any
|
|
/// caller another job's run page.
|
|
async fn record_run_progress(
|
|
OptJobAuthed { job_id, .. }: OptJobAuthed,
|
|
Extension(db): Extension<DB>,
|
|
Path(w_id): Path<String>,
|
|
Json(rows): Json<Vec<windmill_common::dbt_manifest::DbtRunProgressRequest>>,
|
|
) -> Result<()> {
|
|
let Some(job_id) = job_id else {
|
|
return Err(Error::BadRequest(
|
|
"this route records a running job's dbt progress and needs a job token".to_string(),
|
|
));
|
|
};
|
|
// A DBT job's, like its sibling above. Writing nothing secret, but a row
|
|
// keyed to another language's job is a run-page entry for a run that has no
|
|
// models, and the two routes disagreeing on who may call them is how one of
|
|
// them ends up wrong later.
|
|
let is_dbt = sqlx::query_scalar!(
|
|
"SELECT script_lang = 'dbt' FROM v2_job WHERE id = $1 AND workspace_id = $2",
|
|
job_id,
|
|
&w_id
|
|
)
|
|
.fetch_optional(&db)
|
|
.await?
|
|
.flatten()
|
|
.unwrap_or(false);
|
|
if !is_dbt {
|
|
return Err(Error::NotAuthorized(
|
|
"only a dbt job may record dbt run progress".to_string(),
|
|
));
|
|
}
|
|
// The agent's own sweep: these rows have no job foreign key, and the
|
|
// worker-side prune runs only where the pool is reachable, so an
|
|
// agent-only workspace would accumulate one row per model forever.
|
|
windmill_common::dbt_manifest::prune_run_progress(&db, &w_id).await;
|
|
// A run's nodes arrive together; the writes are local to this server, so the
|
|
// loop that would have been a round trip each is a statement each.
|
|
for req in &rows {
|
|
windmill_common::dbt_manifest::record_run_progress(
|
|
&db,
|
|
&w_id,
|
|
&job_id,
|
|
&req.asset_path,
|
|
req.status,
|
|
req.row_count,
|
|
req.error.as_deref(),
|
|
)
|
|
.await;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Whether the workspace configures this warehouse. NOTHING is resolved.
|
|
///
|
|
/// A project that brings its own `profiles.yml` names a warehouse to say where
|
|
/// its assets belong and never opens it, so the answer it needs is a yes/no —
|
|
/// decrypting a connection for that would hand out a credential the run has no
|
|
/// use for.
|
|
async fn warehouse_exists(
|
|
OptJobAuthed { job_id, .. }: OptJobAuthed,
|
|
Extension(db): Extension<DB>,
|
|
Path((w_id, name)): Path<(String, String)>,
|
|
) -> Result<()> {
|
|
// A DBT job's token, like both siblings. No-auth mode carries no job at all,
|
|
// and there the whole instance is unauthenticated.
|
|
if !is_no_auth() {
|
|
let Some(job_id) = job_id else {
|
|
return Err(Error::BadRequest(
|
|
"this route answers for a running job and needs a job token".to_string(),
|
|
));
|
|
};
|
|
let is_dbt = sqlx::query_scalar!(
|
|
"SELECT script_lang = 'dbt' FROM v2_job WHERE id = $1 AND workspace_id = $2",
|
|
job_id,
|
|
&w_id
|
|
)
|
|
.fetch_optional(&db)
|
|
.await?
|
|
.flatten()
|
|
.unwrap_or(false);
|
|
if !is_dbt {
|
|
return Err(Error::NotAuthorized(
|
|
"only a dbt job may ask about a dbt warehouse".to_string(),
|
|
));
|
|
}
|
|
}
|
|
windmill_common::workspaces::validate_dbt_warehouse_name(&name)?;
|
|
// Mapped to a bare yes/no: the resolver's miss lists every warehouse the
|
|
// workspace configures, which is a useful hint to an admin editing settings
|
|
// and a needless disclosure to a job that only asked about one name.
|
|
windmill_common::workspaces::dbt_warehouse_exists(&db, &w_id, &name)
|
|
.await
|
|
.map_err(|_| Error::NotFound(format!("no dbt warehouse named `{name}` in this workspace")))
|
|
}
|