@malloy-publisher/server 0.0.229 → 0.0.230
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/api-doc.yaml +18 -9
- package/dist/app/assets/{EnvironmentPage-QOoHiVeJ.js → EnvironmentPage-wa_EPkwK.js} +1 -1
- package/dist/app/assets/{HomePage-C71GOfVW.js → HomePage-jnCrupQp.js} +1 -1
- package/dist/app/assets/{LightMode-CrgCAwLe.js → LightMode-DYbwNULZ.js} +1 -1
- package/dist/app/assets/{MainPage-BG5__FN3.js → MainPage-CuJLrPNI.js} +1 -1
- package/dist/app/assets/{MaterializationsPage-DE6PnrDR.js → MaterializationsPage-D_67x2ee.js} +1 -1
- package/dist/app/assets/{ModelPage-CcBjcbLm.js → ModelPage-D5JtAWqR.js} +1 -1
- package/dist/app/assets/{PackagePage-JTy3ztkB.js → PackagePage-BRwtqUSG.js} +1 -1
- package/dist/app/assets/{RouteError-Cymbp47a.js → RouteError-CBNNrnSD.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-C_nMnHr8.js → ThemeEditorPage-CTCeBneA.js} +1 -1
- package/dist/app/assets/{WorkbookPage-CPQu-DQx.js → WorkbookPage-SN6f1RBm.js} +1 -1
- package/dist/app/assets/{core-Coi3caGs.es-CSOmajHS.js → core-Dp3q5Ieu.es-CD5FvM2s.js} +1 -1
- package/dist/app/assets/{index-DlWCXghy.js → index-B3Nn8Vm2.js} +459 -446
- package/dist/app/assets/index-BLCx1EdC.js +18 -0
- package/dist/app/assets/{index-DxArlgRD.js → index-C_tJstcx.js} +4 -4
- package/dist/app/assets/{index-CcuuST2X.js → index-CfmBVB6M.js} +1 -1
- package/dist/app/assets/{index-CkmABCAw.js → index-DU4r7GdU.js} +435 -422
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +108 -7
- package/dist/server.mjs +575 -49
- package/package.json +12 -12
- package/scripts/bake-duckdb-extensions.js +5 -2
- package/src/config.ts +40 -0
- package/src/ducklake_version.spec.ts +163 -0
- package/src/ducklake_version.ts +153 -0
- package/src/errors.ts +12 -0
- package/src/malloy_pin_prereqs.spec.ts +25 -0
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/server.ts +7 -0
- package/src/service/authorize.spec.ts +22 -0
- package/src/service/authorize.ts +267 -10
- package/src/service/authorize_integration.spec.ts +1068 -26
- package/src/service/connection.spec.ts +71 -0
- package/src/service/connection.ts +312 -25
- package/src/service/connection_config.spec.ts +21 -0
- package/src/service/connection_config.ts +15 -1
- package/src/service/ducklake_lazy_attach.spec.ts +110 -0
- package/src/service/environment.ts +43 -9
- package/src/service/environment_store.spec.ts +197 -12
- package/src/service/environment_store.ts +88 -14
- package/src/service/extension_fetch_policy.spec.ts +256 -0
- package/src/service/materialization_scheduler.spec.ts +29 -0
- package/src/service/materialization_service.spec.ts +119 -1
- package/src/service/model.spec.ts +67 -0
- package/src/service/model.ts +656 -31
- package/src/service/package.spec.ts +38 -0
- package/src/service/package.ts +12 -1
- package/src/storage/duckdb/MaterializationRepository.spec.ts +39 -2
- package/src/storage/duckdb/MaterializationRepository.ts +12 -0
- package/tests/integration/materialization/scheduler_transitions.integration.spec.ts +256 -0
- package/dist/app/assets/index-CM2qhQCI.js +0 -18
|
@@ -8,6 +8,7 @@ import { components } from "../api";
|
|
|
8
8
|
import {
|
|
9
9
|
buildProxiedSslQuery,
|
|
10
10
|
createEnvironmentConnections,
|
|
11
|
+
resolveProxiedTls,
|
|
11
12
|
testConnectionConfig,
|
|
12
13
|
} from "./connection";
|
|
13
14
|
import { assembleEnvironmentConnections } from "./connection_config";
|
|
@@ -1917,3 +1918,73 @@ describe("buildProxiedSslQuery", () => {
|
|
|
1917
1918
|
}
|
|
1918
1919
|
});
|
|
1919
1920
|
});
|
|
1921
|
+
|
|
1922
|
+
describe("resolveProxiedTls", () => {
|
|
1923
|
+
it("routes verify-full to a raw ssl object with servername = the real host", () => {
|
|
1924
|
+
// verify-full (chain + hostname) can't ride the connectionString through a
|
|
1925
|
+
// tunnel — it needs ssl.servername set to the real DB host so pg checks the
|
|
1926
|
+
// cert's SAN against it, not against the 127.0.0.1 socket address.
|
|
1927
|
+
const { query, ssl } = resolveProxiedTls(
|
|
1928
|
+
"conn",
|
|
1929
|
+
"db.internal.example.com",
|
|
1930
|
+
"verify-full",
|
|
1931
|
+
);
|
|
1932
|
+
expect(query).toBe("");
|
|
1933
|
+
expect(ssl).toEqual({
|
|
1934
|
+
servername: "db.internal.example.com",
|
|
1935
|
+
rejectUnauthorized: true,
|
|
1936
|
+
});
|
|
1937
|
+
});
|
|
1938
|
+
|
|
1939
|
+
it("throws for verify-full with no host (servername would be undefined)", () => {
|
|
1940
|
+
expect(() => resolveProxiedTls("conn", undefined, "verify-full")).toThrow(
|
|
1941
|
+
/verify-full.*no host/i,
|
|
1942
|
+
);
|
|
1943
|
+
});
|
|
1944
|
+
|
|
1945
|
+
it("routes the connectionString-param modes through buildProxiedSslQuery with no ssl object", () => {
|
|
1946
|
+
// verify-ca needs a CA bundle to build its query, so set one for the loop.
|
|
1947
|
+
const prior = process.env.NODE_EXTRA_CA_CERTS;
|
|
1948
|
+
process.env.NODE_EXTRA_CA_CERTS = "/etc/ssl/certs/bundle.pem";
|
|
1949
|
+
try {
|
|
1950
|
+
for (const mode of ["disable", "no-verify", "verify-ca", undefined]) {
|
|
1951
|
+
const { query, ssl } = resolveProxiedTls("conn", "127.0.0.1", mode);
|
|
1952
|
+
expect(query).toBe(buildProxiedSslQuery("conn", mode));
|
|
1953
|
+
expect(ssl).toBeUndefined();
|
|
1954
|
+
}
|
|
1955
|
+
} finally {
|
|
1956
|
+
if (prior === undefined) delete process.env.NODE_EXTRA_CA_CERTS;
|
|
1957
|
+
else process.env.NODE_EXTRA_CA_CERTS = prior;
|
|
1958
|
+
}
|
|
1959
|
+
});
|
|
1960
|
+
|
|
1961
|
+
it("verify-full: a bare connectionString does not clobber the explicit ssl object (pg merge guard)", async () => {
|
|
1962
|
+
// Load-bearing invariant: pg's ConnectionParameters does
|
|
1963
|
+
// Object.assign({}, config, parse(connectionString)). A verify-full
|
|
1964
|
+
// connectionString carries no sslmode, so pg-connection-string adds no
|
|
1965
|
+
// `ssl` key and our explicit { servername, rejectUnauthorized } survives
|
|
1966
|
+
// the merge. If a future pg bump changes this — or we accidentally emit an
|
|
1967
|
+
// sslmode into the query — this guard fails loudly instead of silently
|
|
1968
|
+
// downgrading verify-full.
|
|
1969
|
+
const { query, ssl } = resolveProxiedTls(
|
|
1970
|
+
"conn",
|
|
1971
|
+
"db.internal.example.com",
|
|
1972
|
+
"verify-full",
|
|
1973
|
+
);
|
|
1974
|
+
const { default: ConnectionParameters } = await import(
|
|
1975
|
+
"pg/lib/connection-parameters.js"
|
|
1976
|
+
);
|
|
1977
|
+
const params = new (ConnectionParameters as unknown as new (
|
|
1978
|
+
c: unknown,
|
|
1979
|
+
) => {
|
|
1980
|
+
ssl: unknown;
|
|
1981
|
+
})({
|
|
1982
|
+
connectionString: `postgresql://u:p@127.0.0.1:5432/db${query}`,
|
|
1983
|
+
ssl,
|
|
1984
|
+
});
|
|
1985
|
+
expect(params.ssl).toEqual({
|
|
1986
|
+
servername: "db.internal.example.com",
|
|
1987
|
+
rejectUnauthorized: true,
|
|
1988
|
+
});
|
|
1989
|
+
});
|
|
1990
|
+
});
|
|
@@ -9,6 +9,7 @@ import "@malloydata/db-postgres";
|
|
|
9
9
|
import {
|
|
10
10
|
PooledPostgresConnection,
|
|
11
11
|
type PostgresConnection,
|
|
12
|
+
type PostgresSSLConfig,
|
|
12
13
|
} from "@malloydata/db-postgres";
|
|
13
14
|
// Registers the "publisher" connection type (proxies SQL to a remote Publisher
|
|
14
15
|
// dataplane). No live-class branch is needed in lookupConnection — the default
|
|
@@ -30,6 +31,12 @@ import type { LookupConnection } from "@malloydata/malloy/connection";
|
|
|
30
31
|
import { AxiosError } from "axios";
|
|
31
32
|
import fs from "fs/promises";
|
|
32
33
|
import { components } from "../api";
|
|
34
|
+
import { getExtensionFetchPolicy } from "../config";
|
|
35
|
+
import {
|
|
36
|
+
catalogFormatRangeForEngine,
|
|
37
|
+
isCatalogFormatInRange,
|
|
38
|
+
} from "../ducklake_version";
|
|
39
|
+
import { UnsupportedCatalogFormatError } from "../errors";
|
|
33
40
|
import { logAxiosError, logger } from "../logger";
|
|
34
41
|
import { redactPgSecrets } from "../pg_helpers";
|
|
35
42
|
import {
|
|
@@ -71,26 +78,129 @@ export type InternalConnection = ApiConnection & {
|
|
|
71
78
|
};
|
|
72
79
|
|
|
73
80
|
// Shared utilities
|
|
81
|
+
|
|
82
|
+
// Publisher-owned DuckDB sessions whose extension PRAGMAs have already been
|
|
83
|
+
// pinned. The lookup funnel runs on every resolve, and the attach paths pin
|
|
84
|
+
// too, so this WeakSet keeps the SETs to once per connection object. The
|
|
85
|
+
// DuckLake tier pins with `alwaysDisableAutoinstall` inside its attach, which
|
|
86
|
+
// runs before the funnel ever sees the connection, so the stronger always-off
|
|
87
|
+
// setting is recorded first and never re-evaluated down to policy here.
|
|
88
|
+
const extensionSessionPinned = new WeakSet<Connection>();
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Pin the extension-management PRAGMAs on a Publisher-owned DuckDB session.
|
|
92
|
+
* Publisher installs the extensions it needs explicitly (see
|
|
93
|
+
* {@link installAndLoadExtension}), so DuckDB's own IMPLICIT auto-install
|
|
94
|
+
* (fetching a known extension a query happens to reference) is never
|
|
95
|
+
* load-bearing.
|
|
96
|
+
*
|
|
97
|
+
* Auto-LOAD is left ON (DuckDB's default) so any locally-present extension
|
|
98
|
+
* still lazy-loads — the local-first UX every deployment relies on. Implicit
|
|
99
|
+
* auto-INSTALL is turned OFF when EITHER:
|
|
100
|
+
* - `alwaysDisableAutoinstall` is set — the DuckLake tier's build/serve
|
|
101
|
+
* sessions only ever need the curated extension set, so they never
|
|
102
|
+
* implicitly fetch, regardless of the deployment policy; OR
|
|
103
|
+
* - `EXTENSION_FETCH_POLICY=local-only` — the air-gapped / pinned-image
|
|
104
|
+
* posture, where no session may reach the network.
|
|
105
|
+
*
|
|
106
|
+
* Under the default `on-demand` policy a generic session is left untouched
|
|
107
|
+
* (DuckDB's default), so existing standalone/OSS users see no behaviour change.
|
|
108
|
+
*
|
|
109
|
+
* Called for EVERY Publisher-owned DuckDB session — the environment lookup
|
|
110
|
+
* funnel, the per-package sandbox, and the attach entry points — so the pin has
|
|
111
|
+
* no gap regardless of whether a session has attached databases. On a failed
|
|
112
|
+
* SET: under `local-only` we FAIL CLOSED (throw), because the whole promise of
|
|
113
|
+
* that mode is that no session can reach the network; under the tier's
|
|
114
|
+
* on-demand always-off case the pin is defense-in-depth (the install path
|
|
115
|
+
* already avoids implicit fetches), so we log and continue rather than fail an
|
|
116
|
+
* otherwise-valid attach.
|
|
117
|
+
*/
|
|
118
|
+
export async function applyExtensionSessionSettings(
|
|
119
|
+
connection: DuckDBConnection,
|
|
120
|
+
{
|
|
121
|
+
alwaysDisableAutoinstall = false,
|
|
122
|
+
}: { alwaysDisableAutoinstall?: boolean } = {},
|
|
123
|
+
): Promise<void> {
|
|
124
|
+
const policy = getExtensionFetchPolicy();
|
|
125
|
+
const disableAutoinstall =
|
|
126
|
+
alwaysDisableAutoinstall || policy === "local-only";
|
|
127
|
+
if (!disableAutoinstall || extensionSessionPinned.has(connection)) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
await connection.runSQL("SET autoinstall_known_extensions=false;");
|
|
132
|
+
// Keep autoload on: it is DuckDB's default, but set it explicitly next to
|
|
133
|
+
// an install lockdown so a locally-present/baked extension still loads.
|
|
134
|
+
await connection.runSQL("SET autoload_known_extensions=true;");
|
|
135
|
+
extensionSessionPinned.add(connection);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
138
|
+
if (policy === "local-only") {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`Failed to disable DuckDB implicit extension auto-install under ` +
|
|
141
|
+
`EXTENSION_FETCH_POLICY=local-only; refusing to open a session that ` +
|
|
142
|
+
`could still fetch from the network. Underlying error: ${detail}`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
logger.warn("Failed to pin DuckDB extension session settings", {
|
|
146
|
+
error: detail,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
74
151
|
async function installAndLoadExtension(
|
|
75
152
|
connection: DuckDBConnection,
|
|
76
153
|
extensionName: string,
|
|
77
154
|
fromCommunity = false,
|
|
78
155
|
): Promise<void> {
|
|
156
|
+
const policy = getExtensionFetchPolicy();
|
|
157
|
+
|
|
158
|
+
// `local-only` never fetches: skip INSTALL entirely and rely on the
|
|
159
|
+
// extension already being present on disk (baked into the image). Auto-LOAD
|
|
160
|
+
// stays on (see applyExtensionSessionSettings), so a present extension still
|
|
161
|
+
// lazy-loads; a missing one fails the LOAD below and is reported loudly.
|
|
162
|
+
if (policy === "on-demand") {
|
|
163
|
+
try {
|
|
164
|
+
// Plain INSTALL is local-first: it no-ops when the extension is already
|
|
165
|
+
// present and only fetches when it is missing. We dropped FORCE, which
|
|
166
|
+
// re-fetched from the community CDN whenever the network was up — even
|
|
167
|
+
// for a baked extension — silently drifting the running binary past the
|
|
168
|
+
// offline-verified build.
|
|
169
|
+
const installCommand = fromCommunity
|
|
170
|
+
? `INSTALL ${extensionName} FROM community;`
|
|
171
|
+
: `INSTALL ${extensionName};`;
|
|
172
|
+
await connection.runSQL(installCommand);
|
|
173
|
+
logger.info(`${extensionName} extension installed`);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
logger.info(
|
|
176
|
+
`${extensionName} extension already installed or install skipped`,
|
|
177
|
+
{ error },
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
79
182
|
try {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
: `INSTALL ${extensionName};`;
|
|
83
|
-
await connection.runSQL(installCommand);
|
|
84
|
-
logger.info(`${extensionName} extension installed`);
|
|
183
|
+
await connection.runSQL(`LOAD ${extensionName};`);
|
|
184
|
+
logger.info(`${extensionName} extension loaded`);
|
|
85
185
|
} catch (error) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
186
|
+
if (policy === "local-only") {
|
|
187
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
188
|
+
logger.warn("DuckDB extension unavailable under local-only policy", {
|
|
189
|
+
extension: extensionName,
|
|
190
|
+
policy,
|
|
191
|
+
error: detail,
|
|
192
|
+
});
|
|
193
|
+
throw new Error(
|
|
194
|
+
`DuckDB extension '${extensionName}' is not available locally and ` +
|
|
195
|
+
`EXTENSION_FETCH_POLICY=local-only forbids fetching it. Bake the ` +
|
|
196
|
+
`'${extensionName}' extension into the image (or a pre-populated ` +
|
|
197
|
+
`~/.duckdb/extensions directory), or set ` +
|
|
198
|
+
`EXTENSION_FETCH_POLICY=on-demand to allow fetching missing ` +
|
|
199
|
+
`extensions on first use. Underlying LOAD error: ${detail}`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
throw error;
|
|
90
203
|
}
|
|
91
|
-
|
|
92
|
-
await connection.runSQL(`LOAD ${extensionName};`);
|
|
93
|
-
logger.info(`${extensionName} extension loaded`);
|
|
94
204
|
}
|
|
95
205
|
|
|
96
206
|
async function isDatabaseAttached(
|
|
@@ -339,11 +449,141 @@ async function attachPostgres(
|
|
|
339
449
|
logger.info(`Successfully attached PostgreSQL database: ${attachedDb.name}`);
|
|
340
450
|
}
|
|
341
451
|
|
|
452
|
+
/** Rows out of a DuckDBConnection.runSQL result, in either shape it returns. */
|
|
453
|
+
function runSQLRows(result: unknown): Record<string, unknown>[] {
|
|
454
|
+
if (Array.isArray(result)) return result as Record<string, unknown>[];
|
|
455
|
+
const rows = (result as { rows?: unknown }).rows;
|
|
456
|
+
return Array.isArray(rows) ? (rows as Record<string, unknown>[]) : [];
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Runtime range-preflight for a DuckLake ATTACH. Reads the catalog's recorded
|
|
461
|
+
* format version from its `ducklake_metadata` table via a plain postgres ATTACH
|
|
462
|
+
* (this does NOT invoke the DuckLake extension on the catalog), and checks it
|
|
463
|
+
* against the format range the running DuckDB engine supports (see
|
|
464
|
+
* `ducklake_version.ts`). On a mismatch it throws an
|
|
465
|
+
* {@link UnsupportedCatalogFormatError} (→ HTTP 422) naming the format found,
|
|
466
|
+
* the supported range, and where to migrate — instead of letting the real
|
|
467
|
+
* DuckLake ATTACH fail deep inside DuckDB as an opaque 500.
|
|
468
|
+
*
|
|
469
|
+
* Non-load-bearing on its own read: any failure of the preflight itself
|
|
470
|
+
* (missing `ducklake_metadata` table, non-postgres catalog, query timeout,
|
|
471
|
+
* connect failure, an engine with no matrix row) logs and returns so the real
|
|
472
|
+
* ATTACH below stays the source of truth for unrelated errors. Only a
|
|
473
|
+
* successfully-read, definitively-out-of-range format raises the clean 422.
|
|
474
|
+
*
|
|
475
|
+
* The temp attach uses a per-call unique name so a failed DETACH can't wedge
|
|
476
|
+
* the fixed name (which would make every later preflight for this connection
|
|
477
|
+
* fail its ATTACH with "already exists" and silently skip), and so two
|
|
478
|
+
* concurrent first-touch lookups can't cross-DETACH each other.
|
|
479
|
+
*/
|
|
480
|
+
let ducklakePreflightSeq = 0;
|
|
481
|
+
async function preflightDuckLakeCatalogFormat(
|
|
482
|
+
connection: DuckDBConnection,
|
|
483
|
+
dbName: string,
|
|
484
|
+
pgConnString: string,
|
|
485
|
+
): Promise<void> {
|
|
486
|
+
const tempDb = `${dbName}_fmt_preflight_${++ducklakePreflightSeq}`;
|
|
487
|
+
let catalogFormat: string | undefined;
|
|
488
|
+
try {
|
|
489
|
+
await connection.runSQL(
|
|
490
|
+
`ATTACH '${escapeSQL(pgConnString)}' AS ${tempDb} (TYPE postgres, READ_ONLY);`,
|
|
491
|
+
);
|
|
492
|
+
const result = await connection.runSQL(
|
|
493
|
+
`SELECT value FROM ${tempDb}.ducklake_metadata WHERE key = 'version' LIMIT 1;`,
|
|
494
|
+
);
|
|
495
|
+
const value = runSQLRows(result)[0]?.value;
|
|
496
|
+
catalogFormat = typeof value === "string" ? value : undefined;
|
|
497
|
+
} catch (error) {
|
|
498
|
+
logger.warn(
|
|
499
|
+
"DuckLake catalog-format preflight read failed; falling back to ATTACH",
|
|
500
|
+
{
|
|
501
|
+
dbName,
|
|
502
|
+
error: redactPgSecrets(
|
|
503
|
+
error instanceof Error ? error.message : String(error),
|
|
504
|
+
),
|
|
505
|
+
},
|
|
506
|
+
);
|
|
507
|
+
return;
|
|
508
|
+
} finally {
|
|
509
|
+
try {
|
|
510
|
+
await connection.runSQL(`DETACH ${tempDb};`);
|
|
511
|
+
} catch {
|
|
512
|
+
// The ATTACH may have failed, so there may be nothing to detach.
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (!catalogFormat) {
|
|
517
|
+
// Table present but no version row, or unreadable value: let ATTACH decide.
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Derive the supported range from the engine DuckDB actually reports. An
|
|
522
|
+
// unknown engine (no matrix row) yields null → skip (the CI version-contract
|
|
523
|
+
// check is what guarantees the pinned engine is always covered).
|
|
524
|
+
let engineVersion = "";
|
|
525
|
+
try {
|
|
526
|
+
const versionResult = await connection.runSQL("SELECT version() AS v;");
|
|
527
|
+
engineVersion = String(runSQLRows(versionResult)[0]?.v ?? "");
|
|
528
|
+
} catch (error) {
|
|
529
|
+
logger.warn(
|
|
530
|
+
"DuckLake catalog-format preflight: could not read engine version; skipping",
|
|
531
|
+
{
|
|
532
|
+
dbName,
|
|
533
|
+
error: error instanceof Error ? error.message : String(error),
|
|
534
|
+
},
|
|
535
|
+
);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const range = catalogFormatRangeForEngine(engineVersion);
|
|
540
|
+
if (!range) {
|
|
541
|
+
logger.warn(
|
|
542
|
+
"DuckLake catalog-format preflight: engine not covered by the format matrix; skipping",
|
|
543
|
+
{ dbName, engineVersion },
|
|
544
|
+
);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (!isCatalogFormatInRange(catalogFormat, range)) {
|
|
549
|
+
logger.warn(
|
|
550
|
+
"DuckLake catalog format outside supported range; rejecting",
|
|
551
|
+
{
|
|
552
|
+
dbName,
|
|
553
|
+
catalogFormat,
|
|
554
|
+
supportedMin: range.min,
|
|
555
|
+
supportedMax: range.max,
|
|
556
|
+
engineVersion: range.engineVersion,
|
|
557
|
+
},
|
|
558
|
+
);
|
|
559
|
+
throw new UnsupportedCatalogFormatError(
|
|
560
|
+
`DuckLake catalog '${dbName}' has format version ${catalogFormat}, ` +
|
|
561
|
+
`which is outside the range this Publisher supports ` +
|
|
562
|
+
`(${range.min} ≤ format ≤ ${range.max}). The DuckLake ` +
|
|
563
|
+
`extension bundled with DuckDB v${range.engineVersion} attaches only ` +
|
|
564
|
+
`catalogs in that range without migration. Migrate the catalog to a ` +
|
|
565
|
+
`supported format (see the DuckLake catalog-migration docs at ` +
|
|
566
|
+
`https://ducklake.select/docs), or run a Publisher built against a ` +
|
|
567
|
+
`DuckDB engine whose supported range includes ${catalogFormat}.`,
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
logger.info(
|
|
572
|
+
`DuckLake catalog '${dbName}' format ${catalogFormat} is within supported range ` +
|
|
573
|
+
`${range.min}..${range.max} (engine v${range.engineVersion})`,
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
342
577
|
async function attachDuckLake(
|
|
343
578
|
connection: DuckDBConnection,
|
|
344
579
|
dbName: string,
|
|
345
580
|
ducklakeConfig: components["schemas"]["DucklakeConnection"],
|
|
346
581
|
): Promise<void> {
|
|
582
|
+
// Tier build/serve DuckLake sessions never implicitly auto-install (Publisher
|
|
583
|
+
// installs the DuckLake stack explicitly below) — regardless of the policy.
|
|
584
|
+
await applyExtensionSessionSettings(connection, {
|
|
585
|
+
alwaysDisableAutoinstall: true,
|
|
586
|
+
});
|
|
347
587
|
await installAndLoadExtension(connection, "ducklake");
|
|
348
588
|
await installAndLoadExtension(connection, "postgres");
|
|
349
589
|
await installAndLoadExtension(connection, "aws");
|
|
@@ -387,6 +627,9 @@ async function attachDuckLake(
|
|
|
387
627
|
);
|
|
388
628
|
const escapedBucketUrl = escapeSQL(ducklakeConfig.storage.bucketUrl);
|
|
389
629
|
logger.info(`escapedBucketUrl: ${escapedBucketUrl}`);
|
|
630
|
+
// Range-preflight the catalog's recorded format version so an unsupported
|
|
631
|
+
// catalog fails as a clean, actionable 422 rather than a deep DuckDB 500.
|
|
632
|
+
await preflightDuckLakeCatalogFormat(connection, dbName, pgConnString);
|
|
390
633
|
const attachCommand = `ATTACH OR REPLACE 'ducklake:postgres:${escapedPgConnString}' AS ${dbName} (DATA_PATH '${escapedBucketUrl}', OVERRIDE_DATA_PATH true, READ_ONLY true);`;
|
|
391
634
|
logger.info(
|
|
392
635
|
`Attaching DuckLake database using command: ${redactPgSecrets(attachCommand)}`,
|
|
@@ -632,6 +875,12 @@ async function attachDatabasesToDuckDB(
|
|
|
632
875
|
azure: attachAzureStorage,
|
|
633
876
|
};
|
|
634
877
|
|
|
878
|
+
// Generic (non-tier) attach session: defer to the deployment policy — off
|
|
879
|
+
// under local-only, left at DuckDB's default under on-demand so existing
|
|
880
|
+
// users see no behaviour change. Publisher still installs each attached
|
|
881
|
+
// type's extension explicitly below.
|
|
882
|
+
await applyExtensionSessionSettings(duckdbConnection);
|
|
883
|
+
|
|
635
884
|
// Pre-load extensions needed by any attached database type, once per connection
|
|
636
885
|
const hasAzure = attachedDatabases.some((db) => db.type === "azure");
|
|
637
886
|
if (hasAzure) {
|
|
@@ -926,13 +1175,14 @@ function buildSnowflakePrivateKeyConnection(
|
|
|
926
1175
|
}
|
|
927
1176
|
|
|
928
1177
|
// TLS mode for a proxied postgres connection, as `pg` connectionString query
|
|
929
|
-
// params. @malloydata/db-postgres
|
|
930
|
-
//
|
|
931
|
-
//
|
|
932
|
-
//
|
|
1178
|
+
// params. @malloydata/db-postgres forwards a connectionString straight to pg —
|
|
1179
|
+
// which parses sslmode. The tunnel terminates at 127.0.0.1, so pg's hostname
|
|
1180
|
+
// check can't apply from the connectionString: `verify-ca` validates the cert
|
|
1181
|
+
// chain against the trusted CA bundle (NODE_EXTRA_CA_CERTS, e.g. the baked
|
|
933
1182
|
// Amazon RDS roots) WITHOUT the hostname; `no-verify` encrypts without checking;
|
|
934
|
-
// `disable` uses no TLS.
|
|
935
|
-
//
|
|
1183
|
+
// `disable` uses no TLS. `verify-full` (chain + hostname) can't be expressed as
|
|
1184
|
+
// a connectionString param through a tunnel — it needs a raw `ssl` object with
|
|
1185
|
+
// `servername` set to the real host; see resolveProxiedTls below.
|
|
936
1186
|
export function buildProxiedSslQuery(name: string, sslmode?: string): string {
|
|
937
1187
|
const caBundle = process.env.NODE_EXTRA_CA_CERTS;
|
|
938
1188
|
// Default: encrypt (no-verify) so a force-SSL target (the common RDS case)
|
|
@@ -958,11 +1208,39 @@ export function buildProxiedSslQuery(name: string, sslmode?: string): string {
|
|
|
958
1208
|
return `?uselibpqcompat=true&sslmode=verify-ca&sslrootcert=${encodeURIComponent(caBundle)}`;
|
|
959
1209
|
default:
|
|
960
1210
|
throw new Error(
|
|
961
|
-
`Connection proxy on '${name}' has unsupported sslmode '${mode}' (expected disable | no-verify | verify-ca).`,
|
|
1211
|
+
`Connection proxy on '${name}' has unsupported sslmode '${mode}' (expected disable | no-verify | verify-ca; verify-full is applied via the ssl object in resolveProxiedTls, not this query).`,
|
|
962
1212
|
);
|
|
963
1213
|
}
|
|
964
1214
|
}
|
|
965
1215
|
|
|
1216
|
+
// Resolve a proxied connection's TLS mode into either a connectionString query
|
|
1217
|
+
// suffix (disable/no-verify/verify-ca — parsed by pg from the connectionString)
|
|
1218
|
+
// or a raw `ssl` object. `verify-full` takes the ssl path: full verification
|
|
1219
|
+
// (chain + hostname) through a tunnel needs `servername` set to the real DB host,
|
|
1220
|
+
// which a connectionString sslmode param can't carry. The socket targets the
|
|
1221
|
+
// 127.0.0.1 IP literal, so pg preserves our `servername` (it only overwrites it
|
|
1222
|
+
// with `host` when host is a DNS name), verifying the cert's SAN against the real
|
|
1223
|
+
// host. Verification runs against Node's ambient trust anchors — its bundled
|
|
1224
|
+
// Mozilla CA roots plus NODE_EXTRA_CA_CERTS (e.g. the baked Amazon RDS roots) —
|
|
1225
|
+
// not the OS system store (unless Node is run with --use-system-ca).
|
|
1226
|
+
export function resolveProxiedTls(
|
|
1227
|
+
name: string,
|
|
1228
|
+
host: string | undefined,
|
|
1229
|
+
sslmode?: string,
|
|
1230
|
+
): { query: string; ssl?: PostgresSSLConfig } {
|
|
1231
|
+
if (sslmode === "verify-full") {
|
|
1232
|
+
if (!host) {
|
|
1233
|
+
// validateConnectionShape requires host on a proxied connection, so this
|
|
1234
|
+
// is unreachable in practice — guard so servername is never undefined.
|
|
1235
|
+
throw new Error(
|
|
1236
|
+
`Connection proxy on '${name}' uses sslmode 'verify-full' but has no host to verify against.`,
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
1239
|
+
return { query: "", ssl: { servername: host, rejectUnauthorized: true } };
|
|
1240
|
+
}
|
|
1241
|
+
return { query: buildProxiedSslQuery(name, sslmode) };
|
|
1242
|
+
}
|
|
1243
|
+
|
|
966
1244
|
function buildProxiedPostgresConnection(
|
|
967
1245
|
metadata: EnvironmentConnectionMetadata,
|
|
968
1246
|
endpoint: ProxyEndpoint,
|
|
@@ -983,11 +1261,12 @@ function buildProxiedPostgresConnection(
|
|
|
983
1261
|
? `${enc(pg.userName)}${pg.password ? `:${enc(pg.password)}` : ""}@`
|
|
984
1262
|
: "";
|
|
985
1263
|
const db = pg.databaseName ? `/${enc(pg.databaseName)}` : "";
|
|
986
|
-
const ssl =
|
|
987
|
-
const connectionString = `postgresql://${auth}${endpoint.host}:${endpoint.port}${db}${
|
|
1264
|
+
const { query, ssl } = resolveProxiedTls(name, pg.host, pg.sslmode);
|
|
1265
|
+
const connectionString = `postgresql://${auth}${endpoint.host}:${endpoint.port}${db}${query}`;
|
|
988
1266
|
return new PooledPostgresConnection({
|
|
989
1267
|
name,
|
|
990
1268
|
connectionString,
|
|
1269
|
+
...(ssl ? { ssl } : {}),
|
|
991
1270
|
// Pool sizing mirrors buildSnowflakePrivateKeyConnection.
|
|
992
1271
|
poolMin: 1,
|
|
993
1272
|
poolMax: 20,
|
|
@@ -1248,13 +1527,21 @@ export function buildEnvironmentMalloyConfig(
|
|
|
1248
1527
|
return {
|
|
1249
1528
|
lookupConnection: async (name?: string): Promise<Connection> => {
|
|
1250
1529
|
const metadata = getMetadataForLookup(assembled.metadata, name);
|
|
1530
|
+
const connection = await resolveConnection(name, metadata);
|
|
1531
|
+
// Pin every environment-level DuckDB session against implicit
|
|
1532
|
+
// auto-install (policy-driven) at this one exit — independent of
|
|
1533
|
+
// the attach paths, so the guarantee holds even if a resolution
|
|
1534
|
+
// branch is ever added that doesn't attach. The DuckLake tier
|
|
1535
|
+
// already pinned always-off inside its attach; the WeakSet makes
|
|
1536
|
+
// this a no-op there. (The per-package `:memory:` sandbox is a
|
|
1537
|
+
// separate MalloyConfig and is pinned in package.ts.)
|
|
1538
|
+
if (isDuckDBConnection(connection)) {
|
|
1539
|
+
await applyExtensionSessionSettings(connection);
|
|
1540
|
+
}
|
|
1251
1541
|
// Every resolution branch funnels through this one exit so a
|
|
1252
1542
|
// configured fingerprint is applied no matter which wrapper
|
|
1253
1543
|
// produced the connection (see applyConnectionFingerprint).
|
|
1254
|
-
return applyConnectionFingerprint(
|
|
1255
|
-
await resolveConnection(name, metadata),
|
|
1256
|
-
metadata,
|
|
1257
|
-
);
|
|
1544
|
+
return applyConnectionFingerprint(connection, metadata);
|
|
1258
1545
|
},
|
|
1259
1546
|
};
|
|
1260
1547
|
},
|
|
@@ -572,6 +572,27 @@ describe("SSH proxy validation", () => {
|
|
|
572
572
|
}
|
|
573
573
|
});
|
|
574
574
|
|
|
575
|
+
it("accepts sslmode=verify-full on a proxied connection with no CA bundle (verifies against Node's bundled CA roots + NODE_EXTRA_CA_CERTS)", () => {
|
|
576
|
+
// Unlike verify-ca (which passes an explicit sslrootcert path and so
|
|
577
|
+
// fails fast without NODE_EXTRA_CA_CERTS), verify-full trusts Node's
|
|
578
|
+
// bundled Mozilla CA roots + NODE_EXTRA_CA_CERTS — so it must NOT require
|
|
579
|
+
// a bundle at load.
|
|
580
|
+
const prior = process.env.NODE_EXTRA_CA_CERTS;
|
|
581
|
+
delete process.env.NODE_EXTRA_CA_CERTS;
|
|
582
|
+
try {
|
|
583
|
+
const conn: ApiConnection = {
|
|
584
|
+
...validSshProxy,
|
|
585
|
+
postgresConnection: {
|
|
586
|
+
...validSshProxy.postgresConnection!,
|
|
587
|
+
sslmode: "verify-full",
|
|
588
|
+
},
|
|
589
|
+
};
|
|
590
|
+
expect(() => assembleEnvironmentConnections([conn])).not.toThrow();
|
|
591
|
+
} finally {
|
|
592
|
+
if (prior !== undefined) process.env.NODE_EXTRA_CA_CERTS = prior;
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
|
|
575
596
|
it("rejects sslmode set on a non-proxied connection (silent-ignore footgun)", () => {
|
|
576
597
|
const conn: ApiConnection = {
|
|
577
598
|
name: "pg-direct",
|
|
@@ -11,7 +11,12 @@ type AttachedDatabase = components["schemas"]["AttachedDatabase"];
|
|
|
11
11
|
// than in connection.ts, which imports this module) so both the config-load
|
|
12
12
|
// validator and the connect-time builder derive from one list. Mirrors the
|
|
13
13
|
// `sslmode` enum in api-doc.yaml.
|
|
14
|
-
export const PROXIED_SSLMODES = [
|
|
14
|
+
export const PROXIED_SSLMODES = [
|
|
15
|
+
"disable",
|
|
16
|
+
"no-verify",
|
|
17
|
+
"verify-ca",
|
|
18
|
+
"verify-full",
|
|
19
|
+
] as const;
|
|
15
20
|
|
|
16
21
|
export type CoreConnectionEntry = {
|
|
17
22
|
is: string;
|
|
@@ -356,6 +361,15 @@ function validateConnectionShape(connection: ApiConnection): void {
|
|
|
356
361
|
);
|
|
357
362
|
}
|
|
358
363
|
}
|
|
364
|
+
// No precondition for `verify-full`: unlike `verify-ca` (which passes an
|
|
365
|
+
// explicit `sslrootcert` path), it verifies against Node's ambient trust
|
|
366
|
+
// anchors (its bundled Mozilla CA roots + NODE_EXTRA_CA_CERTS), so
|
|
367
|
+
// requiring a bundle here would wrongly reject targets with a
|
|
368
|
+
// publicly-trusted CA. An untrusted CA surfaces as a clear pg error at
|
|
369
|
+
// connect time instead. This means verify-full's CA trust is broader
|
|
370
|
+
// than verify-ca's pinned bundle — intentional (it adds the hostname
|
|
371
|
+
// check without narrowing trust), so unlike libpq, verify-full here is
|
|
372
|
+
// not a strict superset of verify-ca.
|
|
359
373
|
}
|
|
360
374
|
|
|
361
375
|
// The proxied path builds a connectionString to the local tunnel endpoint;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { DuckDBConnection } from "@malloydata/db-duckdb";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
3
|
+
import fs from "fs/promises";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import sinon from "sinon";
|
|
6
|
+
import { components } from "../api";
|
|
7
|
+
import { buildEnvironmentMalloyConfig } from "./connection";
|
|
8
|
+
|
|
9
|
+
type ApiConnection = components["schemas"]["Connection"];
|
|
10
|
+
|
|
11
|
+
// A DuckLake connection whose Postgres catalog points at an unroutable host.
|
|
12
|
+
// If anything on the boot path tried to reach the catalog, these tests would
|
|
13
|
+
// hang or fail — which is precisely the regression they guard against.
|
|
14
|
+
const UNREACHABLE_DUCKLAKE: ApiConnection = {
|
|
15
|
+
name: "ducklake_lazy",
|
|
16
|
+
type: "ducklake",
|
|
17
|
+
ducklakeConnection: {
|
|
18
|
+
catalog: {
|
|
19
|
+
postgresConnection: {
|
|
20
|
+
host: "192.0.2.1", // TEST-NET-1: guaranteed non-routable
|
|
21
|
+
port: 5432,
|
|
22
|
+
userName: "nobody",
|
|
23
|
+
password: "nobody",
|
|
24
|
+
databaseName: "catalog",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
storage: {
|
|
28
|
+
bucketUrl: "s3://test-bucket",
|
|
29
|
+
s3Connection: {
|
|
30
|
+
accessKeyId: "test",
|
|
31
|
+
secretAccessKey: "test",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
} as ApiConnection;
|
|
36
|
+
|
|
37
|
+
describe("DuckLake lazy attach", () => {
|
|
38
|
+
const envPath = path.join(process.cwd(), "test-ducklake-lazy-attach");
|
|
39
|
+
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
await fs.mkdir(envPath, { recursive: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(async () => {
|
|
45
|
+
sinon.restore();
|
|
46
|
+
await fs.rm(envPath, { recursive: true, force: true }).catch(() => {});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("does not attach the catalog while building the environment config (boot path)", async () => {
|
|
50
|
+
// Stub the DuckDB session so no real database or network work happens; the
|
|
51
|
+
// stub also lets us observe whether ANY SQL ran during config build.
|
|
52
|
+
const runSQL = sinon
|
|
53
|
+
.stub(DuckDBConnection.prototype, "runSQL")
|
|
54
|
+
.resolves({ rows: [] } as never);
|
|
55
|
+
|
|
56
|
+
// Building the config is the worker boot path. It must be synchronous and
|
|
57
|
+
// must not open, construct, or touch the DuckLake session at all.
|
|
58
|
+
const config = buildEnvironmentMalloyConfig(
|
|
59
|
+
[UNREACHABLE_DUCKLAKE],
|
|
60
|
+
envPath,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
expect(config.apiConnections.map((c) => c.name)).toContain(
|
|
64
|
+
"ducklake_lazy",
|
|
65
|
+
);
|
|
66
|
+
// The definitive assertion: zero SQL was issued building the config, so
|
|
67
|
+
// the (unreachable) catalog could not have been contacted on boot.
|
|
68
|
+
expect(runSQL.callCount).toBe(0);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("cannot let an unavailable catalog block startup", async () => {
|
|
72
|
+
sinon
|
|
73
|
+
.stub(DuckDBConnection.prototype, "runSQL")
|
|
74
|
+
.resolves({ rows: [] } as never);
|
|
75
|
+
|
|
76
|
+
// Even with an unreachable catalog, constructing the environment config
|
|
77
|
+
// resolves promptly and never awaits the catalog. If attach were on the
|
|
78
|
+
// boot path, this would hang against 192.0.2.1 until a connect timeout.
|
|
79
|
+
const config = buildEnvironmentMalloyConfig(
|
|
80
|
+
[UNREACHABLE_DUCKLAKE],
|
|
81
|
+
envPath,
|
|
82
|
+
);
|
|
83
|
+
expect(config).toBeDefined();
|
|
84
|
+
expect(config.malloyConfig).toBeDefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("attaches the catalog only on first connection lookup (serve path)", async () => {
|
|
88
|
+
const runSQL = sinon
|
|
89
|
+
.stub(DuckDBConnection.prototype, "runSQL")
|
|
90
|
+
.resolves({ rows: [] } as never);
|
|
91
|
+
|
|
92
|
+
const config = buildEnvironmentMalloyConfig(
|
|
93
|
+
[UNREACHABLE_DUCKLAKE],
|
|
94
|
+
envPath,
|
|
95
|
+
);
|
|
96
|
+
expect(runSQL.callCount).toBe(0);
|
|
97
|
+
|
|
98
|
+
// The lazy attach fires here, on the first lookup — not before.
|
|
99
|
+
await config.malloyConfig.connections.lookupConnection("ducklake_lazy");
|
|
100
|
+
|
|
101
|
+
const issued = runSQL.getCalls().map((c) => String(c.args[0]));
|
|
102
|
+
const attachedDuckLake = issued.some((sql) =>
|
|
103
|
+
sql.includes("ducklake:postgres:"),
|
|
104
|
+
);
|
|
105
|
+
expect(runSQL.callCount).toBeGreaterThan(0);
|
|
106
|
+
expect(attachedDuckLake).toBe(true);
|
|
107
|
+
|
|
108
|
+
await config.releaseConnections().catch(() => {});
|
|
109
|
+
});
|
|
110
|
+
});
|