From 07016a7b510f6a1c31ba88650235b339215d5976 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 6 Aug 2026 18:58:10 +0200 Subject: [PATCH] feat(cli): add deploy-config generator for plugin resources (no callers yet) Standalone module reproducing what 'databricks apps init' renders, verified byte-for-byte against golden fixtures captured from a real scaffold: - config-plan: manifest resources -> app.yaml env (valueFrom=resourceKey), databricks.yml bundle variables + resource bindings. Per-type binding specs for the verified types (sql_warehouse, postgres). Platform-origin fields are excluded from app.yaml env (platform-injected at deploy). - config-writer: comment-preserving additive YAML patch via the 'yaml' package (never clobbers existing entries; idempotent), plus a 'databricks bundle validate' post-write gate. - Unverified resource types emit env entries but skip the databricks.yml binding with a warning rather than guessing. - __fixtures__/: analytics + lakebase configs captured from 'databricks apps init' as golden ground truth. - Adds 'yaml' dependency to packages/shared. 11 tests incl. byte-exact fixture comparison and idempotency. Wired into 'appkit add' in the follow-up commit. Signed-off-by: MarioCadenas --- packages/shared/package.json | 1 + .../registry/__fixtures__/analytics/app.yaml | 4 + .../analytics/appkit.plugins.json | 366 ++++++++++++++++++ .../__fixtures__/analytics/databricks.yml | 32 ++ .../__fixtures__/analytics/env.example.txt | 5 + .../registry/__fixtures__/lakebase/app.yaml | 4 + .../__fixtures__/lakebase/appkit.plugins.json | 366 ++++++++++++++++++ .../__fixtures__/lakebase/databricks.yml | 39 ++ .../__fixtures__/lakebase/env.example.txt | 9 + .../cli/commands/registry/config-plan.test.ts | 115 ++++++ .../src/cli/commands/registry/config-plan.ts | 153 ++++++++ .../commands/registry/config-writer.test.ts | 154 ++++++++ .../cli/commands/registry/config-writer.ts | 207 ++++++++++ pnpm-lock.yaml | 3 + 14 files changed, 1458 insertions(+) create mode 100644 packages/shared/src/cli/commands/registry/__fixtures__/analytics/app.yaml create mode 100644 packages/shared/src/cli/commands/registry/__fixtures__/analytics/appkit.plugins.json create mode 100644 packages/shared/src/cli/commands/registry/__fixtures__/analytics/databricks.yml create mode 100644 packages/shared/src/cli/commands/registry/__fixtures__/analytics/env.example.txt create mode 100644 packages/shared/src/cli/commands/registry/__fixtures__/lakebase/app.yaml create mode 100644 packages/shared/src/cli/commands/registry/__fixtures__/lakebase/appkit.plugins.json create mode 100644 packages/shared/src/cli/commands/registry/__fixtures__/lakebase/databricks.yml create mode 100644 packages/shared/src/cli/commands/registry/__fixtures__/lakebase/env.example.txt create mode 100644 packages/shared/src/cli/commands/registry/config-plan.test.ts create mode 100644 packages/shared/src/cli/commands/registry/config-plan.ts create mode 100644 packages/shared/src/cli/commands/registry/config-writer.test.ts create mode 100644 packages/shared/src/cli/commands/registry/config-writer.ts diff --git a/packages/shared/package.json b/packages/shared/package.json index 21edd56cc..f497cda84 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -44,6 +44,7 @@ "@standard-schema/spec": "1.1.0", "commander": "12.1.0", "picocolors": "1.1.1", + "yaml": "2.8.2", "zod": "4.3.6" } } diff --git a/packages/shared/src/cli/commands/registry/__fixtures__/analytics/app.yaml b/packages/shared/src/cli/commands/registry/__fixtures__/analytics/app.yaml new file mode 100644 index 000000000..860b549ae --- /dev/null +++ b/packages/shared/src/cli/commands/registry/__fixtures__/analytics/app.yaml @@ -0,0 +1,4 @@ +command: ['npm', 'run', 'start'] +env: + - name: DATABRICKS_WAREHOUSE_ID + valueFrom: sql-warehouse diff --git a/packages/shared/src/cli/commands/registry/__fixtures__/analytics/appkit.plugins.json b/packages/shared/src/cli/commands/registry/__fixtures__/analytics/appkit.plugins.json new file mode 100644 index 000000000..f27feddcc --- /dev/null +++ b/packages/shared/src/cli/commands/registry/__fixtures__/analytics/appkit.plugins.json @@ -0,0 +1,366 @@ +{ + "$schema": "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", + "version": "2.0", + "plugins": { + "agents": { + "name": "agents", + "displayName": "Agents Plugin", + "description": "AI agents driven by markdown configs or code, with auto-tool-discovery from registered plugins", + "package": "@databricks/appkit", + "resources": { + "required": [], + "optional": [ + { + "type": "serving_endpoint", + "alias": "Default LLM for agents", + "resourceKey": "agents-serving-endpoint", + "description": "Default streaming-capable LLM endpoint for agents that don't pin their own model", + "permission": "CAN_QUERY", + "fields": { + "name": { + "env": "DATABRICKS_SERVING_ENDPOINT_NAME", + "description": "Default LLM serving endpoint name", + "origin": "user" + } + } + } + ] + }, + "stability": "beta" + }, + "analytics": { + "name": "analytics", + "displayName": "Analytics Plugin", + "description": "SQL query execution against Databricks SQL Warehouses", + "package": "@databricks/appkit", + "resources": { + "required": [ + { + "type": "sql_warehouse", + "alias": "SQL Warehouse", + "resourceKey": "sql-warehouse", + "description": "SQL Warehouse for executing analytics queries", + "permission": "CAN_USE", + "fields": { + "id": { + "env": "DATABRICKS_WAREHOUSE_ID", + "description": "SQL Warehouse ID", + "discovery": { + "type": "kind", + "resourceKind": "warehouse" + }, + "origin": "user" + } + } + } + ], + "optional": [] + }, + "scaffolding": { + "rules": { + "must": [ + "Before init, ensure the SQL Warehouse passed via --set analytics.sql-warehouse.id is running" + ], + "should": [ + "After init, ensure config/queries/ has at least one .sql file before running npm run typegen" + ] + } + } + }, + "files": { + "name": "files", + "displayName": "Files Plugin", + "description": "File operations against Databricks Volumes and Unity Catalog", + "package": "@databricks/appkit", + "resources": { + "required": [ + { + "type": "volume", + "alias": "Files", + "resourceKey": "files", + "description": "Permission to write to volumes", + "permission": "WRITE_VOLUME", + "fields": { + "path": { + "env": "DATABRICKS_VOLUME_FILES", + "description": "Volume path for file storage (e.g. /Volumes/catalog/schema/volume_name)", + "discovery": { + "type": "kind", + "resourceKind": "volume", + "select": "full_name" + }, + "origin": "user" + } + } + } + ], + "optional": [] + }, + "scaffolding": { + "rules": { + "must": [ + "Before init, verify your Unity Catalog volume exists and you have WRITE_VOLUME permission" + ] + } + } + }, + "genie": { + "name": "genie", + "displayName": "Genie Plugin", + "description": "AI/BI Genie space integration for natural language data queries", + "package": "@databricks/appkit", + "resources": { + "required": [ + { + "type": "genie_space", + "alias": "Genie Space", + "resourceKey": "genie-space", + "description": "Genie Space for AI-powered data queries. Space IDs configured via plugin config.", + "permission": "CAN_RUN", + "fields": { + "id": { + "env": "DATABRICKS_GENIE_SPACE_ID", + "description": "Default Genie Space ID", + "discovery": { + "type": "kind", + "resourceKind": "genie_space" + }, + "origin": "user" + }, + "name": { + "description": "Genie Space display name", + "origin": "user" + } + } + } + ], + "optional": [] + }, + "scaffolding": { + "rules": { + "must": [ + "After init, configure the 'spaces' map in plugin config with alias-to-Space-ID mappings" + ] + } + } + }, + "jobs": { + "name": "jobs", + "displayName": "Jobs Plugin", + "description": "Manage Databricks Lakeflow Jobs.", + "package": "@databricks/appkit", + "resources": { + "required": [ + { + "type": "job", + "alias": "Job", + "resourceKey": "job", + "description": "A Databricks job to trigger and monitor", + "permission": "CAN_MANAGE_RUN", + "fields": { + "id": { + "env": "DATABRICKS_JOB_ID", + "description": "Numeric Databricks job ID. Find it in the Jobs UI or via `databricks jobs list`.", + "origin": "user" + } + } + } + ], + "optional": [] + } + }, + "lakebase": { + "name": "lakebase", + "displayName": "Lakebase", + "description": "SQL query execution against Databricks Lakebase Autoscaling", + "package": "@databricks/appkit", + "resources": { + "required": [ + { + "type": "postgres", + "alias": "Postgres", + "resourceKey": "postgres", + "description": "Lakebase Postgres database for persistent storage", + "permission": "CAN_CONNECT_AND_CREATE", + "fields": { + "project": { + "description": "Full Lakebase Postgres project resource name. Obtain by running `databricks postgres list-projects`, select the desired item from the output array and use its .name value.", + "examples": ["projects/{project-id}"], + "discovery": { + "type": "kind", + "resourceKind": "postgres_project", + "select": "name" + }, + "origin": "user" + }, + "branch": { + "description": "Full Lakebase Postgres branch resource name. Obtain by running `databricks postgres list-branches {project-name}`, select the desired item from the output array and use its .name value. Requires the project resource name.", + "examples": ["projects/{project-id}/branches/{branch-id}"], + "discovery": { + "type": "kind", + "resourceKind": "postgres_branch", + "select": "name", + "dependsOn": "project" + }, + "origin": "user" + }, + "database": { + "description": "Full Lakebase Postgres database resource name. Obtain by running `databricks postgres list-databases {branch-name}`, select the desired item from the output array and use its .name value. Requires the branch resource name.", + "examples": [ + "projects/{project-id}/branches/{branch-id}/databases/{database-id}" + ], + "discovery": { + "type": "kind", + "resourceKind": "postgres_database", + "select": "name", + "dependsOn": "branch" + }, + "origin": "user" + }, + "host": { + "env": "PGHOST", + "description": "Postgres host for local development. Auto-injected by the platform at deploy time.", + "localOnly": true, + "resolve": "postgres:host", + "origin": "platform" + }, + "databaseName": { + "env": "PGDATABASE", + "description": "Postgres database name for local development. Auto-injected by the platform at deploy time.", + "localOnly": true, + "resolve": "postgres:databaseName", + "origin": "platform" + }, + "endpointPath": { + "env": "LAKEBASE_ENDPOINT", + "description": "Lakebase endpoint resource name. Auto-injected at runtime via app.yaml valueFrom: postgres. For local development, obtain by running `databricks postgres list-endpoints {branch-name}`, select the desired item from the output array and use its .name value.", + "bundleIgnore": true, + "examples": [ + "projects/{project-id}/branches/{branch-id}/endpoints/{endpoint-id}" + ], + "resolve": "postgres:endpointPath", + "origin": "cli" + }, + "port": { + "env": "PGPORT", + "description": "Postgres port. Auto-injected by the platform at deploy time.", + "localOnly": true, + "value": "5432", + "origin": "platform" + }, + "sslmode": { + "env": "PGSSLMODE", + "description": "Postgres SSL mode. Auto-injected by the platform at deploy time.", + "localOnly": true, + "value": "require", + "origin": "platform" + } + } + } + ], + "optional": [] + }, + "scaffolding": { + "rules": { + "should": [ + "After init, run any database migrations for your chosen ORM before first request", + "After init, verify Lakebase connectivity with 'psql $PGHOST -c \"select 1\"'" + ] + } + } + }, + "server": { + "name": "server", + "displayName": "Server Plugin", + "description": "HTTP server with Express, static file serving, and Vite dev mode support", + "package": "@databricks/appkit", + "resources": { + "required": [], + "optional": [] + }, + "requiredByTemplate": true + }, + "serving": { + "name": "serving", + "displayName": "Model Serving Plugin", + "description": "Authenticated proxy to Databricks Model Serving endpoints", + "package": "@databricks/appkit", + "resources": { + "required": [ + { + "type": "serving_endpoint", + "alias": "Serving Endpoint", + "resourceKey": "serving-endpoint", + "description": "Model Serving endpoint for inference", + "permission": "CAN_QUERY", + "fields": { + "name": { + "env": "DATABRICKS_SERVING_ENDPOINT_NAME", + "description": "Serving endpoint name", + "origin": "user" + } + } + } + ], + "optional": [] + } + } + }, + "scaffolding": { + "command": "databricks apps init", + "flags": { + "--name": { + "description": "Project name — sets fixture-analytics in package.json, databricks.yml, and .env. Required for non-interactive scaffolding.", + "required": true, + "pattern": "^[a-z][a-z0-9-]*$" + }, + "--template": { + "description": "Template path (local directory or GitHub URL)", + "required": false + }, + "--version": { + "description": "AppKit version to use; defaults to auto-detected", + "required": false + }, + "--features": { + "description": "Plugins to enable (comma-separated, no spaces; must match keys in this manifest's plugins map)", + "required": false, + "pattern": "^[a-zA-Z0-9_-]+(,[a-zA-Z0-9_-]+)*$" + }, + "--set": { + "description": "Set resource values (format: plugin.resourceKey.field=value, repeatable)", + "required": false + }, + "--output-dir": { + "description": "Directory to write the project to", + "required": false + }, + "--description": { + "description": "App description", + "required": false + }, + "--run": { + "description": "Run the app after creation (none, dev, dev-remote)", + "required": false + }, + "--auto-approve": { + "description": "Pass as a bare flag (no value) to skip prompts for optional resources. Not recommended for agent-driven init — conflicts with the 'ask user when in doubt' rule.", + "required": false + }, + "--profile": { + "description": "Databricks CLI profile to use for authentication (global flag)", + "required": false + } + }, + "rules": { + "must": [ + "Keep all secrets and credentials only in app.yaml, databricks.yml, and/or .env" + ], + "should": ["ask user when in doubt of resource to use for plugin"], + "never": [ + "guess resources when multiple or no options are available", + "embed secrets in files that will go to the client-bundle" + ] + } + } +} diff --git a/packages/shared/src/cli/commands/registry/__fixtures__/analytics/databricks.yml b/packages/shared/src/cli/commands/registry/__fixtures__/analytics/databricks.yml new file mode 100644 index 000000000..3d75dac43 --- /dev/null +++ b/packages/shared/src/cli/commands/registry/__fixtures__/analytics/databricks.yml @@ -0,0 +1,32 @@ +bundle: + name: fixture-analytics + +variables: + sql_warehouse_id: + description: SQL Warehouse ID + +resources: + apps: + app: + name: "fixture-analytics" + description: "fixture capture" + source_code_path: ./ + # Uncomment to enable on behalf of user API scopes. Available scopes: sql, dashboards.genie, files.files, serving.serving-endpoints + # user_api_scopes: + # - sql + + # The resources which this app has access to. + resources: + - name: sql-warehouse + sql_warehouse: + id: ${var.sql_warehouse_id} + permission: CAN_USE + +targets: + default: + default: true + workspace: + host: https://e2-dogfood.staging.cloud.databricks.com + + variables: + sql_warehouse_id: abc123warehouse diff --git a/packages/shared/src/cli/commands/registry/__fixtures__/analytics/env.example.txt b/packages/shared/src/cli/commands/registry/__fixtures__/analytics/env.example.txt new file mode 100644 index 000000000..4ca5e82e9 --- /dev/null +++ b/packages/shared/src/cli/commands/registry/__fixtures__/analytics/env.example.txt @@ -0,0 +1,5 @@ +DATABRICKS_HOST=https://... +DATABRICKS_WAREHOUSE_ID=your_sql_warehouse_id +DATABRICKS_APP_PORT=8000 +DATABRICKS_APP_NAME=fixture-analytics +FLASK_RUN_HOST=0.0.0.0 diff --git a/packages/shared/src/cli/commands/registry/__fixtures__/lakebase/app.yaml b/packages/shared/src/cli/commands/registry/__fixtures__/lakebase/app.yaml new file mode 100644 index 000000000..2d626e2f8 --- /dev/null +++ b/packages/shared/src/cli/commands/registry/__fixtures__/lakebase/app.yaml @@ -0,0 +1,4 @@ +command: ['npm', 'run', 'start'] +env: + - name: LAKEBASE_ENDPOINT + valueFrom: postgres diff --git a/packages/shared/src/cli/commands/registry/__fixtures__/lakebase/appkit.plugins.json b/packages/shared/src/cli/commands/registry/__fixtures__/lakebase/appkit.plugins.json new file mode 100644 index 000000000..afd224672 --- /dev/null +++ b/packages/shared/src/cli/commands/registry/__fixtures__/lakebase/appkit.plugins.json @@ -0,0 +1,366 @@ +{ + "$schema": "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", + "version": "2.0", + "plugins": { + "agents": { + "name": "agents", + "displayName": "Agents Plugin", + "description": "AI agents driven by markdown configs or code, with auto-tool-discovery from registered plugins", + "package": "@databricks/appkit", + "resources": { + "required": [], + "optional": [ + { + "type": "serving_endpoint", + "alias": "Default LLM for agents", + "resourceKey": "agents-serving-endpoint", + "description": "Default streaming-capable LLM endpoint for agents that don't pin their own model", + "permission": "CAN_QUERY", + "fields": { + "name": { + "env": "DATABRICKS_SERVING_ENDPOINT_NAME", + "description": "Default LLM serving endpoint name", + "origin": "user" + } + } + } + ] + }, + "stability": "beta" + }, + "analytics": { + "name": "analytics", + "displayName": "Analytics Plugin", + "description": "SQL query execution against Databricks SQL Warehouses", + "package": "@databricks/appkit", + "resources": { + "required": [ + { + "type": "sql_warehouse", + "alias": "SQL Warehouse", + "resourceKey": "sql-warehouse", + "description": "SQL Warehouse for executing analytics queries", + "permission": "CAN_USE", + "fields": { + "id": { + "env": "DATABRICKS_WAREHOUSE_ID", + "description": "SQL Warehouse ID", + "discovery": { + "type": "kind", + "resourceKind": "warehouse" + }, + "origin": "user" + } + } + } + ], + "optional": [] + }, + "scaffolding": { + "rules": { + "must": [ + "Before init, ensure the SQL Warehouse passed via --set analytics.sql-warehouse.id is running" + ], + "should": [ + "After init, ensure config/queries/ has at least one .sql file before running npm run typegen" + ] + } + } + }, + "files": { + "name": "files", + "displayName": "Files Plugin", + "description": "File operations against Databricks Volumes and Unity Catalog", + "package": "@databricks/appkit", + "resources": { + "required": [ + { + "type": "volume", + "alias": "Files", + "resourceKey": "files", + "description": "Permission to write to volumes", + "permission": "WRITE_VOLUME", + "fields": { + "path": { + "env": "DATABRICKS_VOLUME_FILES", + "description": "Volume path for file storage (e.g. /Volumes/catalog/schema/volume_name)", + "discovery": { + "type": "kind", + "resourceKind": "volume", + "select": "full_name" + }, + "origin": "user" + } + } + } + ], + "optional": [] + }, + "scaffolding": { + "rules": { + "must": [ + "Before init, verify your Unity Catalog volume exists and you have WRITE_VOLUME permission" + ] + } + } + }, + "genie": { + "name": "genie", + "displayName": "Genie Plugin", + "description": "AI/BI Genie space integration for natural language data queries", + "package": "@databricks/appkit", + "resources": { + "required": [ + { + "type": "genie_space", + "alias": "Genie Space", + "resourceKey": "genie-space", + "description": "Genie Space for AI-powered data queries. Space IDs configured via plugin config.", + "permission": "CAN_RUN", + "fields": { + "id": { + "env": "DATABRICKS_GENIE_SPACE_ID", + "description": "Default Genie Space ID", + "discovery": { + "type": "kind", + "resourceKind": "genie_space" + }, + "origin": "user" + }, + "name": { + "description": "Genie Space display name", + "origin": "user" + } + } + } + ], + "optional": [] + }, + "scaffolding": { + "rules": { + "must": [ + "After init, configure the 'spaces' map in plugin config with alias-to-Space-ID mappings" + ] + } + } + }, + "jobs": { + "name": "jobs", + "displayName": "Jobs Plugin", + "description": "Manage Databricks Lakeflow Jobs.", + "package": "@databricks/appkit", + "resources": { + "required": [ + { + "type": "job", + "alias": "Job", + "resourceKey": "job", + "description": "A Databricks job to trigger and monitor", + "permission": "CAN_MANAGE_RUN", + "fields": { + "id": { + "env": "DATABRICKS_JOB_ID", + "description": "Numeric Databricks job ID. Find it in the Jobs UI or via `databricks jobs list`.", + "origin": "user" + } + } + } + ], + "optional": [] + } + }, + "lakebase": { + "name": "lakebase", + "displayName": "Lakebase", + "description": "SQL query execution against Databricks Lakebase Autoscaling", + "package": "@databricks/appkit", + "resources": { + "required": [ + { + "type": "postgres", + "alias": "Postgres", + "resourceKey": "postgres", + "description": "Lakebase Postgres database for persistent storage", + "permission": "CAN_CONNECT_AND_CREATE", + "fields": { + "project": { + "description": "Full Lakebase Postgres project resource name. Obtain by running `databricks postgres list-projects`, select the desired item from the output array and use its .name value.", + "examples": ["projects/{project-id}"], + "discovery": { + "type": "kind", + "resourceKind": "postgres_project", + "select": "name" + }, + "origin": "user" + }, + "branch": { + "description": "Full Lakebase Postgres branch resource name. Obtain by running `databricks postgres list-branches {project-name}`, select the desired item from the output array and use its .name value. Requires the project resource name.", + "examples": ["projects/{project-id}/branches/{branch-id}"], + "discovery": { + "type": "kind", + "resourceKind": "postgres_branch", + "select": "name", + "dependsOn": "project" + }, + "origin": "user" + }, + "database": { + "description": "Full Lakebase Postgres database resource name. Obtain by running `databricks postgres list-databases {branch-name}`, select the desired item from the output array and use its .name value. Requires the branch resource name.", + "examples": [ + "projects/{project-id}/branches/{branch-id}/databases/{database-id}" + ], + "discovery": { + "type": "kind", + "resourceKind": "postgres_database", + "select": "name", + "dependsOn": "branch" + }, + "origin": "user" + }, + "host": { + "env": "PGHOST", + "description": "Postgres host for local development. Auto-injected by the platform at deploy time.", + "localOnly": true, + "resolve": "postgres:host", + "origin": "platform" + }, + "databaseName": { + "env": "PGDATABASE", + "description": "Postgres database name for local development. Auto-injected by the platform at deploy time.", + "localOnly": true, + "resolve": "postgres:databaseName", + "origin": "platform" + }, + "endpointPath": { + "env": "LAKEBASE_ENDPOINT", + "description": "Lakebase endpoint resource name. Auto-injected at runtime via app.yaml valueFrom: postgres. For local development, obtain by running `databricks postgres list-endpoints {branch-name}`, select the desired item from the output array and use its .name value.", + "bundleIgnore": true, + "examples": [ + "projects/{project-id}/branches/{branch-id}/endpoints/{endpoint-id}" + ], + "resolve": "postgres:endpointPath", + "origin": "cli" + }, + "port": { + "env": "PGPORT", + "description": "Postgres port. Auto-injected by the platform at deploy time.", + "localOnly": true, + "value": "5432", + "origin": "platform" + }, + "sslmode": { + "env": "PGSSLMODE", + "description": "Postgres SSL mode. Auto-injected by the platform at deploy time.", + "localOnly": true, + "value": "require", + "origin": "platform" + } + } + } + ], + "optional": [] + }, + "scaffolding": { + "rules": { + "should": [ + "After init, run any database migrations for your chosen ORM before first request", + "After init, verify Lakebase connectivity with 'psql $PGHOST -c \"select 1\"'" + ] + } + } + }, + "server": { + "name": "server", + "displayName": "Server Plugin", + "description": "HTTP server with Express, static file serving, and Vite dev mode support", + "package": "@databricks/appkit", + "resources": { + "required": [], + "optional": [] + }, + "requiredByTemplate": true + }, + "serving": { + "name": "serving", + "displayName": "Model Serving Plugin", + "description": "Authenticated proxy to Databricks Model Serving endpoints", + "package": "@databricks/appkit", + "resources": { + "required": [ + { + "type": "serving_endpoint", + "alias": "Serving Endpoint", + "resourceKey": "serving-endpoint", + "description": "Model Serving endpoint for inference", + "permission": "CAN_QUERY", + "fields": { + "name": { + "env": "DATABRICKS_SERVING_ENDPOINT_NAME", + "description": "Serving endpoint name", + "origin": "user" + } + } + } + ], + "optional": [] + } + } + }, + "scaffolding": { + "command": "databricks apps init", + "flags": { + "--name": { + "description": "Project name — sets fixture-lb in package.json, databricks.yml, and .env. Required for non-interactive scaffolding.", + "required": true, + "pattern": "^[a-z][a-z0-9-]*$" + }, + "--template": { + "description": "Template path (local directory or GitHub URL)", + "required": false + }, + "--version": { + "description": "AppKit version to use; defaults to auto-detected", + "required": false + }, + "--features": { + "description": "Plugins to enable (comma-separated, no spaces; must match keys in this manifest's plugins map)", + "required": false, + "pattern": "^[a-zA-Z0-9_-]+(,[a-zA-Z0-9_-]+)*$" + }, + "--set": { + "description": "Set resource values (format: plugin.resourceKey.field=value, repeatable)", + "required": false + }, + "--output-dir": { + "description": "Directory to write the project to", + "required": false + }, + "--description": { + "description": "App description", + "required": false + }, + "--run": { + "description": "Run the app after creation (none, dev, dev-remote)", + "required": false + }, + "--auto-approve": { + "description": "Pass as a bare flag (no value) to skip prompts for optional resources. Not recommended for agent-driven init — conflicts with the 'ask user when in doubt' rule.", + "required": false + }, + "--profile": { + "description": "Databricks CLI profile to use for authentication (global flag)", + "required": false + } + }, + "rules": { + "must": [ + "Keep all secrets and credentials only in app.yaml, databricks.yml, and/or .env" + ], + "should": ["ask user when in doubt of resource to use for plugin"], + "never": [ + "guess resources when multiple or no options are available", + "embed secrets in files that will go to the client-bundle" + ] + } + } +} diff --git a/packages/shared/src/cli/commands/registry/__fixtures__/lakebase/databricks.yml b/packages/shared/src/cli/commands/registry/__fixtures__/lakebase/databricks.yml new file mode 100644 index 000000000..4492677c8 --- /dev/null +++ b/packages/shared/src/cli/commands/registry/__fixtures__/lakebase/databricks.yml @@ -0,0 +1,39 @@ +bundle: + name: fixture-lb + +variables: + postgres_branch: + description: Full Lakebase Postgres branch resource name. Obtain by running `databricks postgres list-branches {project-name}`, select the desired item from the output array and use its .name value. Requires the project resource name. + postgres_database: + description: Full Lakebase Postgres database resource name. Obtain by running `databricks postgres list-databases {branch-name}`, select the desired item from the output array and use its .name value. Requires the branch resource name. + postgres_project: + description: Full Lakebase Postgres project resource name. Obtain by running `databricks postgres list-projects`, select the desired item from the output array and use its .name value. + +resources: + apps: + app: + name: "fixture-lb" + description: "lb fixture" + source_code_path: ./ + # Uncomment to enable on behalf of user API scopes. Available scopes: sql, dashboards.genie, files.files, serving.serving-endpoints + # user_api_scopes: + # - sql + + # The resources which this app has access to. + resources: + - name: postgres + postgres: + branch: ${var.postgres_branch} + database: ${var.postgres_database} + permission: CAN_CONNECT_AND_CREATE + +targets: + default: + default: true + workspace: + host: https://e2-dogfood.staging.cloud.databricks.com + + variables: + postgres_branch: projects/p1/branches/b1 + postgres_database: projects/p1/branches/b1/databases/db1 + postgres_project: projects/p1 diff --git a/packages/shared/src/cli/commands/registry/__fixtures__/lakebase/env.example.txt b/packages/shared/src/cli/commands/registry/__fixtures__/lakebase/env.example.txt new file mode 100644 index 000000000..3a50eb6c8 --- /dev/null +++ b/packages/shared/src/cli/commands/registry/__fixtures__/lakebase/env.example.txt @@ -0,0 +1,9 @@ +DATABRICKS_HOST=https://... +PGDATABASE=your_postgres_databaseName +LAKEBASE_ENDPOINT=your_postgres_endpointPath +PGHOST=your_postgres_host +PGPORT=5432 +PGSSLMODE=require +DATABRICKS_APP_PORT=8000 +DATABRICKS_APP_NAME=fixture-lb +FLASK_RUN_HOST=0.0.0.0 diff --git a/packages/shared/src/cli/commands/registry/config-plan.test.ts b/packages/shared/src/cli/commands/registry/config-plan.test.ts new file mode 100644 index 000000000..39cccfe1d --- /dev/null +++ b/packages/shared/src/cli/commands/registry/config-plan.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { buildConfigPlan } from "./config-plan"; +import type { ResourceRequirementRow } from "./requirements"; + +/** A DABs `${var.}` reference, built to avoid a JS-template literal. */ +function varRef(name: string): string { + return "${var." + name + "}"; +} + +const WAREHOUSE: ResourceRequirementRow = { + type: "sql_warehouse", + resourceKey: "sql-warehouse", + permission: "CAN_USE", + required: true, + fields: [{ key: "id", env: "DATABRICKS_WAREHOUSE_ID", origin: "user" }], +}; + +// Mirrors the postgres resource from the lakebase fixture manifest. +const POSTGRES: ResourceRequirementRow = { + type: "postgres", + resourceKey: "postgres", + permission: "CAN_CONNECT_AND_CREATE", + required: true, + fields: [ + { key: "project", origin: "user" }, + { key: "branch", origin: "user" }, + { key: "database", origin: "user" }, + { key: "host", env: "PGHOST", origin: "platform", localOnly: true }, + { key: "endpointPath", env: "LAKEBASE_ENDPOINT", origin: "cli" }, + { key: "port", env: "PGPORT", origin: "platform", value: "5432" }, + ], +}; + +describe("buildConfigPlan — sql_warehouse", () => { + it("produces the app.yaml env entry (valueFrom = resourceKey)", () => { + const plan = buildConfigPlan([WAREHOUSE]); + expect(plan.appYamlEnv).toEqual([ + { name: "DATABRICKS_WAREHOUSE_ID", valueFrom: "sql-warehouse" }, + ]); + }); + + it("produces the sql_warehouse_id bundle variable and binding", () => { + const plan = buildConfigPlan([WAREHOUSE], { + DATABRICKS_WAREHOUSE_ID: "abc123warehouse", + }); + expect(plan.bundleVariables).toEqual([ + { + name: "sql_warehouse_id", + description: undefined, + value: "abc123warehouse", + }, + ]); + expect(plan.resourceBindings).toEqual([ + { + name: "sql-warehouse", + type: "sql_warehouse", + permission: "CAN_USE", + fields: { id: varRef("sql_warehouse_id") }, + }, + ]); + expect(plan.unverifiedTypes).toEqual([]); + }); +}); + +describe("buildConfigPlan — postgres", () => { + it("binds only branch+database, but declares all three variables", () => { + const plan = buildConfigPlan([POSTGRES], { + // user-provided values keyed by field key (no env for these) + project: "projects/p1", + branch: "projects/p1/branches/b1", + database: "projects/p1/branches/b1/databases/db1", + }); + expect(plan.bundleVariables.map((v) => v.name)).toEqual([ + "postgres_project", + "postgres_branch", + "postgres_database", + ]); + expect(plan.resourceBindings).toEqual([ + { + name: "postgres", + type: "postgres", + permission: "CAN_CONNECT_AND_CREATE", + fields: { + branch: varRef("postgres_branch"), + database: varRef("postgres_database"), + }, + }, + ]); + }); + + it("puts only cli-origin fields in app.yaml env (not platform)", () => { + const plan = buildConfigPlan([POSTGRES]); + expect(plan.appYamlEnv).toEqual([ + { name: "LAKEBASE_ENDPOINT", valueFrom: "postgres" }, + ]); + }); +}); + +describe("buildConfigPlan — unverified types", () => { + it("still emits env but flags the type and writes no binding", () => { + const genie: ResourceRequirementRow = { + type: "genie_space", + resourceKey: "genie-space", + required: true, + fields: [{ key: "id", env: "GENIE_SPACE_ID", origin: "user" }], + }; + const plan = buildConfigPlan([genie]); + expect(plan.appYamlEnv).toEqual([ + { name: "GENIE_SPACE_ID", valueFrom: "genie-space" }, + ]); + expect(plan.resourceBindings).toEqual([]); + expect(plan.bundleVariables).toEqual([]); + expect(plan.unverifiedTypes).toEqual(["genie_space"]); + }); +}); diff --git a/packages/shared/src/cli/commands/registry/config-plan.ts b/packages/shared/src/cli/commands/registry/config-plan.ts new file mode 100644 index 000000000..2ce47ff43 --- /dev/null +++ b/packages/shared/src/cli/commands/registry/config-plan.ts @@ -0,0 +1,153 @@ +import type { ResourceRequirementRow } from "./requirements"; + +/** + * Deploy-config generation for a plugin's resources, reproducing what + * `databricks apps init` renders. Verified byte-for-byte against golden + * fixtures (see __fixtures__/) for the resource types listed in + * {@link BINDING_SPECS}. Unverified types degrade safely: their env entries + * are still produced (that shape is uniform), but the databricks.yml resource + * binding is skipped with a warning rather than guessed. + */ + +/** An `app.yaml` env entry: `- name: ` + `valueFrom: `. */ +export interface AppYamlEnvEntry { + name: string; + valueFrom: string; +} + +/** A `databricks.yml` top-level bundle variable. */ +export interface BundleVariable { + name: string; + description?: string; + /** The value placed under targets.default.variables. */ + value?: string; +} + +/** A `databricks.yml` app resource binding under resources.apps.app.resources. */ +export interface ResourceBinding { + /** Binding name (= resourceKey). */ + name: string; + /** Resource type key, e.g. sql_warehouse / postgres. */ + type: string; + permission?: string; + /** Binding fields → `${var.}` references. */ + fields: Record; +} + +export interface ConfigPlan { + appYamlEnv: AppYamlEnvEntry[]; + bundleVariables: BundleVariable[]; + resourceBindings: ResourceBinding[]; + /** Resource types encountered that have no verified binding spec. */ + unverifiedTypes: string[]; +} + +/** + * Per-type rules for producing databricks.yml bundle variables and the app + * resource binding. Only types verified against golden fixtures appear here. + * + * - `bindingFields`: field keys included in the resource binding (a subset of + * the manifest fields; e.g. postgres binds branch+database but not project). + * - `variable(field)`: the bundle-variable name for a given field key. + */ +interface BindingSpec { + bindingFields: string[]; + variable: (fieldKey: string) => string; +} + +const BINDING_SPECS: Record = { + // Verified against __fixtures__/analytics. + sql_warehouse: { + bindingFields: ["id"], + // fixture: variable is `sql_warehouse_id` + variable: (f) => `sql_warehouse_${f}`, + }, + // Verified against __fixtures__/lakebase. + postgres: { + bindingFields: ["branch", "database"], + // fixture: variables are `postgres_` (project/branch/database) + variable: (f) => `postgres_${f}`, + }, +}; + +/** Field keys that become bundle variables for a type (superset of binding). */ +const VARIABLE_FIELDS: Record = { + sql_warehouse: ["id"], + postgres: ["project", "branch", "database"], +}; + +/** + * Builds the deploy-config plan for a set of resource rows. `values` supplies + * the concrete values for the target-level bundle variables (keyed by the + * manifest field's env var name for env-bearing fields, else by field key); + * missing values leave the variable value undefined. + */ +export function buildConfigPlan( + rows: ResourceRequirementRow[], + values: Record = {}, +): ConfigPlan { + const appYamlEnv: AppYamlEnvEntry[] = []; + const bundleVariables: BundleVariable[] = []; + const resourceBindings: ResourceBinding[] = []; + const unverifiedTypes: string[] = []; + const seenEnv = new Set(); + const seenVar = new Set(); + + for (const row of rows) { + // app.yaml env: every env-bearing field maps to a valueFrom = resourceKey. + // Platform-injected fields (origin=platform) are NOT bound here — the + // platform provides them directly (fixtures confirm only cli/user fields + // appear in app.yaml env). + const resourceKey = row.resourceKey ?? row.type; + for (const field of row.fields) { + if (!field.env || field.origin === "platform") continue; + if (seenEnv.has(field.env)) continue; + seenEnv.add(field.env); + appYamlEnv.push({ name: field.env, valueFrom: resourceKey }); + } + + const spec = BINDING_SPECS[row.type]; + if (!spec) { + if (!unverifiedTypes.includes(row.type)) unverifiedTypes.push(row.type); + continue; + } + + // Bundle variables (superset of binding fields for this type). + const varFields = VARIABLE_FIELDS[row.type] ?? spec.bindingFields; + for (const fieldKey of varFields) { + const varName = spec.variable(fieldKey); + if (seenVar.has(varName)) continue; + seenVar.add(varName); + const field = row.fields.find((f) => f.key === fieldKey); + const valueKey = field?.env ?? fieldKey; + bundleVariables.push({ + name: varName, + description: field?.description, + value: values[valueKey] ?? field?.value, + }); + } + + // Resource binding: only the spec's binding fields, referencing ${var.X}. + const fields: Record = {}; + for (const fieldKey of spec.bindingFields) { + fields[fieldKey] = `\${var.${spec.variable(fieldKey)}}`; + } + resourceBindings.push({ + name: resourceKey, + type: row.type, + permission: row.permission, + fields, + }); + } + + return { appYamlEnv, bundleVariables, resourceBindings, unverifiedTypes }; +} + +/** True when the plan has any deploy-config content to write. */ +export function planHasContent(plan: ConfigPlan): boolean { + return ( + plan.appYamlEnv.length > 0 || + plan.bundleVariables.length > 0 || + plan.resourceBindings.length > 0 + ); +} diff --git a/packages/shared/src/cli/commands/registry/config-writer.test.ts b/packages/shared/src/cli/commands/registry/config-writer.test.ts new file mode 100644 index 000000000..2d8292568 --- /dev/null +++ b/packages/shared/src/cli/commands/registry/config-writer.test.ts @@ -0,0 +1,154 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { parseDocument } from "yaml"; +import { buildConfigPlan } from "./config-plan"; +import { writeConfig } from "./config-writer"; +import type { ResourceRequirementRow } from "./requirements"; + +const FIXTURES = path.join(__dirname, "__fixtures__"); +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "config-writer-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // best effort + } + } + tempDirs.length = 0; +}); + +/** Compares two YAML strings by parsed value (ignores incidental formatting). */ +function sameYaml(a: string, b: string): boolean { + const pa = parseDocument(a).toJSON(); + const pb = parseDocument(b).toJSON(); + return JSON.stringify(pa) === JSON.stringify(pb); +} + +const WAREHOUSE: ResourceRequirementRow = { + type: "sql_warehouse", + resourceKey: "sql-warehouse", + permission: "CAN_USE", + required: true, + fields: [{ key: "id", env: "DATABRICKS_WAREHOUSE_ID", origin: "user" }], +}; + +describe("writeConfig — golden fixtures (analytics)", () => { + it("app.yaml env matches the databricks-rendered fixture", () => { + const cwd = makeTempDir(); + const plan = buildConfigPlan([WAREHOUSE], { + DATABRICKS_WAREHOUSE_ID: "abc123warehouse", + }); + writeConfig(cwd, plan); + + const generated = fs.readFileSync(path.join(cwd, "app.yaml"), "utf-8"); + const golden = fs.readFileSync( + path.join(FIXTURES, "analytics", "app.yaml"), + "utf-8", + ); + // The fixture also has `command:`; our additive writer only owns `env`. + const genEnv = parseDocument(generated).get("env"); + const goldEnv = parseDocument(golden).get("env"); + expect(JSON.stringify(genEnv)).toBe(JSON.stringify(goldEnv)); + }); + + it("databricks.yml variables + binding match the fixture's shapes", () => { + const cwd = makeTempDir(); + const plan = buildConfigPlan([WAREHOUSE], { + DATABRICKS_WAREHOUSE_ID: "abc123warehouse", + }); + writeConfig(cwd, plan); + + const generated = parseDocument( + fs.readFileSync(path.join(cwd, "databricks.yml"), "utf-8"), + ).toJSON(); + const golden = parseDocument( + fs.readFileSync( + path.join(FIXTURES, "analytics", "databricks.yml"), + "utf-8", + ), + ).toJSON(); + + // Variable definition + expect(generated.variables.sql_warehouse_id).toBeDefined(); + // Resource binding matches + expect(generated.resources.apps.app.resources).toEqual( + golden.resources.apps.app.resources, + ); + // Target value + expect(generated.targets.default.variables.sql_warehouse_id).toBe( + golden.targets.default.variables.sql_warehouse_id, + ); + }); +}); + +describe("writeConfig — additive patching", () => { + it("is idempotent: re-writing changes nothing", () => { + const cwd = makeTempDir(); + const plan = buildConfigPlan([WAREHOUSE], { + DATABRICKS_WAREHOUSE_ID: "abc123warehouse", + }); + const first = writeConfig(cwd, plan); + expect(first.appYamlChanged).toBe(true); + + const appAfterFirst = fs.readFileSync(path.join(cwd, "app.yaml"), "utf-8"); + const second = writeConfig(cwd, plan); + expect(second.appYamlChanged).toBe(false); + expect(second.databricksYmlChanged).toBe(false); + expect(fs.readFileSync(path.join(cwd, "app.yaml"), "utf-8")).toBe( + appAfterFirst, + ); + }); + + it("never clobbers an existing env entry or user comments", () => { + const cwd = makeTempDir(); + fs.writeFileSync( + path.join(cwd, "app.yaml"), + "command: ['npm', 'run', 'start']\n# my comment\nenv:\n - name: EXISTING\n valueFrom: other\n", + ); + const plan = buildConfigPlan([WAREHOUSE]); + writeConfig(cwd, plan); + + const out = fs.readFileSync(path.join(cwd, "app.yaml"), "utf-8"); + expect(out).toContain("# my comment"); + expect(out).toContain("EXISTING"); + expect(out).toContain("DATABRICKS_WAREHOUSE_ID"); + // command line preserved + expect(out).toContain("command:"); + }); + + it("skips databricks.yml binding for unverified types but keeps env", () => { + const cwd = makeTempDir(); + const genie: ResourceRequirementRow = { + type: "genie_space", + resourceKey: "genie-space", + required: true, + fields: [{ key: "id", env: "GENIE_SPACE_ID", origin: "user" }], + }; + const result = writeConfig(cwd, buildConfigPlan([genie])); + expect(result.unverifiedTypes).toEqual(["genie_space"]); + expect(fs.existsSync(path.join(cwd, "app.yaml"))).toBe(true); + // no binding written → databricks.yml not created + expect(fs.existsSync(path.join(cwd, "databricks.yml"))).toBe(false); + }); + + it("produces valid round-trippable YAML", () => { + const cwd = makeTempDir(); + writeConfig( + cwd, + buildConfigPlan([WAREHOUSE], { DATABRICKS_WAREHOUSE_ID: "w1" }), + ); + const db = fs.readFileSync(path.join(cwd, "databricks.yml"), "utf-8"); + expect(() => parseDocument(db).toJSON()).not.toThrow(); + expect(sameYaml(db, db)).toBe(true); + }); +}); diff --git a/packages/shared/src/cli/commands/registry/config-writer.ts b/packages/shared/src/cli/commands/registry/config-writer.ts new file mode 100644 index 000000000..da3649ea9 --- /dev/null +++ b/packages/shared/src/cli/commands/registry/config-writer.ts @@ -0,0 +1,207 @@ +import { type SpawnSyncReturns, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import pc from "picocolors"; +import { parseDocument, type YAMLMap, type YAMLSeq } from "yaml"; +import { + type AppYamlEnvEntry, + type ConfigPlan, + planHasContent, + type ResourceBinding, +} from "./config-plan"; + +export interface ConfigWriteResult { + appYamlChanged: boolean; + databricksYmlChanged: boolean; + /** Env/binding names actually added (skipping ones already present). */ + added: string[]; + /** Resource types skipped for lack of a verified binding spec. */ + unverifiedTypes: string[]; +} + +/** Reads and parses a YAML file into a Document, or a fresh doc if absent. */ +function loadDoc(file: string): ReturnType { + if (fs.existsSync(file)) { + return parseDocument(fs.readFileSync(file, "utf-8")); + } + return parseDocument(""); +} + +/** + * Additively patches `app.yaml`'s `env:` list with entries not already present + * (matched by `name`). Returns the names added. + */ +function patchAppYaml(file: string, entries: AppYamlEnvEntry[]): string[] { + if (entries.length === 0) return []; + const doc = loadDoc(file); + let seq = doc.get("env") as YAMLSeq | undefined; + if (!seq || typeof (seq as YAMLSeq).add !== "function") { + doc.set("env", doc.createNode([])); + seq = doc.get("env") as YAMLSeq; + } + + const existingNames = new Set(); + for (const item of seq.items) { + const name = (item as YAMLMap)?.get?.("name"); + if (typeof name === "string") existingNames.add(name); + } + + const added: string[] = []; + for (const entry of entries) { + if (existingNames.has(entry.name)) continue; + seq.add(doc.createNode({ name: entry.name, valueFrom: entry.valueFrom })); + added.push(entry.name); + } + + if (added.length > 0) fs.writeFileSync(file, doc.toString()); + return added; +} + +/** Navigates/creates a nested map path, returning the leaf map. */ +function ensureMap( + doc: ReturnType, + pathKeys: string[], +): YAMLMap { + let node = doc.contents as unknown as YAMLMap; + const walked: string[] = []; + for (const key of pathKeys) { + walked.push(key); + let child = doc.getIn(walked) as YAMLMap | undefined; + if (!child || typeof (child as YAMLMap).set !== "function") { + doc.setIn(walked, doc.createNode({})); + child = doc.getIn(walked) as YAMLMap; + } + node = child; + } + return node; +} + +/** + * Additively patches `databricks.yml`: adds bundle `variables`, the app + * `resources` bindings, and the target-level variable values — each only if + * not already present. Returns the binding/variable names added. + */ +function patchDatabricksYml(file: string, plan: ConfigPlan): string[] { + if (plan.bundleVariables.length === 0 && plan.resourceBindings.length === 0) { + return []; + } + const doc = loadDoc(file); + const added: string[] = []; + + // Top-level bundle variables. + if (plan.bundleVariables.length > 0) { + const vars = ensureMap(doc, ["variables"]); + for (const v of plan.bundleVariables) { + if (vars.has(v.name)) continue; + const body: Record = {}; + if (v.description) body.description = v.description; + vars.set(v.name, doc.createNode(body)); + added.push(v.name); + } + } + + // App resource bindings. + if (plan.resourceBindings.length > 0) { + const app = ensureMap(doc, ["resources", "apps", "app"]); + let bindings = app.get("resources") as YAMLSeq | undefined; + if (!bindings || typeof (bindings as YAMLSeq).add !== "function") { + app.set("resources", doc.createNode([])); + bindings = app.get("resources") as YAMLSeq; + } + const existing = new Set(); + for (const item of bindings.items) { + const name = (item as YAMLMap)?.get?.("name"); + if (typeof name === "string") existing.add(name); + } + for (const binding of plan.resourceBindings) { + if (existing.has(binding.name)) continue; + bindings.add(doc.createNode(bindingToNode(binding))); + added.push(binding.name); + } + } + + // Target-level variable values. + const withValues = plan.bundleVariables.filter((v) => v.value !== undefined); + if (withValues.length > 0) { + const targetVars = ensureMap(doc, ["targets", "default", "variables"]); + for (const v of withValues) { + if (targetVars.has(v.name)) continue; + targetVars.set(v.name, v.value); + } + } + + if (added.length > 0) fs.writeFileSync(file, doc.toString()); + return added; +} + +/** Shapes a binding into the `{name, : {…fields, permission}}` node. */ +function bindingToNode(binding: ResourceBinding): Record { + const inner: Record = { ...binding.fields }; + if (binding.permission) inner.permission = binding.permission; + return { name: binding.name, [binding.type]: inner }; +} + +/** + * Applies a config plan to `app.yaml` and `databricks.yml` in `cwd` via + * comment-preserving additive patches. Never overwrites existing entries. + */ +export function writeConfig(cwd: string, plan: ConfigPlan): ConfigWriteResult { + const appAdded = patchAppYaml(path.join(cwd, "app.yaml"), plan.appYamlEnv); + const dbAdded = patchDatabricksYml(path.join(cwd, "databricks.yml"), plan); + return { + appYamlChanged: appAdded.length > 0, + databricksYmlChanged: dbAdded.length > 0, + added: [...new Set([...appAdded, ...dbAdded])], + unverifiedTypes: plan.unverifiedTypes, + }; +} + +/** + * Runs `databricks bundle validate` as a post-write correctness gate. Returns + * true when the config validates (or when the CLI is unavailable — a missing + * CLI shouldn't fail an install). Surfaces validation errors to the user. + */ +export function validateBundle(cwd: string, profile?: string): boolean { + const args = ["bundle", "validate"]; + if (profile) args.push("-p", profile); + let result: SpawnSyncReturns; + try { + result = spawnSync("databricks", args, { cwd, encoding: "utf-8" }); + } catch { + console.warn( + pc.yellow(" Skipped bundle validate (databricks CLI not found)."), + ); + return true; + } + if (result.error) { + console.warn( + pc.yellow(" Skipped bundle validate (databricks CLI not found)."), + ); + return true; + } + if (result.status !== 0) { + console.warn(pc.yellow(" databricks bundle validate reported issues:")); + if (result.stderr) console.warn(result.stderr.trim()); + return false; + } + return true; +} + +/** Reports what the config write did, including any unverified-type warnings. */ +export function reportConfigWrite(result: ConfigWriteResult): void { + if (result.added.length > 0) { + console.log( + `${pc.green("Updated deploy config:")} ${result.added.join(", ")}`, + ); + } + if (result.unverifiedTypes.length > 0) { + console.warn( + pc.yellow( + ` No databricks.yml binding written for: ${result.unverifiedTypes.join(", ")}. ` + + "Add the resource binding manually before deploy.", + ), + ); + } +} + +export { planHasContent }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ca405211..723e08a3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -566,6 +566,9 @@ importers: picocolors: specifier: 1.1.1 version: 1.1.1 + yaml: + specifier: 2.8.2 + version: 2.8.2 zod: specifier: 4.3.6 version: 4.3.6