From 4361a33a711fbce534e1f19eecfa1be75cad26c5 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 6 Aug 2026 19:00:50 +0200 Subject: [PATCH] feat(cli): write app.yaml + databricks.yml resource bindings on add Wires the deploy-config generator into 'appkit add' for full parity: - After reconciling .env, builds a config plan from the added plugins' resources and additively patches app.yaml + databricks.yml (values from --env flags / prompts feed the bundle target variables). - Runs 'databricks bundle validate' (with --profile) when databricks.yml changed, as a post-write correctness gate. - Warns when a resource type needs a user_api_scope (genie_space, volume, serving_endpoint) since scopes are deferred to the manifest scope-extension follow-up. - Adds -p/--profile flag. Tests for scopesForResources. Signed-off-by: MarioCadenas --- .../src/cli/commands/registry/add.test.ts | 34 ++++++++- .../shared/src/cli/commands/registry/add.ts | 71 ++++++++++++++++++- 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/cli/commands/registry/add.test.ts b/packages/shared/src/cli/commands/registry/add.test.ts index d04da681a..fdba037c2 100644 --- a/packages/shared/src/cli/commands/registry/add.test.ts +++ b/packages/shared/src/cli/commands/registry/add.test.ts @@ -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 { 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)); @@ -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); + }); +}); diff --git a/packages/shared/src/cli/commands/registry/add.ts b/packages/shared/src/cli/commands/registry/add.ts index 27f7a7726..5f6c20af0 100644 --- a/packages/shared/src/cli/commands/registry/add.ts +++ b/packages/shared/src/cli/commands/registry/add.ts @@ -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 { @@ -211,6 +217,8 @@ interface AddOptions { yes?: boolean; /** Pre-supplied env values from repeated --env KEY=VALUE flags. */ env?: Record; + /** Databricks profile passed to `bundle validate` after writing config. */ + profile?: string; } async function runAdd(refs: string[], opts: AddOptions): Promise { @@ -333,7 +341,61 @@ async function runAdd(refs: string[], opts: AddOptions): Promise { 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 = { ...(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 = { + 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 { + const needed = new Map(); + 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. */ @@ -366,6 +428,7 @@ export const addCommand = new Command("add") collectEnvFlag, {}, ) + .option("-p, --profile ", "Databricks profile for bundle validate") .addHelpText( "after", ` @@ -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