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.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.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..c6980cdead1 --- /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 edited inputs + +>>> [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]", + "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.edited.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: 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]}] + +=== 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..4a773ef943f --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/runtime/script @@ -0,0 +1,58 @@ +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. 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 + +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 @@ -121,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 { @@ -134,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 } 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..20bb5e5a5f8 --- /dev/null +++ b/libs/template/workspace_context.go @@ -0,0 +1,154 @@ +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" + 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 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 +} + +// 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 := url.Parse(c.host) + if err != nil { + 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 + } + 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..d20a45f621a --- /dev/null +++ b/libs/template/workspace_context_test.go @@ -0,0 +1,100 @@ +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) + }) + } +} + +// 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/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