From 0744f9a84c2334deaf31aff6e976b5a608797bd5 Mon Sep 17 00:00:00 2001 From: Galymzhan Date: Thu, 23 Jul 2026 15:01:52 +0200 Subject: [PATCH] feat(cli): add appkit doctor command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../shared/src/cli/commands/doctor/README.md | 53 +++ .../commands/doctor/checks-existence.test.ts | 333 +++++++++++++++++ .../cli/commands/doctor/checks-existence.ts | 346 ++++++++++++++++++ .../src/cli/commands/doctor/checks.test.ts | 184 ++++++++++ .../shared/src/cli/commands/doctor/checks.ts | 197 ++++++++++ .../cli/commands/doctor/databricks-client.ts | 96 +++++ .../shared/src/cli/commands/doctor/index.ts | 43 +++ .../src/cli/commands/doctor/report.test.ts | 132 +++++++ .../shared/src/cli/commands/doctor/report.ts | 101 +++++ .../commands/doctor/resolve-targets.test.ts | 180 +++++++++ .../cli/commands/doctor/resolve-targets.ts | 140 +++++++ .../src/cli/commands/doctor/run.test.ts | 84 +++++ .../shared/src/cli/commands/doctor/run.ts | 88 +++++ .../shared/src/cli/commands/doctor/types.ts | 63 ++++ packages/shared/src/cli/index.ts | 2 + 15 files changed, 2042 insertions(+) create mode 100644 packages/shared/src/cli/commands/doctor/README.md create mode 100644 packages/shared/src/cli/commands/doctor/checks-existence.test.ts create mode 100644 packages/shared/src/cli/commands/doctor/checks-existence.ts create mode 100644 packages/shared/src/cli/commands/doctor/checks.test.ts create mode 100644 packages/shared/src/cli/commands/doctor/checks.ts create mode 100644 packages/shared/src/cli/commands/doctor/databricks-client.ts create mode 100644 packages/shared/src/cli/commands/doctor/index.ts create mode 100644 packages/shared/src/cli/commands/doctor/report.test.ts create mode 100644 packages/shared/src/cli/commands/doctor/report.ts create mode 100644 packages/shared/src/cli/commands/doctor/resolve-targets.test.ts create mode 100644 packages/shared/src/cli/commands/doctor/resolve-targets.ts create mode 100644 packages/shared/src/cli/commands/doctor/run.test.ts create mode 100644 packages/shared/src/cli/commands/doctor/run.ts create mode 100644 packages/shared/src/cli/commands/doctor/types.ts diff --git a/packages/shared/src/cli/commands/doctor/README.md b/packages/shared/src/cli/commands/doctor/README.md new file mode 100644 index 000000000..b746c6f38 --- /dev/null +++ b/packages/shared/src/cli/commands/doctor/README.md @@ -0,0 +1,53 @@ +# `appkit doctor` + +Diagnoses whether an AppKit app's declared Databricks resources are actually +usable — going beyond the startup env-var presence check to probe existence and +reachability against the live workspace. + +## The check model — three layers + +For each declared resource, doctor runs the offline `config` layer then the live +`existence` layer; `auth` runs once up front. It stops at the first hard failure +so the reported problem is the *root* cause, not a symptom. + +| Layer | Question | How | +| ------------ | ----------------------------------------------------- | ---------------------------------------------------------- | +| `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 | +| `config` | Are the resource's field env vars **present**? | offline presence check of `process.env` (presence only — see note) | +| `existence` | Does the resource exist and is it reachable? | cheapest per-type live probe (`warehouses.get`, `servingEndpoints.get`, …); Lakebase runs a real `SELECT 1` | + +`config` checks env-var presence only; whether a value points at a real resource +is the `existence` layer's job. (`DATABRICKS_HOST` is the exception — `auth` +validates it structurally, since a bad host means no client can be built.) + +## Files + +- `types.ts` — the contract: layers, statuses, `ResourceTarget`, `DoctorReport`. +- `resolve-targets.ts` — reads `appkit.plugins.json` → `ResourceTarget[]`. +- `databricks-client.ts` — the sole SDK seam: dynamic `import()` of the SDK / + `@databricks/appkit`, builds a `WorkspaceClient` and Lakebase pool, graceful + fallback when uninstalled. +- `checks.ts` — `checkAuth`, `checkConfig`, `checkExistence`. +- `checks-existence.ts` — per-type existence probe dispatch + error classifier. +- `run.ts` — orchestration: auth once → per resource (config → existence). +- `report.ts` — human table + `--json`, and the CI-gating exit code. +- `index.ts` — the Commander command + flags. + +## Flags + +- `--profile ` — Databricks CLI profile to authenticate with. +- `--json` — machine-readable report. + +Exit code is non-zero if auth or any resource is in an `error` state, so +`appkit doctor` can gate CI / pre-deploy. + +Checks run as the identity that runs doctor (the developer locally, the app in +deployment). + +## Existence coverage + +Control-plane `.get()` for `sql_warehouse`, `serving_endpoint`, `genie_space`, +`job`, `volume`, `vector_search_index`, and `uc_function`; a real `SELECT 1` +connection for `postgres`/Lakebase. Other types report `skipped`. + +Requires live credentials (a configured Databricks profile/token). diff --git a/packages/shared/src/cli/commands/doctor/checks-existence.test.ts b/packages/shared/src/cli/commands/doctor/checks-existence.test.ts new file mode 100644 index 000000000..f621ef5b7 --- /dev/null +++ b/packages/shared/src/cli/commands/doctor/checks-existence.test.ts @@ -0,0 +1,333 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { runExistenceProbe, toThreeLevelVolumeName } from "./checks-existence"; +import type { ResourceTarget } from "./types"; + +// Mock the SDK bridge so the postgres probe can be driven without a real +// Lakebase connection. `getLakebasePool` returns a fake pool per test. +const { mockGetLakebasePool, endSpy } = vi.hoisted(() => ({ + mockGetLakebasePool: vi.fn(), + endSpy: vi.fn(async () => {}), +})); +vi.mock("./databricks-client", () => ({ + AppkitNotInstalledError: class AppkitNotInstalledError extends Error {}, + getLakebasePool: mockGetLakebasePool, +})); + +function target(overrides: Partial = {}): ResourceTarget { + return { + type: "sql_warehouse", + resourceKey: "sql-warehouse", + alias: "SQL Warehouse", + plugin: "analytics", + requiredPermission: "CAN_USE", + required: true, + envVars: ["DATABRICKS_WAREHOUSE_ID"], + fieldValues: { id: "wh-123" }, + ...overrides, + }; +} + +/** Builds an SDK-like error carrying a statusCode (as ApiError does). */ +function apiError(statusCode: number, message = "boom"): Error { + return Object.assign(new Error(message), { statusCode }); +} + +describe("runExistenceProbe — sql_warehouse", () => { + it("ok when the warehouse exists and is RUNNING", async () => { + const client = { + warehouses: { get: async () => ({ state: "RUNNING" }) }, + }; + const r = await runExistenceProbe(client, target()); + expect(r.status).toBe("ok"); + }); + + it("warns when the warehouse exists but is STOPPED", async () => { + const client = { + warehouses: { get: async () => ({ state: "STOPPED" }) }, + }; + const r = await runExistenceProbe(client, target()); + expect(r.status).toBe("warn"); + expect(r.code).toBe("WAREHOUSE_NOT_RUNNING"); + }); + + it("errors NOT_FOUND on a 404", async () => { + const client = { + warehouses: { + get: async () => { + throw apiError(404); + }, + }, + }; + const r = await runExistenceProbe(client, target()); + expect(r.status).toBe("error"); + expect(r.code).toBe("NOT_FOUND"); + }); + + it("errors INVALID_VALUE on a 400 with a clean message", async () => { + const client = { + warehouses: { + get: async () => { + throw Object.assign( + new Error( + 'Response from server (Bad Request) {"error_code":"INVALID_PARAMETER_VALUE","message":"bogus is not a valid endpoint id."}', + ), + { statusCode: 400, errorCode: "INVALID_PARAMETER_VALUE" }, + ); + }, + }, + }; + const r = await runExistenceProbe(client, target()); + expect(r.status).toBe("error"); + expect(r.code).toBe("INVALID_VALUE"); + // The clean inner message is extracted, not the whole JSON blob. + expect(r.detail).toContain("not a valid endpoint id"); + expect(r.detail).not.toContain("error_code"); + }); + + it("errors ACCESS_DENIED on a 403", async () => { + const client = { + warehouses: { + get: async () => { + throw apiError(403); + }, + }, + }; + const r = await runExistenceProbe(client, target()); + expect(r.status).toBe("error"); + expect(r.code).toBe("ACCESS_DENIED"); + }); + + it("skips when the id field is missing", async () => { + const client = { warehouses: { get: async () => ({}) } }; + const r = await runExistenceProbe(client, target({ fieldValues: {} })); + expect(r.status).toBe("skipped"); + expect(r.code).toBe("MISSING_FIELD"); + }); +}); + +describe("runExistenceProbe — job", () => { + it("errors on a non-integer job id", async () => { + const client = { jobs: { get: async () => ({}) } }; + const r = await runExistenceProbe( + client, + target({ type: "job", fieldValues: { id: "not-a-number" } }), + ); + expect(r.status).toBe("error"); + expect(r.code).toBe("INVALID_ID"); + }); + + it("ok for a valid integer job id", async () => { + const client = { jobs: { get: async () => ({}) } }; + const r = await runExistenceProbe( + client, + target({ type: "job", fieldValues: { id: "42" } }), + ); + expect(r.status).toBe("ok"); + }); +}); + +describe("runExistenceProbe — unsupported type", () => { + it("skips NOT_IMPLEMENTED for a type with no probe", async () => { + const r = await runExistenceProbe( + {}, + target({ type: "secret", fieldValues: {} }), + ); + expect(r.status).toBe("skipped"); + expect(r.code).toBe("NOT_IMPLEMENTED"); + }); +}); + +describe("runExistenceProbe — postgres (Lakebase)", () => { + afterEach(() => { + mockGetLakebasePool.mockReset(); + endSpy.mockClear(); + }); + + function pgTarget(overrides: Partial = {}): ResourceTarget { + return target({ + type: "postgres", + requiredPermission: "CAN_CONNECT_AND_CREATE", + fieldValues: { host: "ep.example.com", endpointPath: "projects/x" }, + ...overrides, + }); + } + + it("ok when SELECT 1 succeeds, and closes the pool", async () => { + const query = vi.fn(async () => ({ rows: [{ "?column?": 1 }] })); + mockGetLakebasePool.mockResolvedValue({ query, end: endSpy }); + + const r = await runExistenceProbe({}, pgTarget()); + expect(r.status).toBe("ok"); + expect(query).toHaveBeenCalledWith("SELECT 1"); + expect(endSpy).toHaveBeenCalled(); // pool always closed + }); + + it("errors CONNECTION_FAILED when the query throws, and still closes", async () => { + const query = vi.fn(async () => { + throw new Error("ECONNREFUSED 10.0.0.1:5432"); + }); + mockGetLakebasePool.mockResolvedValue({ query, end: endSpy }); + + const r = await runExistenceProbe({}, pgTarget()); + expect(r.status).toBe("error"); + expect(r.code).toBe("CONNECTION_FAILED"); + expect(r.detail).toContain("ECONNREFUSED"); + expect(endSpy).toHaveBeenCalled(); + }); + + it("adds an OAuth/PGUSER hint on 'password authentication failed'", async () => { + const query = vi.fn(async () => { + throw new Error( + 'password authentication failed for user "galymzhan.zhangazy%40databricks.com"', + ); + }); + mockGetLakebasePool.mockResolvedValue({ query, end: endSpy }); + + const r = await runExistenceProbe({}, pgTarget()); + expect(r.status).toBe("error"); + expect(r.code).toBe("CONNECTION_FAILED"); + // The raw error stays in detail; the actionable guidance is a separate hint. + expect(r.detail).toMatch(/password authentication failed/i); + expect(r.hint).toMatch(/OAuth token as the password/i); + expect(r.hint).toMatch(/PGUSER/); + }); + + it("does NOT add the auth hint for unrelated connection errors", async () => { + const query = vi.fn(async () => { + throw new Error("ECONNREFUSED 10.0.0.1:5432"); + }); + mockGetLakebasePool.mockResolvedValue({ query, end: endSpy }); + + const r = await runExistenceProbe({}, pgTarget()); + expect(r.hint).toBeUndefined(); + }); + + it("skips with MISSING_FIELD when neither host nor endpoint resolved", async () => { + const r = await runExistenceProbe({}, pgTarget({ fieldValues: {} })); + expect(r.status).toBe("skipped"); + expect(r.code).toBe("MISSING_FIELD"); + // Pool was never created. + expect(mockGetLakebasePool).not.toHaveBeenCalled(); + }); +}); + +// These use the REAL first-party manifest field keys (e.g. vector-search's +// camelCase `indexName`) — the case that previously slipped through as a silent +// skip. Each supported probe gets a happy path + a missing-field skip. +describe("runExistenceProbe — per-type coverage with real manifest keys", () => { + it("serving_endpoint: ok via `name`", async () => { + const client = { servingEndpoints: { get: async () => ({}) } }; + const r = await runExistenceProbe( + client, + target({ + type: "serving_endpoint", + fieldValues: { name: "my-endpoint" }, + }), + ); + expect(r.status).toBe("ok"); + }); + + it("serving_endpoint: hints id-vs-name when configured by id and the probe fails", async () => { + const client = { + servingEndpoints: { + get: async () => { + throw Object.assign(new Error("not found"), { statusCode: 404 }); + }, + }, + }; + const r = await runExistenceProbe( + client, + target({ type: "serving_endpoint", fieldValues: { id: "abc-123" } }), + ); + expect(r.status).toBe("error"); + expect(r.hint).toMatch(/looked up by name/i); + }); + + it("serving_endpoint: no id-vs-name hint when configured by name", async () => { + const client = { + servingEndpoints: { + get: async () => { + throw Object.assign(new Error("not found"), { statusCode: 404 }); + }, + }, + }; + const r = await runExistenceProbe( + client, + target({ type: "serving_endpoint", fieldValues: { name: "my-ep" } }), + ); + expect(r.status).toBe("error"); + expect(r.hint).toBeUndefined(); + }); + + it("genie_space: ok via `id`", async () => { + const client = { genie: { getSpace: async () => ({}) } }; + const r = await runExistenceProbe( + client, + target({ type: "genie_space", fieldValues: { id: "01ef" } }), + ); + expect(r.status).toBe("ok"); + }); + + it("volume: ok via `path` (real manifest key)", async () => { + const client = { volumes: { read: async () => ({}) } }; + const r = await runExistenceProbe( + client, + target({ + type: "volume", + fieldValues: { path: "/Volumes/main/default/files" }, + }), + ); + expect(r.status).toBe("ok"); + }); + + it("uc_function: ok via `name`", async () => { + const client = { functions: { get: async () => ({}) } }; + const r = await runExistenceProbe( + client, + target({ type: "uc_function", fieldValues: { name: "cat.sch.fn" } }), + ); + expect(r.status).toBe("ok"); + }); + + it("vector_search_index: probes via camelCase `indexName` (regression: bug #1)", async () => { + const getIndex = vi.fn(async () => ({})); + const client = { vectorSearchIndexes: { getIndex } }; + const r = await runExistenceProbe( + client, + target({ + type: "vector_search_index", + fieldValues: { indexName: "main.default.idx", endpointName: "ep" }, + }), + ); + expect(r.status).toBe("ok"); + expect(getIndex).toHaveBeenCalledWith({ index_name: "main.default.idx" }); + }); + + it("vector_search_index: skips MISSING_FIELD when index name absent", async () => { + const client = { vectorSearchIndexes: { getIndex: async () => ({}) } }; + const r = await runExistenceProbe( + client, + target({ type: "vector_search_index", fieldValues: {} }), + ); + expect(r.status).toBe("skipped"); + expect(r.code).toBe("MISSING_FIELD"); + }); +}); + +describe("toThreeLevelVolumeName", () => { + it("passes through a dotted 3-level name", () => { + expect(toThreeLevelVolumeName("main.default.files")).toBe( + "main.default.files", + ); + }); + + it("extracts from a /Volumes path", () => { + expect(toThreeLevelVolumeName("/Volumes/main/default/files/sub/x")).toBe( + "main.default.files", + ); + }); + + it("returns null for an unparseable value", () => { + expect(toThreeLevelVolumeName("just-a-name")).toBeNull(); + }); +}); diff --git a/packages/shared/src/cli/commands/doctor/checks-existence.ts b/packages/shared/src/cli/commands/doctor/checks-existence.ts new file mode 100644 index 000000000..25526ad19 --- /dev/null +++ b/packages/shared/src/cli/commands/doctor/checks-existence.ts @@ -0,0 +1,346 @@ +/** + * Layer: existence — per-resource-type probes that prove a declared resource + * exists and is reachable via the cheapest read the SDK offers. + * + * The client is typed structurally (not via the SDK) so `shared` stays SDK-free. + */ + +import { + AppkitNotInstalledError, + getLakebasePool, + type LakebasePoolHandle, +} from "./databricks-client"; +import type { LayerResult, ResourceTarget } from "./types"; + +interface DoctorWorkspaceClient { + warehouses: { + get: (r: { id: string }) => Promise<{ state?: string }>; + }; + servingEndpoints: { + get: (r: { name: string }) => Promise; + }; + genie: { + getSpace: (r: { space_id: string }) => Promise; + }; + jobs: { + get: (r: { job_id: number }) => Promise; + }; + volumes: { + read: (r: { name: string }) => Promise; + }; + vectorSearchIndexes: { + getIndex: (r: { index_name: string }) => Promise; + }; + functions: { + get: (r: { name: string }) => Promise; + }; +} + +type ExistenceProbe = ( + client: DoctorWorkspaceClient, + target: ResourceTarget, +) => Promise; + +// The SDK's ApiError carries statusCode/errorCode; we read them off the caught +// error structurally rather than importing the class, keeping `shared` SDK-free. +function statusCodeOf(err: unknown): number | undefined { + if (err && typeof err === "object" && "statusCode" in err) { + const code = (err as { statusCode?: unknown }).statusCode; + if (typeof code === "number") return code; + } + return undefined; +} + +function errorCodeOf(err: unknown): string | undefined { + if (err && typeof err === "object" && "errorCode" in err) { + const code = (err as { errorCode?: unknown }).errorCode; + if (typeof code === "string" && code.length > 0) return code; + } + return undefined; +} + +// The SDK message often embeds a JSON blob (`Response from server (...) +// {"message":"..."}`); pull the inner `message` out so doctor prints one clean +// line, not a dump. +function cleanMessage(err: unknown): string { + const raw = err instanceof Error ? err.message : String(err); + const m = raw.match(/"message"\s*:\s*"([^"]+)"/); + return m ? m[1] : raw; +} + +function classifyError(err: unknown, target: ResourceTarget): LayerResult { + const status = statusCodeOf(err); + const errorCode = errorCodeOf(err); + const message = cleanMessage(err); + + if (status === 404 || errorCode === "RESOURCE_DOES_NOT_EXIST") { + return { + layer: "existence", + status: "error", + code: "NOT_FOUND", + detail: `${target.type} not found — check the configured id/name (${message})`, + }; + } + // A malformed id/name often comes back as 400 rather than 404. + if (status === 400 || errorCode === "INVALID_PARAMETER_VALUE") { + return { + layer: "existence", + status: "error", + code: "INVALID_VALUE", + detail: `invalid ${target.type} id/name: ${message}`, + }; + } + if (status === 403 || errorCode === "PERMISSION_DENIED") { + return { + layer: "existence", + status: "error", + code: "ACCESS_DENIED", + detail: `access denied reading ${target.type} — the identity may lack visibility (${message})`, + }; + } + return { + layer: "existence", + status: "error", + code: "PROBE_FAILED", + detail: `failed to read ${target.type}: ${message}`, + }; +} + +/** Normalizes a field key for comparison: manifests use camelCase (`indexName`) + * while scaffold defaults use snake_case (`index_name`), so match case- and + * separator-insensitively. */ +function normalizeKey(key: string): string { + return key.toLowerCase().replace(/[_-]/g, ""); +} + +/** Looks up a resolved field value by any of the accepted key spellings. */ +function field(target: ResourceTarget, ...names: string[]): string | null { + const wanted = new Set(names.map(normalizeKey)); + for (const [key, value] of Object.entries(target.fieldValues)) { + if (wanted.has(normalizeKey(key)) && value.length > 0) return value; + } + return null; +} + +function missingField(fieldName: string): LayerResult { + return { + layer: "existence", + status: "skipped", + code: "MISSING_FIELD", + detail: `cannot probe existence: no resolved value for "${fieldName}"`, + }; +} + +const probeWarehouse: ExistenceProbe = async (client, target) => { + const id = field(target, "id"); + if (!id) return missingField("id"); + try { + const wh = await client.warehouses.get({ id }); + const state = wh.state; + if (state && state !== "RUNNING") { + return { + layer: "existence", + status: "warn", + code: "WAREHOUSE_NOT_RUNNING", + detail: `warehouse exists but is ${state} (will cold-start on first query)`, + }; + } + return { layer: "existence", status: "ok" }; + } catch (err) { + return classifyError(err, target); + } +}; + +const probeServing: ExistenceProbe = async (client, target) => { + const name = field(target, "name"); + // Serving endpoints are looked up by name. If the manifest instead keys this + // resource by `id`, we still probe with that value but flag the likely cause + // when it fails, since the API only accepts a name. + const idOnly = name === null ? field(target, "id") : null; + const value = name ?? idOnly; + if (!value) return missingField("name"); + try { + await client.servingEndpoints.get({ name: value }); + return { layer: "existence", status: "ok" }; + } catch (err) { + const result = classifyError(err, target); + if (idOnly && result.status === "error") { + result.hint = + "Serving endpoints are looked up by name, but this resource is configured by id. Set DATABRICKS_SERVING_ENDPOINT_NAME to the endpoint's name."; + } + return result; + } +}; + +const probeGenie: ExistenceProbe = async (client, target) => { + const spaceId = field(target, "id"); + if (!spaceId) return missingField("id"); + try { + await client.genie.getSpace({ space_id: spaceId }); + return { layer: "existence", status: "ok" }; + } catch (err) { + return classifyError(err, target); + } +}; + +const probeJob: ExistenceProbe = async (client, target) => { + const raw = field(target, "id"); + if (!raw) return missingField("id"); + const jobId = Number(raw); + if (!Number.isInteger(jobId)) { + return { + layer: "existence", + status: "error", + code: "INVALID_ID", + detail: `job id is not a valid integer: "${raw}"`, + }; + } + try { + await client.jobs.get({ job_id: jobId }); + return { layer: "existence", status: "ok" }; + } catch (err) { + return classifyError(err, target); + } +}; + +const probeVolume: ExistenceProbe = async (client, target) => { + // The read API wants the 3-level name (catalog.schema.volume), but the env + // value is usually a /Volumes/... path. + const raw = field(target, "path", "name"); + if (!raw) return missingField("path"); + const name = toThreeLevelVolumeName(raw); + if (!name) { + return { + layer: "existence", + status: "error", + code: "INVALID_NAME", + detail: `cannot derive a 3-level volume name from "${raw}"`, + }; + } + try { + await client.volumes.read({ name }); + return { layer: "existence", status: "ok" }; + } catch (err) { + return classifyError(err, target); + } +}; + +const probeVectorIndex: ExistenceProbe = async (client, target) => { + const name = field(target, "indexName", "index_name", "name"); + if (!name) return missingField("indexName"); + try { + await client.vectorSearchIndexes.getIndex({ index_name: name }); + return { layer: "existence", status: "ok" }; + } catch (err) { + return classifyError(err, target); + } +}; + +const probeFunction: ExistenceProbe = async (client, target) => { + const name = field(target, "name"); + if (!name) return missingField("name"); + try { + await client.functions.get({ name }); + return { layer: "existence", status: "ok" }; + } catch (err) { + return classifyError(err, target); + } +}; + +// Lakebase authenticates with an OAuth token as the Postgres password, so +// "password authentication failed" almost never means a wrong password — it +// usually means PGUSER doesn't match the token's identity. +function lakebaseAuthHint(message: string): string | undefined { + if (/password authentication failed/i.test(message)) { + return ( + "Lakebase uses an OAuth token as the password, so this usually means the" + + " PGUSER/role doesn't match your identity. Check PGUSER is your exact" + + " login (the literal `user@domain`, not URL-encoded)." + ); + } + return undefined; +} + +// Lakebase has no cheap control-plane `.get()`, so existence is proven by a +// real connection + `SELECT 1` (exercising endpoint resolution, OAuth token +// mint, TLS, and reachability — the same path the app uses). +const probePostgres: ExistenceProbe = async (client, target) => { + if (!field(target, "endpointPath") && !field(target, "host")) { + return missingField("host/endpoint"); + } + + let pool: LakebasePoolHandle | null = null; + try { + pool = await getLakebasePool(client); + await pool.query("SELECT 1"); + return { layer: "existence", status: "ok" }; + } catch (err) { + if (err instanceof AppkitNotInstalledError) { + return { + layer: "existence", + status: "skipped", + code: "APPKIT_NOT_INSTALLED", + detail: err.message, + }; + } + const message = cleanMessage(err); + return { + layer: "existence", + status: "error", + code: "CONNECTION_FAILED", + detail: `could not connect to Lakebase Postgres: ${message}`, + hint: lakebaseAuthHint(message), + }; + } finally { + if (pool) { + try { + await pool.end(); + } catch { + // best-effort close + } + } + } +}; + +/** + * Normalizes a volume reference to the SDK's 3-level `catalog.schema.volume` + * name, accepting either an already-dotted name or a `/Volumes/c/s/v/...` path. + */ +export function toThreeLevelVolumeName(raw: string): string | null { + const trimmed = raw.trim(); + if (/^[^./\s]+\.[^./\s]+\.[^./\s]+$/.test(trimmed)) return trimmed; + + const m = trimmed.match(/^\/Volumes\/([^/]+)\/([^/]+)\/([^/]+)/); + if (m) return `${m[1]}.${m[2]}.${m[3]}`; + + return null; +} + +// Types not listed (secret, uc_connection, database, app, experiment) have no +// probe and fall through to NOT_IMPLEMENTED in runExistenceProbe. +const PROBES: Record = { + sql_warehouse: probeWarehouse, + serving_endpoint: probeServing, + genie_space: probeGenie, + job: probeJob, + volume: probeVolume, + vector_search_index: probeVectorIndex, + uc_function: probeFunction, + postgres: probePostgres, +}; + +export async function runExistenceProbe( + client: unknown, + target: ResourceTarget, +): Promise { + const probe = PROBES[target.type]; + if (!probe) { + return { + layer: "existence", + status: "skipped", + code: "NOT_IMPLEMENTED", + detail: `existence check not implemented for ${target.type}`, + }; + } + return probe(client as DoctorWorkspaceClient, target); +} diff --git a/packages/shared/src/cli/commands/doctor/checks.test.ts b/packages/shared/src/cli/commands/doctor/checks.test.ts new file mode 100644 index 000000000..34bc109a6 --- /dev/null +++ b/packages/shared/src/cli/commands/doctor/checks.test.ts @@ -0,0 +1,184 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { checkAuth, checkConfig, validateHost } from "./checks"; +import type { ResourceTarget } from "./types"; + +const { mockGetServiceClient } = vi.hoisted(() => ({ + mockGetServiceClient: vi.fn(), +})); +vi.mock("./databricks-client", () => ({ + SdkNotInstalledError: class SdkNotInstalledError extends Error {}, + getServiceClient: mockGetServiceClient, +})); + +function target(overrides: Partial = {}): ResourceTarget { + return { + type: "sql_warehouse", + resourceKey: "sql-warehouse", + alias: "SQL Warehouse", + plugin: "analytics", + requiredPermission: "CAN_USE", + required: true, + envVars: ["DOCTOR_TEST_ENV"], + fieldValues: {}, + ...overrides, + }; +} + +describe("checkConfig", () => { + const saved = { ...process.env }; + + beforeEach(() => { + delete process.env.DOCTOR_TEST_ENV; + delete process.env.DOCTOR_TEST_ENV_2; + }); + + afterEach(() => { + process.env = { ...saved }; + }); + + it("errors when a required resource's env var is unset", async () => { + const result = await checkConfig(target()); + expect(result.status).toBe("error"); + expect(result.code).toBe("ENV_MISSING"); + }); + + it("warns (not errors) when an optional resource's env var is unset", async () => { + const result = await checkConfig(target({ required: false })); + expect(result.status).toBe("warn"); + expect(result.code).toBe("ENV_MISSING_OPTIONAL"); + }); + + it("passes an unfilled-looking value (existence check is the authority)", async () => { + // Placeholder-looking values are NOT flagged here by design — the live + // existence check decides whether a set value is real. + process.env.DOCTOR_TEST_ENV = "your_sql_warehouse_id"; + const result = await checkConfig(target()); + expect(result.status).toBe("ok"); + }); + + it("treats an empty string as missing", async () => { + process.env.DOCTOR_TEST_ENV = " "; + const result = await checkConfig(target()); + expect(result.status).toBe("error"); + expect(result.code).toBe("ENV_MISSING"); + }); + + it("passes when all env vars are set to real-looking values", async () => { + process.env.DOCTOR_TEST_ENV = "abc123def456"; + const result = await checkConfig(target()); + expect(result.status).toBe("ok"); + }); + + it("passes when a resource declares no env vars", async () => { + const result = await checkConfig(target({ envVars: [] })); + expect(result.status).toBe("ok"); + }); + + it("reports all missing env vars when several are unset", async () => { + const result = await checkConfig( + target({ envVars: ["DOCTOR_TEST_ENV", "DOCTOR_TEST_ENV_2"] }), + ); + expect(result.status).toBe("error"); + expect(result.detail).toContain("DOCTOR_TEST_ENV"); + expect(result.detail).toContain("DOCTOR_TEST_ENV_2"); + }); +}); + +describe("validateHost", () => { + it("accepts an unset host (SDK auth chain takes over)", () => { + expect(validateHost(undefined)).toBeNull(); + expect(validateHost("")).toBeNull(); + }); + + it("accepts a real workspace URL", () => { + expect(validateHost("https://foo.cloud.databricks.com")).toBeNull(); + expect(validateHost("https://adb-123.4.azuredatabricks.net")).toBeNull(); + }); + + it("rejects the unfilled template placeholder https://...", () => { + expect(validateHost("https://...")).toMatch(/placeholder/i); + }); + + it("rejects a non-URL value", () => { + expect(validateHost("not-a-url")).toMatch(/not a valid URL/i); + }); + + it("rejects a non-http(s) scheme", () => { + expect(validateHost("ftp://foo.databricks.com")).toMatch(/http/i); + }); +}); + +describe("checkAuth", () => { + const savedHost = process.env.DATABRICKS_HOST; + + afterEach(() => { + mockGetServiceClient.mockReset(); + if (savedHost === undefined) delete process.env.DATABRICKS_HOST; + else process.env.DATABRICKS_HOST = savedHost; + }); + + it("ok + returns the client when currentUser.me() succeeds", async () => { + process.env.DATABRICKS_HOST = "https://foo.cloud.databricks.com"; + const client = { currentUser: { me: async () => ({ userName: "sp-1" }) } }; + mockGetServiceClient.mockResolvedValue({ client }); + + const { result, client: returned } = await checkAuth({}); + expect(result.status).toBe("ok"); + expect(result.code).toBe("AUTH_OK"); + expect(result.detail).toContain("sp-1"); + expect(returned).toBe(client); // handed to the live layers + }); + + it("errors HOST_INVALID before touching the SDK", async () => { + process.env.DATABRICKS_HOST = "https://..."; + const { result, client } = await checkAuth({}); + expect(result.status).toBe("error"); + expect(result.code).toBe("HOST_INVALID"); + expect(client).toBeUndefined(); + expect(mockGetServiceClient).not.toHaveBeenCalled(); + }); + + it("errors SDK_NOT_INSTALLED when the bridge reports the SDK is absent", async () => { + process.env.DATABRICKS_HOST = "https://foo.cloud.databricks.com"; + const { SdkNotInstalledError } = await import("./databricks-client"); + mockGetServiceClient.mockRejectedValue(new SdkNotInstalledError()); + + const { result } = await checkAuth({}); + expect(result.status).toBe("error"); + expect(result.code).toBe("SDK_NOT_INSTALLED"); + }); + + it("errors AUTH_FAILED on any other auth error", async () => { + process.env.DATABRICKS_HOST = "https://foo.cloud.databricks.com"; + mockGetServiceClient.mockRejectedValue(new Error("something odd")); + + const { result } = await checkAuth({}); + expect(result.status).toBe("error"); + expect(result.code).toBe("AUTH_FAILED"); + expect(result.detail).toContain("something odd"); + expect(result.hint).toBeUndefined(); // unrecognized failure → no guess + }); + + it("hints to reauthenticate on an expired/failed CLI token (real SDK message)", async () => { + process.env.DATABRICKS_HOST = "https://foo.cloud.databricks.com"; + mockGetServiceClient.mockRejectedValue( + new Error( + "default auth: databricks-cli: cannot get access token: Command failed: databricks auth token", + ), + ); + + const { result } = await checkAuth({ profile: "prod" }); + expect(result.hint).toMatch(/expired|reauthenticate/i); + expect(result.hint).toContain("databricks auth login --profile prod"); + }); + + it("hints about a missing profile (real SDK message)", async () => { + process.env.DATABRICKS_HOST = "https://foo.cloud.databricks.com"; + mockGetServiceClient.mockRejectedValue( + new Error("resolve: ~/.databrickscfg has no nope profile configured"), + ); + + const { result } = await checkAuth({}); + expect(result.hint).toMatch(/Profile not found/i); + }); +}); diff --git a/packages/shared/src/cli/commands/doctor/checks.ts b/packages/shared/src/cli/commands/doctor/checks.ts new file mode 100644 index 000000000..815f4e0e0 --- /dev/null +++ b/packages/shared/src/cli/commands/doctor/checks.ts @@ -0,0 +1,197 @@ +/** + * The `auth` and `config` layer checks. (The `existence` layer's per-type + * probes live in `checks-existence.ts`.) + */ + +import { runExistenceProbe } from "./checks-existence"; +import { getServiceClient, SdkNotInstalledError } from "./databricks-client"; +import type { + AuthCheckResult, + DoctorOptions, + LayerResult, + ResourceTarget, +} from "./types"; + +/** The auth result plus the resolved client (present only on success), which + * the orchestrator hands to the live layers so they don't rebuild it. */ +export interface AuthOutcome { + result: AuthCheckResult; + client?: unknown; +} + +interface CurrentUserClient { + currentUser: { + me: () => Promise<{ id?: string; userName?: string }>; + }; +} + +/** + * Validates `DATABRICKS_HOST` before we hand it to the SDK, so an unfilled + * placeholder (e.g. `https://...`) gets a clear message instead of the SDK's + * opaque "cannot configure default credentials" error. Returns an error + * message, or null when the host is acceptable (or unset — then the SDK's own + * auth chain takes over). + * + * @internal exported for unit testing. + */ +export function validateHost(host: string | undefined): string | null { + if (host === undefined || host.trim().length === 0) return null; + + let url: URL; + try { + url = new URL(host); + } catch { + return `DATABRICKS_HOST is not a valid URL: "${host}"`; + } + + if (url.protocol !== "https:" && url.protocol !== "http:") { + return `DATABRICKS_HOST must be an http(s) URL: "${host}"`; + } + + // Placeholders like "https://..." parse as a URL but have a hostname with no + // real (dotted, alphanumeric) label. + const hostname = url.hostname; + const hasRealLabel = /[a-z0-9]/i.test(hostname) && hostname.includes("."); + if (!hasRealLabel || /^[.\-_]+$/.test(hostname)) { + return `DATABRICKS_HOST looks like an unfilled placeholder: "${host}"`; + } + + return null; +} + +/** + * Layer: auth. Runs once, app-wide; a failure short-circuits every resource's + * existence check (they all need the client). + */ +export async function checkAuth(options: DoctorOptions): Promise { + const hostError = validateHost(process.env.DATABRICKS_HOST); + if (hostError) { + return { + result: { + status: "error", + code: "HOST_INVALID", + detail: hostError, + host: process.env.DATABRICKS_HOST, + profile: options.profile, + }, + }; + } + + try { + const { client } = await getServiceClient(options.profile); + const me = await (client as CurrentUserClient).currentUser.me(); + const who = me.userName ?? me.id ?? "unknown"; + + return { + client, + result: { + status: "ok", + code: "AUTH_OK", + detail: `authenticated as ${who}`, + host: process.env.DATABRICKS_HOST, + profile: options.profile, + }, + }; + } catch (err) { + if (err instanceof SdkNotInstalledError) { + return { + result: { + status: "error", + code: "SDK_NOT_INSTALLED", + detail: err.message, + profile: options.profile, + }, + }; + } + const detail = err instanceof Error ? err.message : String(err); + return { + result: { + status: "error", + code: "AUTH_FAILED", + detail: `failed to authenticate to the workspace: ${detail}`, + hint: authFailureHint(detail, options.profile), + host: process.env.DATABRICKS_HOST, + profile: options.profile, + }, + }; + } +} + +/** + * Infers guidance for the common auth failures, which the SDK surfaces as + * opaque strings. Patterns are matched against real SDK messages (a missing + * profile, an expired CLI token, and no credentials at all), most specific + * first. Returns the command that fixes each, or undefined for the unknown. + */ +function authFailureHint( + message: string, + profile?: string, +): string | undefined { + const loginCmd = profile + ? `databricks auth login --profile ${profile}` + : "databricks auth login"; + + // "resolve: ~/.databrickscfg has no profile configured" + if ( + /has no .* profile configured|profile .* (does not exist|not found)/i.test( + message, + ) + ) { + return `Profile not found in ~/.databrickscfg. Run \`${loginCmd}\`, or pass an existing profile via --profile.`; + } + // Expired/failed CLI token: "cannot get access token", "databricks auth + // token", "refresh token is invalid", "reauthenticate". + if ( + /cannot get access token|refresh token|reauthenticate|databricks auth token|token .*expired/i.test( + message, + ) + ) { + return `Your login has expired or the token could not be fetched. Run \`${loginCmd}\` to reauthenticate.`; + } + // No credentials resolved by any auth method. + if ( + /cannot configure default credentials|default auth|no .*credentials/i.test( + message, + ) + ) { + return `No credentials found. Run \`${loginCmd}\`, or set a profile via --profile / DATABRICKS_CONFIG_PROFILE.`; + } + return undefined; +} + +/** + * Layer: config. Offline check that each declared env var is present. Presence + * only — whether a set value points at a real resource is the existence layer's + * job, so this never guesses at placeholder values. + */ +export async function checkConfig( + target: ResourceTarget, +): Promise { + const missing: string[] = []; + + for (const envVar of target.envVars) { + const value = process.env[envVar]; + if (value === undefined || value.trim().length === 0) { + missing.push(envVar); + } + } + + if (missing.length > 0) { + return { + layer: "config", + status: target.required ? "error" : "warn", + code: target.required ? "ENV_MISSING" : "ENV_MISSING_OPTIONAL", + detail: `${target.required ? "required" : "optional"} resource is missing env var(s): ${missing.join(", ")}`, + }; + } + + return { layer: "config", status: "ok" }; +} + +/** Layer: existence. Dispatches to the per-type probe in `checks-existence.ts`. */ +export async function checkExistence( + target: ResourceTarget, + client: unknown, +): Promise { + return runExistenceProbe(client, target); +} diff --git a/packages/shared/src/cli/commands/doctor/databricks-client.ts b/packages/shared/src/cli/commands/doctor/databricks-client.ts new file mode 100644 index 000000000..8084f9654 --- /dev/null +++ b/packages/shared/src/cli/commands/doctor/databricks-client.ts @@ -0,0 +1,96 @@ +/** + * The single seam where `appkit doctor` crosses into the Databricks SDK. + * + * The CLI lives in the SDK-free `shared` package, so it reaches the SDK via a + * runtime `import(...)` — keeping `shared` free of the dependency and degrading + * gracefully when it's absent. All Databricks-touching check code goes through + * this module; nothing else in the doctor command imports the SDK. + */ + +/** Raised when `@databricks/sdk-experimental` is not resolvable at runtime. */ +export class SdkNotInstalledError extends Error { + constructor() { + super( + "The 'doctor' command requires the Databricks SDK (a dependency of @databricks/appkit). Please install @databricks/appkit to run connection checks.", + ); + this.name = "SdkNotInstalledError"; + } +} + +export interface ServiceClientHandle { + /** WorkspaceClient, typed as unknown to keep `shared` SDK-free. */ + client: unknown; +} + +function isModuleNotFound(err: unknown): boolean { + return ( + err instanceof Error && + (err.message.includes("Cannot find module") || + err.message.includes("Cannot find package") || + (err as NodeJS.ErrnoException).code === "ERR_MODULE_NOT_FOUND") + ); +} + +/** Env var the Databricks SDK's unified auth reads to select a CLI profile. */ +const CONFIG_PROFILE_ENV = "DATABRICKS_CONFIG_PROFILE"; + +/** Constructs a `WorkspaceClient` via the SDK's unified-auth chain. `profile` + * is applied through the env var the SDK reads. */ +export async function getServiceClient( + profile?: string, +): Promise { + if (profile) { + // Permanent for this process — fine for a one-shot CLI, but note it if this + // is ever reused in a test or long-lived context. + process.env[CONFIG_PROFILE_ENV] = profile; + } + + let sdk: typeof import("@databricks/sdk-experimental"); + try { + sdk = await import("@databricks/sdk-experimental"); + } catch (err) { + if (isModuleNotFound(err)) { + throw new SdkNotInstalledError(); + } + throw err; + } + + const client = new sdk.WorkspaceClient({}); + return { client }; +} + +/** Raised when `@databricks/appkit` (needed for the Lakebase probe) is absent. */ +export class AppkitNotInstalledError extends Error { + constructor() { + super( + "The Lakebase connection check requires @databricks/appkit. Please install it to run this check.", + ); + this.name = "AppkitNotInstalledError"; + } +} + +/** Minimal `pg.Pool`-shaped handle. The caller owns it and must call `end()`. */ +export interface LakebasePoolHandle { + query: (sql: string) => Promise; + end: () => Promise; +} + +/** Builds a Lakebase pool via `createLakebasePool`. Connection settings + * (`PGHOST`, `LAKEBASE_ENDPOINT`, …) come from the environment; we inject only + * the resolved workspace client for OAuth token minting. */ +export async function getLakebasePool( + client: unknown, +): Promise { + let appkit: typeof import("@databricks/appkit"); + try { + appkit = await import("@databricks/appkit"); + } catch (err) { + if (isModuleNotFound(err)) { + throw new AppkitNotInstalledError(); + } + throw err; + } + + const pool = appkit.createLakebasePool({ workspaceClient: client as never }); + return pool as unknown as LakebasePoolHandle; +} diff --git a/packages/shared/src/cli/commands/doctor/index.ts b/packages/shared/src/cli/commands/doctor/index.ts new file mode 100644 index 000000000..dfaf1dcea --- /dev/null +++ b/packages/shared/src/cli/commands/doctor/index.ts @@ -0,0 +1,43 @@ +import process from "node:process"; +import { Command } from "commander"; +import { exitCodeFor, printReport, printReportJson } from "./report"; +import { runDoctor } from "./run"; +import type { DoctorOptions } from "./types"; + +async function runDoctorCommand(options: DoctorOptions): Promise { + const report = await runDoctor(options); + + if (options.json) { + printReportJson(report); + } else { + printReport(report); + } + + process.exit(exitCodeFor(report)); +} + +export const doctorCommand = new Command("doctor") + .description( + "Diagnose an AppKit app's Databricks resources: authentication, config, and resource existence/reachability", + ) + .option( + "-m, --manifest ", + "Path to the resolved template manifest", + "appkit.plugins.json", + ) + .option("-p, --profile ", "Databricks CLI profile to authenticate with") + .option("--json", "Output the diagnostic report as JSON") + .addHelpText( + "after", + ` +Examples: + $ appkit doctor + $ appkit doctor --profile my-profile + $ appkit doctor --json`, + ) + .action((opts: DoctorOptions) => + runDoctorCommand(opts).catch((err) => { + console.error(err); + process.exit(1); + }), + ); diff --git a/packages/shared/src/cli/commands/doctor/report.test.ts b/packages/shared/src/cli/commands/doctor/report.test.ts new file mode 100644 index 000000000..7cc666ef8 --- /dev/null +++ b/packages/shared/src/cli/commands/doctor/report.test.ts @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { exitCodeFor, printReport, printReportJson } from "./report"; +import type { DoctorReport, ResourceCheckResult } from "./types"; + +function report(overrides: Partial = {}): DoctorReport { + return { + auth: { status: "ok" }, + resources: [], + summary: { ok: 0, warn: 0, error: 0, skipped: 0 }, + ...overrides, + }; +} + +/** Runs `fn` with console.log captured; returns the printed lines. */ +function capture(fn: () => void): string[] { + const lines: string[] = []; + const spy = vi.spyOn(console, "log").mockImplementation((msg?: unknown) => { + lines.push(String(msg)); + }); + try { + fn(); + return lines; + } finally { + spy.mockRestore(); + } +} + +function res( + status: ResourceCheckResult["status"], + type: string, +): ResourceCheckResult { + return { + target: { + type, + resourceKey: type, + alias: type, + plugin: "p", + requiredPermission: "X", + required: true, + envVars: [], + fieldValues: {}, + }, + status, + layers: [], + }; +} + +describe("printReport ordering", () => { + afterEach(() => vi.restoreAllMocks()); + + it("prints resources most-severe first (error, warn, skipped, ok)", () => { + const lines: string[] = []; + vi.spyOn(console, "log").mockImplementation((msg?: unknown) => { + lines.push(String(msg)); + }); + + // Deliberately supplied in non-severity order. + const report: DoctorReport = { + auth: { status: "ok" }, + resources: [ + res("warn", "warned"), + res("ok", "okay"), + res("error", "errored"), + res("skipped", "skip"), + ], + summary: { ok: 1, warn: 1, error: 1, skipped: 1 }, + }; + + printReport(report); + + const order = ["errored", "warned", "skip", "okay"].map((t) => + lines.findIndex((l) => l.includes(t)), + ); + // Every row was printed and appears in strictly increasing (severity) order. + expect(order.every((i) => i >= 0)).toBe(true); + expect(order).toEqual([...order].sort((a, b) => a - b)); + }); + + it("summary shows only non-zero categories", () => { + const lines = capture(() => + printReport( + report({ summary: { ok: 3, warn: 0, error: 0, skipped: 0 } }), + ), + ); + expect(lines.some((l) => l === "3 ok")).toBe(true); + expect(lines.some((l) => /warning|error|skipped/.test(l))).toBe(false); + }); + + it("folds an auth error into the summary error count", () => { + const lines = capture(() => + printReport( + report({ + auth: { status: "error", detail: "bad creds" }, + summary: { ok: 0, warn: 0, error: 0, skipped: 0 }, + }), + ), + ); + // Auth failure counts as an error even though no resource errored. + expect(lines.some((l) => l.startsWith("1 error"))).toBe(true); + expect(lines.some((l) => /Fix authentication first/.test(l))).toBe(true); + }); +}); + +describe("exitCodeFor", () => { + it("is 0 when auth ok and no resource errors", () => { + expect( + exitCodeFor( + report({ summary: { ok: 2, warn: 1, error: 0, skipped: 1 } }), + ), + ).toBe(0); + }); + + it("is 1 when auth failed", () => { + expect(exitCodeFor(report({ auth: { status: "error" } }))).toBe(1); + }); + + it("is 1 when any resource errored", () => { + expect( + exitCodeFor( + report({ summary: { ok: 0, warn: 0, error: 1, skipped: 0 } }), + ), + ).toBe(1); + }); +}); + +describe("printReportJson", () => { + it("emits the full report as parseable JSON", () => { + const r = report({ summary: { ok: 1, warn: 0, error: 0, skipped: 0 } }); + const lines = capture(() => printReportJson(r)); + expect(JSON.parse(lines.join("\n"))).toEqual(r); + }); +}); diff --git a/packages/shared/src/cli/commands/doctor/report.ts b/packages/shared/src/cli/commands/doctor/report.ts new file mode 100644 index 000000000..65cadc3da --- /dev/null +++ b/packages/shared/src/cli/commands/doctor/report.ts @@ -0,0 +1,101 @@ +/** + * Rendering for `appkit doctor` — turns a {@link DoctorReport} into either a + * human-readable console report or JSON, matching the `--json` convention used + * by `plugin list` / `plugin validate`. + */ + +import type { CheckStatus, DoctorReport } from "./types"; + +function glyph(status: CheckStatus): string { + switch (status) { + case "ok": + return "✓"; + case "warn": + return "⚠"; + case "error": + return "✗"; + default: + return "•"; + } +} + +/** Row display order: most severe first (stable within a status). */ +const DISPLAY_ORDER: Record = { + error: 0, + warn: 1, + skipped: 2, + ok: 3, +}; + +/** Shows only the non-zero categories. */ +function summaryLine(counts: { + ok: number; + warn: number; + error: number; + skipped: number; +}): string { + const parts: string[] = []; + if (counts.error) + parts.push(`${counts.error} error${counts.error > 1 ? "s" : ""}`); + if (counts.warn) + parts.push(`${counts.warn} warning${counts.warn > 1 ? "s" : ""}`); + if (counts.ok) parts.push(`${counts.ok} ok`); + if (counts.skipped) parts.push(`${counts.skipped} skipped`); + return parts.length > 0 ? parts.join(", ") : "nothing to check"; +} + +export function printReport(report: DoctorReport): void { + const { auth, resources, summary } = report; + + console.log(""); + console.log("Databricks AppKit — connection check"); + if (auth.profile) console.log(` profile ${auth.profile}`); + if (auth.host) console.log(` workspace ${auth.host}`); + console.log(""); + + console.log(`${glyph(auth.status)} Auth ${auth.detail ?? auth.status}`); + if (auth.hint) console.log(` hint: ${auth.hint}`); + console.log(""); + + if (resources.length === 0) { + console.log("No resources declared."); + } else { + console.log("Resources"); + const ordered = [...resources].sort( + (a, b) => DISPLAY_ORDER[a.status] - DISPLAY_ORDER[b.status], + ); + for (const r of ordered) { + const { target } = r; + // The `plugin · type` attribution only earns its place when a row needs + // fixing (tells you where to go); on a pass the alias alone is cleaner. + const suffix = + r.status !== "ok" ? ` ${target.plugin} · ${target.type}` : ""; + console.log(` ${glyph(r.status)} ${target.alias}${suffix}`); + for (const layer of r.layers) { + if (layer.status === "ok") continue; + if (layer.detail) console.log(` ↳ ${layer.detail}`); + if (layer.hint) console.log(` hint: ${layer.hint}`); + } + } + } + console.log(""); + + // Fold auth failure into the counts so it doesn't read as "0 errors" just + // because resources weren't probed. + const authError = auth.status === "error" ? 1 : 0; + console.log(summaryLine({ ...summary, error: summary.error + authError })); + if (authError) { + console.log("Fix authentication first, then re-run to check resources."); + } + console.log(""); +} + +export function printReportJson(report: DoctorReport): void { + console.log(JSON.stringify(report, null, 2)); +} + +/** Non-zero if auth or any resource errored, so `appkit doctor` can gate CI. */ +export function exitCodeFor(report: DoctorReport): number { + if (report.auth.status === "error") return 1; + return report.summary.error > 0 ? 1 : 0; +} diff --git a/packages/shared/src/cli/commands/doctor/resolve-targets.test.ts b/packages/shared/src/cli/commands/doctor/resolve-targets.test.ts new file mode 100644 index 000000000..aee758e84 --- /dev/null +++ b/packages/shared/src/cli/commands/doctor/resolve-targets.test.ts @@ -0,0 +1,180 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + resolveTargetsFromCwd, + targetsFromManifestFile, +} from "./resolve-targets"; + +function makeTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "doctor-targets-")); +} + +function cleanDir(dir: string): void { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // best effort + } +} + +const MANIFEST = { + version: "2.0", + plugins: { + analytics: { + resources: { + required: [ + { + type: "sql_warehouse", + resourceKey: "sql-warehouse", + alias: "SQL Warehouse", + permission: "CAN_USE", + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }, + ], + optional: [], + }, + }, + agents: { + resources: { + required: [], + optional: [ + { + type: "serving_endpoint", + resourceKey: "serving", + alias: "Chat model", + permission: "CAN_QUERY", + fields: { name: { env: "DATABRICKS_SERVING_ENDPOINT_NAME" } }, + }, + ], + }, + }, + server: { resources: { required: [], optional: [] } }, + }, +}; + +describe("targetsFromManifestFile", () => { + const dirs: string[] = []; + afterEach(() => { + for (const d of dirs) cleanDir(d); + dirs.length = 0; + }); + + function writeManifest(obj: unknown): string { + const dir = makeTempDir(); + dirs.push(dir); + const p = path.join(dir, "appkit.plugins.json"); + fs.writeFileSync(p, JSON.stringify(obj)); + return p; + } + + it("flattens required and optional resources across plugins", () => { + const targets = targetsFromManifestFile(writeManifest(MANIFEST)); + expect(targets).toHaveLength(2); + + const warehouse = targets.find((t) => t.type === "sql_warehouse"); + expect(warehouse).toMatchObject({ + type: "sql_warehouse", + resourceKey: "sql-warehouse", + alias: "SQL Warehouse", + plugin: "analytics", + requiredPermission: "CAN_USE", + required: true, + envVars: ["DATABRICKS_WAREHOUSE_ID"], + }); + + const serving = targets.find((t) => t.type === "serving_endpoint"); + expect(serving).toMatchObject({ + plugin: "agents", + required: false, + envVars: ["DATABRICKS_SERVING_ENDPOINT_NAME"], + }); + }); + + it("excludes value-default and platform-injected fields from envVars (regression: bug #3)", () => { + const targets = targetsFromManifestFile( + writeManifest({ + version: "2.0", + plugins: { + lakebase: { + resources: { + required: [ + { + type: "postgres", + resourceKey: "pg", + alias: "Postgres", + permission: "CAN_CONNECT_AND_CREATE", + fields: { + endpointPath: { env: "LAKEBASE_ENDPOINT", origin: "cli" }, + host: { + env: "PGHOST", + localOnly: true, + origin: "platform", + }, + port: { + env: "PGPORT", + value: "5432", + localOnly: true, + origin: "platform", + }, + sslmode: { + env: "PGSSLMODE", + value: "require", + localOnly: true, + origin: "platform", + }, + }, + }, + ], + optional: [], + }, + }, + }, + }), + ); + const pg = targets.find((t) => t.type === "postgres"); + // Only the user-supplied endpoint is presence-checked; value-default and + // platform-injected fields must not be flagged as missing. + expect(pg?.envVars).toEqual(["LAKEBASE_ENDPOINT"]); + }); + + it("returns an empty list for a manifest with no resources", () => { + const targets = targetsFromManifestFile( + writeManifest({ version: "2.0", plugins: { server: {} } }), + ); + expect(targets).toEqual([]); + }); + + it("throws on invalid JSON", () => { + const dir = makeTempDir(); + dirs.push(dir); + const p = path.join(dir, "appkit.plugins.json"); + fs.writeFileSync(p, "{ not json"); + expect(() => targetsFromManifestFile(p)).toThrow(/Failed to parse/); + }); +}); + +describe("resolveTargetsFromCwd", () => { + const dirs: string[] = []; + afterEach(() => { + for (const d of dirs) cleanDir(d); + dirs.length = 0; + }); + + it("returns an empty list when no manifest is present", () => { + const dir = makeTempDir(); + dirs.push(dir); + expect(resolveTargetsFromCwd(dir)).toEqual([]); + }); + + it("reads the manifest from the given cwd", () => { + const dir = makeTempDir(); + dirs.push(dir); + fs.writeFileSync( + path.join(dir, "appkit.plugins.json"), + JSON.stringify(MANIFEST), + ); + expect(resolveTargetsFromCwd(dir)).toHaveLength(2); + }); +}); diff --git a/packages/shared/src/cli/commands/doctor/resolve-targets.ts b/packages/shared/src/cli/commands/doctor/resolve-targets.ts new file mode 100644 index 000000000..a4886d383 --- /dev/null +++ b/packages/shared/src/cli/commands/doctor/resolve-targets.ts @@ -0,0 +1,140 @@ +/** + * Reads the app's resolved template manifest (`appkit.plugins.json`) and + * flattens each plugin's declared resources into the flat {@link ResourceTarget} + * shape the checks consume. Offline and SDK-free — parses the same file + * `appkit plugin list` reads. + */ + +import fs from "node:fs"; +import path from "node:path"; +import type { ResourceTarget } from "./types"; + +export const DEFAULT_MANIFEST_FILE = "appkit.plugins.json"; + +interface ManifestField { + env?: string; + /** Static default value baked into the manifest. */ + value?: string; + /** Computed origin: how the value is supplied (see the manifest schema). */ + origin?: "user" | "platform" | "static" | "cli"; + /** Only generated into the local .env; the platform injects it at deploy. */ + localOnly?: boolean; +} + +interface ManifestResource { + type: string; + resourceKey: string; + alias?: string; + permission: string; + fields?: Record; +} + +interface ManifestPlugin { + resources?: { + required?: ManifestResource[]; + optional?: ManifestResource[]; + }; +} + +interface TemplateManifest { + plugins?: Record; +} + +/** A field's env var is only the developer's to supply when it has no static + * default and isn't platform-injected at deploy time (origin "platform" / + * localOnly). Those the config layer must not flag as missing. */ +function isUserSuppliedEnv(field: ManifestField): boolean { + if (field.value !== undefined) return false; + if (field.origin === "platform" || field.origin === "static") return false; + if (field.localOnly) return false; + return true; +} + +/** Env vars the config layer should presence-check (i.e. user-supplied ones). */ +function envVarsOf(resource: ManifestResource): string[] { + const fields = resource.fields ?? {}; + const envs: string[] = []; + for (const field of Object.values(fields)) { + if (field?.env && isUserSuppliedEnv(field)) envs.push(field.env); + } + return envs; +} + +/** Resolves each field's value from the environment, keyed by manifest field + * name. Unset/empty fields are omitted so probes can tell provided from absent. */ +function fieldValuesOf(resource: ManifestResource): Record { + const fields = resource.fields ?? {}; + const values: Record = {}; + for (const [fieldName, field] of Object.entries(fields)) { + const env = field?.env; + if (!env) continue; + const value = process.env[env]; + if (value !== undefined && value.trim().length > 0) { + values[fieldName] = value; + } + } + return values; +} + +function toTarget( + plugin: string, + resource: ManifestResource, + required: boolean, +): ResourceTarget { + return { + type: resource.type, + resourceKey: resource.resourceKey, + alias: resource.alias ?? resource.resourceKey, + plugin, + requiredPermission: resource.permission, + required, + envVars: envVarsOf(resource), + fieldValues: fieldValuesOf(resource), + }; +} + +/** @throws if the file cannot be read or parsed. */ +export function targetsFromManifestFile( + manifestPath: string, +): ResourceTarget[] { + let raw: string; + try { + raw = fs.readFileSync(manifestPath, "utf-8"); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to read manifest file ${manifestPath}: ${msg}`); + } + + let data: TemplateManifest; + try { + data = JSON.parse(raw) as TemplateManifest; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to parse manifest file ${manifestPath}: ${msg}`); + } + + const targets: ResourceTarget[] = []; + for (const [pluginName, plugin] of Object.entries(data.plugins ?? {})) { + const resources = plugin.resources ?? {}; + for (const resource of resources.required ?? []) { + targets.push(toTarget(pluginName, resource, true)); + } + for (const resource of resources.optional ?? []) { + targets.push(toTarget(pluginName, resource, false)); + } + } + return targets; +} + +/** Returns an empty list if the manifest is absent — an app may legitimately + * declare no resources. */ +export function resolveTargetsFromCwd( + cwd: string = process.cwd(), + manifestFile: string = DEFAULT_MANIFEST_FILE, +): ResourceTarget[] { + const manifestPath = path.resolve(cwd, manifestFile); + if (!fs.existsSync(manifestPath)) { + return []; + } + return targetsFromManifestFile(manifestPath); +} diff --git a/packages/shared/src/cli/commands/doctor/run.test.ts b/packages/shared/src/cli/commands/doctor/run.test.ts new file mode 100644 index 000000000..5de53668d --- /dev/null +++ b/packages/shared/src/cli/commands/doctor/run.test.ts @@ -0,0 +1,84 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { runDoctor } from "./run"; + +/** + * Tests for the doctor orchestrator. The auth layer reaches the Databricks SDK, + * so these mock the bridge to keep the orchestration assertions deterministic + * and offline. + */ +vi.mock("./databricks-client", () => ({ + SdkNotInstalledError: class SdkNotInstalledError extends Error {}, + // Default: a client whose currentUser.me() succeeds. + getServiceClient: vi.fn(async () => ({ + client: { currentUser: { me: async () => ({ userName: "app-sp" }) } }, + })), +})); + +describe("runDoctor", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("reports ok auth by default", async () => { + const report = await runDoctor({}); + expect(report.auth.status).toBe("ok"); + expect(report.auth.code).toBe("AUTH_OK"); + }); + + it("resolves no resources when cwd has no manifest, with an empty summary", async () => { + // Point cwd at a dir guaranteed to have no appkit.plugins.json. + const spy = vi.spyOn(process, "cwd").mockReturnValue("/nonexistent-doctor"); + const report = await runDoctor({}); + expect(report.resources).toEqual([]); + expect(report.summary).toEqual({ ok: 0, warn: 0, error: 0, skipped: 0 }); + spy.mockRestore(); + }); + + it("still resolves and offline-checks resources when auth fails", async () => { + const { getServiceClient } = await import("./databricks-client"); + vi.mocked(getServiceClient).mockRejectedValueOnce(new Error("no creds")); + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "doctor-run-")); + fs.writeFileSync( + path.join(dir, "appkit.plugins.json"), + JSON.stringify({ + version: "2.0", + plugins: { + analytics: { + resources: { + required: [ + { + type: "sql_warehouse", + resourceKey: "sql-warehouse", + alias: "SQL Warehouse", + permission: "CAN_USE", + fields: { id: { env: "DOCTOR_RUN_MISSING_ENV" } }, + }, + ], + optional: [], + }, + }, + }, + }), + ); + const spy = vi.spyOn(process, "cwd").mockReturnValue(dir); + delete process.env.DOCTOR_RUN_MISSING_ENV; + + const report = await runDoctor({}); + + expect(report.auth.status).toBe("error"); + // Resource was still resolved and offline-checked despite auth failure. + expect(report.resources).toHaveLength(1); + expect(report.resources[0].status).toBe("error"); + const configLayer = report.resources[0].layers.find( + (l) => l.layer === "config", + ); + expect(configLayer?.status).toBe("error"); + + spy.mockRestore(); + fs.rmSync(dir, { recursive: true, force: true }); + }); +}); diff --git a/packages/shared/src/cli/commands/doctor/run.ts b/packages/shared/src/cli/commands/doctor/run.ts new file mode 100644 index 000000000..b65c1da9c --- /dev/null +++ b/packages/shared/src/cli/commands/doctor/run.ts @@ -0,0 +1,88 @@ +/** + * Orchestration for `appkit doctor`. + * + * Resolves the resources the app declares, runs the app-wide auth check once, + * then per resource runs the offline `config` layer followed by the live + * `existence` layer. Rolls everything up into a {@link DoctorReport}. + */ + +import { checkAuth, checkConfig, checkExistence } from "./checks"; +import { resolveTargetsFromCwd } from "./resolve-targets"; +import type { + CheckStatus, + DoctorOptions, + DoctorReport, + LayerResult, + ResourceCheckResult, + ResourceTarget, +} from "./types"; + +const STATUS_SEVERITY: Record = { + ok: 0, + skipped: 1, + warn: 2, + error: 3, +}; + +function worst(a: CheckStatus, b: CheckStatus): CheckStatus { + return STATUS_SEVERITY[b] > STATUS_SEVERITY[a] ? b : a; +} + +/** Returns an empty list when no manifest is present — an app may legitimately + * declare no resources, which doctor reports rather than treating as an error. */ +export async function resolveTargets( + options: DoctorOptions, +): Promise { + return resolveTargetsFromCwd(process.cwd(), options.manifest); +} + +async function checkResource( + target: ResourceTarget, + // undefined = auth failed, so the live existence layer is skipped. + client: unknown | undefined, +): Promise { + const layers: LayerResult[] = []; + let rolled: CheckStatus = "ok"; + + const configResult = await checkConfig(target); + layers.push(configResult); + rolled = worst(rolled, configResult.status); + // A hard config failure (missing id) makes the existence probe meaningless. + if (configResult.status === "error") { + return { target, status: rolled, layers }; + } + + if (client === undefined) { + layers.push({ + layer: "existence", + status: "skipped", + code: "AUTH_UNAVAILABLE", + detail: "skipped because workspace authentication failed", + }); + rolled = worst(rolled, "skipped"); + } else { + const result = await checkExistence(target, client); + layers.push(result); + rolled = worst(rolled, result.status); + } + + return { target, status: rolled, layers }; +} + +export async function runDoctor(options: DoctorOptions): Promise { + const { result: auth, client } = await checkAuth(options); + + const summary = { ok: 0, warn: 0, error: 0, skipped: 0 }; + const resources: ResourceCheckResult[] = []; + + // Resources are resolved and config-checked even when auth failed, so a bad + // connection still surfaces config problems instead of hiding them all. + const targets = await resolveTargets(options); + for (const target of targets) { + const result = await checkResource(target, client); + resources.push(result); + summary[result.status] += 1; + } + + return { auth, resources, summary }; +} diff --git a/packages/shared/src/cli/commands/doctor/types.ts b/packages/shared/src/cli/commands/doctor/types.ts new file mode 100644 index 000000000..3f70d92f6 --- /dev/null +++ b/packages/shared/src/cli/commands/doctor/types.ts @@ -0,0 +1,63 @@ +/** Type definitions for the `appkit doctor` command. */ + +/** Layers run per resource, in order; a hard failure short-circuits the rest. */ +export type CheckLayer = "auth" | "config" | "existence"; + +export type CheckStatus = "ok" | "warn" | "error" | "skipped"; + +export interface LayerResult { + layer: CheckLayer; + status: CheckStatus; + /** The raw finding (what happened). */ + detail?: string; + /** Optional inferred guidance, rendered on its own line below `detail`. */ + hint?: string; + /** Machine-readable code for `--json` consumers (e.g. `NOT_FOUND`). */ + code?: string; +} + +export interface ResourceTarget { + type: string; + resourceKey: string; + /** Human-readable label. */ + alias: string; + plugin: string; + /** Declared permission level; shown for context, not checked. */ + requiredPermission: string; + /** Mandatory (vs optional) for the app. */ + required: boolean; + envVars: string[]; + /** Field values resolved from `process.env`, keyed by manifest field name + * (e.g. `id`, `name`). Unset fields are omitted. */ + fieldValues: Record; +} + +export interface ResourceCheckResult { + target: ResourceTarget; + /** Worst status across all layers run for this resource. */ + status: CheckStatus; + layers: LayerResult[]; +} + +export interface AuthCheckResult { + status: CheckStatus; + detail?: string; + /** Optional inferred guidance, rendered on its own line below `detail`. */ + hint?: string; + code?: string; + host?: string; + profile?: string; +} + +export interface DoctorReport { + auth: AuthCheckResult; + resources: ResourceCheckResult[]; + summary: { ok: number; warn: number; error: number; skipped: number }; +} + +export interface DoctorOptions { + /** Path to the resolved template manifest (defaults to appkit.plugins.json). */ + manifest?: string; + profile?: string; + json?: boolean; +} diff --git a/packages/shared/src/cli/index.ts b/packages/shared/src/cli/index.ts index aa60157c8..53398b0e1 100644 --- a/packages/shared/src/cli/index.ts +++ b/packages/shared/src/cli/index.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; import { Command } from "commander"; import { codemodCommand } from "./commands/codemod/index.js"; import { docsCommand } from "./commands/docs.js"; +import { doctorCommand } from "./commands/doctor/index.js"; import { generateTypesCommand } from "./commands/generate-types.js"; import { lintCommand } from "./commands/lint.js"; import { pluginCommand } from "./commands/plugin/index.js"; @@ -28,5 +29,6 @@ cmd.addCommand(lintCommand); cmd.addCommand(docsCommand); cmd.addCommand(pluginCommand); cmd.addCommand(codemodCommand); +cmd.addCommand(doctorCommand); await cmd.parseAsync();