Skip to content

Commit 0744f9a

Browse files
committed
feat(cli): add appkit doctor command
Diagnose whether an AppKit app's declared Databricks resources are actually usable, beyond the startup env-var check. Registered as `appkit doctor`. Three layers per resource: - auth: validate DATABRICKS_HOST, then currentUser.me() (once, app-wide) - config: offline env-var presence check - existence: live per-type probe — control-plane .get() for warehouse, serving, genie, job, volume, vector index, uc_function; a real SELECT 1 connection for Lakebase/postgres. Errors are classified (NOT_FOUND, INVALID_VALUE, ACCESS_DENIED) with clean one-line messages. Actionable hints translate opaque failures into the fix: expired/missing credentials to the right `databricks auth login`, a serving endpoint keyed by id to its name, and a Lakebase auth failure to the PGUSER/identity mismatch. Reaches the Databricks SDK / @databricks/appkit only through a runtime import in databricks-client.ts, keeping the SDK-free shared package free of the dependency and degrading gracefully when it is absent. Output is a friendly list (errors first; plugin/type + reason shown only on rows needing attention) or --json; exit code is non-zero on any error so it can gate CI. Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
1 parent e606900 commit 0744f9a

15 files changed

Lines changed: 2042 additions & 0 deletions
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# `appkit doctor`
2+
3+
Diagnoses whether an AppKit app's declared Databricks resources are actually
4+
usable — going beyond the startup env-var presence check to probe existence and
5+
reachability against the live workspace.
6+
7+
## The check model — three layers
8+
9+
For each declared resource, doctor runs the offline `config` layer then the live
10+
`existence` layer; `auth` runs once up front. It stops at the first hard failure
11+
so the reported problem is the *root* cause, not a symptom.
12+
13+
| Layer | Question | How |
14+
| ------------ | ----------------------------------------------------- | ---------------------------------------------------------- |
15+
| `auth` | Can we authenticate to the workspace at all? | validate `DATABRICKS_HOST` is a real URL, then `currentUser.me()` — once, app-wide; a failure skips the live layer |
16+
| `config` | Are the resource's field env vars **present**? | offline presence check of `process.env` (presence only — see note) |
17+
| `existence` | Does the resource exist and is it reachable? | cheapest per-type live probe (`warehouses.get`, `servingEndpoints.get`, …); Lakebase runs a real `SELECT 1` |
18+
19+
`config` checks env-var presence only; whether a value points at a real resource
20+
is the `existence` layer's job. (`DATABRICKS_HOST` is the exception — `auth`
21+
validates it structurally, since a bad host means no client can be built.)
22+
23+
## Files
24+
25+
- `types.ts` — the contract: layers, statuses, `ResourceTarget`, `DoctorReport`.
26+
- `resolve-targets.ts` — reads `appkit.plugins.json``ResourceTarget[]`.
27+
- `databricks-client.ts` — the sole SDK seam: dynamic `import()` of the SDK /
28+
`@databricks/appkit`, builds a `WorkspaceClient` and Lakebase pool, graceful
29+
fallback when uninstalled.
30+
- `checks.ts``checkAuth`, `checkConfig`, `checkExistence`.
31+
- `checks-existence.ts` — per-type existence probe dispatch + error classifier.
32+
- `run.ts` — orchestration: auth once → per resource (config → existence).
33+
- `report.ts` — human table + `--json`, and the CI-gating exit code.
34+
- `index.ts` — the Commander command + flags.
35+
36+
## Flags
37+
38+
- `--profile <name>` — Databricks CLI profile to authenticate with.
39+
- `--json` — machine-readable report.
40+
41+
Exit code is non-zero if auth or any resource is in an `error` state, so
42+
`appkit doctor` can gate CI / pre-deploy.
43+
44+
Checks run as the identity that runs doctor (the developer locally, the app in
45+
deployment).
46+
47+
## Existence coverage
48+
49+
Control-plane `.get()` for `sql_warehouse`, `serving_endpoint`, `genie_space`,
50+
`job`, `volume`, `vector_search_index`, and `uc_function`; a real `SELECT 1`
51+
connection for `postgres`/Lakebase. Other types report `skipped`.
52+
53+
Requires live credentials (a configured Databricks profile/token).
Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { runExistenceProbe, toThreeLevelVolumeName } from "./checks-existence";
3+
import type { ResourceTarget } from "./types";
4+
5+
// Mock the SDK bridge so the postgres probe can be driven without a real
6+
// Lakebase connection. `getLakebasePool` returns a fake pool per test.
7+
const { mockGetLakebasePool, endSpy } = vi.hoisted(() => ({
8+
mockGetLakebasePool: vi.fn(),
9+
endSpy: vi.fn(async () => {}),
10+
}));
11+
vi.mock("./databricks-client", () => ({
12+
AppkitNotInstalledError: class AppkitNotInstalledError extends Error {},
13+
getLakebasePool: mockGetLakebasePool,
14+
}));
15+
16+
function target(overrides: Partial<ResourceTarget> = {}): ResourceTarget {
17+
return {
18+
type: "sql_warehouse",
19+
resourceKey: "sql-warehouse",
20+
alias: "SQL Warehouse",
21+
plugin: "analytics",
22+
requiredPermission: "CAN_USE",
23+
required: true,
24+
envVars: ["DATABRICKS_WAREHOUSE_ID"],
25+
fieldValues: { id: "wh-123" },
26+
...overrides,
27+
};
28+
}
29+
30+
/** Builds an SDK-like error carrying a statusCode (as ApiError does). */
31+
function apiError(statusCode: number, message = "boom"): Error {
32+
return Object.assign(new Error(message), { statusCode });
33+
}
34+
35+
describe("runExistenceProbe — sql_warehouse", () => {
36+
it("ok when the warehouse exists and is RUNNING", async () => {
37+
const client = {
38+
warehouses: { get: async () => ({ state: "RUNNING" }) },
39+
};
40+
const r = await runExistenceProbe(client, target());
41+
expect(r.status).toBe("ok");
42+
});
43+
44+
it("warns when the warehouse exists but is STOPPED", async () => {
45+
const client = {
46+
warehouses: { get: async () => ({ state: "STOPPED" }) },
47+
};
48+
const r = await runExistenceProbe(client, target());
49+
expect(r.status).toBe("warn");
50+
expect(r.code).toBe("WAREHOUSE_NOT_RUNNING");
51+
});
52+
53+
it("errors NOT_FOUND on a 404", async () => {
54+
const client = {
55+
warehouses: {
56+
get: async () => {
57+
throw apiError(404);
58+
},
59+
},
60+
};
61+
const r = await runExistenceProbe(client, target());
62+
expect(r.status).toBe("error");
63+
expect(r.code).toBe("NOT_FOUND");
64+
});
65+
66+
it("errors INVALID_VALUE on a 400 with a clean message", async () => {
67+
const client = {
68+
warehouses: {
69+
get: async () => {
70+
throw Object.assign(
71+
new Error(
72+
'Response from server (Bad Request) {"error_code":"INVALID_PARAMETER_VALUE","message":"bogus is not a valid endpoint id."}',
73+
),
74+
{ statusCode: 400, errorCode: "INVALID_PARAMETER_VALUE" },
75+
);
76+
},
77+
},
78+
};
79+
const r = await runExistenceProbe(client, target());
80+
expect(r.status).toBe("error");
81+
expect(r.code).toBe("INVALID_VALUE");
82+
// The clean inner message is extracted, not the whole JSON blob.
83+
expect(r.detail).toContain("not a valid endpoint id");
84+
expect(r.detail).not.toContain("error_code");
85+
});
86+
87+
it("errors ACCESS_DENIED on a 403", async () => {
88+
const client = {
89+
warehouses: {
90+
get: async () => {
91+
throw apiError(403);
92+
},
93+
},
94+
};
95+
const r = await runExistenceProbe(client, target());
96+
expect(r.status).toBe("error");
97+
expect(r.code).toBe("ACCESS_DENIED");
98+
});
99+
100+
it("skips when the id field is missing", async () => {
101+
const client = { warehouses: { get: async () => ({}) } };
102+
const r = await runExistenceProbe(client, target({ fieldValues: {} }));
103+
expect(r.status).toBe("skipped");
104+
expect(r.code).toBe("MISSING_FIELD");
105+
});
106+
});
107+
108+
describe("runExistenceProbe — job", () => {
109+
it("errors on a non-integer job id", async () => {
110+
const client = { jobs: { get: async () => ({}) } };
111+
const r = await runExistenceProbe(
112+
client,
113+
target({ type: "job", fieldValues: { id: "not-a-number" } }),
114+
);
115+
expect(r.status).toBe("error");
116+
expect(r.code).toBe("INVALID_ID");
117+
});
118+
119+
it("ok for a valid integer job id", async () => {
120+
const client = { jobs: { get: async () => ({}) } };
121+
const r = await runExistenceProbe(
122+
client,
123+
target({ type: "job", fieldValues: { id: "42" } }),
124+
);
125+
expect(r.status).toBe("ok");
126+
});
127+
});
128+
129+
describe("runExistenceProbe — unsupported type", () => {
130+
it("skips NOT_IMPLEMENTED for a type with no probe", async () => {
131+
const r = await runExistenceProbe(
132+
{},
133+
target({ type: "secret", fieldValues: {} }),
134+
);
135+
expect(r.status).toBe("skipped");
136+
expect(r.code).toBe("NOT_IMPLEMENTED");
137+
});
138+
});
139+
140+
describe("runExistenceProbe — postgres (Lakebase)", () => {
141+
afterEach(() => {
142+
mockGetLakebasePool.mockReset();
143+
endSpy.mockClear();
144+
});
145+
146+
function pgTarget(overrides: Partial<ResourceTarget> = {}): ResourceTarget {
147+
return target({
148+
type: "postgres",
149+
requiredPermission: "CAN_CONNECT_AND_CREATE",
150+
fieldValues: { host: "ep.example.com", endpointPath: "projects/x" },
151+
...overrides,
152+
});
153+
}
154+
155+
it("ok when SELECT 1 succeeds, and closes the pool", async () => {
156+
const query = vi.fn(async () => ({ rows: [{ "?column?": 1 }] }));
157+
mockGetLakebasePool.mockResolvedValue({ query, end: endSpy });
158+
159+
const r = await runExistenceProbe({}, pgTarget());
160+
expect(r.status).toBe("ok");
161+
expect(query).toHaveBeenCalledWith("SELECT 1");
162+
expect(endSpy).toHaveBeenCalled(); // pool always closed
163+
});
164+
165+
it("errors CONNECTION_FAILED when the query throws, and still closes", async () => {
166+
const query = vi.fn(async () => {
167+
throw new Error("ECONNREFUSED 10.0.0.1:5432");
168+
});
169+
mockGetLakebasePool.mockResolvedValue({ query, end: endSpy });
170+
171+
const r = await runExistenceProbe({}, pgTarget());
172+
expect(r.status).toBe("error");
173+
expect(r.code).toBe("CONNECTION_FAILED");
174+
expect(r.detail).toContain("ECONNREFUSED");
175+
expect(endSpy).toHaveBeenCalled();
176+
});
177+
178+
it("adds an OAuth/PGUSER hint on 'password authentication failed'", async () => {
179+
const query = vi.fn(async () => {
180+
throw new Error(
181+
'password authentication failed for user "galymzhan.zhangazy%40databricks.com"',
182+
);
183+
});
184+
mockGetLakebasePool.mockResolvedValue({ query, end: endSpy });
185+
186+
const r = await runExistenceProbe({}, pgTarget());
187+
expect(r.status).toBe("error");
188+
expect(r.code).toBe("CONNECTION_FAILED");
189+
// The raw error stays in detail; the actionable guidance is a separate hint.
190+
expect(r.detail).toMatch(/password authentication failed/i);
191+
expect(r.hint).toMatch(/OAuth token as the password/i);
192+
expect(r.hint).toMatch(/PGUSER/);
193+
});
194+
195+
it("does NOT add the auth hint for unrelated connection errors", async () => {
196+
const query = vi.fn(async () => {
197+
throw new Error("ECONNREFUSED 10.0.0.1:5432");
198+
});
199+
mockGetLakebasePool.mockResolvedValue({ query, end: endSpy });
200+
201+
const r = await runExistenceProbe({}, pgTarget());
202+
expect(r.hint).toBeUndefined();
203+
});
204+
205+
it("skips with MISSING_FIELD when neither host nor endpoint resolved", async () => {
206+
const r = await runExistenceProbe({}, pgTarget({ fieldValues: {} }));
207+
expect(r.status).toBe("skipped");
208+
expect(r.code).toBe("MISSING_FIELD");
209+
// Pool was never created.
210+
expect(mockGetLakebasePool).not.toHaveBeenCalled();
211+
});
212+
});
213+
214+
// These use the REAL first-party manifest field keys (e.g. vector-search's
215+
// camelCase `indexName`) — the case that previously slipped through as a silent
216+
// skip. Each supported probe gets a happy path + a missing-field skip.
217+
describe("runExistenceProbe — per-type coverage with real manifest keys", () => {
218+
it("serving_endpoint: ok via `name`", async () => {
219+
const client = { servingEndpoints: { get: async () => ({}) } };
220+
const r = await runExistenceProbe(
221+
client,
222+
target({
223+
type: "serving_endpoint",
224+
fieldValues: { name: "my-endpoint" },
225+
}),
226+
);
227+
expect(r.status).toBe("ok");
228+
});
229+
230+
it("serving_endpoint: hints id-vs-name when configured by id and the probe fails", async () => {
231+
const client = {
232+
servingEndpoints: {
233+
get: async () => {
234+
throw Object.assign(new Error("not found"), { statusCode: 404 });
235+
},
236+
},
237+
};
238+
const r = await runExistenceProbe(
239+
client,
240+
target({ type: "serving_endpoint", fieldValues: { id: "abc-123" } }),
241+
);
242+
expect(r.status).toBe("error");
243+
expect(r.hint).toMatch(/looked up by name/i);
244+
});
245+
246+
it("serving_endpoint: no id-vs-name hint when configured by name", async () => {
247+
const client = {
248+
servingEndpoints: {
249+
get: async () => {
250+
throw Object.assign(new Error("not found"), { statusCode: 404 });
251+
},
252+
},
253+
};
254+
const r = await runExistenceProbe(
255+
client,
256+
target({ type: "serving_endpoint", fieldValues: { name: "my-ep" } }),
257+
);
258+
expect(r.status).toBe("error");
259+
expect(r.hint).toBeUndefined();
260+
});
261+
262+
it("genie_space: ok via `id`", async () => {
263+
const client = { genie: { getSpace: async () => ({}) } };
264+
const r = await runExistenceProbe(
265+
client,
266+
target({ type: "genie_space", fieldValues: { id: "01ef" } }),
267+
);
268+
expect(r.status).toBe("ok");
269+
});
270+
271+
it("volume: ok via `path` (real manifest key)", async () => {
272+
const client = { volumes: { read: async () => ({}) } };
273+
const r = await runExistenceProbe(
274+
client,
275+
target({
276+
type: "volume",
277+
fieldValues: { path: "/Volumes/main/default/files" },
278+
}),
279+
);
280+
expect(r.status).toBe("ok");
281+
});
282+
283+
it("uc_function: ok via `name`", async () => {
284+
const client = { functions: { get: async () => ({}) } };
285+
const r = await runExistenceProbe(
286+
client,
287+
target({ type: "uc_function", fieldValues: { name: "cat.sch.fn" } }),
288+
);
289+
expect(r.status).toBe("ok");
290+
});
291+
292+
it("vector_search_index: probes via camelCase `indexName` (regression: bug #1)", async () => {
293+
const getIndex = vi.fn(async () => ({}));
294+
const client = { vectorSearchIndexes: { getIndex } };
295+
const r = await runExistenceProbe(
296+
client,
297+
target({
298+
type: "vector_search_index",
299+
fieldValues: { indexName: "main.default.idx", endpointName: "ep" },
300+
}),
301+
);
302+
expect(r.status).toBe("ok");
303+
expect(getIndex).toHaveBeenCalledWith({ index_name: "main.default.idx" });
304+
});
305+
306+
it("vector_search_index: skips MISSING_FIELD when index name absent", async () => {
307+
const client = { vectorSearchIndexes: { getIndex: async () => ({}) } };
308+
const r = await runExistenceProbe(
309+
client,
310+
target({ type: "vector_search_index", fieldValues: {} }),
311+
);
312+
expect(r.status).toBe("skipped");
313+
expect(r.code).toBe("MISSING_FIELD");
314+
});
315+
});
316+
317+
describe("toThreeLevelVolumeName", () => {
318+
it("passes through a dotted 3-level name", () => {
319+
expect(toThreeLevelVolumeName("main.default.files")).toBe(
320+
"main.default.files",
321+
);
322+
});
323+
324+
it("extracts from a /Volumes path", () => {
325+
expect(toThreeLevelVolumeName("/Volumes/main/default/files/sub/x")).toBe(
326+
"main.default.files",
327+
);
328+
});
329+
330+
it("returns null for an unparseable value", () => {
331+
expect(toThreeLevelVolumeName("just-a-name")).toBeNull();
332+
});
333+
});

0 commit comments

Comments
 (0)