Skip to content
Draft
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
1 change: 1 addition & 0 deletions packages/auth/src/credentials/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export class M2mCredentialsError extends Error {
/** Discriminant codes for {@link U2mCredentialsError}. */
export type U2mCredentialsErrorCode =
| 'PROFILE_REQUIRED'
| 'PROFILE_NOT_FOUND'
| 'CLI_NOT_FOUND'
| 'LEGACY_CLI_DETECTED'
| 'TOKEN_FETCH_FAILED'
Expand Down
114 changes: 108 additions & 6 deletions packages/auth/src/credentials/u2m.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {join, sep} from 'node:path';
import {env, platform} from 'node:process';
import {promisify} from 'node:util';

import {ProfileError, resolve} from '@databricks/sdk-core/profiles';
import {z} from 'zod';

import type {Token, TokenCredentials} from '../auth';
Expand All @@ -22,8 +23,15 @@ import {U2mCredentialsError} from './errors';
const execFileAsync = promisify(execFile);

/**
* Distinguishes the modern Go-based Databricks CLI (>= 0.100.0) from the
* legacy Python CLI by minimum file size.
* Minimum Databricks CLI version that supports `databricks auth token`. The
* legacy Python CLI predates this and is not compatible.
*/
const MIN_CLI_VERSION = {major: 0, minor: 100, patch: 0};

/**
* Fallback heuristic for {@link isModernCli} when `databricks version` cannot
* be parsed: the modern Go-based CLI binary is always larger than this, while
* the legacy Python launcher is smaller.
*/
const DATABRICKS_CLI_MIN_SIZE = 1024 * 1024;

Expand Down Expand Up @@ -61,6 +69,7 @@ export function newU2mCredentials(
}

async function fetchCliToken(options: U2mCredentialsOptions): Promise<Token> {
await ensureProfileExists(options.profile);
const cliPath = await findDatabricksCli(options.cliPath);
return execCliCommand([
cliPath,
Expand All @@ -71,6 +80,27 @@ async function fetchCliToken(options: U2mCredentialsOptions): Promise<Token> {
]);
}

/**
* Verifies that the requested profile exists in the Databricks config file.
*
* `databricks auth token --profile X` treats an unknown profile as "no
* profile" and fails with a host-related error, which is misleading. Checking
* up front lets us throw a precise error that names the missing profile.
*/
async function ensureProfileExists(profile: string): Promise<void> {
try {
await resolve({profile});
} catch (e) {
if (e instanceof ProfileError && e.code === 'PROFILE_NOT_FOUND') {
throw new U2mCredentialsError(
'PROFILE_NOT_FOUND',
`profile "${profile}" was not found in the Databricks config file`
);
}
throw e;
}
}

const cliTokenResponseSchema = z.object({
access_token: z.string().min(1),
token_type: z.string().optional(),
Expand Down Expand Up @@ -135,17 +165,30 @@ function cliErrorMessage(e: unknown): string {
if (typeof e === 'object' && e !== null) {
const err = e as ExecFileError;
if (err.stderr !== undefined) {
return err.stderr.toString().trim();
return stripErrorPrefix(err.stderr.toString().trim());
}
return err.message;
}
return String(e);
}

/**
* Removes a leading "Error:" label that the CLI prints on its stderr. Without
* this the wrapped message reads "Error: Error: ..." once the SDK adds its own
* context.
*/
function stripErrorPrefix(message: string): string {
const match = /^Error:\s*/.exec(message);
if (match === null) {
return message;
}
return message.slice(match[0].length);
}

/**
* Locates the `databricks` CLI binary, either at `cliPath` (if provided) or
* by searching `PATH`. Validates that the binary is the modern Go CLI and
* not the legacy Python one, via a minimum-size check.
* by searching `PATH`. Validates that the binary is the modern Go CLI and not
* the legacy Python one.
*/
async function findDatabricksCli(cliPath?: string): Promise<string> {
if (cliPath !== undefined) {
Expand Down Expand Up @@ -202,11 +245,70 @@ async function validateCliPath(path: string): Promise<string> {
if (info.isDirectory()) {
throw new U2mCredentialsError('CLI_NOT_FOUND', 'databricks CLI not found');
}
if (info.size < DATABRICKS_CLI_MIN_SIZE) {
if (!(await isModernCli(path, info.size))) {
throw new U2mCredentialsError(
'LEGACY_CLI_DETECTED',
'legacy databricks CLI detected; upgrade to >= 0.100.0'
);
}
return path;
}

/**
* Reports whether the binary at `path` is the modern Go-based Databricks CLI.
*
* The modern CLI reports its version as `Databricks CLI v<semver>`; the legacy
* Python CLI does not. When the version cannot be obtained (e.g. the binary is
* not executable in this environment), fall back to the binary-size heuristic,
* since the legacy launcher is far smaller than `size` bytes.
*/
async function isModernCli(path: string, size: number): Promise<boolean> {
const version = await cliVersion(path);
if (version !== undefined) {
return isAtLeastMinVersion(version);
}
return size >= DATABRICKS_CLI_MIN_SIZE;
}

interface CliVersion {
major: number;
minor: number;
patch: number;
}

const CLI_VERSION_PATTERN = /Databricks CLI v(\d+)\.(\d+)\.(\d+)/;

/**
* Runs `<path> version` and parses the reported semantic version. Returns
* undefined when the command fails or its output does not match the modern
* CLI's version banner.
*/
async function cliVersion(path: string): Promise<CliVersion | undefined> {
let stdout: string;
try {
const result = await execFileAsync(path, ['version']);
stdout = result.stdout;
} catch {
return undefined;
}
const match = CLI_VERSION_PATTERN.exec(stdout);
if (match === null) {
return undefined;
}
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
};
}

/** Reports whether `version` is at least {@link MIN_CLI_VERSION}. */
function isAtLeastMinVersion(version: CliVersion): boolean {
if (version.major !== MIN_CLI_VERSION.major) {
return version.major > MIN_CLI_VERSION.major;
}
if (version.minor !== MIN_CLI_VERSION.minor) {
return version.minor > MIN_CLI_VERSION.minor;
}
return version.patch >= MIN_CLI_VERSION.patch;
}
Loading
Loading