Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions acceptance/bundle/deploy/immutable/databricks.yml.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
bundle:
name: test-bundle-immutable-$UNIQUE_NAME
immutable: true

artifacts:
python_artifact:
type: whl
build: uv build --wheel

resources:
jobs:
my_job:
name: my job
tasks:
- task_key: spark_python_task
spark_python_task:
python_file: ./src/main.py
environment_key: env
- task_key: notebook_task
notebook_task:
notebook_path: ./src/notebook.py
- task_key: python_wheel_task
python_wheel_task:
package_name: immutable
entry_point: main
environment_key: env
environments:
- environment_key: env
spec:
environment_version: "4"
dependencies:
- ./dist/*.whl
3 changes: 3 additions & 0 deletions acceptance/bundle/deploy/immutable/out.test.toml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

54 changes: 54 additions & 0 deletions acceptance/bundle/deploy/immutable/output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@

>>> [CLI] bundle validate
Name: test-bundle-immutable-[UNIQUE_NAME]
Target: default
Workspace:
User: [USERNAME]
Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle-immutable-[UNIQUE_NAME]/default

Validation OK!

>>> [CLI] bundle deploy
Building python_artifact...
Uploading immutable bundle snapshot...
Deploying resources...
Updating deployment state...
Deployment complete!

>>> [CLI] jobs get [NUMID]
"/Workspace/Users/[UUID]/.snapshots/test-bundle-immutable-[UNIQUE_NAME]/11f80ca6d8923bf75b57e475d4ca9ba4bb1d6d48c58aace8d3f2a1289b51c6e0/src/files/src/main.py"

>>> [CLI] jobs get [NUMID]
"/Workspace/Users/[UUID]/.snapshots/test-bundle-immutable-[UNIQUE_NAME]/11f80ca6d8923bf75b57e475d4ca9ba4bb1d6d48c58aace8d3f2a1289b51c6e0/src/files/src/notebook"

>>> [CLI] jobs get [NUMID]
[
"/Workspace/Users/[UUID]/.snapshots/test-bundle-immutable-[UNIQUE_NAME]/11f80ca6d8923bf75b57e475d4ca9ba4bb1d6d48c58aace8d3f2a1289b51c6e0/src/artifacts/.internal/immutable-0.0.1-py3-none-any.whl"
]

>>> [CLI] bundle run my_job
Run URL: [DATABRICKS_URL]/?o=[NUMID]#job/[NUMID]/run/[NUMID]

[TIMESTAMP] "my job" RUNNING
[TIMESTAMP] "my job" TERMINATED SUCCESS
Output:
=======
Task python_wheel_task:
Hello from Python Wheel Task!

=======
Task notebook_task:

=======
Task spark_python_task:
Hello from Spark Python Task!


>>> [CLI] bundle destroy --auto-approve
The following resources will be deleted:
delete resources.jobs.my_job

All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/test-bundle-immutable-[UNIQUE_NAME]/default

Deleting files...
Destroy complete!
31 changes: 31 additions & 0 deletions acceptance/bundle/deploy/immutable/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
[project]
name = "immutable"
version = "0.0.1"
authors = [{ name = "andrew.nester@databricks.com" }]
requires-python = ">=3.10,<3.13"
dependencies = [
# Any dependencies for jobs and pipelines in this project can be added here
# See also https://docs.databricks.com/dev-tools/bundles/library-dependencies
#
# LIMITATION: for pipelines, dependencies are cached during development;
# add dependencies to the 'environment' section of your pipeline.yml file instead
]

[dependency-groups]
dev = [
"pytest",
"ruff",
"databricks-dlt",
"databricks-connect>=15.4,<15.5",
"ipykernel",
]

[project.scripts]
main = "immutable.main:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.ruff]
line-length = 120
17 changes: 17 additions & 0 deletions acceptance/bundle/deploy/immutable/script
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
envsubst < databricks.yml.tmpl > databricks.yml
cleanup() {
trace $CLI bundle destroy --auto-approve
}
trap cleanup EXIT

trace $CLI bundle validate
trace $CLI bundle deploy


# Get a job and check that task paths are immutable
JOB_ID=$($CLI bundle summary -o json | jq -r '.resources.jobs.my_job.id')
trace $CLI jobs get $JOB_ID | jq '.settings.tasks' | jq '.[] | select(.spark_python_task != null) | .spark_python_task.python_file'
trace $CLI jobs get $JOB_ID | jq '.settings.tasks' | jq '.[] | select(.notebook_task != null) | .notebook_task.notebook_path'
trace $CLI jobs get $JOB_ID | jq '.settings.environments[0].spec.dependencies'

trace $CLI bundle run my_job
Empty file.
6 changes: 6 additions & 0 deletions acceptance/bundle/deploy/immutable/src/immutable/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
def main():
print("Hello from Python Wheel Task!")


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions acceptance/bundle/deploy/immutable/src/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
print("Hello from Spark Python Task!")
3 changes: 3 additions & 0 deletions acceptance/bundle/deploy/immutable/src/notebook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Databricks notebook source

print("Hello from Notebook Task!")
10 changes: 10 additions & 0 deletions acceptance/bundle/deploy/immutable/test.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Local = false
Cloud = true

Ignore = [
"databricks.yml",
".databricks",
".venv",
"script",
"*.pyc",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
bundle:
name: my-bundle
immutable: true

sync:
exclude:
# Test framework files that are not part of the bundle source.
- "repls.json"
- "user_repls.json"
- "script"
- "*.toml"

resources:
jobs:
my_job:
name: my job
tasks:
- task_key: my_task
existing_cluster_id: "0101-120000-aaaaaaaa"
spark_python_task:
python_file: ./src/main.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 30 additions & 0 deletions acceptance/bundle/validate/immutable_workspace_paths/output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@

>>> [CLI] bundle validate -o json
Warning: Pattern user_repls.json does not match any files
at sync.exclude[1]
in databricks.yml:9:7

{
"workspace": {
"artifact_path": "/Workspace/Users/[USERNAME]/.bundle/my-bundle/default/artifacts",
"current_user": {
"domain_friendly_name": "[USERNAME]",
"id": "[USERID]",
"short_name": "[USERNAME]",
"userName": "[USERNAME]"
},
"file_path": "/Workspace/Users/[USERNAME]/.bundle/my-bundle/default/files",
"resource_path": "/Workspace/Users/[USERNAME]/.bundle/my-bundle/default/resources",
"root_path": "/Workspace/Users/[USERNAME]/.bundle/my-bundle/default",
"state_path": "/Workspace/Users/[USERNAME]/.bundle/my-bundle/default/state"
},
"tasks": [
{
"existing_cluster_id": "0101-120000-aaaaaaaa",
"spark_python_task": {
"python_file": "${workspace.snapshot_path}/src/files/src/main.py"
},
"task_key": "my_task"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
trace $CLI bundle validate -o json | jq '{workspace: .workspace, tasks: .resources.jobs.my_job.tasks}'
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
print("hello")
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Local = true
Cloud = false
Ignore = [".databricks"]
7 changes: 7 additions & 0 deletions bundle/config/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,11 @@ type Bundle struct {
// A stable generated UUID for the bundle. This is normally serialized by
// Databricks first party template when a user runs bundle init.
Uuid string `json:"uuid,omitempty"`

// Immutable specifies that bundle files and artifacts are uploaded as a single
// immutable snapshot rather than being synced individually. When true, the
// deployment calls /api/2.0/repos/snapshots with a zip containing all files
// and sets workspace.file_path and workspace.artifact_path to the returned
// content-addressed path. validate and plan make no mutative API calls.
Immutable bool `json:"immutable,omitempty"`
}
24 changes: 24 additions & 0 deletions bundle/config/mutator/resolve_variable_references.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ type resolveVariableReferences struct {
includeResources bool

artifactsReferenceUsed bool

// excludePaths lists variable reference paths (e.g. "workspace.file_path") whose
// resolution should be skipped. References to these paths remain unresolved so a
// later mutator can set the value and re-run resolution.
excludePaths []string
}

func ResolveVariableReferencesOnlyResources(prefixes ...string) bundle.Mutator {
Expand All @@ -74,6 +79,22 @@ func ResolveVariableReferencesOnlyResources(prefixes ...string) bundle.Mutator {
}
}

// ResolveVariableReferencesOnlyResourcesExcluding resolves variable references in
// resources while leaving references to the specified paths unresolved.
// Used by ProcessStaticResources for immutable bundles so that ${workspace.snapshot_path}
// is not resolved during Initialize; it is resolved in the Deploy phase after
// snapshot.Upload() sets workspace.snapshot_path to the API-assigned path.
func ResolveVariableReferencesOnlyResourcesExcluding(excludePaths ...string) bundle.Mutator {
return &resolveVariableReferences{
prefixes: defaultPrefixes,
lookupFn: lookup,
extraRounds: maxResolutionRounds - 1,
pattern: dyn.NewPattern(dyn.Key("resources")),
includeResources: true,
excludePaths: excludePaths,
}
}

func ResolveVariableReferencesWithoutResources(prefixes ...string) bundle.Mutator {
if len(prefixes) == 0 {
prefixes = defaultPrefixes
Expand Down Expand Up @@ -229,6 +250,9 @@ func (m *resolveVariableReferences) resolveOnce(b *bundle.Bundle, prefixes []dyn

// Perform resolution only if the path starts with one of the specified prefixes.
if slices.ContainsFunc(prefixes, path.HasPrefix) {
if slices.Contains(m.excludePaths, path.String()) {
return dyn.InvalidValue, dynvar.ErrSkipResolution
}
value, err := m.lookupFn(normalized, path, b)
hasUpdates = hasUpdates || (err == nil && value.IsValid())
return value, err
Expand Down
45 changes: 45 additions & 0 deletions bundle/config/mutator/resolve_variable_references_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import (
"github.com/databricks/cli/bundle"
"github.com/databricks/cli/bundle/config"
"github.com/databricks/cli/bundle/config/resources"
"github.com/databricks/databricks-sdk-go/service/jobs"
"github.com/databricks/databricks-sdk-go/service/pipelines"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -63,3 +65,46 @@ func TestResolveVariableReferencesWithSourceLinkedDeployment(t *testing.T) {
testCase.assert(t, b)
}
}

// TestResolveVariableReferencesExcludePaths verifies that paths listed in excludePaths
// are skipped during resolution and left as unresolved variable references.
// This is used by ProcessStaticResources for immutable bundles so that
// ${workspace.file_path} and ${workspace.artifact_path} can be resolved later
// (in the Build phase, after artifacts are built and the correct snapshot path is known).
func TestResolveVariableReferencesExcludePaths(t *testing.T) {
b := &bundle.Bundle{
Config: config.Root{
Workspace: config.Workspace{
FilePath: "/snapshot/path/src/files",
ArtifactPath: "/snapshot/path/src/artifacts",
},
Resources: config.Resources{
Jobs: map[string]*resources.Job{
"job1": {
JobSettings: jobs.JobSettings{
Tasks: []jobs.Task{
{
SparkPythonTask: &jobs.SparkPythonTask{
PythonFile: "${workspace.file_path}/main.py",
},
},
},
},
},
},
},
},
}

// With exclusion: ${workspace.file_path} should remain unresolved.
diags := bundle.Apply(t.Context(), b, ResolveVariableReferencesOnlyResourcesExcluding("workspace.file_path", "workspace.artifact_path"))
require.NoError(t, diags.Error())
assert.Equal(t, "${workspace.file_path}/main.py", b.Config.Resources.Jobs["job1"].Tasks[0].SparkPythonTask.PythonFile,
"reference should remain unresolved when path is excluded")

// Without exclusion: ${workspace.file_path} should resolve normally.
diags = bundle.Apply(t.Context(), b, ResolveVariableReferencesOnlyResources())
require.NoError(t, diags.Error())
assert.Equal(t, "/snapshot/path/src/files/main.py", b.Config.Resources.Jobs["job1"].Tasks[0].SparkPythonTask.PythonFile,
"reference should resolve after exclusion is lifted")
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,26 @@ func (p processStaticResources) Apply(ctx context.Context, b *bundle.Bundle) dia
// we need to resolve variables because they can change path values:
// - variable can be used a prefix
// - path can be part of a complex variable value

// For immutable bundles, defer resolving ${workspace.snapshot_path} in resources.
// The actual snapshot path is only known after snapshot.Upload() returns the
// API-assigned path in the deploy phase.
var resourceResolver bundle.Mutator
if b.Config.Bundle.Immutable {
resourceResolver = mutator.ResolveVariableReferencesOnlyResourcesExcluding(
"workspace.snapshot_path",
)
} else {
resourceResolver = mutator.ResolveVariableReferencesOnlyResources()
}

bundle.ApplySeqContext(
ctx,
b,
// Reads (dynamic): * (strings) (searches for variable references in string values)
// Updates (dynamic): resources.* (strings) (resolves variable references to their actual values)
// Resolves variable references in 'resources' using bundle, workspace, and variables prefixes
mutator.ResolveVariableReferencesOnlyResources(),
resourceResolver,
// After normal variable resolution, log all ${resources.*} references
mutator.LogResourceReferences(),
mutator.NormalizePaths(),
Expand Down
Loading
Loading