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
34 changes: 33 additions & 1 deletion packages/shared/src/cli/commands/registry/add.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { describe, expect, it, vi } from "vitest";
import { resolveItems } from "./add";
import { resolveItems, scopesForResources } from "./add";
import type { RegistryItem } from "./client";
import type { ResourceRequirementRow } from "./requirements";

function item(name: string, extra: Partial<RegistryItem> = {}): RegistryItem {
return { name, ...extra };
}

function resourceRow(type: string): ResourceRequirementRow {
return { type, required: true, fields: [] };
}

describe("resolveItems", () => {
it("returns requested items in order", async () => {
const fetch = vi.fn(async (name: string) => item(name));
Expand Down Expand Up @@ -59,3 +64,30 @@ describe("resolveItems", () => {
expect(result.map((i) => i.name)).toEqual(["a", "b"]);
});
});

describe("scopesForResources", () => {
it("maps scope-needing resource types to their user_api_scope", () => {
const scopes = scopesForResources([
resourceRow("genie_space"),
resourceRow("serving_endpoint"),
resourceRow("volume"),
]);
expect(Object.fromEntries(scopes)).toEqual({
genie_space: "dashboards.genie",
serving_endpoint: "serving.serving-endpoints",
volume: "files.files",
});
});

it("returns empty for resources that need no scope", () => {
expect(scopesForResources([resourceRow("sql_warehouse")]).size).toBe(0);
});

it("de-dupes repeated types", () => {
const scopes = scopesForResources([
resourceRow("genie_space"),
resourceRow("genie_space"),
]);
expect(scopes.size).toBe(1);
});
});
71 changes: 68 additions & 3 deletions packages/shared/src/cli/commands/registry/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import {
type RegistryItemFile,
stripNamespace,
} from "./client";
import { buildConfigPlan, planHasContent } from "./config-plan";
import {
reportConfigWrite,
validateBundle,
writeConfig,
} from "./config-writer";
import { REGISTRY_REPO, type RegistryToken, resolveToken } from "./constants";
import { reportEnvResolutions, syncEnv } from "./env-writer";
import {
Expand Down Expand Up @@ -211,6 +217,8 @@ interface AddOptions {
yes?: boolean;
/** Pre-supplied env values from repeated --env KEY=VALUE flags. */
env?: Record<string, string>;
/** Databricks profile passed to `bundle validate` after writing config. */
profile?: string;
}

async function runAdd(refs: string[], opts: AddOptions): Promise<void> {
Expand Down Expand Up @@ -333,7 +341,61 @@ async function runAdd(refs: string[], opts: AddOptions): Promise<void> {
values: opts.env,
});
reportEnvResolutions(resolutions);

// Deploy config (app.yaml + databricks.yml). Values come from what the
// user supplied for env fields (flags or prompts); other fields fall back
// to their manifest defaults inside buildConfigPlan.
const values: Record<string, string> = { ...(opts.env ?? {}) };
for (const r of resolutions) {
if (r.value !== undefined) values[r.env] = r.value;
}
const plan = buildConfigPlan(allRequirements, values);
if (planHasContent(plan)) {
const result = writeConfig(cwd, plan);
reportConfigWrite(result);
if (result.databricksYmlChanged) validateBundle(cwd, opts.profile);
}
warnScopeNeeding(allRequirements);
}
}

/**
* v1 does not write `user_api_scopes` (deferred to the manifest scope
* extension). Warn when an added plugin's resource type is known to need one,
* so the user adds it before deploy.
*/
/** Resource types known to require a user_api_scope, and the scope each needs. */
export const SCOPE_BY_RESOURCE_TYPE: Record<string, string> = {
genie_space: "dashboards.genie",
serving_endpoint: "serving.serving-endpoints",
// volumes/files-backed access uses files.files
volume: "files.files",
};

/** Returns the user_api_scopes implied by a set of resource rows (deduped). */
export function scopesForResources(
rows: ResourceRequirementRow[],
): Map<string, string> {
const needed = new Map<string, string>();
for (const row of rows) {
const scope = SCOPE_BY_RESOURCE_TYPE[row.type];
if (scope) needed.set(row.type, scope);
}
return needed;
}

function warnScopeNeeding(rows: ResourceRequirementRow[]): void {
const needed = scopesForResources(rows);
if (needed.size === 0) return;
const list = [...needed.entries()]
.map(([type, scope]) => `${type} → ${scope}`)
.join(", ");
console.warn(
pc.yellow(
`\n Note: these resources may need a user_api_scope before deploy: ${list}.\n` +
" Add it under resources.apps.app.user_api_scopes in databricks.yml.",
),
);
}

/** Commander reducer for repeatable `--env KEY=VALUE` flags. */
Expand Down Expand Up @@ -366,6 +428,7 @@ export const addCommand = new Command("add")
collectEnvFlag,
{},
)
.option("-p, --profile <name>", "Databricks profile for bundle validate")
.addHelpText(
"after",
`
Expand All @@ -375,9 +438,11 @@ No components.json is required. Item type is detected automatically:
them in your createApp call (use --no-register to skip the server edit)

Server plugins declare Databricks resources. On add, their env vars are
reconciled into .env (and names into .env.example). Interactive by default;
pass --yes for agents/CI (uses --env values, leaves the rest unset) and
--env KEY=VALUE to supply values non-interactively.
reconciled into .env (and names into .env.example), and the deploy config
(app.yaml + databricks.yml resource bindings) is patched to match — existing
entries are never clobbered. Interactive by default; pass --yes for agents/CI
(uses --env values, leaves the rest unset) and --env KEY=VALUE to supply
values non-interactively. Pass --profile to validate the bundle after writing.

The frontend/server roots are detected from common layouts, so you can run
this from the repo root. While the registry repo is private, a read token is
Expand Down