Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions packages/shared/src/cli/commands/doctor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ so the reported problem is the *root* cause, not a symptom.
| ------------ | ----------------------------------------------------- | ---------------------------------------------------------- |
| `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` |
| `existence` | Does the resource exist and is it reachable? | cheapest per-type live probe (`warehouses.getWarehouse`, `modelServing.getInferenceEndpoint`, …); 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`
Expand Down Expand Up @@ -217,15 +217,16 @@ skipped outright, not merged.)

Because of that, a failed or conflicted auth row reports **both** `host:` and
`profile:`. The host shown is the one the SDK actually resolved, read from
`client.config.host` — which the SDK populates lazily on the first API call, so
it's read *after* `me()`, not at construction. When the client never got built,
the host is recovered from the `host=…` fragment the SDK appends to its
`ConfigError`; failing that, it falls back to `DATABRICKS_HOST`. Every path is
passed through `sanitizeHost`, so embedded `user:pass@` credentials can't leak.
`client.getHost()`. That resolves from env + profile only (no network), so it's
available even when the credentials are rejected. When resolution itself fails
(missing profile, no host anywhere), it falls back to `DATABRICKS_HOST`. Every
path is passed through `sanitizeHost`, so embedded `user:pass@` credentials
can't leak.

`HOST_PROFILE_CONFLICT` compares `DATABRICKS_HOST` against the profile's own
declared host, read offline from `~/.databrickscfg` via the SDK's exported
`loadConfigFile` (the resolved config is useless here — env has already won).
declared host, read offline from `~/.databrickscfg` via sdk-core's profile
`resolve` with the env overlay disabled (the resolved config is useless here —
env has already won).
Comparison ignores scheme, case, and trailing slash. It's a **warning**, not an
error: the credentials do work, so it must not gate CI, but a green tick would
hide a real misconfiguration. When auth *also* fails, the conflict replaces the
Expand Down
78 changes: 54 additions & 24 deletions packages/shared/src/cli/commands/doctor/checks-existence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,15 @@ function apiError(statusCode: number, message = "boom"): Error {
describe("runExistenceProbe — sql_warehouse", () => {
it("ok when the warehouse exists and is RUNNING", async () => {
const client = {
warehouses: { get: async () => ({ state: "RUNNING" }) },
warehouses: { getWarehouse: 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" }) },
warehouses: { getWarehouse: async () => ({ state: "STOPPED" }) },
};
const r = await runExistenceProbe(client, target());
expect(r.status).toBe("warn");
Expand All @@ -53,7 +53,7 @@ describe("runExistenceProbe — sql_warehouse", () => {
it("errors NOT_FOUND on a 404", async () => {
const client = {
warehouses: {
get: async () => {
getWarehouse: async () => {
throw apiError(404);
},
},
Expand All @@ -66,7 +66,7 @@ describe("runExistenceProbe — sql_warehouse", () => {
it("errors INVALID_VALUE on a 400, quoting the value (no type/blob leak)", async () => {
const client = {
warehouses: {
get: async () => {
getWarehouse: async () => {
throw Object.assign(
new Error(
'Response from server (Bad Request) {"error_code":"INVALID_PARAMETER_VALUE","message":"bogus is not a valid endpoint id."}',
Expand All @@ -90,7 +90,7 @@ describe("runExistenceProbe — sql_warehouse", () => {
it("errors ACCESS_DENIED on a 403", async () => {
const client = {
warehouses: {
get: async () => {
getWarehouse: async () => {
throw apiError(403);
},
},
Expand All @@ -103,7 +103,7 @@ describe("runExistenceProbe — sql_warehouse", () => {
it("hedges on a 403, which several APIs also return for a missing resource", async () => {
const client = {
warehouses: {
get: async () => {
getWarehouse: async () => {
throw apiError(403);
},
},
Expand All @@ -114,8 +114,31 @@ describe("runExistenceProbe — sql_warehouse", () => {
expect(r.detail).toContain("wh-1");
});

// The modular SDK's ApiError carries `httpStatusCode` / `code`, not the
// legacy `statusCode` / `errorCode`; both must classify the same.
it("classifies the modular ApiError shape (httpStatusCode / code)", async () => {
const modular = (httpStatusCode: number, code: string) => ({
getWarehouse: async () => {
throw Object.assign(new Error("boom"), { httpStatusCode, code });
},
});
const cases: Array<[number, string, string]> = [
[404, "RESOURCE_DOES_NOT_EXIST", "NOT_FOUND"],
[400, "INVALID_PARAMETER_VALUE", "INVALID_VALUE"],
[403, "PERMISSION_DENIED", "ACCESS_DENIED"],
[-1, "RESOURCE_DOES_NOT_EXIST", "NOT_FOUND"],
];
for (const [status, code, expected] of cases) {
const r = await runExistenceProbe(
{ warehouses: modular(status, code) },
target(),
);
expect(r.code, `${status}/${code}`).toBe(expected);
}
});

it("skips when the id field is missing", async () => {
const client = { warehouses: { get: async () => ({}) } };
const client = { warehouses: { getWarehouse: async () => ({}) } };
const r = await runExistenceProbe(client, target({ fieldValues: {} }));
expect(r.status).toBe("skipped");
expect(r.code).toBe("MISSING_FIELD");
Expand All @@ -124,7 +147,7 @@ describe("runExistenceProbe — sql_warehouse", () => {

describe("runExistenceProbe — job", () => {
it("errors on a non-integer job id", async () => {
const client = { jobs: { get: async () => ({}) } };
const client = { jobs: { getJob: async () => ({}) } };
const r = await runExistenceProbe(
client,
target({ type: "job", fieldValues: { id: "not-a-number" } }),
Expand All @@ -134,7 +157,7 @@ describe("runExistenceProbe — job", () => {
});

it("ok for a valid integer job id", async () => {
const client = { jobs: { get: async () => ({}) } };
const client = { jobs: { getJob: async () => ({}) } };
const r = await runExistenceProbe(
client,
target({ type: "job", fieldValues: { id: "42" } }),
Expand All @@ -149,8 +172,8 @@ describe("runExistenceProbe — job", () => {
let seen: unknown;
const client = {
jobs: {
get: async (req: { job_id: unknown }) => {
seen = req.job_id;
getJob: async (req: { jobId: unknown }) => {
seen = req.jobId;
return {};
},
},
Expand All @@ -164,7 +187,7 @@ describe("runExistenceProbe — job", () => {
});

it("rejects numeric forms the API can't take (1e3, 0x10)", async () => {
const client = { jobs: { get: async () => ({}) } };
const client = { jobs: { getJob: async () => ({}) } };
for (const id of ["1e3", "0x10", "4.5", "-1"]) {
const r = await runExistenceProbe(
client,
Expand Down Expand Up @@ -260,7 +283,7 @@ describe("runExistenceProbe — postgres (Lakebase)", () => {

describe("runExistenceProbe — per-type coverage with real manifest keys", () => {
it("serving_endpoint: ok via `name`", async () => {
const client = { servingEndpoints: { get: async () => ({}) } };
const client = { modelServing: { getInferenceEndpoint: async () => ({}) } };
const r = await runExistenceProbe(
client,
target({
Expand All @@ -273,8 +296,8 @@ describe("runExistenceProbe — per-type coverage with real manifest keys", () =

it("serving_endpoint: hints id-vs-name when configured by id and the probe fails", async () => {
const client = {
servingEndpoints: {
get: async () => {
modelServing: {
getInferenceEndpoint: async () => {
throw Object.assign(new Error("not found"), { statusCode: 404 });
},
},
Expand All @@ -289,8 +312,8 @@ describe("runExistenceProbe — per-type coverage with real manifest keys", () =

it("serving_endpoint: no id-vs-name hint when configured by name", async () => {
const client = {
servingEndpoints: {
get: async () => {
modelServing: {
getInferenceEndpoint: async () => {
throw Object.assign(new Error("not found"), { statusCode: 404 });
},
},
Expand All @@ -304,7 +327,7 @@ describe("runExistenceProbe — per-type coverage with real manifest keys", () =
});

it("genie_space: ok via `id`", async () => {
const client = { genie: { getSpace: async () => ({}) } };
const client = { genie: { genieGetSpace: async () => ({}) } };
const r = await runExistenceProbe(
client,
target({ type: "genie_space", fieldValues: { id: "01ef" } }),
Expand All @@ -313,7 +336,8 @@ describe("runExistenceProbe — per-type coverage with real manifest keys", () =
});

it("volume: ok via `path` (real manifest key)", async () => {
const client = { volumes: { read: async () => ({}) } };
const getVolume = vi.fn(async () => ({}));
const client = { volumes: { getVolume } };
const r = await runExistenceProbe(
client,
target({
Expand All @@ -322,20 +346,26 @@ describe("runExistenceProbe — per-type coverage with real manifest keys", () =
}),
);
expect(r.status).toBe("ok");
// UC get requests key the name as `fullNameArg`, not `fullName`.
expect(getVolume).toHaveBeenCalledWith({
fullNameArg: "main.default.files",
});
});

it("uc_function: ok via `name`", async () => {
const client = { functions: { get: async () => ({}) } };
const getFunction = vi.fn(async () => ({}));
const client = { functions: { getFunction } };
const r = await runExistenceProbe(
client,
target({ type: "uc_function", fieldValues: { name: "cat.sch.fn" } }),
);
expect(r.status).toBe("ok");
expect(getFunction).toHaveBeenCalledWith({ fullNameArg: "cat.sch.fn" });
});

it("vector_search_index: probes via camelCase `indexName`", async () => {
const getIndex = vi.fn(async () => ({}));
const client = { vectorSearchIndexes: { getIndex } };
const getVectorIndex = vi.fn(async () => ({}));
const client = { vectorSearch: { getVectorIndex } };
const r = await runExistenceProbe(
client,
target({
Expand All @@ -344,11 +374,11 @@ describe("runExistenceProbe — per-type coverage with real manifest keys", () =
}),
);
expect(r.status).toBe("ok");
expect(getIndex).toHaveBeenCalledWith({ index_name: "main.default.idx" });
expect(getVectorIndex).toHaveBeenCalledWith({ name: "main.default.idx" });
});

it("vector_search_index: skips MISSING_FIELD when index name absent", async () => {
const client = { vectorSearchIndexes: { getIndex: async () => ({}) } };
const client = { vectorSearch: { getVectorIndex: async () => ({}) } };
const r = await runExistenceProbe(
client,
target({ type: "vector_search_index", fieldValues: {} }),
Expand Down
64 changes: 31 additions & 33 deletions packages/shared/src/cli/commands/doctor/checks-existence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* 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.
* The client is typed structurally against the facade's modular clients.
*/

import {
Expand All @@ -18,25 +18,25 @@ const EXISTENCE_OK: LayerResult = { layer: "existence", status: "ok" };

interface DoctorWorkspaceClient {
warehouses: {
get: (r: { id: string }) => Promise<{ state?: string }>;
};
servingEndpoints: {
get: (r: { name: string }) => Promise<unknown>;
getWarehouse: (r: { id: string }) => Promise<{ state?: string }>;
};
genie: {
getSpace: (r: { space_id: string }) => Promise<unknown>;
genieGetSpace: (r: { spaceId: string }) => Promise<unknown>;
};
jobs: {
get: (r: { job_id: number }) => Promise<unknown>;
getJob: (r: { jobId: bigint }) => Promise<unknown>;
};
volumes: {
read: (r: { name: string }) => Promise<unknown>;
vectorSearch: {
getVectorIndex: (r: { name: string }) => Promise<unknown>;
};
vectorSearchIndexes: {
getIndex: (r: { index_name: string }) => Promise<unknown>;
modelServing: {
getInferenceEndpoint: (r: { name: string }) => Promise<unknown>;
};
volumes: {
getVolume: (r: { fullNameArg: string }) => Promise<unknown>;
};
functions: {
get: (r: { name: string }) => Promise<unknown>;
getFunction: (r: { fullNameArg: string }) => Promise<unknown>;
};
}

Expand All @@ -45,21 +45,21 @@ type ExistenceProbe = (
target: ResourceTarget,
) => Promise<LayerResult>;

// Read statusCode/errorCode off the SDK's ApiError structurally.
// Read the HTTP status / Databricks error code off an ApiError structurally.
// Two shapes can reach here: the wrapper's `ApiError` (`statusCode` /
// `errorCode`) and the modular SDK's (`httpStatusCode` / `code`).
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;
if (!err || typeof err !== "object") return undefined;
const e = err as { statusCode?: unknown; httpStatusCode?: unknown };
const code = e.statusCode ?? e.httpStatusCode;
return typeof code === "number" && code >= 0 ? code : 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;
if (!err || typeof err !== "object") return undefined;
const e = err as { errorCode?: unknown; code?: unknown };
const code = e.errorCode ?? e.code;
return typeof code === "string" && code.length > 0 ? code : undefined;
}

// The SDK message often embeds a JSON blob; pull the inner `message` out so
Expand Down Expand Up @@ -153,7 +153,7 @@ 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 wh = await client.warehouses.getWarehouse({ id });
const state = wh.state;
if (state && state !== "RUNNING") {
return {
Expand All @@ -177,7 +177,7 @@ const probeServing: ExistenceProbe = async (client, target) => {
const value = name ?? idOnly;
if (!value) return missingField("name");
try {
await client.servingEndpoints.get({ name: value });
await client.modelServing.getInferenceEndpoint({ name: value });
return EXISTENCE_OK;
} catch (err) {
const result = classifyError(err, target);
Expand All @@ -193,7 +193,7 @@ const probeGenie: ExistenceProbe = async (client, target) => {
const spaceId = field(target, "id");
if (!spaceId) return missingField("id");
try {
await client.genie.getSpace({ space_id: spaceId });
await client.genie.genieGetSpace({ spaceId });
return EXISTENCE_OK;
} catch (err) {
return classifyError(err, target);
Expand All @@ -217,10 +217,8 @@ const probeJob: ExistenceProbe = async (client, target) => {
};
}
try {
// Pass the digits through unconverted to preserve int64 precision; the SDK
// serialises them straight into the request, so a string works even though
// the type says number.
await client.jobs.get({ job_id: id as unknown as number });
// BigInt keeps int64 precision (the digits were validated above).
await client.jobs.getJob({ jobId: BigInt(id) });
return EXISTENCE_OK;
} catch (err) {
return classifyError(err, target);
Expand All @@ -242,7 +240,7 @@ const probeVolume: ExistenceProbe = async (client, target) => {
};
}
try {
await client.volumes.read({ name });
await client.volumes.getVolume({ fullNameArg: name });
return EXISTENCE_OK;
} catch (err) {
return classifyError(err, target);
Expand All @@ -253,7 +251,7 @@ 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 });
await client.vectorSearch.getVectorIndex({ name });
return EXISTENCE_OK;
} catch (err) {
return classifyError(err, target);
Expand All @@ -264,7 +262,7 @@ const probeFunction: ExistenceProbe = async (client, target) => {
const name = field(target, "name");
if (!name) return missingField("name");
try {
await client.functions.get({ name });
await client.functions.getFunction({ fullNameArg: name });
return EXISTENCE_OK;
} catch (err) {
return classifyError(err, target);
Expand Down
Loading
Loading