@dbx-tools/appkit 0.3.29 → 0.3.30
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/README.md +77 -29
- package/index.ts +1 -0
- package/package.json +4 -4
- package/src/appkit.ts +21 -2
- package/src/config.ts +34 -16
- package/src/create-app.ts +104 -26
- package/src/databricks.ts +28 -3
- package/src/lakebase-resolver.ts +347 -150
- package/src/pgaddress.ts +28 -9
- package/src/plugin.ts +17 -2
- package/src/provision.ts +38 -6
- package/test/config.test.ts +63 -0
- package/test/lakebase-resolver.test.ts +82 -0
- package/test/pgaddress.test.ts +76 -0
- package/test/plugin.test.ts +64 -0
- package/test/provision.test.ts +35 -0
package/src/pgaddress.ts
CHANGED
|
@@ -43,27 +43,33 @@
|
|
|
43
43
|
* @module
|
|
44
44
|
*/
|
|
45
45
|
|
|
46
|
+
/** Postgres TLS modes accepted by {@link SslMode}, in `PGSSLMODE` spelling. */
|
|
47
|
+
export const SSL_MODES = ["require", "disable", "prefer"] as const;
|
|
48
|
+
|
|
46
49
|
/** Postgres TLS mode passed through to `pg`. */
|
|
47
|
-
export type SslMode =
|
|
50
|
+
export type SslMode = (typeof SSL_MODES)[number];
|
|
48
51
|
|
|
49
52
|
/**
|
|
50
53
|
* Optional Lakebase Postgres connection fields shared by parsed addresses,
|
|
51
54
|
* resolver/env inputs, and resolved connections.
|
|
52
55
|
*/
|
|
53
56
|
export interface LakebaseConnectionInputs {
|
|
54
|
-
/** Lakebase project id. */
|
|
57
|
+
/** Lakebase project id. Resolved from the workspace when unset. */
|
|
55
58
|
project?: string;
|
|
56
|
-
/** Branch id within the project. */
|
|
59
|
+
/** Branch id within the project. Defaults to the project's default branch. */
|
|
57
60
|
branch?: string;
|
|
58
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Canonical endpoint resource path (`projects/.../endpoints/...`), from
|
|
63
|
+
* `LAKEBASE_ENDPOINT`. Defaults to the branch's read-write endpoint.
|
|
64
|
+
*/
|
|
59
65
|
endpoint?: string;
|
|
60
|
-
/** Postgres database name (`PGDATABASE`). */
|
|
66
|
+
/** Postgres database name (`PGDATABASE`). Defaults to `databricks_postgres`. */
|
|
61
67
|
database?: string;
|
|
62
|
-
/** Postgres hostname (`PGHOST`). */
|
|
68
|
+
/** Postgres hostname (`PGHOST`). Defaults to the resolved endpoint's host. */
|
|
63
69
|
host?: string;
|
|
64
|
-
/** Postgres port (`PGPORT`). */
|
|
70
|
+
/** Postgres port (`PGPORT`). Defaults to 5432. */
|
|
65
71
|
port?: number;
|
|
66
|
-
/** Postgres TLS mode (`PGSSLMODE`). */
|
|
72
|
+
/** Postgres TLS mode (`PGSSLMODE`). Defaults to `require`. */
|
|
67
73
|
sslMode?: SslMode;
|
|
68
74
|
}
|
|
69
75
|
|
|
@@ -88,6 +94,15 @@ const HOSTNAME_HINT_RE = /^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)+$/i;
|
|
|
88
94
|
* Parse a Lakebase connection input into whatever pieces it carries.
|
|
89
95
|
* See module docstring for the supported formats. Returns `{}` for
|
|
90
96
|
* `undefined`, empty strings, and unrecognized inputs.
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* import { pgaddress } from "@dbx-tools/appkit";
|
|
100
|
+
*
|
|
101
|
+
* pgaddress.parseAddress("projects/demo/branches/production/endpoints/ep-1");
|
|
102
|
+
* // { project: "demo", branch: "production", endpointId: "ep-1", endpoint: "projects/..." }
|
|
103
|
+
*
|
|
104
|
+
* pgaddress.parseAddress("postgresql://me@ep-1.database.azuredatabricks.net/app?sslmode=require");
|
|
105
|
+
* // { host: "ep-1.database.azuredatabricks.net", user: "me", database: "app", sslMode: "require" }
|
|
91
106
|
*/
|
|
92
107
|
export function parseAddress(input: string | undefined | null): ParsedAddress {
|
|
93
108
|
if (!input) return {};
|
|
@@ -138,12 +153,16 @@ function parseUri(s: string): ParsedAddress {
|
|
|
138
153
|
if (db) result.database = decodeURIComponent(db);
|
|
139
154
|
const sslmodeRaw = url.searchParams.get("sslmode") ?? url.searchParams.get("sslMode");
|
|
140
155
|
const sslmode = sslmodeRaw?.toLowerCase();
|
|
141
|
-
if (sslmode
|
|
156
|
+
if (isSslMode(sslmode)) {
|
|
142
157
|
result.sslMode = sslmode;
|
|
143
158
|
}
|
|
144
159
|
return result;
|
|
145
160
|
}
|
|
146
161
|
|
|
162
|
+
function isSslMode(value: string | undefined): value is SslMode {
|
|
163
|
+
return SSL_MODES.some((mode) => mode === value);
|
|
164
|
+
}
|
|
165
|
+
|
|
147
166
|
function parseResourcePathSegments(s: string): ParsedAddress {
|
|
148
167
|
const parts = s.split("/");
|
|
149
168
|
if (parts[0] !== "projects" || parts.length < 2) {
|
package/src/plugin.ts
CHANGED
|
@@ -20,7 +20,10 @@
|
|
|
20
20
|
* @module
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import {
|
|
23
|
+
import { ConfigurationError } from "@databricks/appkit";
|
|
24
|
+
import { log, type NameLike } from "@dbx-tools/shared-core";
|
|
25
|
+
|
|
26
|
+
const logger = log.logger("plugin");
|
|
24
27
|
|
|
25
28
|
/**
|
|
26
29
|
* Minimal structural shape of `this.context`. We mirror only the method we
|
|
@@ -71,6 +74,9 @@ const dataCache = new WeakMap<PluginDataFactory, PluginData>();
|
|
|
71
74
|
*/
|
|
72
75
|
export function data<F extends PluginDataFactory, D extends ReturnType<F>>(factory: F): D {
|
|
73
76
|
const cached = dataCache.get(factory);
|
|
77
|
+
// The cache is keyed by the erased `PluginDataFactory` bound, so `WeakMap`
|
|
78
|
+
// hands back the widened `PluginData`; only the caller's `F` knows the exact
|
|
79
|
+
// descriptor type.
|
|
74
80
|
if (cached !== undefined) {
|
|
75
81
|
return cached as D;
|
|
76
82
|
}
|
|
@@ -100,6 +106,8 @@ export function instance<F extends PluginDataFactory>(
|
|
|
100
106
|
): PluginInstanceOf<F> | undefined {
|
|
101
107
|
if (!ctx) return undefined;
|
|
102
108
|
const name = data(factory).name;
|
|
109
|
+
// AppKit's registry is a `Map<string, unknown>`, so the instance type is only
|
|
110
|
+
// recoverable from the factory the caller passed.
|
|
103
111
|
return ctx.getPlugins().get(name) as PluginInstanceOf<F> | undefined;
|
|
104
112
|
}
|
|
105
113
|
|
|
@@ -127,5 +135,12 @@ export function require<F extends PluginDataFactory>(
|
|
|
127
135
|
const prefix =
|
|
128
136
|
typeof caller === "string" ? `${caller}: ` : caller?.name ? `${caller.name}: ` : "";
|
|
129
137
|
const registeredName = data(factory).name;
|
|
130
|
-
|
|
138
|
+
logger.debug("required plugin not registered", {
|
|
139
|
+
plugin: registeredName,
|
|
140
|
+
registered: [...(ctx?.getPlugins().keys() ?? [])],
|
|
141
|
+
});
|
|
142
|
+
throw ConfigurationError.resourceNotFound(
|
|
143
|
+
`${prefix}plugin '${registeredName}'`,
|
|
144
|
+
`Add ${registeredName}() to the plugins passed to createApp.`,
|
|
145
|
+
);
|
|
131
146
|
}
|
package/src/provision.ts
CHANGED
|
@@ -21,18 +21,36 @@
|
|
|
21
21
|
* @module
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { createLakebasePool, getWorkspaceClient } from "@databricks/appkit";
|
|
25
|
-
import { error,
|
|
24
|
+
import { createLakebasePool, getWorkspaceClient, ValidationError } from "@databricks/appkit";
|
|
25
|
+
import { error, log } from "@dbx-tools/shared-core";
|
|
26
26
|
|
|
27
27
|
import { isAppEnv } from "./databricks";
|
|
28
28
|
|
|
29
|
+
const defaultLogger = log.logger("provision");
|
|
30
|
+
|
|
29
31
|
/** AppKit persistent-cache schema (see AppKit's `PersistentStorage`). */
|
|
30
32
|
const CACHE_SCHEMA = "appkit";
|
|
31
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Characters a Lakebase Postgres role can legitimately contain: workspace
|
|
36
|
+
* identities are emails, service principals are UUIDs.
|
|
37
|
+
*/
|
|
38
|
+
const ROLE_PATTERN = /^[A-Za-z0-9._@+-]{1,255}$/;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Provisioning runs at boot as the local developer identity, before any plugin
|
|
42
|
+
* exists, so it is outside AppKit's interceptor chain and inherits no timeout
|
|
43
|
+
* from it. Both bounds are explicit for that reason.
|
|
44
|
+
*/
|
|
45
|
+
const CONNECT_TIMEOUT_MS = 10_000;
|
|
46
|
+
const STATEMENT_TIMEOUT_MS = 15_000;
|
|
47
|
+
|
|
32
48
|
/**
|
|
33
49
|
* Quote a Postgres identifier: wrap in double quotes and double any embedded
|
|
34
|
-
* quote.
|
|
35
|
-
*
|
|
50
|
+
* quote. Schema and role names are identifiers, and Postgres does not accept a
|
|
51
|
+
* bind parameter in an identifier position, so quoting is the only defense
|
|
52
|
+
* available for these statements. Lakebase role names are usually emails
|
|
53
|
+
* (`user@host`), which must be quoted to be a valid identifier at all.
|
|
36
54
|
*/
|
|
37
55
|
function quoteIdent(ident: string): string {
|
|
38
56
|
return `"${ident.replace(/"/g, '""')}"`;
|
|
@@ -42,8 +60,14 @@ function quoteIdent(ident: string): string {
|
|
|
42
60
|
* Idempotent grants that make the (already-existing) AppKit cache schema fully
|
|
43
61
|
* usable by `role`. The `ALTER DEFAULT PRIVILEGES` lines cover the cache table
|
|
44
62
|
* whenever the schema owner creates it later.
|
|
63
|
+
*
|
|
64
|
+
* Throws a {@link ValidationError} when `role` is not a plausible Postgres role
|
|
65
|
+
* name, since the value lands in an identifier position.
|
|
45
66
|
*/
|
|
46
|
-
function cacheGrantStatements(role: string): readonly string[] {
|
|
67
|
+
export function cacheGrantStatements(role: string): readonly string[] {
|
|
68
|
+
if (!ROLE_PATTERN.test(role)) {
|
|
69
|
+
throw ValidationError.invalidValue("role", role, "a Postgres role name");
|
|
70
|
+
}
|
|
47
71
|
const schema = quoteIdent(CACHE_SCHEMA);
|
|
48
72
|
const target = quoteIdent(role);
|
|
49
73
|
return [
|
|
@@ -66,10 +90,16 @@ function cacheGrantStatements(role: string): readonly string[] {
|
|
|
66
90
|
*
|
|
67
91
|
* @param role - Postgres role to grant to and connect as (the resolved
|
|
68
92
|
* workspace-client identity); skips when undefined.
|
|
93
|
+
* @param logger - Logger to report progress on. Defaults to this module's.
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* import { provision } from "@dbx-tools/appkit";
|
|
97
|
+
*
|
|
98
|
+
* await provision.provisionCacheSchema("app-service-principal@databricks.com");
|
|
69
99
|
*/
|
|
70
100
|
export async function provisionCacheSchema(
|
|
71
|
-
logger: log.Logger,
|
|
72
101
|
role: string | undefined,
|
|
102
|
+
logger: log.Logger = defaultLogger,
|
|
73
103
|
): Promise<void> {
|
|
74
104
|
if (isAppEnv()) {
|
|
75
105
|
logger.debug("autopg: skip cache provisioning (inside a Databricks App)");
|
|
@@ -88,6 +118,8 @@ export async function provisionCacheSchema(
|
|
|
88
118
|
const pool = createLakebasePool({
|
|
89
119
|
user: role,
|
|
90
120
|
workspaceClient: getWorkspaceClient({}),
|
|
121
|
+
connectionTimeoutMillis: CONNECT_TIMEOUT_MS,
|
|
122
|
+
statement_timeout: STATEMENT_TIMEOUT_MS,
|
|
91
123
|
});
|
|
92
124
|
try {
|
|
93
125
|
const found = await pool.query(
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, describe, it } from "node:test";
|
|
3
|
+
import { ValidationError } from "@databricks/appkit";
|
|
4
|
+
import { resolveConfigValue, withCliSources, type ConfigSource } from "../src/config";
|
|
5
|
+
|
|
6
|
+
const ENV_KEY = "DBX_TOOLS_APPKIT_TEST_VALUE";
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
delete process.env[ENV_KEY];
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("config precedence", () => {
|
|
13
|
+
it("prefers explicit config over the environment", async () => {
|
|
14
|
+
process.env[ENV_KEY] = "from-env";
|
|
15
|
+
const value = await resolveConfigValue(ENV_KEY, {
|
|
16
|
+
explicit: { [ENV_KEY]: "from-explicit" },
|
|
17
|
+
});
|
|
18
|
+
assert.equal(value, "from-explicit");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("falls back to the environment when explicit config omits the key", async () => {
|
|
22
|
+
process.env[ENV_KEY] = "from-env";
|
|
23
|
+
const value = await resolveConfigValue(ENV_KEY, { explicit: { OTHER_KEY: "x" } });
|
|
24
|
+
assert.equal(value, "from-env");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("prepends explicit even when the caller passes its own source list", async () => {
|
|
28
|
+
process.env[ENV_KEY] = "from-env";
|
|
29
|
+
const value = await resolveConfigValue(ENV_KEY, {
|
|
30
|
+
sources: ["env"],
|
|
31
|
+
explicit: { [ENV_KEY]: "from-explicit" },
|
|
32
|
+
});
|
|
33
|
+
assert.equal(value, "from-explicit");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("lets a cli flag win when cli sources are requested", async () => {
|
|
37
|
+
process.env[ENV_KEY] = "from-env";
|
|
38
|
+
const value = await resolveConfigValue(ENV_KEY, {
|
|
39
|
+
sources: withCliSources(),
|
|
40
|
+
cli: { [ENV_KEY]: "from-cli" },
|
|
41
|
+
explicit: { [ENV_KEY]: "from-explicit" },
|
|
42
|
+
});
|
|
43
|
+
assert.equal(value, "from-cli");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("orders cli sources ahead of explicit and the default sources", () => {
|
|
47
|
+
assert.deepEqual(withCliSources(), ["cli", "explicit", "env", "bundle"]);
|
|
48
|
+
assert.deepEqual(withCliSources(["explicit", "env"]), ["cli", "explicit", "env"]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("skips blank values instead of treating them as resolved", async () => {
|
|
52
|
+
process.env[ENV_KEY] = "from-env";
|
|
53
|
+
const value = await resolveConfigValue(ENV_KEY, { explicit: { [ENV_KEY]: " " } });
|
|
54
|
+
assert.equal(value, "from-env");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("rejects an unknown source", async () => {
|
|
58
|
+
await assert.rejects(
|
|
59
|
+
() => resolveConfigValue(ENV_KEY, { sources: ["nope" as ConfigSource] }),
|
|
60
|
+
ValidationError,
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { ValidationError } from "@databricks/appkit";
|
|
4
|
+
import { nextPollDelay, parsePort, parseSslMode, pollDelay } from "../src/lakebase-resolver";
|
|
5
|
+
|
|
6
|
+
describe("PGPORT validation", () => {
|
|
7
|
+
it("accepts a numeric string and a number", () => {
|
|
8
|
+
assert.equal(parsePort("5433"), 5433);
|
|
9
|
+
assert.equal(parsePort(5432), 5432);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("treats absent and empty values as unset", () => {
|
|
13
|
+
assert.equal(parsePort(undefined), undefined);
|
|
14
|
+
assert.equal(parsePort(""), undefined);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("rejects anything that is not a TCP port instead of yielding NaN", () => {
|
|
18
|
+
for (const bad of ["abc", "0", "70000", "5432.5"]) {
|
|
19
|
+
assert.throws(
|
|
20
|
+
() => parsePort(bad),
|
|
21
|
+
(err) => {
|
|
22
|
+
assert.ok(err instanceof ValidationError);
|
|
23
|
+
assert.match(err.message, /PGPORT/);
|
|
24
|
+
return true;
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("PGSSLMODE validation", () => {
|
|
32
|
+
it("normalizes case and surrounding space", () => {
|
|
33
|
+
assert.equal(parseSslMode(" Require "), "require");
|
|
34
|
+
assert.equal(parseSslMode("disable"), "disable");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("treats absent and empty values as unset", () => {
|
|
38
|
+
assert.equal(parseSslMode(undefined), undefined);
|
|
39
|
+
assert.equal(parseSslMode(""), undefined);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("rejects a mode pg does not accept", () => {
|
|
43
|
+
assert.throws(
|
|
44
|
+
() => parseSslMode("verify-full"),
|
|
45
|
+
(err) => {
|
|
46
|
+
assert.ok(err instanceof ValidationError);
|
|
47
|
+
assert.match(err.message, /PGSSLMODE/);
|
|
48
|
+
return true;
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("poll backoff", () => {
|
|
55
|
+
it("grows with the attempt and stays inside the jitter band", () => {
|
|
56
|
+
const base = 2_000;
|
|
57
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
58
|
+
const delay = nextPollDelay(attempt, base);
|
|
59
|
+
assert.ok(delay >= 0);
|
|
60
|
+
assert.ok(delay <= 15_000 * 1.2 + 1, `attempt ${attempt} produced ${delay}`);
|
|
61
|
+
}
|
|
62
|
+
const first = Array.from({ length: 20 }, () => nextPollDelay(0, base));
|
|
63
|
+
const later = Array.from({ length: 20 }, () => nextPollDelay(4, base));
|
|
64
|
+
assert.ok(Math.max(...first) < Math.min(...later));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("caps the delay so a long wait keeps polling", () => {
|
|
68
|
+
assert.ok(nextPollDelay(50, 2_000) <= 15_000 * 1.2 + 1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("rejects with the abort reason when cancelled mid-wait", async () => {
|
|
72
|
+
const controller = new AbortController();
|
|
73
|
+
const waiting = pollDelay(5, 10_000, controller.signal);
|
|
74
|
+
const reason = new Error("boot cancelled");
|
|
75
|
+
controller.abort(reason);
|
|
76
|
+
await assert.rejects(waiting, reason);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("rejects immediately for an already-aborted signal", async () => {
|
|
80
|
+
await assert.rejects(pollDelay(0, 10_000, AbortSignal.abort(new Error("gone"))), /gone/);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { parseAddress, parseResourcePath, SSL_MODES } from "../src/pgaddress";
|
|
4
|
+
|
|
5
|
+
describe("pgaddress parseAddress", () => {
|
|
6
|
+
it("returns nothing for empty and unrecognized input", () => {
|
|
7
|
+
assert.deepEqual(parseAddress(undefined), {});
|
|
8
|
+
assert.deepEqual(parseAddress(""), {});
|
|
9
|
+
assert.deepEqual(parseAddress(" "), {});
|
|
10
|
+
assert.deepEqual(parseAddress("Not An Address"), {});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("splits a Postgres URI into host, user, database, port and ssl mode", () => {
|
|
14
|
+
const parsed = parseAddress(
|
|
15
|
+
"postgresql://me%40acme.com@ep-1.database.eastus2.azuredatabricks.net:5433/app?sslmode=disable",
|
|
16
|
+
);
|
|
17
|
+
assert.equal(parsed.host, "ep-1.database.eastus2.azuredatabricks.net");
|
|
18
|
+
assert.equal(parsed.user, "me@acme.com");
|
|
19
|
+
assert.equal(parsed.database, "app");
|
|
20
|
+
assert.equal(parsed.port, 5433);
|
|
21
|
+
assert.equal(parsed.sslMode, "disable");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("ignores an ssl mode the driver does not accept", () => {
|
|
25
|
+
assert.equal(
|
|
26
|
+
parseAddress("postgres://h.example.com/db?sslmode=verify-full").sslMode,
|
|
27
|
+
undefined,
|
|
28
|
+
);
|
|
29
|
+
assert.deepEqual([...SSL_MODES], ["require", "disable", "prefer"]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("recovers project, branch and endpoint from a canonical endpoint path", () => {
|
|
33
|
+
const path = "projects/demo/branches/production/endpoints/ep-1";
|
|
34
|
+
assert.deepEqual(parseAddress(path), {
|
|
35
|
+
project: "demo",
|
|
36
|
+
branch: "production",
|
|
37
|
+
endpointId: "ep-1",
|
|
38
|
+
endpoint: path,
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("keeps a database resource id separate from PGDATABASE", () => {
|
|
43
|
+
assert.deepEqual(
|
|
44
|
+
parseAddress("projects/demo/branches/production/databases/databricks-postgres"),
|
|
45
|
+
{
|
|
46
|
+
project: "demo",
|
|
47
|
+
branch: "production",
|
|
48
|
+
databaseResourceId: "databricks-postgres",
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("reads shorter resource paths", () => {
|
|
54
|
+
assert.deepEqual(parseAddress("projects/demo"), { project: "demo" });
|
|
55
|
+
assert.deepEqual(parseAddress("projects/demo/branches/main"), {
|
|
56
|
+
project: "demo",
|
|
57
|
+
branch: "main",
|
|
58
|
+
});
|
|
59
|
+
assert.deepEqual(parseAddress("projects/demo/branches"), {});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("treats a dotted value as a hostname and a bare slug as a project id", () => {
|
|
63
|
+
assert.deepEqual(parseAddress("ep-1.database.azuredatabricks.net"), {
|
|
64
|
+
host: "ep-1.database.azuredatabricks.net",
|
|
65
|
+
});
|
|
66
|
+
assert.deepEqual(parseAddress("dbx-tools-demo"), { project: "dbx-tools-demo" });
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("pgaddress parseResourcePath", () => {
|
|
71
|
+
it("only accepts `projects/` paths so a bare branch id is not read as a project", () => {
|
|
72
|
+
assert.deepEqual(parseResourcePath("production"), {});
|
|
73
|
+
assert.deepEqual(parseResourcePath(undefined), {});
|
|
74
|
+
assert.equal(parseResourcePath("projects/demo/branches/main").branch, "main");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { ConfigurationError } from "@databricks/appkit";
|
|
4
|
+
import { data, instance, require as requirePlugin, type PluginContextLike } from "../src/plugin";
|
|
5
|
+
|
|
6
|
+
class FakeLakebasePlugin {
|
|
7
|
+
exports() {
|
|
8
|
+
return { pool: "pool" };
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function fakeFactory(name: string, calls: { count: number }) {
|
|
13
|
+
return () => {
|
|
14
|
+
calls.count += 1;
|
|
15
|
+
return { plugin: FakeLakebasePlugin, name };
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function fakeContext(entries: Record<string, unknown>): PluginContextLike {
|
|
20
|
+
const plugins = new Map(Object.entries(entries));
|
|
21
|
+
return { getPlugins: () => plugins };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("plugin lookup", () => {
|
|
25
|
+
it("caches the factory descriptor per factory", () => {
|
|
26
|
+
const calls = { count: 0 };
|
|
27
|
+
const factory = fakeFactory("lakebase", calls);
|
|
28
|
+
assert.equal(data(factory).name, "lakebase");
|
|
29
|
+
assert.equal(data(factory).name, "lakebase");
|
|
30
|
+
assert.equal(calls.count, 1);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns the registered instance, or undefined without a context", () => {
|
|
34
|
+
const factory = fakeFactory("lakebase", { count: 0 });
|
|
35
|
+
const plugin = new FakeLakebasePlugin();
|
|
36
|
+
assert.equal(instance(fakeContext({ lakebase: plugin }), factory), plugin);
|
|
37
|
+
assert.equal(instance(fakeContext({}), factory), undefined);
|
|
38
|
+
assert.equal(instance(undefined, factory), undefined);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("require returns the instance when registered", () => {
|
|
42
|
+
const factory = fakeFactory("lakebase", { count: 0 });
|
|
43
|
+
const plugin = new FakeLakebasePlugin();
|
|
44
|
+
assert.equal(requirePlugin(fakeContext({ lakebase: plugin }), factory), plugin);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("require throws a ConfigurationError naming the plugin and the caller", () => {
|
|
48
|
+
const factory = fakeFactory("lakebase", { count: 0 });
|
|
49
|
+
assert.throws(
|
|
50
|
+
() => requirePlugin(fakeContext({ server: {} }), factory, "mastra"),
|
|
51
|
+
(err) => {
|
|
52
|
+
assert.ok(err instanceof ConfigurationError);
|
|
53
|
+
assert.match(err.message, /mastra/);
|
|
54
|
+
assert.match(err.message, /lakebase/);
|
|
55
|
+
return true;
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("require throws without a context at all", () => {
|
|
61
|
+
const factory = fakeFactory("lakebase", { count: 0 });
|
|
62
|
+
assert.throws(() => requirePlugin(undefined, factory), ConfigurationError);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { ValidationError } from "@databricks/appkit";
|
|
4
|
+
import { cacheGrantStatements } from "../src/provision";
|
|
5
|
+
|
|
6
|
+
describe("cache schema grants", () => {
|
|
7
|
+
it("quotes the schema and an email-shaped role", () => {
|
|
8
|
+
const statements = cacheGrantStatements("me@acme.com");
|
|
9
|
+
assert.equal(statements.length, 5);
|
|
10
|
+
for (const sql of statements) {
|
|
11
|
+
assert.match(sql, /"appkit"/);
|
|
12
|
+
assert.match(sql, /"me@acme\.com"/);
|
|
13
|
+
}
|
|
14
|
+
assert.equal(statements[0], 'GRANT USAGE, CREATE ON SCHEMA "appkit" TO "me@acme.com"');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("rejects a role name that could not be an identifier", () => {
|
|
18
|
+
for (const bad of ['ev"il', "role; DROP SCHEMA appkit", "", "a b"]) {
|
|
19
|
+
assert.throws(
|
|
20
|
+
() => cacheGrantStatements(bad),
|
|
21
|
+
(err) => {
|
|
22
|
+
assert.ok(err instanceof ValidationError);
|
|
23
|
+
assert.match(err.message, /role/);
|
|
24
|
+
return true;
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("keeps the grants idempotent and scoped to the cache schema", () => {
|
|
31
|
+
const statements = cacheGrantStatements("sp-1234").join("\n");
|
|
32
|
+
assert.match(statements, /ALTER DEFAULT PRIVILEGES IN SCHEMA "appkit"/);
|
|
33
|
+
assert.doesNotMatch(statements, /public/);
|
|
34
|
+
});
|
|
35
|
+
});
|