From 5f2f6db907434d3b396115d143929cf535c967b6 Mon Sep 17 00:00:00 2001 From: "pavlo.kozlov" Date: Wed, 4 Nov 2026 15:40:55 +0000 Subject: [PATCH 1/5] Expose the workspace context to bundle templates as .workspace Templates get the workspace of the selected profile as .workspace, in template files and in schema rendering. It has Hostname, ID and URL. URL adds ?w= when a profile, an environment variable, or the bundle in the working directory sets the workspace ID, so that a host shared by several workspaces selects the right one. An ID that the SDK resolved from host metadata is not added, so the host still matches a profile without workspace_id under strict profile matching. ID fails when the ID is unknown, and doesn't make a request to find it. A workspace input declared in the schema takes precedence. Co-authored-by: Isaac --- .../bundles/workspace-template-variable.md | 1 + .../boolean/databricks_template_schema.json | 9 + .../input/boolean/template/workspace.txt.tmpl | 1 + .../workspace_variable/input/input.empty.json | 1 + .../workspace_variable/input/input.false.json | 1 + .../input/input.invalid.json | 1 + .../workspace_variable/input/input.value.json | 1 + .../workspace_variable/input/input.zero.json | 1 + .../integer/databricks_template_schema.json | 9 + .../input/integer/template/workspace.txt.tmpl | 1 + .../no_value/databricks_template_schema.json | 10 + .../no_value/template/workspace.txt.tmpl | 1 + .../workspace_variable/input/out.test.toml | 2 + .../workspace_variable/input/output.txt | 108 ++++++++++ .../workspace_variable/input/script | 44 ++++ .../string/databricks_template_schema.json | 19 ++ .../input/string/template/workspace.txt.tmpl | 2 + .../databricks_template_schema.json | 3 + .../undeclared/template/workspace.txt.tmpl | 1 + .../id/databricks_template_schema.json | 1 + .../runtime/id/template/id.txt.tmpl | 1 + .../runtime/input.absolute-url.json | 1 + .../runtime/input.path.json | 1 + .../runtime/input.semicolon.json | 1 + .../runtime/input.workspace-selector.json | 1 + .../workspace_variable/runtime/out.test.toml | 2 + .../workspace_variable/runtime/output.txt | 197 ++++++++++++++++++ .../workspace_variable/runtime/script | 57 +++++ .../workspace_variable/runtime/test.toml | 1 + .../urls/databricks_template_schema.json | 16 ++ .../runtime/urls/template/urls.txt.tmpl | 9 + .../workspace_variable/script.prepare | 32 +++ .../workspace_variable/test.toml | 2 + libs/template/renderer.go | 5 +- libs/template/schema_renderer.go | 7 +- libs/template/schema_renderer_test.go | 20 +- libs/template/workspace_context.go | 147 +++++++++++++ libs/template/workspace_context_test.go | 85 ++++++++ libs/template/writer.go | 4 + libs/workspaceurls/urls.go | 6 +- libs/workspaceurls/urls_test.go | 4 +- 41 files changed, 805 insertions(+), 11 deletions(-) create mode 100644 .nextchanges/bundles/workspace-template-variable.md create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/input/boolean/databricks_template_schema.json create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/input/boolean/template/workspace.txt.tmpl create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/input/input.empty.json create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/input/input.false.json create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/input/input.invalid.json create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/input/input.value.json create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/input/input.zero.json create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/input/integer/databricks_template_schema.json create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/input/integer/template/workspace.txt.tmpl create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/input/no_value/databricks_template_schema.json create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/input/no_value/template/workspace.txt.tmpl create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/input/out.test.toml create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/input/output.txt create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/input/script create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/input/string/databricks_template_schema.json create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/input/string/template/workspace.txt.tmpl create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/input/undeclared/databricks_template_schema.json create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/input/undeclared/template/workspace.txt.tmpl create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/runtime/id/databricks_template_schema.json create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/runtime/id/template/id.txt.tmpl create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/runtime/input.absolute-url.json create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/runtime/input.path.json create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/runtime/input.semicolon.json create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/runtime/input.workspace-selector.json create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/runtime/out.test.toml create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/runtime/script create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/runtime/test.toml create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/runtime/urls/databricks_template_schema.json create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/runtime/urls/template/urls.txt.tmpl create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/script.prepare create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/test.toml create mode 100644 libs/template/workspace_context.go create mode 100644 libs/template/workspace_context_test.go diff --git a/.nextchanges/bundles/workspace-template-variable.md b/.nextchanges/bundles/workspace-template-variable.md new file mode 100644 index 00000000000..1bb36bf36df --- /dev/null +++ b/.nextchanges/bundles/workspace-template-variable.md @@ -0,0 +1 @@ +* Expose the workspace of the selected profile to bundle templates as `.workspace`, with `Hostname`, `ID`, and `URL`, so templates can address a workspace on a host shared by several workspaces. A template input named `workspace` takes precedence. ([#7349](https://github.com/databricks/cli/pull/7349)) diff --git a/acceptance/bundle/templates-machinery/workspace_variable/input/boolean/databricks_template_schema.json b/acceptance/bundle/templates-machinery/workspace_variable/input/boolean/databricks_template_schema.json new file mode 100644 index 00000000000..19bc236302b --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/input/boolean/databricks_template_schema.json @@ -0,0 +1,9 @@ +{ + "properties": { + "workspace": { + "type": "boolean", + "description": "Create a workspace", + "default": false + } + } +} diff --git a/acceptance/bundle/templates-machinery/workspace_variable/input/boolean/template/workspace.txt.tmpl b/acceptance/bundle/templates-machinery/workspace_variable/input/boolean/template/workspace.txt.tmpl new file mode 100644 index 00000000000..9e5d42abd4e --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/input/boolean/template/workspace.txt.tmpl @@ -0,0 +1 @@ +workspace: {{ .workspace }} diff --git a/acceptance/bundle/templates-machinery/workspace_variable/input/input.empty.json b/acceptance/bundle/templates-machinery/workspace_variable/input/input.empty.json new file mode 100644 index 00000000000..bcc37d2095b --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/input/input.empty.json @@ -0,0 +1 @@ +{"workspace": ""} diff --git a/acceptance/bundle/templates-machinery/workspace_variable/input/input.false.json b/acceptance/bundle/templates-machinery/workspace_variable/input/input.false.json new file mode 100644 index 00000000000..67c5f8d642e --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/input/input.false.json @@ -0,0 +1 @@ +{"workspace": false} diff --git a/acceptance/bundle/templates-machinery/workspace_variable/input/input.invalid.json b/acceptance/bundle/templates-machinery/workspace_variable/input/input.invalid.json new file mode 100644 index 00000000000..4af72084022 --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/input/input.invalid.json @@ -0,0 +1 @@ +{"workspace": "Team Dev"} diff --git a/acceptance/bundle/templates-machinery/workspace_variable/input/input.value.json b/acceptance/bundle/templates-machinery/workspace_variable/input/input.value.json new file mode 100644 index 00000000000..0237d890f93 --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/input/input.value.json @@ -0,0 +1 @@ +{"workspace": "team-dev"} diff --git a/acceptance/bundle/templates-machinery/workspace_variable/input/input.zero.json b/acceptance/bundle/templates-machinery/workspace_variable/input/input.zero.json new file mode 100644 index 00000000000..2937ed82f73 --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/input/input.zero.json @@ -0,0 +1 @@ +{"workspace": 0} diff --git a/acceptance/bundle/templates-machinery/workspace_variable/input/integer/databricks_template_schema.json b/acceptance/bundle/templates-machinery/workspace_variable/input/integer/databricks_template_schema.json new file mode 100644 index 00000000000..f013832cf8e --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/input/integer/databricks_template_schema.json @@ -0,0 +1,9 @@ +{ + "properties": { + "workspace": { + "type": "integer", + "description": "Workspace number", + "default": 0 + } + } +} diff --git a/acceptance/bundle/templates-machinery/workspace_variable/input/integer/template/workspace.txt.tmpl b/acceptance/bundle/templates-machinery/workspace_variable/input/integer/template/workspace.txt.tmpl new file mode 100644 index 00000000000..9e5d42abd4e --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/input/integer/template/workspace.txt.tmpl @@ -0,0 +1 @@ +workspace: {{ .workspace }} diff --git a/acceptance/bundle/templates-machinery/workspace_variable/input/no_value/databricks_template_schema.json b/acceptance/bundle/templates-machinery/workspace_variable/input/no_value/databricks_template_schema.json new file mode 100644 index 00000000000..189a0f041f8 --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/input/no_value/databricks_template_schema.json @@ -0,0 +1,10 @@ +{ + "welcome_message": "Welcome, {{ .workspace }}", + "properties": { + "workspace": { + "type": "string", + "description": "Workspace name", + "default": "team-prod" + } + } +} diff --git a/acceptance/bundle/templates-machinery/workspace_variable/input/no_value/template/workspace.txt.tmpl b/acceptance/bundle/templates-machinery/workspace_variable/input/no_value/template/workspace.txt.tmpl new file mode 100644 index 00000000000..9e5d42abd4e --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/input/no_value/template/workspace.txt.tmpl @@ -0,0 +1 @@ +workspace: {{ .workspace }} diff --git a/acceptance/bundle/templates-machinery/workspace_variable/input/out.test.toml b/acceptance/bundle/templates-machinery/workspace_variable/input/out.test.toml new file mode 100644 index 00000000000..a927a5fbc06 --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/input/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/templates-machinery/workspace_variable/input/output.txt b/acceptance/bundle/templates-machinery/workspace_variable/input/output.txt new file mode 100644 index 00000000000..8b3716d99b2 --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/input/output.txt @@ -0,0 +1,108 @@ + +=== String input + +>>> [CLI] bundle debug render-template-schema ./string +{ + "welcome_message": null, + "success_message": "Created for \"\"", + "properties": { + "message": { + "description": "Message for ", + "default": "Deploying to " + }, + "workspace": { + "description": "Workspace name", + "default": "" + } + } +} + +>>> [CLI] bundle debug render-template-schema ./string --input-file input.value.json +{ + "welcome_message": null, + "success_message": "Created for \"team-dev\"", + "properties": { + "message": { + "description": "Message for team-dev", + "default": "Deploying to team-dev" + }, + "workspace": { + "description": "Workspace name", + "default": "" + } + } +} + +>>> [CLI] bundle init ./string +Created for "" +workspace_name: "" +message: "Deploying to " + +>>> [CLI] bundle init ./string --config-file input.empty.json +Created for "" +workspace_name: "" +message: "Deploying to " + +>>> [CLI] bundle init ./string --config-file input.value.json +Created for "team-dev" +workspace_name: "team-dev" +message: "Deploying to team-dev" + +>>> musterr [CLI] bundle init ./string --config-file input.invalid.json +Error: failed to load config from file input.invalid.json: invalid value for workspace: "Team Dev". Use lowercase letters and dashes + +=== Input without a value yet + +>>> [CLI] bundle debug render-template-schema ./no_value +{ + "welcome_message": "Welcome, team-prod", + "success_message": null, + "properties": { + "workspace": { + "description": "Workspace name", + "default": "team-prod" + } + } +} + +>>> musterr [CLI] bundle init ./no_value +Error: variable "workspace" not defined + +>>> [CLI] bundle init ./no_value --config-file input.value.json +Welcome, team-dev +✨ Successfully initialized template +workspace: team-dev + +=== Boolean input + +>>> [CLI] bundle init ./boolean +✨ Successfully initialized template +workspace: false + +>>> [CLI] bundle init ./boolean --config-file input.false.json +✨ Successfully initialized template +workspace: false + +=== Integer input + +>>> [CLI] bundle init ./integer +✨ Successfully initialized template +workspace: 0 + +>>> [CLI] bundle init ./integer --config-file input.zero.json +✨ Successfully initialized template +workspace: 0 + +=== Undeclared input + +>>> [CLI] bundle debug render-template-schema ./undeclared --input-file input.value.json +{ + "welcome_message": "workspace: {[CUSTOM_HOSTNAME] [CUSTOM_HOST] [WORKSPACE_ID] [WORKSPACE_ID]}", + "success_message": null, + "properties": {} +} + +>>> [CLI] bundle init ./undeclared --config-file input.value.json +workspace: {[CUSTOM_HOSTNAME] [CUSTOM_HOST] [WORKSPACE_ID] [WORKSPACE_ID]} +✨ Successfully initialized template +workspace: {[CUSTOM_HOSTNAME] [CUSTOM_HOST] [WORKSPACE_ID] [WORKSPACE_ID]} diff --git a/acceptance/bundle/templates-machinery/workspace_variable/input/script b/acceptance/bundle/templates-machinery/workspace_variable/input/script new file mode 100644 index 00000000000..8d7ec6d1ef1 --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/input/script @@ -0,0 +1,44 @@ +# A template input named "workspace" takes precedence over the workspace +# context, with and without a value supplied. +export DATABRICKS_CONFIG_PROFILE=workspace_custom_host + +title "String input\n" +trace $CLI bundle debug render-template-schema ./string | schema_fields +trace $CLI bundle debug render-template-schema ./string --input-file input.value.json | schema_fields +for config in "" "--config-file input.empty.json" "--config-file input.value.json"; do + trace $CLI bundle init ./string $config + cat workspace.txt + rm workspace.txt +done +trace musterr $CLI bundle init ./string --config-file input.invalid.json + +# The welcome message is rendered before inputs get their values. The input +# still owns the name, so the workspace context isn't used in its place. +title "Input without a value yet\n" +trace $CLI bundle debug render-template-schema ./no_value | schema_fields +trace musterr $CLI bundle init ./no_value +trace $CLI bundle init ./no_value --config-file input.value.json +cat workspace.txt +rm workspace.txt + +title "Boolean input\n" +for config in "" "--config-file input.false.json"; do + trace $CLI bundle init ./boolean $config + cat workspace.txt + rm workspace.txt +done + +title "Integer input\n" +for config in "" "--config-file input.zero.json"; do + trace $CLI bundle init ./integer $config + cat workspace.txt + rm workspace.txt +done + +# An input value that the schema doesn't declare doesn't claim the name, so +# schema rendering and bundle init both use the workspace context. +title "Undeclared input\n" +trace $CLI bundle debug render-template-schema ./undeclared --input-file input.value.json | schema_fields +trace $CLI bundle init ./undeclared --config-file input.value.json +cat workspace.txt +rm workspace.txt diff --git a/acceptance/bundle/templates-machinery/workspace_variable/input/string/databricks_template_schema.json b/acceptance/bundle/templates-machinery/workspace_variable/input/string/databricks_template_schema.json new file mode 100644 index 00000000000..53241b9228d --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/input/string/databricks_template_schema.json @@ -0,0 +1,19 @@ +{ + "properties": { + "workspace": { + "type": "string", + "description": "Workspace name", + "default": "", + "pattern": "^[a-z-]*$", + "pattern_match_failure_message": "Use lowercase letters and dashes", + "order": 1 + }, + "message": { + "type": "string", + "description": "Message for {{ .workspace }}", + "default": "Deploying to {{ .workspace }}", + "order": 2 + } + }, + "success_message": "Created for \"{{ .workspace }}\"" +} diff --git a/acceptance/bundle/templates-machinery/workspace_variable/input/string/template/workspace.txt.tmpl b/acceptance/bundle/templates-machinery/workspace_variable/input/string/template/workspace.txt.tmpl new file mode 100644 index 00000000000..71517cc7023 --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/input/string/template/workspace.txt.tmpl @@ -0,0 +1,2 @@ +workspace_name: "{{ .workspace }}" +message: "{{ .message }}" diff --git a/acceptance/bundle/templates-machinery/workspace_variable/input/undeclared/databricks_template_schema.json b/acceptance/bundle/templates-machinery/workspace_variable/input/undeclared/databricks_template_schema.json new file mode 100644 index 00000000000..0a7bdea3c50 --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/input/undeclared/databricks_template_schema.json @@ -0,0 +1,3 @@ +{ + "welcome_message": "workspace: {{ .workspace }}" +} diff --git a/acceptance/bundle/templates-machinery/workspace_variable/input/undeclared/template/workspace.txt.tmpl b/acceptance/bundle/templates-machinery/workspace_variable/input/undeclared/template/workspace.txt.tmpl new file mode 100644 index 00000000000..9e5d42abd4e --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/input/undeclared/template/workspace.txt.tmpl @@ -0,0 +1 @@ +workspace: {{ .workspace }} diff --git a/acceptance/bundle/templates-machinery/workspace_variable/runtime/id/databricks_template_schema.json b/acceptance/bundle/templates-machinery/workspace_variable/runtime/id/databricks_template_schema.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/runtime/id/databricks_template_schema.json @@ -0,0 +1 @@ +{} diff --git a/acceptance/bundle/templates-machinery/workspace_variable/runtime/id/template/id.txt.tmpl b/acceptance/bundle/templates-machinery/workspace_variable/runtime/id/template/id.txt.tmpl new file mode 100644 index 00000000000..a641e355a08 --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/runtime/id/template/id.txt.tmpl @@ -0,0 +1 @@ +ID: {{ .workspace.ID }} diff --git a/acceptance/bundle/templates-machinery/workspace_variable/runtime/input.absolute-url.json b/acceptance/bundle/templates-machinery/workspace_variable/runtime/input.absolute-url.json new file mode 100644 index 00000000000..d94c3f6501d --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/runtime/input.absolute-url.json @@ -0,0 +1 @@ +{"path": "https://other.test/jobs"} diff --git a/acceptance/bundle/templates-machinery/workspace_variable/runtime/input.path.json b/acceptance/bundle/templates-machinery/workspace_variable/runtime/input.path.json new file mode 100644 index 00000000000..262cc735f87 --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/runtime/input.path.json @@ -0,0 +1 @@ +{"path": "/pipelines?tab=all"} diff --git a/acceptance/bundle/templates-machinery/workspace_variable/runtime/input.semicolon.json b/acceptance/bundle/templates-machinery/workspace_variable/runtime/input.semicolon.json new file mode 100644 index 00000000000..1c25b12af91 --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/runtime/input.semicolon.json @@ -0,0 +1 @@ +{"path": "/jobs?w=999;x=1"} diff --git a/acceptance/bundle/templates-machinery/workspace_variable/runtime/input.workspace-selector.json b/acceptance/bundle/templates-machinery/workspace_variable/runtime/input.workspace-selector.json new file mode 100644 index 00000000000..f2ced50eb36 --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/runtime/input.workspace-selector.json @@ -0,0 +1 @@ +{"path": "/jobs?o=123"} diff --git a/acceptance/bundle/templates-machinery/workspace_variable/runtime/out.test.toml b/acceptance/bundle/templates-machinery/workspace_variable/runtime/out.test.toml new file mode 100644 index 00000000000..a927a5fbc06 --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/runtime/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt b/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt new file mode 100644 index 00000000000..45b0ac8ae41 --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt @@ -0,0 +1,197 @@ + +=== Profile workspace_classic + +>>> [CLI] bundle debug render-template-schema ./urls --profile workspace_classic +{ + "welcome_message": "Initializing for [DATABRICKS_URL]", + "success_message": "Open [DATABRICKS_URL]/jobs?view=list#top", + "properties": { + "jobs_url": { + "description": "Jobs page", + "default": "[DATABRICKS_URL]/jobs" + }, + "path": { + "description": "Path to open on [DATABRICKS_HOST]", + "default": "/jobs?view=list#top" + } + } +} + +>>> [CLI] bundle init ./urls --profile workspace_classic +Initializing for [DATABRICKS_URL] +Open [DATABRICKS_URL]/jobs?view=list#top +workspace_host: [DATABRICKS_URL] +Hostname: [DATABRICKS_HOST] +URL: [DATABRICKS_URL] +URL "/jobs": [DATABRICKS_URL]/jobs +URL "/jobs?view=list#top": [DATABRICKS_URL]/jobs?view=list#top +jobs_url: [DATABRICKS_URL]/jobs +Root keys: jobs_url path workspace +Root data: map[jobs_url:[DATABRICKS_URL]/jobs path:/jobs?view=list#top workspace:{[DATABRICKS_HOST] [DATABRICKS_URL] [WORKSPACE_ID] }] + +=== Profile workspace_classic_id + +>>> [CLI] bundle debug render-template-schema ./urls --profile workspace_classic_id +{ + "welcome_message": "Initializing for [DATABRICKS_URL]?w=[WORKSPACE_ID]", + "success_message": "Open [DATABRICKS_URL]/jobs?view=list&w=[WORKSPACE_ID]#top", + "properties": { + "jobs_url": { + "description": "Jobs page", + "default": "[DATABRICKS_URL]/jobs?w=[WORKSPACE_ID]" + }, + "path": { + "description": "Path to open on [DATABRICKS_HOST]", + "default": "/jobs?view=list#top" + } + } +} + +>>> [CLI] bundle init ./urls --profile workspace_classic_id +Initializing for [DATABRICKS_URL]?w=[WORKSPACE_ID] +Open [DATABRICKS_URL]/jobs?view=list&w=[WORKSPACE_ID]#top +workspace_host: [DATABRICKS_URL] +Hostname: [DATABRICKS_HOST] +URL: [DATABRICKS_URL]?w=[WORKSPACE_ID] +URL "/jobs": [DATABRICKS_URL]/jobs?w=[WORKSPACE_ID] +URL "/jobs?view=list#top": [DATABRICKS_URL]/jobs?view=list&w=[WORKSPACE_ID]#top +jobs_url: [DATABRICKS_URL]/jobs?w=[WORKSPACE_ID] +Root keys: jobs_url path workspace +Root data: map[jobs_url:[DATABRICKS_URL]/jobs?w=[WORKSPACE_ID] path:/jobs?view=list#top workspace:{[DATABRICKS_HOST] [DATABRICKS_URL] [WORKSPACE_ID] [WORKSPACE_ID]}] + +=== Profile workspace_custom_host + +>>> [CLI] bundle debug render-template-schema ./urls --profile workspace_custom_host +{ + "welcome_message": "Initializing for [CUSTOM_HOST]?w=[WORKSPACE_ID]", + "success_message": "Open [CUSTOM_HOST]/jobs?view=list&w=[WORKSPACE_ID]#top", + "properties": { + "jobs_url": { + "description": "Jobs page", + "default": "[CUSTOM_HOST]/jobs?w=[WORKSPACE_ID]" + }, + "path": { + "description": "Path to open on [CUSTOM_HOSTNAME]", + "default": "/jobs?view=list#top" + } + } +} + +>>> [CLI] bundle init ./urls --profile workspace_custom_host +Initializing for [CUSTOM_HOST]?w=[WORKSPACE_ID] +Open [CUSTOM_HOST]/jobs?view=list&w=[WORKSPACE_ID]#top +workspace_host: [CUSTOM_HOST] +Hostname: [CUSTOM_HOSTNAME] +URL: [CUSTOM_HOST]?w=[WORKSPACE_ID] +URL "/jobs": [CUSTOM_HOST]/jobs?w=[WORKSPACE_ID] +URL "/jobs?view=list#top": [CUSTOM_HOST]/jobs?view=list&w=[WORKSPACE_ID]#top +jobs_url: [CUSTOM_HOST]/jobs?w=[WORKSPACE_ID] +Root keys: jobs_url path workspace +Root data: map[jobs_url:[CUSTOM_HOST]/jobs?w=[WORKSPACE_ID] path:/jobs?view=list#top workspace:{[CUSTOM_HOSTNAME] [CUSTOM_HOST] [WORKSPACE_ID] [WORKSPACE_ID]}] + +=== Profile no_workspace_id + +>>> [CLI] bundle debug render-template-schema ./urls --profile no_workspace_id +{ + "welcome_message": "Initializing for [DATABRICKS_URL]", + "success_message": "Open [DATABRICKS_URL]/jobs?view=list#top", + "properties": { + "jobs_url": { + "description": "Jobs page", + "default": "[DATABRICKS_URL]/jobs" + }, + "path": { + "description": "Path to open on [DATABRICKS_HOST]", + "default": "/jobs?view=list#top" + } + } +} + +>>> [CLI] bundle init ./urls --profile no_workspace_id +Initializing for [DATABRICKS_URL] +Open [DATABRICKS_URL]/jobs?view=list#top +workspace_host: [DATABRICKS_URL] +Hostname: [DATABRICKS_HOST] +URL: [DATABRICKS_URL] +URL "/jobs": [DATABRICKS_URL]/jobs +URL "/jobs?view=list#top": [DATABRICKS_URL]/jobs?view=list#top +jobs_url: [DATABRICKS_URL]/jobs +Root keys: jobs_url path workspace +Root data: map[jobs_url:[DATABRICKS_URL]/jobs path:/jobs?view=list#top workspace:{[DATABRICKS_HOST] [DATABRICKS_URL] }] + +=== Rerender and init with inputs + +>>> [CLI] bundle debug render-template-schema ./urls --profile workspace_custom_host --input-file input.path.json +{ + "welcome_message": "Initializing for [CUSTOM_HOST]?w=[WORKSPACE_ID]", + "success_message": "Open [CUSTOM_HOST]/pipelines?tab=all&w=[WORKSPACE_ID]", + "properties": { + "jobs_url": { + "description": "Jobs page", + "default": "[CUSTOM_HOST]/jobs?w=[WORKSPACE_ID]" + }, + "path": { + "description": "Path to open on [CUSTOM_HOSTNAME]", + "default": "/jobs?view=list#top" + } + } +} + +>>> [CLI] bundle init ./urls --profile workspace_custom_host --config-file input.path.json +Initializing for [CUSTOM_HOST]?w=[WORKSPACE_ID] +Open [CUSTOM_HOST]/pipelines?tab=all&w=[WORKSPACE_ID] +workspace_host: [CUSTOM_HOST] +Hostname: [CUSTOM_HOSTNAME] +URL: [CUSTOM_HOST]?w=[WORKSPACE_ID] +URL "/jobs": [CUSTOM_HOST]/jobs?w=[WORKSPACE_ID] +URL "/pipelines?tab=all": [CUSTOM_HOST]/pipelines?tab=all&w=[WORKSPACE_ID] +jobs_url: [CUSTOM_HOST]/jobs?w=[WORKSPACE_ID] +Root keys: jobs_url path workspace +Root data: map[jobs_url:[CUSTOM_HOST]/jobs?w=[WORKSPACE_ID] path:/pipelines?tab=all workspace:{[CUSTOM_HOSTNAME] [CUSTOM_HOST] [WORKSPACE_ID] [WORKSPACE_ID]}] + +=== Invalid URL paths + +>>> musterr [CLI] bundle init ./urls --profile workspace_custom_host --config-file input.absolute-url.json +Initializing for [CUSTOM_HOST]?w=[WORKSPACE_ID] +Error: failed to compute file content for urls.txt.tmpl. template: :6:25: executing "" at <$ws.URL>: error calling URL: workspace URL path "https://other.test/jobs" must be relative to the workspace host + +>>> musterr [CLI] bundle init ./urls --profile workspace_custom_host --config-file input.workspace-selector.json +Initializing for [CUSTOM_HOST]?w=[WORKSPACE_ID] +Error: failed to compute file content for urls.txt.tmpl. template: :6:25: executing "" at <$ws.URL>: error calling URL: workspace URL path "/jobs?o=123" must not set the "o" query parameter, the workspace is selected by the profile + +>>> musterr [CLI] bundle init ./urls --profile workspace_custom_host --config-file input.semicolon.json +Initializing for [CUSTOM_HOST]?w=[WORKSPACE_ID] +Error: failed to compute file content for urls.txt.tmpl. template: :6:25: executing "" at <$ws.URL>: error calling URL: failed to parse query of workspace URL path "/jobs?w=999;x=1": invalid semicolon separator in query + +=== Workspace ID from environment variables + +>>> DATABRICKS_CONFIG_FILE=/dev/null DATABRICKS_HOST=[CUSTOM_HOST] DATABRICKS_WORKSPACE_ID=[WORKSPACE_ID] [CLI] bundle init ./urls +Initializing for [CUSTOM_HOST]?w=[WORKSPACE_ID] +Open [CUSTOM_HOST]/jobs?view=list&w=[WORKSPACE_ID]#top +URL: [CUSTOM_HOST]?w=[WORKSPACE_ID] +URL "/jobs": [CUSTOM_HOST]/jobs?w=[WORKSPACE_ID] +URL "/jobs?view=list#top": [CUSTOM_HOST]/jobs?view=list&w=[WORKSPACE_ID]#top + +=== Workspace ID from the bundle in the working directory + +>>> [CLI] bundle init ../urls +Initializing for [DATABRICKS_URL]?w=[WORKSPACE_ID] +Open [DATABRICKS_URL]/jobs?view=list&w=[WORKSPACE_ID]#top +URL: [DATABRICKS_URL]?w=[WORKSPACE_ID] +URL "/jobs": [DATABRICKS_URL]/jobs?w=[WORKSPACE_ID] +URL "/jobs?view=list#top": [DATABRICKS_URL]/jobs?view=list&w=[WORKSPACE_ID]#top + +=== ID + +>>> [CLI] bundle init ./id --profile workspace_classic +✨ Successfully initialized template +ID: [WORKSPACE_ID] + +>>> [CLI] bundle init ./id --profile workspace_custom_host +✨ Successfully initialized template +ID: [WORKSPACE_ID] + +>>> musterr [CLI] bundle init ./id --profile no_workspace_id +Error: failed to compute file content for id.txt.tmpl. template: :1:17: executing "" at <.workspace.ID>: error calling ID: cannot determine workspace ID, please set workspace_id in the configuration profile or the DATABRICKS_WORKSPACE_ID environment variable + +>>> print_requests.py --get --oneline ^//telemetry-ext diff --git a/acceptance/bundle/templates-machinery/workspace_variable/runtime/script b/acceptance/bundle/templates-machinery/workspace_variable/runtime/script new file mode 100644 index 00000000000..5a9145a5f6f --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/runtime/script @@ -0,0 +1,57 @@ +for profile in workspace_classic workspace_classic_id workspace_custom_host no_workspace_id; do + title "Profile $profile\n" + trace $CLI bundle debug render-template-schema ./urls --profile $profile | schema_fields + trace $CLI bundle init ./urls --profile $profile + cat urls.txt + rm urls.txt +done + +# Render the schema again with the current inputs, then run init with the same +# inputs and profile. +title "Rerender and init with inputs\n" +trace $CLI bundle debug render-template-schema ./urls --profile workspace_custom_host --input-file input.path.json | schema_fields +trace $CLI bundle init ./urls --profile workspace_custom_host --config-file input.path.json +cat urls.txt +rm urls.txt + +title "Invalid URL paths\n" +trace musterr $CLI bundle init ./urls --profile workspace_custom_host --config-file input.absolute-url.json +trace musterr $CLI bundle init ./urls --profile workspace_custom_host --config-file input.workspace-selector.json +trace musterr $CLI bundle init ./urls --profile workspace_custom_host --config-file input.semicolon.json + +# No profile, so that only the environment variable sets the ID. +title "Workspace ID from environment variables\n" +trace DATABRICKS_CONFIG_FILE=/dev/null DATABRICKS_HOST=$CUSTOM_HOST DATABRICKS_WORKSPACE_ID=$WORKSPACE_ID $CLI bundle init ./urls +grep ^URL urls.txt +rm urls.txt + +# bundle init reads the workspace configuration of a bundle in the working directory. +title "Workspace ID from the bundle in the working directory\n" +mkdir in_bundle +cat > in_bundle/databricks.yml < LOG.requests +trace $CLI bundle init ./id --profile workspace_custom_host +cat id.txt +rm id.txt +trace musterr $CLI bundle init ./id --profile no_workspace_id + +# Reading a missing ID doesn't make a request to discover it. +trace print_requests.py --get --oneline ^//telemetry-ext diff --git a/acceptance/bundle/templates-machinery/workspace_variable/runtime/test.toml b/acceptance/bundle/templates-machinery/workspace_variable/runtime/test.toml new file mode 100644 index 00000000000..159efe02696 --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/runtime/test.toml @@ -0,0 +1 @@ +RecordRequests = true diff --git a/acceptance/bundle/templates-machinery/workspace_variable/runtime/urls/databricks_template_schema.json b/acceptance/bundle/templates-machinery/workspace_variable/runtime/urls/databricks_template_schema.json new file mode 100644 index 00000000000..56f03b64db2 --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/runtime/urls/databricks_template_schema.json @@ -0,0 +1,16 @@ +{ + "welcome_message": "Initializing for {{ .workspace.URL }}", + "properties": { + "path": { + "type": "string", + "description": "Path to open on {{ .workspace.Hostname }}", + "default": "/jobs?view=list#top" + }, + "jobs_url": { + "type": "string", + "description": "Jobs page", + "default": "{{ $ws := .workspace }}{{ $ws.URL \"/jobs\" }}" + } + }, + "success_message": "Open {{ .workspace.URL .path }}" +} diff --git a/acceptance/bundle/templates-machinery/workspace_variable/runtime/urls/template/urls.txt.tmpl b/acceptance/bundle/templates-machinery/workspace_variable/runtime/urls/template/urls.txt.tmpl new file mode 100644 index 00000000000..539714bfc4a --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/runtime/urls/template/urls.txt.tmpl @@ -0,0 +1,9 @@ +{{- $ws := .workspace -}} +workspace_host: {{ workspace_host }} +Hostname: {{ .workspace.Hostname }} +URL: {{ .workspace.URL }} +URL "/jobs": {{ .workspace.URL "/jobs" }} +URL "{{ .path }}": {{ $ws.URL .path }} +jobs_url: {{ .jobs_url }} +Root keys:{{ range $key, $value := . }} {{ $key }}{{ end }} +Root data: {{ . }} diff --git a/acceptance/bundle/templates-machinery/workspace_variable/script.prepare b/acceptance/bundle/templates-machinery/workspace_variable/script.prepare new file mode 100644 index 00000000000..531e2c9565e --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/script.prepare @@ -0,0 +1,32 @@ +# The test server is reachable as both 127.0.0.1 and localhost. The second +# stands in for a host shared by several workspaces. +CUSTOM_HOST=${DATABRICKS_HOST/127.0.0.1/localhost} +add_repl "$CUSTOM_HOST" CUSTOM_HOST +add_repl "${CUSTOM_HOST#*://}" CUSTOM_HOSTNAME +WORKSPACE_ID=$(curl -s "$DATABRICKS_HOST/.well-known/databricks-config" | jq -r .workspace_id) + +# workspace_classic has no workspace_id, so the SDK resolves it from host +# metadata. The "none" sentinel prevents that. +cat > .databrickscfg < with empty string for missing variables diff --git a/libs/template/schema_renderer_test.go b/libs/template/schema_renderer_test.go index 9e6c1cc1ca9..10172e26ef0 100644 --- a/libs/template/schema_renderer_test.go +++ b/libs/template/schema_renderer_test.go @@ -29,7 +29,9 @@ func TestRenderSchemaWithLocalTemplate(t *testing.T) { testutil.WriteFile(t, inputFile, `{"name": "TestUser"}`) ctx := t.Context() - ctx = cmdctx.SetWorkspaceClient(ctx, &databricks.WorkspaceClient{}) + ctx = cmdctx.SetWorkspaceClient(ctx, &databricks.WorkspaceClient{ + Config: &workspaceConfig.Config{Host: "https://myhost.test"}, + }) reader := NewLocalReader(tmpDir) result, err := RenderSchema(ctx, reader, RenderSchemaInput{ @@ -55,7 +57,9 @@ func TestRenderSchemaWithoutInputFile(t *testing.T) { testutil.WriteFile(t, filepath.Join(tmpDir, "databricks_template_schema.json"), schemaContent) ctx := t.Context() - ctx = cmdctx.SetWorkspaceClient(ctx, &databricks.WorkspaceClient{}) + ctx = cmdctx.SetWorkspaceClient(ctx, &databricks.WorkspaceClient{ + Config: &workspaceConfig.Config{Host: "https://myhost.test"}, + }) reader := NewLocalReader(tmpDir) result, err := RenderSchema(ctx, reader, RenderSchemaInput{}) @@ -76,7 +80,9 @@ func TestRenderSchemaWithMissingVariable(t *testing.T) { testutil.WriteFile(t, inputFile, `{}`) ctx := t.Context() - ctx = cmdctx.SetWorkspaceClient(ctx, &databricks.WorkspaceClient{}) + ctx = cmdctx.SetWorkspaceClient(ctx, &databricks.WorkspaceClient{ + Config: &workspaceConfig.Config{Host: "https://myhost.test"}, + }) reader := NewLocalReader(tmpDir) // Missing variables are rendered as empty strings @@ -94,7 +100,9 @@ func TestRenderSchemaWithInvalidInputFile(t *testing.T) { testutil.WriteFile(t, filepath.Join(tmpDir, "databricks_template_schema.json"), schemaContent) ctx := t.Context() - ctx = cmdctx.SetWorkspaceClient(ctx, &databricks.WorkspaceClient{}) + ctx = cmdctx.SetWorkspaceClient(ctx, &databricks.WorkspaceClient{ + Config: &workspaceConfig.Config{Host: "https://myhost.test"}, + }) reader := NewLocalReader(tmpDir) _, err := RenderSchema(ctx, reader, RenderSchemaInput{ @@ -114,7 +122,9 @@ func TestRenderSchemaWithMalformedInputJson(t *testing.T) { testutil.WriteFile(t, inputFile, `{invalid json}`) ctx := t.Context() - ctx = cmdctx.SetWorkspaceClient(ctx, &databricks.WorkspaceClient{}) + ctx = cmdctx.SetWorkspaceClient(ctx, &databricks.WorkspaceClient{ + Config: &workspaceConfig.Config{Host: "https://myhost.test"}, + }) reader := NewLocalReader(tmpDir) _, err := RenderSchema(ctx, reader, RenderSchemaInput{ diff --git a/libs/template/workspace_context.go b/libs/template/workspace_context.go new file mode 100644 index 00000000000..18e9fabe7e2 --- /dev/null +++ b/libs/template/workspace_context.go @@ -0,0 +1,147 @@ +package template + +import ( + "context" + "errors" + "fmt" + "maps" + "net/url" + "slices" + + "github.com/databricks/cli/libs/cmdctx" + "github.com/databricks/cli/libs/databrickscfg/profile" + "github.com/databricks/cli/libs/jsonschema" + "github.com/databricks/cli/libs/workspaceurls" + sdkconfig "github.com/databricks/databricks-sdk-go/config" +) + +// workspaceVariable is the root template variable that holds the workspace context. +const workspaceVariable = "workspace" + +// Query parameters that select a workspace on a host shared by several workspaces. +// Matches what the SDK reads from a host URL (workspaceIDFromQuery in databricks-sdk-go/config). +var workspaceSelectorParams = []string{"w", "o", "workspace_id"} + +// Sources of a workspace ID that the user set: a profile, an environment variable, or the +// bundle in the working directory (bundle/config/workspace.go labels its attributes "bundle"). +// The SDK records no source for an ID that it resolves from host metadata or from the host +// query, so such an ID reports the dynamic configuration source. +var userSetWorkspaceIDSources = []sdkconfig.SourceType{ + sdkconfig.SourceFile, + sdkconfig.SourceEnv, + sdkconfig.SourceType("bundle"), +} + +// workspaceContext is exposed to templates as .workspace. Unlike the workspace_host helper, +// it carries the workspace ID, which is required to address a workspace on a host shared by +// several workspaces. +// +// The fields are strings so that templates which print the root data get stable output. +type workspaceContext struct { + // Hostname is the workspace host without scheme, path, or query. It keeps the port if the + // host has one. + Hostname string + + host string + workspaceID string + + // urlWorkspaceID is the workspace ID that URL adds as ?w=. It is empty when the user didn't + // set the ID. A classic profile has no workspace_id, so a host with ?w= wouldn't match it + // under DATABRICKS_BUNDLE_STRICT_PROFILE_MATCH. + urlWorkspaceID string +} + +// loadWorkspaceContext returns the workspace context of the selected profile, or nil if the +// schema declares a workspace input. A declared input owns the name even before it has a value. +// Input values the schema doesn't declare don't claim the name, because bundle init ignores them. +func loadWorkspaceContext(ctx context.Context, schema *jsonschema.Schema) (*workspaceContext, error) { + if _, ok := schema.Properties[workspaceVariable]; ok { + return nil, nil + } + cfg := cmdctx.WorkspaceClient(ctx).Config + host, err := url.Parse(cfg.Host) + if err != nil { + return nil, fmt.Errorf("failed to parse workspace host %q: %w", cfg.Host, err) + } + workspaceID := cfg.WorkspaceID + if workspaceID == profile.WorkspaceIDNone { + workspaceID = "" + } + var urlWorkspaceID string + if attr, ok := cfg.GetAuthDetails().Configuration["workspace_id"]; ok && slices.Contains(userSetWorkspaceIDSources, attr.Source.Type) { + urlWorkspaceID = workspaceID + } + return &workspaceContext{ + Hostname: host.Host, + host: cfg.Host, + workspaceID: workspaceID, + urlWorkspaceID: urlWorkspaceID, + }, nil +} + +// templateData returns the root data for rendering a template. The workspace context is added +// to a copy of the input values, so it never takes part in input defaults, prompts, or validation. +// It replaces an input value the schema doesn't declare, which only schema rendering keeps. +func templateData(values map[string]any, workspace *workspaceContext) map[string]any { + if workspace == nil { + return values + } + data := make(map[string]any, len(values)+1) + maps.Copy(data, values) + data[workspaceVariable] = *workspace + return data +} + +// ID returns the workspace ID from the resolved configuration. It fails when the ID is unknown, +// so that templates don't render a configuration that cannot be routed to a workspace on a host +// shared by several workspaces. It doesn't make a request to discover the ID. +func (c workspaceContext) ID() (string, error) { + if c.workspaceID == "" { + return "", errors.New("cannot determine workspace ID, please set workspace_id in the configuration profile or the DATABRICKS_WORKSPACE_ID environment variable") + } + return c.workspaceID, nil +} + +// URL returns the workspace URL with an optional path relative to the workspace host, +// for example URL "/jobs". It adds ?w= when the profile, an environment variable, or the +// bundle sets the workspace ID. +func (c workspaceContext) URL(path ...string) (string, error) { + if len(path) > 1 { + return "", fmt.Errorf("URL accepts at most one path, got %d", len(path)) + } + base, err := workspaceurls.WorkspaceBaseURL(c.host, c.urlWorkspaceID) + if err != nil { + return "", err + } + if len(path) == 0 { + return base.String(), nil + } + ref, err := url.Parse(path[0]) + if err != nil { + return "", fmt.Errorf("failed to parse workspace URL path %q: %w", path[0], err) + } + if ref.Scheme != "" || ref.Host != "" { + return "", fmt.Errorf("workspace URL path %q must be relative to the workspace host", path[0]) + } + // url.URL.Query drops pairs it cannot parse, which would hide a workspace selector. + query, err := url.ParseQuery(ref.RawQuery) + if err != nil { + return "", fmt.Errorf("failed to parse query of workspace URL path %q: %w", path[0], err) + } + for _, param := range workspaceSelectorParams { + if query.Has(param) { + return "", fmt.Errorf("workspace URL path %q must not set the %q query parameter, the workspace is selected by the profile", path[0], param) + } + } + u := base.ResolveReference(ref) + // The path's query replaces the base URL query, which holds only the workspace selector. + // Append the selector after the path's parameters to keep their order. + u.RawQuery = ref.RawQuery + if base.RawQuery != "" { + if u.RawQuery != "" { + u.RawQuery += "&" + } + u.RawQuery += base.RawQuery + } + return u.String(), nil +} diff --git a/libs/template/workspace_context_test.go b/libs/template/workspace_context_test.go new file mode 100644 index 00000000000..64a885734d0 --- /dev/null +++ b/libs/template/workspace_context_test.go @@ -0,0 +1,85 @@ +package template + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" + + "github.com/databricks/cli/internal/testutil" + "github.com/databricks/cli/libs/cmdctx" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/databricks-sdk-go" + workspaceConfig "github.com/databricks/databricks-sdk-go/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Acceptance tests run without a TTY, so prompts are covered here. +func TestPromptForValuesWithWorkspace(t *testing.T) { + tests := []struct { + name string + schema string + answers string + prompts string + values map[string]any + }{ + { + name: "workspace context", + schema: `{"properties": {"jobs_url": { + "type": "string", + "description": "Jobs page on {{ .workspace.Hostname }}", + "default": "{{ .workspace.URL \"/jobs\" }}" + }}}`, + answers: "\n", + prompts: "Jobs page on myhost.test [https://myhost.test/jobs]: ", + values: map[string]any{"jobs_url": "https://myhost.test/jobs"}, + }, + { + name: "declared workspace input", + schema: `{"properties": { + "workspace": {"type": "string", "description": "Workspace name", "default": "team-dev", "order": 1}, + "message": {"type": "string", "description": "Message for {{ .workspace }}", "default": "Deploying to {{ .workspace }}", "order": 2} + }}`, + answers: "team-prod\n\n", + prompts: "Workspace name [team-dev]: Message for team-prod [Deploying to team-prod]: ", + values: map[string]any{"workspace": "team-prod", "message": "Deploying to team-prod"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx, tst := cmdio.SetupTest(t.Context(), cmdio.TestOptions{PromptSupported: true}) + ctx = cmdctx.SetWorkspaceClient(ctx, &databricks.WorkspaceClient{ + Config: &workspaceConfig.Config{Host: "https://myhost.test"}, + }) + + dir := t.TempDir() + testutil.WriteFile(t, filepath.Join(dir, schemaFileName), tc.schema) + c, err := newConfig(ctx, os.DirFS(dir), schemaFileName) + require.NoError(t, err) + r, err := newRenderer(ctx, c.values, loadHelpers(ctx), os.DirFS(dir), templateDirName, libraryDirName) + require.NoError(t, err) + r.workspace, err = loadWorkspaceContext(ctx, c.schema) + require.NoError(t, err) + + var prompts bytes.Buffer + copied := make(chan struct{}) + go func() { + _, _ = io.Copy(&prompts, tst.Stderr) + close(copied) + }() + go func() { + _, _ = tst.Stdin.WriteString(tc.answers) + _ = tst.Stdin.Flush() + }() + + require.NoError(t, c.promptForValues(r)) + tst.Done() + <-copied + + assert.Equal(t, tc.prompts, prompts.String()) + assert.Equal(t, tc.values, c.values) + }) + } +} diff --git a/libs/template/writer.go b/libs/template/writer.go index 78bf610f213..6b9313625e0 100644 --- a/libs/template/writer.go +++ b/libs/template/writer.go @@ -125,6 +125,10 @@ func (tmpl *defaultWriter) promptForInput(ctx context.Context, reader Reader) er if err != nil { return err } + tmpl.renderer.workspace, err = loadWorkspaceContext(ctx, tmpl.config.schema) + if err != nil { + return err + } // Print welcome message welcome := tmpl.config.schema.WelcomeMessage diff --git a/libs/workspaceurls/urls.go b/libs/workspaceurls/urls.go index 0c31a7a0071..7ace7c019c4 100644 --- a/libs/workspaceurls/urls.go +++ b/libs/workspaceurls/urls.go @@ -174,7 +174,7 @@ func ResourceURL(baseURL url.URL, resourceType, id string) string { // query parameter (use when the workspace ID is unknown or already encoded in // the hostname). func BuildResourceURL(host, resourceType, id, workspaceID string) (string, error) { - baseURL, err := workspaceBaseURL(host, workspaceID) + baseURL, err := WorkspaceBaseURL(host, workspaceID) if err != nil { return "", err } @@ -193,7 +193,9 @@ func resolveAlias(resourceType string) string { return resourceType } -func workspaceBaseURL(host, workspaceID string) (*url.URL, error) { +// WorkspaceBaseURL parses the workspace host and adds ?w= unless the +// workspace ID is empty or already encoded in the hostname. +func WorkspaceBaseURL(host, workspaceID string) (*url.URL, error) { baseURL, err := url.Parse(host) if err != nil { return nil, fmt.Errorf("invalid workspace host %q: %w", host, err) diff --git a/libs/workspaceurls/urls_test.go b/libs/workspaceurls/urls_test.go index 4667cd7bb3e..2694fb10fd3 100644 --- a/libs/workspaceurls/urls_test.go +++ b/libs/workspaceurls/urls_test.go @@ -71,7 +71,7 @@ func TestWorkspaceBaseURL(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := workspaceBaseURL(tt.host, tt.workspaceID) + got, err := WorkspaceBaseURL(tt.host, tt.workspaceID) require.NoError(t, err) assert.Equal(t, tt.expected, got.String()) }) @@ -79,7 +79,7 @@ func TestWorkspaceBaseURL(t *testing.T) { } func TestWorkspaceBaseURLInvalidHost(t *testing.T) { - _, err := workspaceBaseURL("://invalid", "") + _, err := WorkspaceBaseURL("://invalid", "") assert.ErrorContains(t, err, "invalid workspace host") } From f3d97e086ae2735f45ee294fda6a989354f2db60 Mon Sep 17 00:00:00 2001 From: "pavlo.kozlov" Date: Wed, 4 Nov 2026 16:12:12 +0000 Subject: [PATCH 2/5] Keep ?w= in .workspace.URL when the hostname has the workspace ID Strict profile matching compares the workspace ID in the bundle host with the profile's workspace_id and doesn't read it from an adb- hostname. Without ?w=, a profile with workspace_id on such a host didn't match the generated host. URL no longer uses workspaceurls.WorkspaceBaseURL, which skips ?w= there, so that package is unchanged. Co-authored-by: Isaac --- libs/template/workspace_context.go | 19 +++++++++++++------ libs/template/workspace_context_test.go | 15 +++++++++++++++ libs/workspaceurls/urls.go | 6 ++---- libs/workspaceurls/urls_test.go | 4 ++-- 4 files changed, 32 insertions(+), 12 deletions(-) diff --git a/libs/template/workspace_context.go b/libs/template/workspace_context.go index 18e9fabe7e2..20bb5e5a5f8 100644 --- a/libs/template/workspace_context.go +++ b/libs/template/workspace_context.go @@ -11,7 +11,6 @@ import ( "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/databrickscfg/profile" "github.com/databricks/cli/libs/jsonschema" - "github.com/databricks/cli/libs/workspaceurls" sdkconfig "github.com/databricks/databricks-sdk-go/config" ) @@ -45,9 +44,10 @@ type workspaceContext struct { host string workspaceID string - // urlWorkspaceID is the workspace ID that URL adds as ?w=. It is empty when the user didn't - // set the ID. A classic profile has no workspace_id, so a host with ?w= wouldn't match it - // under DATABRICKS_BUNDLE_STRICT_PROFILE_MATCH. + // urlWorkspaceID is the workspace ID that URL adds as ?w=. It is empty unless the user set the + // ID. Strict profile matching (DATABRICKS_BUNDLE_STRICT_PROFILE_MATCH) compares the ID in the + // host with the profile's workspace_id, and a profile without workspace_id matches only a host + // without an ID. urlWorkspaceID string } @@ -109,9 +109,16 @@ func (c workspaceContext) URL(path ...string) (string, error) { if len(path) > 1 { return "", fmt.Errorf("URL accepts at most one path, got %d", len(path)) } - base, err := workspaceurls.WorkspaceBaseURL(c.host, c.urlWorkspaceID) + base, err := url.Parse(c.host) if err != nil { - return "", err + return "", fmt.Errorf("failed to parse workspace host %q: %w", c.host, err) + } + // Unlike workspaceurls.WorkspaceBaseURL, this adds ?w= also when the hostname has the ID + // (adb-), because strict profile matching reads the ID only from the query. + if c.urlWorkspaceID != "" { + query := base.Query() + query.Set("w", c.urlWorkspaceID) + base.RawQuery = query.Encode() } if len(path) == 0 { return base.String(), nil diff --git a/libs/template/workspace_context_test.go b/libs/template/workspace_context_test.go index 64a885734d0..d20a45f621a 100644 --- a/libs/template/workspace_context_test.go +++ b/libs/template/workspace_context_test.go @@ -83,3 +83,18 @@ func TestPromptForValuesWithWorkspace(t *testing.T) { }) } } + +// Acceptance tests can't use an adb- hostname, because the test server is reachable only +// on localhost. +func TestWorkspaceURLKeepsIDOnHostnameWithID(t *testing.T) { + ws := workspaceContext{host: "https://adb-111.4.azuredatabricks.net", workspaceID: "111", urlWorkspaceID: "111"} + + // Strict profile matching reads the ID from the query, not from the hostname. + host, err := ws.URL() + require.NoError(t, err) + assert.Equal(t, "https://adb-111.4.azuredatabricks.net?w=111", host) + + jobs, err := ws.URL("/jobs") + require.NoError(t, err) + assert.Equal(t, "https://adb-111.4.azuredatabricks.net/jobs?w=111", jobs) +} diff --git a/libs/workspaceurls/urls.go b/libs/workspaceurls/urls.go index 7ace7c019c4..0c31a7a0071 100644 --- a/libs/workspaceurls/urls.go +++ b/libs/workspaceurls/urls.go @@ -174,7 +174,7 @@ func ResourceURL(baseURL url.URL, resourceType, id string) string { // query parameter (use when the workspace ID is unknown or already encoded in // the hostname). func BuildResourceURL(host, resourceType, id, workspaceID string) (string, error) { - baseURL, err := WorkspaceBaseURL(host, workspaceID) + baseURL, err := workspaceBaseURL(host, workspaceID) if err != nil { return "", err } @@ -193,9 +193,7 @@ func resolveAlias(resourceType string) string { return resourceType } -// WorkspaceBaseURL parses the workspace host and adds ?w= unless the -// workspace ID is empty or already encoded in the hostname. -func WorkspaceBaseURL(host, workspaceID string) (*url.URL, error) { +func workspaceBaseURL(host, workspaceID string) (*url.URL, error) { baseURL, err := url.Parse(host) if err != nil { return nil, fmt.Errorf("invalid workspace host %q: %w", host, err) diff --git a/libs/workspaceurls/urls_test.go b/libs/workspaceurls/urls_test.go index 2694fb10fd3..4667cd7bb3e 100644 --- a/libs/workspaceurls/urls_test.go +++ b/libs/workspaceurls/urls_test.go @@ -71,7 +71,7 @@ func TestWorkspaceBaseURL(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := WorkspaceBaseURL(tt.host, tt.workspaceID) + got, err := workspaceBaseURL(tt.host, tt.workspaceID) require.NoError(t, err) assert.Equal(t, tt.expected, got.String()) }) @@ -79,7 +79,7 @@ func TestWorkspaceBaseURL(t *testing.T) { } func TestWorkspaceBaseURLInvalidHost(t *testing.T) { - _, err := WorkspaceBaseURL("://invalid", "") + _, err := workspaceBaseURL("://invalid", "") assert.ErrorContains(t, err, "invalid workspace host") } From c8c621cc6d5ee91cc218e63270af79d126d39bf5 Mon Sep 17 00:00:00 2001 From: "pavlo.kozlov" Date: Wed, 4 Nov 2026 16:46:00 +0000 Subject: [PATCH 3/5] Render schema defaults of inputs that already have a value RenderSchema skipped a property when the input file had a value for it, so its default came back as template text. A caller that renders the schema again with the current inputs got, for example, the raw .workspace.URL expression. The default is now rendered, and the input value is still the one used for the following properties. Co-authored-by: Isaac --- .../workspace_variable/runtime/input.edited.json | 1 + .../workspace_variable/runtime/input.path.json | 1 - .../workspace_variable/runtime/output.txt | 10 +++++----- .../workspace_variable/runtime/script | 9 +++++---- libs/template/schema_renderer.go | 16 +++++++++------- 5 files changed, 20 insertions(+), 17 deletions(-) create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/runtime/input.edited.json delete mode 100644 acceptance/bundle/templates-machinery/workspace_variable/runtime/input.path.json diff --git a/acceptance/bundle/templates-machinery/workspace_variable/runtime/input.edited.json b/acceptance/bundle/templates-machinery/workspace_variable/runtime/input.edited.json new file mode 100644 index 00000000000..26f5a078a45 --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/runtime/input.edited.json @@ -0,0 +1 @@ +{"path": "/pipelines?tab=all", "jobs_url": "https://example.test/my-jobs"} diff --git a/acceptance/bundle/templates-machinery/workspace_variable/runtime/input.path.json b/acceptance/bundle/templates-machinery/workspace_variable/runtime/input.path.json deleted file mode 100644 index 262cc735f87..00000000000 --- a/acceptance/bundle/templates-machinery/workspace_variable/runtime/input.path.json +++ /dev/null @@ -1 +0,0 @@ -{"path": "/pipelines?tab=all"} diff --git a/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt b/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt index 45b0ac8ae41..c6980cdead1 100644 --- a/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt +++ b/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt @@ -119,9 +119,9 @@ jobs_url: [DATABRICKS_URL]/jobs Root keys: jobs_url path workspace Root data: map[jobs_url:[DATABRICKS_URL]/jobs path:/jobs?view=list#top workspace:{[DATABRICKS_HOST] [DATABRICKS_URL] }] -=== Rerender and init with inputs +=== Rerender and init with edited inputs ->>> [CLI] bundle debug render-template-schema ./urls --profile workspace_custom_host --input-file input.path.json +>>> [CLI] bundle debug render-template-schema ./urls --profile workspace_custom_host --input-file input.edited.json { "welcome_message": "Initializing for [CUSTOM_HOST]?w=[WORKSPACE_ID]", "success_message": "Open [CUSTOM_HOST]/pipelines?tab=all&w=[WORKSPACE_ID]", @@ -137,7 +137,7 @@ Root data: map[jobs_url:[DATABRICKS_URL]/jobs path:/jobs?view=list#top workspace } } ->>> [CLI] bundle init ./urls --profile workspace_custom_host --config-file input.path.json +>>> [CLI] bundle init ./urls --profile workspace_custom_host --config-file input.edited.json Initializing for [CUSTOM_HOST]?w=[WORKSPACE_ID] Open [CUSTOM_HOST]/pipelines?tab=all&w=[WORKSPACE_ID] workspace_host: [CUSTOM_HOST] @@ -145,9 +145,9 @@ Hostname: [CUSTOM_HOSTNAME] URL: [CUSTOM_HOST]?w=[WORKSPACE_ID] URL "/jobs": [CUSTOM_HOST]/jobs?w=[WORKSPACE_ID] URL "/pipelines?tab=all": [CUSTOM_HOST]/pipelines?tab=all&w=[WORKSPACE_ID] -jobs_url: [CUSTOM_HOST]/jobs?w=[WORKSPACE_ID] +jobs_url: https://example.test/my-jobs Root keys: jobs_url path workspace -Root data: map[jobs_url:[CUSTOM_HOST]/jobs?w=[WORKSPACE_ID] path:/pipelines?tab=all workspace:{[CUSTOM_HOSTNAME] [CUSTOM_HOST] [WORKSPACE_ID] [WORKSPACE_ID]}] +Root data: map[jobs_url:https://example.test/my-jobs path:/pipelines?tab=all workspace:{[CUSTOM_HOSTNAME] [CUSTOM_HOST] [WORKSPACE_ID] [WORKSPACE_ID]}] === Invalid URL paths diff --git a/acceptance/bundle/templates-machinery/workspace_variable/runtime/script b/acceptance/bundle/templates-machinery/workspace_variable/runtime/script index 5a9145a5f6f..4a773ef943f 100644 --- a/acceptance/bundle/templates-machinery/workspace_variable/runtime/script +++ b/acceptance/bundle/templates-machinery/workspace_variable/runtime/script @@ -7,10 +7,11 @@ for profile in workspace_classic workspace_classic_id workspace_custom_host no_w done # Render the schema again with the current inputs, then run init with the same -# inputs and profile. -title "Rerender and init with inputs\n" -trace $CLI bundle debug render-template-schema ./urls --profile workspace_custom_host --input-file input.path.json | schema_fields -trace $CLI bundle init ./urls --profile workspace_custom_host --config-file input.path.json +# inputs and profile. The input has an edited jobs_url: the schema still shows +# its rendered default, and init keeps the edited value. +title "Rerender and init with edited inputs\n" +trace $CLI bundle debug render-template-schema ./urls --profile workspace_custom_host --input-file input.edited.json | schema_fields +trace $CLI bundle init ./urls --profile workspace_custom_host --config-file input.edited.json cat urls.txt rm urls.txt diff --git a/libs/template/schema_renderer.go b/libs/template/schema_renderer.go index 96ecbce4962..d66dd7b47a1 100644 --- a/libs/template/schema_renderer.go +++ b/libs/template/schema_renderer.go @@ -27,7 +27,8 @@ type RenderSchemaResult struct { // are rendered (welcome_message, success_message, property descriptions, and defaults). // // Rendering is done in two passes: -// 1. First pass: Render all default values (in order) and add them to the values map +// 1. First pass: Render all default values (in order) and add those of properties +// without an input value to the values map // 2. Second pass: Render descriptions and other fields using the populated values func RenderSchema(ctx context.Context, reader Reader, input RenderSchemaInput) (*RenderSchemaResult, error) { schemaFS, err := reader.SchemaFS(ctx) @@ -126,10 +127,9 @@ func RenderSchema(ctx context.Context, reader Reader, input RenderSchemaInput) ( continue } - // Skip if value already provided in input - if _, exists := values[propName]; exists { - continue - } + // A value from the input is what the user entered, so it is kept. The default + // is still rendered, so that a caller showing it doesn't get template text. + _, supplied := values[propName] // Render default value (only if it's a string) if def, ok := prop["default"].(string); ok { @@ -139,8 +139,10 @@ func RenderSchema(ctx context.Context, reader Reader, input RenderSchemaInput) ( } prop["default"] = rendered // Add to values map for use in subsequent renders - values[propName] = rendered - } else if def, ok := prop["default"]; ok { + if !supplied { + values[propName] = rendered + } + } else if def, ok := prop["default"]; ok && !supplied { // Non-string default (bool, int, etc.) - skip render values[propName] = def } From 51344dbb2ebb8a614e3c8bd5ac2f024b229f41ad Mon Sep 17 00:00:00 2001 From: "pavlo.kozlov" Date: Wed, 4 Nov 2026 17:17:04 +0000 Subject: [PATCH 4/5] Drop the port from .workspace.Hostname Hostname is now the host name only, without the port, like url.URL.Hostname. Databricks hosts don't have an explicit port, so this changes nothing for them. Co-authored-by: Isaac --- .../workspace_variable/input/output.txt | 6 ++-- .../workspace_variable/runtime/output.txt | 30 +++++++++---------- .../workspace_variable/script.prepare | 1 - libs/template/workspace_context.go | 5 ++-- 4 files changed, 20 insertions(+), 22 deletions(-) diff --git a/acceptance/bundle/templates-machinery/workspace_variable/input/output.txt b/acceptance/bundle/templates-machinery/workspace_variable/input/output.txt index 8b3716d99b2..dc0b5d63fda 100644 --- a/acceptance/bundle/templates-machinery/workspace_variable/input/output.txt +++ b/acceptance/bundle/templates-machinery/workspace_variable/input/output.txt @@ -97,12 +97,12 @@ workspace: 0 >>> [CLI] bundle debug render-template-schema ./undeclared --input-file input.value.json { - "welcome_message": "workspace: {[CUSTOM_HOSTNAME] [CUSTOM_HOST] [WORKSPACE_ID] [WORKSPACE_ID]}", + "welcome_message": "workspace: {localhost [CUSTOM_HOST] [WORKSPACE_ID] [WORKSPACE_ID]}", "success_message": null, "properties": {} } >>> [CLI] bundle init ./undeclared --config-file input.value.json -workspace: {[CUSTOM_HOSTNAME] [CUSTOM_HOST] [WORKSPACE_ID] [WORKSPACE_ID]} +workspace: {localhost [CUSTOM_HOST] [WORKSPACE_ID] [WORKSPACE_ID]} ✨ Successfully initialized template -workspace: {[CUSTOM_HOSTNAME] [CUSTOM_HOST] [WORKSPACE_ID] [WORKSPACE_ID]} +workspace: {localhost [CUSTOM_HOST] [WORKSPACE_ID] [WORKSPACE_ID]} diff --git a/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt b/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt index c6980cdead1..a0cc2c7ab0e 100644 --- a/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt +++ b/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt @@ -11,7 +11,7 @@ "default": "[DATABRICKS_URL]/jobs" }, "path": { - "description": "Path to open on [DATABRICKS_HOST]", + "description": "Path to open on 127.0.0.1", "default": "/jobs?view=list#top" } } @@ -21,13 +21,13 @@ Initializing for [DATABRICKS_URL] Open [DATABRICKS_URL]/jobs?view=list#top workspace_host: [DATABRICKS_URL] -Hostname: [DATABRICKS_HOST] +Hostname: 127.0.0.1 URL: [DATABRICKS_URL] URL "/jobs": [DATABRICKS_URL]/jobs URL "/jobs?view=list#top": [DATABRICKS_URL]/jobs?view=list#top jobs_url: [DATABRICKS_URL]/jobs Root keys: jobs_url path workspace -Root data: map[jobs_url:[DATABRICKS_URL]/jobs path:/jobs?view=list#top workspace:{[DATABRICKS_HOST] [DATABRICKS_URL] [WORKSPACE_ID] }] +Root data: map[jobs_url:[DATABRICKS_URL]/jobs path:/jobs?view=list#top workspace:{127.0.0.1 [DATABRICKS_URL] [WORKSPACE_ID] }] === Profile workspace_classic_id @@ -41,7 +41,7 @@ Root data: map[jobs_url:[DATABRICKS_URL]/jobs path:/jobs?view=list#top workspace "default": "[DATABRICKS_URL]/jobs?w=[WORKSPACE_ID]" }, "path": { - "description": "Path to open on [DATABRICKS_HOST]", + "description": "Path to open on 127.0.0.1", "default": "/jobs?view=list#top" } } @@ -51,13 +51,13 @@ Root data: map[jobs_url:[DATABRICKS_URL]/jobs path:/jobs?view=list#top workspace Initializing for [DATABRICKS_URL]?w=[WORKSPACE_ID] Open [DATABRICKS_URL]/jobs?view=list&w=[WORKSPACE_ID]#top workspace_host: [DATABRICKS_URL] -Hostname: [DATABRICKS_HOST] +Hostname: 127.0.0.1 URL: [DATABRICKS_URL]?w=[WORKSPACE_ID] URL "/jobs": [DATABRICKS_URL]/jobs?w=[WORKSPACE_ID] URL "/jobs?view=list#top": [DATABRICKS_URL]/jobs?view=list&w=[WORKSPACE_ID]#top jobs_url: [DATABRICKS_URL]/jobs?w=[WORKSPACE_ID] Root keys: jobs_url path workspace -Root data: map[jobs_url:[DATABRICKS_URL]/jobs?w=[WORKSPACE_ID] path:/jobs?view=list#top workspace:{[DATABRICKS_HOST] [DATABRICKS_URL] [WORKSPACE_ID] [WORKSPACE_ID]}] +Root data: map[jobs_url:[DATABRICKS_URL]/jobs?w=[WORKSPACE_ID] path:/jobs?view=list#top workspace:{127.0.0.1 [DATABRICKS_URL] [WORKSPACE_ID] [WORKSPACE_ID]}] === Profile workspace_custom_host @@ -71,7 +71,7 @@ Root data: map[jobs_url:[DATABRICKS_URL]/jobs?w=[WORKSPACE_ID] path:/jobs?view=l "default": "[CUSTOM_HOST]/jobs?w=[WORKSPACE_ID]" }, "path": { - "description": "Path to open on [CUSTOM_HOSTNAME]", + "description": "Path to open on localhost", "default": "/jobs?view=list#top" } } @@ -81,13 +81,13 @@ Root data: map[jobs_url:[DATABRICKS_URL]/jobs?w=[WORKSPACE_ID] path:/jobs?view=l Initializing for [CUSTOM_HOST]?w=[WORKSPACE_ID] Open [CUSTOM_HOST]/jobs?view=list&w=[WORKSPACE_ID]#top workspace_host: [CUSTOM_HOST] -Hostname: [CUSTOM_HOSTNAME] +Hostname: localhost URL: [CUSTOM_HOST]?w=[WORKSPACE_ID] URL "/jobs": [CUSTOM_HOST]/jobs?w=[WORKSPACE_ID] URL "/jobs?view=list#top": [CUSTOM_HOST]/jobs?view=list&w=[WORKSPACE_ID]#top jobs_url: [CUSTOM_HOST]/jobs?w=[WORKSPACE_ID] Root keys: jobs_url path workspace -Root data: map[jobs_url:[CUSTOM_HOST]/jobs?w=[WORKSPACE_ID] path:/jobs?view=list#top workspace:{[CUSTOM_HOSTNAME] [CUSTOM_HOST] [WORKSPACE_ID] [WORKSPACE_ID]}] +Root data: map[jobs_url:[CUSTOM_HOST]/jobs?w=[WORKSPACE_ID] path:/jobs?view=list#top workspace:{localhost [CUSTOM_HOST] [WORKSPACE_ID] [WORKSPACE_ID]}] === Profile no_workspace_id @@ -101,7 +101,7 @@ Root data: map[jobs_url:[CUSTOM_HOST]/jobs?w=[WORKSPACE_ID] path:/jobs?view=list "default": "[DATABRICKS_URL]/jobs" }, "path": { - "description": "Path to open on [DATABRICKS_HOST]", + "description": "Path to open on 127.0.0.1", "default": "/jobs?view=list#top" } } @@ -111,13 +111,13 @@ Root data: map[jobs_url:[CUSTOM_HOST]/jobs?w=[WORKSPACE_ID] path:/jobs?view=list Initializing for [DATABRICKS_URL] Open [DATABRICKS_URL]/jobs?view=list#top workspace_host: [DATABRICKS_URL] -Hostname: [DATABRICKS_HOST] +Hostname: 127.0.0.1 URL: [DATABRICKS_URL] URL "/jobs": [DATABRICKS_URL]/jobs URL "/jobs?view=list#top": [DATABRICKS_URL]/jobs?view=list#top jobs_url: [DATABRICKS_URL]/jobs Root keys: jobs_url path workspace -Root data: map[jobs_url:[DATABRICKS_URL]/jobs path:/jobs?view=list#top workspace:{[DATABRICKS_HOST] [DATABRICKS_URL] }] +Root data: map[jobs_url:[DATABRICKS_URL]/jobs path:/jobs?view=list#top workspace:{127.0.0.1 [DATABRICKS_URL] }] === Rerender and init with edited inputs @@ -131,7 +131,7 @@ Root data: map[jobs_url:[DATABRICKS_URL]/jobs path:/jobs?view=list#top workspace "default": "[CUSTOM_HOST]/jobs?w=[WORKSPACE_ID]" }, "path": { - "description": "Path to open on [CUSTOM_HOSTNAME]", + "description": "Path to open on localhost", "default": "/jobs?view=list#top" } } @@ -141,13 +141,13 @@ Root data: map[jobs_url:[DATABRICKS_URL]/jobs path:/jobs?view=list#top workspace Initializing for [CUSTOM_HOST]?w=[WORKSPACE_ID] Open [CUSTOM_HOST]/pipelines?tab=all&w=[WORKSPACE_ID] workspace_host: [CUSTOM_HOST] -Hostname: [CUSTOM_HOSTNAME] +Hostname: localhost URL: [CUSTOM_HOST]?w=[WORKSPACE_ID] URL "/jobs": [CUSTOM_HOST]/jobs?w=[WORKSPACE_ID] URL "/pipelines?tab=all": [CUSTOM_HOST]/pipelines?tab=all&w=[WORKSPACE_ID] jobs_url: https://example.test/my-jobs Root keys: jobs_url path workspace -Root data: map[jobs_url:https://example.test/my-jobs path:/pipelines?tab=all workspace:{[CUSTOM_HOSTNAME] [CUSTOM_HOST] [WORKSPACE_ID] [WORKSPACE_ID]}] +Root data: map[jobs_url:https://example.test/my-jobs path:/pipelines?tab=all workspace:{localhost [CUSTOM_HOST] [WORKSPACE_ID] [WORKSPACE_ID]}] === Invalid URL paths diff --git a/acceptance/bundle/templates-machinery/workspace_variable/script.prepare b/acceptance/bundle/templates-machinery/workspace_variable/script.prepare index 531e2c9565e..ff3cea03950 100644 --- a/acceptance/bundle/templates-machinery/workspace_variable/script.prepare +++ b/acceptance/bundle/templates-machinery/workspace_variable/script.prepare @@ -2,7 +2,6 @@ # stands in for a host shared by several workspaces. CUSTOM_HOST=${DATABRICKS_HOST/127.0.0.1/localhost} add_repl "$CUSTOM_HOST" CUSTOM_HOST -add_repl "${CUSTOM_HOST#*://}" CUSTOM_HOSTNAME WORKSPACE_ID=$(curl -s "$DATABRICKS_HOST/.well-known/databricks-config" | jq -r .workspace_id) # workspace_classic has no workspace_id, so the SDK resolves it from host diff --git a/libs/template/workspace_context.go b/libs/template/workspace_context.go index 20bb5e5a5f8..a373c4b89bf 100644 --- a/libs/template/workspace_context.go +++ b/libs/template/workspace_context.go @@ -37,8 +37,7 @@ var userSetWorkspaceIDSources = []sdkconfig.SourceType{ // // The fields are strings so that templates which print the root data get stable output. type workspaceContext struct { - // Hostname is the workspace host without scheme, path, or query. It keeps the port if the - // host has one. + // Hostname is the workspace host without scheme, port, path, or query. Hostname string host string @@ -72,7 +71,7 @@ func loadWorkspaceContext(ctx context.Context, schema *jsonschema.Schema) (*work urlWorkspaceID = workspaceID } return &workspaceContext{ - Hostname: host.Host, + Hostname: host.Hostname(), host: cfg.Host, workspaceID: workspaceID, urlWorkspaceID: urlWorkspaceID, From ea56a043217f83d4c277197439537f9e9a3f3d3d Mon Sep 17 00:00:00 2001 From: "pavlo.kozlov" Date: Thu, 5 Nov 2026 13:54:11 +0000 Subject: [PATCH 5/5] Keep the port in .workspace.Hostname This reverts 51344dbb2. Templates that strip the scheme from workspace_host get host:port, so keeping the port gives them the same value with .workspace.Hostname. Databricks hosts don't have an explicit port, so for them both are the same. Co-authored-by: Isaac --- .../workspace_variable/input/output.txt | 6 ++-- .../workspace_variable/runtime/output.txt | 30 +++++++++---------- .../workspace_variable/script.prepare | 1 + libs/template/workspace_context.go | 5 ++-- 4 files changed, 22 insertions(+), 20 deletions(-) diff --git a/acceptance/bundle/templates-machinery/workspace_variable/input/output.txt b/acceptance/bundle/templates-machinery/workspace_variable/input/output.txt index dc0b5d63fda..8b3716d99b2 100644 --- a/acceptance/bundle/templates-machinery/workspace_variable/input/output.txt +++ b/acceptance/bundle/templates-machinery/workspace_variable/input/output.txt @@ -97,12 +97,12 @@ workspace: 0 >>> [CLI] bundle debug render-template-schema ./undeclared --input-file input.value.json { - "welcome_message": "workspace: {localhost [CUSTOM_HOST] [WORKSPACE_ID] [WORKSPACE_ID]}", + "welcome_message": "workspace: {[CUSTOM_HOSTNAME] [CUSTOM_HOST] [WORKSPACE_ID] [WORKSPACE_ID]}", "success_message": null, "properties": {} } >>> [CLI] bundle init ./undeclared --config-file input.value.json -workspace: {localhost [CUSTOM_HOST] [WORKSPACE_ID] [WORKSPACE_ID]} +workspace: {[CUSTOM_HOSTNAME] [CUSTOM_HOST] [WORKSPACE_ID] [WORKSPACE_ID]} ✨ Successfully initialized template -workspace: {localhost [CUSTOM_HOST] [WORKSPACE_ID] [WORKSPACE_ID]} +workspace: {[CUSTOM_HOSTNAME] [CUSTOM_HOST] [WORKSPACE_ID] [WORKSPACE_ID]} diff --git a/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt b/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt index a0cc2c7ab0e..c6980cdead1 100644 --- a/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt +++ b/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt @@ -11,7 +11,7 @@ "default": "[DATABRICKS_URL]/jobs" }, "path": { - "description": "Path to open on 127.0.0.1", + "description": "Path to open on [DATABRICKS_HOST]", "default": "/jobs?view=list#top" } } @@ -21,13 +21,13 @@ Initializing for [DATABRICKS_URL] Open [DATABRICKS_URL]/jobs?view=list#top workspace_host: [DATABRICKS_URL] -Hostname: 127.0.0.1 +Hostname: [DATABRICKS_HOST] URL: [DATABRICKS_URL] URL "/jobs": [DATABRICKS_URL]/jobs URL "/jobs?view=list#top": [DATABRICKS_URL]/jobs?view=list#top jobs_url: [DATABRICKS_URL]/jobs Root keys: jobs_url path workspace -Root data: map[jobs_url:[DATABRICKS_URL]/jobs path:/jobs?view=list#top workspace:{127.0.0.1 [DATABRICKS_URL] [WORKSPACE_ID] }] +Root data: map[jobs_url:[DATABRICKS_URL]/jobs path:/jobs?view=list#top workspace:{[DATABRICKS_HOST] [DATABRICKS_URL] [WORKSPACE_ID] }] === Profile workspace_classic_id @@ -41,7 +41,7 @@ Root data: map[jobs_url:[DATABRICKS_URL]/jobs path:/jobs?view=list#top workspace "default": "[DATABRICKS_URL]/jobs?w=[WORKSPACE_ID]" }, "path": { - "description": "Path to open on 127.0.0.1", + "description": "Path to open on [DATABRICKS_HOST]", "default": "/jobs?view=list#top" } } @@ -51,13 +51,13 @@ Root data: map[jobs_url:[DATABRICKS_URL]/jobs path:/jobs?view=list#top workspace Initializing for [DATABRICKS_URL]?w=[WORKSPACE_ID] Open [DATABRICKS_URL]/jobs?view=list&w=[WORKSPACE_ID]#top workspace_host: [DATABRICKS_URL] -Hostname: 127.0.0.1 +Hostname: [DATABRICKS_HOST] URL: [DATABRICKS_URL]?w=[WORKSPACE_ID] URL "/jobs": [DATABRICKS_URL]/jobs?w=[WORKSPACE_ID] URL "/jobs?view=list#top": [DATABRICKS_URL]/jobs?view=list&w=[WORKSPACE_ID]#top jobs_url: [DATABRICKS_URL]/jobs?w=[WORKSPACE_ID] Root keys: jobs_url path workspace -Root data: map[jobs_url:[DATABRICKS_URL]/jobs?w=[WORKSPACE_ID] path:/jobs?view=list#top workspace:{127.0.0.1 [DATABRICKS_URL] [WORKSPACE_ID] [WORKSPACE_ID]}] +Root data: map[jobs_url:[DATABRICKS_URL]/jobs?w=[WORKSPACE_ID] path:/jobs?view=list#top workspace:{[DATABRICKS_HOST] [DATABRICKS_URL] [WORKSPACE_ID] [WORKSPACE_ID]}] === Profile workspace_custom_host @@ -71,7 +71,7 @@ Root data: map[jobs_url:[DATABRICKS_URL]/jobs?w=[WORKSPACE_ID] path:/jobs?view=l "default": "[CUSTOM_HOST]/jobs?w=[WORKSPACE_ID]" }, "path": { - "description": "Path to open on localhost", + "description": "Path to open on [CUSTOM_HOSTNAME]", "default": "/jobs?view=list#top" } } @@ -81,13 +81,13 @@ Root data: map[jobs_url:[DATABRICKS_URL]/jobs?w=[WORKSPACE_ID] path:/jobs?view=l Initializing for [CUSTOM_HOST]?w=[WORKSPACE_ID] Open [CUSTOM_HOST]/jobs?view=list&w=[WORKSPACE_ID]#top workspace_host: [CUSTOM_HOST] -Hostname: localhost +Hostname: [CUSTOM_HOSTNAME] URL: [CUSTOM_HOST]?w=[WORKSPACE_ID] URL "/jobs": [CUSTOM_HOST]/jobs?w=[WORKSPACE_ID] URL "/jobs?view=list#top": [CUSTOM_HOST]/jobs?view=list&w=[WORKSPACE_ID]#top jobs_url: [CUSTOM_HOST]/jobs?w=[WORKSPACE_ID] Root keys: jobs_url path workspace -Root data: map[jobs_url:[CUSTOM_HOST]/jobs?w=[WORKSPACE_ID] path:/jobs?view=list#top workspace:{localhost [CUSTOM_HOST] [WORKSPACE_ID] [WORKSPACE_ID]}] +Root data: map[jobs_url:[CUSTOM_HOST]/jobs?w=[WORKSPACE_ID] path:/jobs?view=list#top workspace:{[CUSTOM_HOSTNAME] [CUSTOM_HOST] [WORKSPACE_ID] [WORKSPACE_ID]}] === Profile no_workspace_id @@ -101,7 +101,7 @@ Root data: map[jobs_url:[CUSTOM_HOST]/jobs?w=[WORKSPACE_ID] path:/jobs?view=list "default": "[DATABRICKS_URL]/jobs" }, "path": { - "description": "Path to open on 127.0.0.1", + "description": "Path to open on [DATABRICKS_HOST]", "default": "/jobs?view=list#top" } } @@ -111,13 +111,13 @@ Root data: map[jobs_url:[CUSTOM_HOST]/jobs?w=[WORKSPACE_ID] path:/jobs?view=list Initializing for [DATABRICKS_URL] Open [DATABRICKS_URL]/jobs?view=list#top workspace_host: [DATABRICKS_URL] -Hostname: 127.0.0.1 +Hostname: [DATABRICKS_HOST] URL: [DATABRICKS_URL] URL "/jobs": [DATABRICKS_URL]/jobs URL "/jobs?view=list#top": [DATABRICKS_URL]/jobs?view=list#top jobs_url: [DATABRICKS_URL]/jobs Root keys: jobs_url path workspace -Root data: map[jobs_url:[DATABRICKS_URL]/jobs path:/jobs?view=list#top workspace:{127.0.0.1 [DATABRICKS_URL] }] +Root data: map[jobs_url:[DATABRICKS_URL]/jobs path:/jobs?view=list#top workspace:{[DATABRICKS_HOST] [DATABRICKS_URL] }] === Rerender and init with edited inputs @@ -131,7 +131,7 @@ Root data: map[jobs_url:[DATABRICKS_URL]/jobs path:/jobs?view=list#top workspace "default": "[CUSTOM_HOST]/jobs?w=[WORKSPACE_ID]" }, "path": { - "description": "Path to open on localhost", + "description": "Path to open on [CUSTOM_HOSTNAME]", "default": "/jobs?view=list#top" } } @@ -141,13 +141,13 @@ Root data: map[jobs_url:[DATABRICKS_URL]/jobs path:/jobs?view=list#top workspace Initializing for [CUSTOM_HOST]?w=[WORKSPACE_ID] Open [CUSTOM_HOST]/pipelines?tab=all&w=[WORKSPACE_ID] workspace_host: [CUSTOM_HOST] -Hostname: localhost +Hostname: [CUSTOM_HOSTNAME] URL: [CUSTOM_HOST]?w=[WORKSPACE_ID] URL "/jobs": [CUSTOM_HOST]/jobs?w=[WORKSPACE_ID] URL "/pipelines?tab=all": [CUSTOM_HOST]/pipelines?tab=all&w=[WORKSPACE_ID] jobs_url: https://example.test/my-jobs Root keys: jobs_url path workspace -Root data: map[jobs_url:https://example.test/my-jobs path:/pipelines?tab=all workspace:{localhost [CUSTOM_HOST] [WORKSPACE_ID] [WORKSPACE_ID]}] +Root data: map[jobs_url:https://example.test/my-jobs path:/pipelines?tab=all workspace:{[CUSTOM_HOSTNAME] [CUSTOM_HOST] [WORKSPACE_ID] [WORKSPACE_ID]}] === Invalid URL paths diff --git a/acceptance/bundle/templates-machinery/workspace_variable/script.prepare b/acceptance/bundle/templates-machinery/workspace_variable/script.prepare index ff3cea03950..531e2c9565e 100644 --- a/acceptance/bundle/templates-machinery/workspace_variable/script.prepare +++ b/acceptance/bundle/templates-machinery/workspace_variable/script.prepare @@ -2,6 +2,7 @@ # stands in for a host shared by several workspaces. CUSTOM_HOST=${DATABRICKS_HOST/127.0.0.1/localhost} add_repl "$CUSTOM_HOST" CUSTOM_HOST +add_repl "${CUSTOM_HOST#*://}" CUSTOM_HOSTNAME WORKSPACE_ID=$(curl -s "$DATABRICKS_HOST/.well-known/databricks-config" | jq -r .workspace_id) # workspace_classic has no workspace_id, so the SDK resolves it from host diff --git a/libs/template/workspace_context.go b/libs/template/workspace_context.go index a373c4b89bf..20bb5e5a5f8 100644 --- a/libs/template/workspace_context.go +++ b/libs/template/workspace_context.go @@ -37,7 +37,8 @@ var userSetWorkspaceIDSources = []sdkconfig.SourceType{ // // The fields are strings so that templates which print the root data get stable output. type workspaceContext struct { - // Hostname is the workspace host without scheme, port, path, or query. + // Hostname is the workspace host without scheme, path, or query. It keeps the port if the + // host has one. Hostname string host string @@ -71,7 +72,7 @@ func loadWorkspaceContext(ctx context.Context, schema *jsonschema.Schema) (*work urlWorkspaceID = workspaceID } return &workspaceContext{ - Hostname: host.Hostname(), + Hostname: host.Host, host: cfg.Host, workspaceID: workspaceID, urlWorkspaceID: urlWorkspaceID,