Skip to content

Commit d0eb393

Browse files
authored
Skip local file validation in config sync (#4648)
## Changes Skip local file validation in config-remote-sync ## Why Use case: * user renames pipeline root folder in UI * folder path is updated in WSFS * folder path is updated in the resource object by the frontend code * <== sync starts here * when CLI attempts config-sync, it fails with “stat: folder not found error.” The issue occurs because we have a validation step that checks for the existence of local files during the path translation phase. We do need path translation because config sync needs fully resolved paths to compare it with fields from remote object, but validation is not necessary in this case ## Tests 1. acceptance test in config_remote_sync 2. unit test in translate_path.go
1 parent f6a7ba3 commit d0eb393

File tree

9 files changed

+291
-4
lines changed

9 files changed

+291
-4
lines changed
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
bundle:
2+
name: test-bundle-$UNIQUE_NAME
3+
4+
resources:
5+
pipelines:
6+
my_pipeline:
7+
name: test-pipeline-$UNIQUE_NAME
8+
root_path: ./pipeline_root
9+
libraries:
10+
- notebook:
11+
path: /Users/{{workspace_user_name}}/notebook
12+
13+
jobs:
14+
my_job:
15+
tasks:
16+
- task_key: main
17+
notebook_task:
18+
notebook_path: ./src/notebook.py
19+
new_cluster:
20+
spark_version: $DEFAULT_SPARK_VERSION
21+
node_type_id: $NODE_TYPE_ID
22+
num_workers: 1
23+
24+
targets:
25+
default:
26+
mode: development

acceptance/bundle/config-remote-sync/validation_errors/out.test.toml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files...
2+
Deploying resources...
3+
Updating deployment state...
4+
Deployment complete!
5+
6+
=== Set correct paths and add git info remotely
7+
=== Break local paths to simulate stale config
8+
=== Sync with broken local paths
9+
Detected changes in 2 resource(s):
10+
11+
Resource: resources.jobs.my_job
12+
git_source: add
13+
tasks[task_key='main'].notebook_task.notebook_path: replace
14+
15+
Resource: resources.pipelines.my_pipeline
16+
root_path: replace
17+
18+
19+
20+
=== Configuration changes
21+
22+
>>> diff.py databricks.yml.backup databricks.yml
23+
--- databricks.yml.backup
24+
+++ databricks.yml
25+
@@ -6,5 +6,5 @@
26+
my_pipeline:
27+
name: test-pipeline-[UNIQUE_NAME]
28+
- root_path: ./pipeline_root
29+
+ root_path: ./pipeline_root_v2
30+
libraries:
31+
- notebook:
32+
@@ -16,9 +16,13 @@
33+
- task_key: main
34+
notebook_task:
35+
- notebook_path: ./src/notebook.py
36+
+ notebook_path: /Users/[USERNAME]/notebook
37+
new_cluster:
38+
spark_version: 13.3.x-snapshot-scala2.12
39+
node_type_id: [NODE_TYPE_ID]
40+
num_workers: 1
41+
+ git_source:
42+
+ git_branch: main
43+
+ git_provider: gitHub
44+
+ git_url: https://github.com/databricks/databricks-sdk-go.git
45+
46+
targets:
47+
48+
>>> [CLI] bundle destroy --auto-approve
49+
The following resources will be deleted:
50+
delete resources.jobs.my_job
51+
delete resources.pipelines.my_pipeline
52+
53+
This action will result in the deletion of the following Lakeflow Spark Declarative Pipelines along with the
54+
Streaming Tables (STs) and Materialized Views (MVs) managed by them:
55+
delete resources.pipelines.my_pipeline
56+
57+
All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default
58+
59+
Deleting files...
60+
Destroy complete!
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
envsubst < databricks.yml.tmpl > databricks.yml
2+
3+
# Create valid paths so that initial deploy succeeds
4+
mkdir -p pipeline_root src
5+
echo '# Databricks notebook source' > src/notebook.py
6+
7+
cleanup() {
8+
# Restore valid paths for destroy to work (includes pipeline_root_v2 from sync)
9+
mkdir -p pipeline_root pipeline_root_v2 src
10+
echo '# Databricks notebook source' > src/notebook.py
11+
trace $CLI bundle destroy --auto-approve
12+
}
13+
trap cleanup EXIT
14+
15+
$CLI bundle deploy
16+
job_id="$(read_id.py my_job)"
17+
pipeline_id="$(read_id.py my_pipeline)"
18+
19+
20+
title "Set correct paths and add git info remotely"
21+
edit_resource.py pipelines $pipeline_id <<EOF
22+
r["root_path"] = "${PWD}/pipeline_root_v2"
23+
EOF
24+
25+
edit_resource.py jobs $job_id <<EOF
26+
r["tasks"][0]["notebook_task"]["notebook_path"] = "/Users/${CURRENT_USER_NAME}/notebook"
27+
r["git_source"] = {
28+
"git_url": "https://github.com/databricks/databricks-sdk-go.git",
29+
"git_branch": "main",
30+
"git_provider": "gitHub"
31+
}
32+
EOF
33+
34+
35+
title "Break local paths to simulate stale config"
36+
# Remove the directories so the config now references non-existent relative paths
37+
rm -rf pipeline_root src
38+
39+
40+
title "Sync with broken local paths"
41+
echo
42+
cp databricks.yml databricks.yml.backup
43+
$CLI bundle config-remote-sync --save
44+
45+
title "Configuration changes"
46+
echo
47+
trace diff.py databricks.yml.backup databricks.yml
48+
rm databricks.yml.backup
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
RecordRequests = false
2+
Ignore = [".databricks", "databricks.yml", "databricks.yml.backup", "src", "pipeline_root"]
3+
4+
[Env]
5+
DATABRICKS_BUNDLE_ENABLE_EXPERIMENTAL_YAML_SYNC = "true"
6+
7+
[EnvMatrix]
8+
DATABRICKS_BUNDLE_ENGINE = ["direct", "terraform"]

bundle/bundle.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,14 @@ type Bundle struct {
149149
// files
150150
AutoApprove bool
151151

152+
// SkipLocalFileValidation makes path translation tolerant of missing local files.
153+
// When set, TranslatePaths computes workspace paths without verifying files exist.
154+
// Used by config-remote-sync: a user may modify resource paths remotely (e.g.,
155+
// rename a pipeline root folder in the UI), and the updated paths may not exist
156+
// locally. Path translation is still needed to produce fully resolved paths for
157+
// comparison with remote state, but local file validation would incorrectly fail.
158+
SkipLocalFileValidation bool
159+
152160
// Tagging is used to normalize tag keys and values.
153161
// The implementation depends on the cloud being targeted.
154162
Tagging tags.Cloud

bundle/config/mutator/translate_paths.go

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,14 @@ type translateContext struct {
8484
// It is equal to ${workspace.file_path} for regular deployments.
8585
// It points to the source root path for source-linked deployments.
8686
remoteRoot string
87+
88+
// skipLocalFileValidation makes path translation tolerant of missing local files.
89+
// When set, paths are translated without verifying files exist on the local filesystem.
90+
// This is used by config-remote-sync: a user may rename a resource's root folder
91+
// in the workspace UI, and the updated path may not exist locally. Path translation
92+
// is still needed to produce fully resolved paths for comparison with remote state,
93+
// but local file validation would incorrectly fail.
94+
skipLocalFileValidation bool
8795
}
8896

8997
// rewritePath converts a given relative path from the loaded config to a new path based on the passed rewriting function
@@ -178,6 +186,11 @@ func (t *translateContext) rewritePath(
178186
}
179187

180188
func (t *translateContext) translateNotebookPath(ctx context.Context, literal, localFullPath, localRelPath string) (string, error) {
189+
if t.skipLocalFileValidation {
190+
localRelPathNoExt := strings.TrimSuffix(localRelPath, path.Ext(localRelPath))
191+
return path.Join(t.remoteRoot, localRelPathNoExt), nil
192+
}
193+
181194
nb, _, err := notebook.DetectWithFS(t.b.SyncRoot, localRelPath)
182195
if errors.Is(err, fs.ErrNotExist) {
183196
if path.Ext(localFullPath) != notebook.ExtensionNone {
@@ -213,6 +226,10 @@ to contain one of the following file extensions: [%s]`, literal, strings.Join(no
213226
}
214227

215228
func (t *translateContext) translateFilePath(ctx context.Context, literal, localFullPath, localRelPath string) (string, error) {
229+
if t.skipLocalFileValidation {
230+
return path.Join(t.remoteRoot, localRelPath), nil
231+
}
232+
216233
nb, _, err := notebook.DetectWithFS(t.b.SyncRoot, localRelPath)
217234
if errors.Is(err, fs.ErrNotExist) {
218235
return "", fmt.Errorf("file %s not found", literal)
@@ -227,6 +244,10 @@ func (t *translateContext) translateFilePath(ctx context.Context, literal, local
227244
}
228245

229246
func (t *translateContext) translateDirectoryPath(ctx context.Context, literal, localFullPath, localRelPath string) (string, error) {
247+
if t.skipLocalFileValidation {
248+
return path.Join(t.remoteRoot, localRelPath), nil
249+
}
250+
230251
info, err := t.b.SyncRoot.Stat(localRelPath)
231252
if err != nil {
232253
return "", err
@@ -242,6 +263,10 @@ func (t *translateContext) translateGlobPath(ctx context.Context, literal, local
242263
}
243264

244265
func (t *translateContext) translateLocalAbsoluteDirectoryPath(ctx context.Context, literal, localFullPath, _ string) (string, error) {
266+
if t.skipLocalFileValidation {
267+
return localFullPath, nil
268+
}
269+
245270
info, err := os.Stat(filepath.FromSlash(localFullPath))
246271
if errors.Is(err, fs.ErrNotExist) {
247272
return "", fmt.Errorf("directory %s not found", literal)
@@ -311,8 +336,9 @@ func applyTranslations(ctx context.Context, b *bundle.Bundle, t *translateContex
311336

312337
func (m *translatePaths) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics {
313338
t := &translateContext{
314-
b: b,
315-
seen: make(map[string]string),
339+
b: b,
340+
seen: make(map[string]string),
341+
skipLocalFileValidation: b.SkipLocalFileValidation,
316342
}
317343

318344
return applyTranslations(ctx, b, t, []func(context.Context, dyn.Value) (dyn.Value, error){
@@ -327,8 +353,9 @@ func (m *translatePaths) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagn
327353

328354
func (m *translatePathsDashboards) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics {
329355
t := &translateContext{
330-
b: b,
331-
seen: make(map[string]string),
356+
b: b,
357+
seen: make(map[string]string),
358+
skipLocalFileValidation: b.SkipLocalFileValidation,
332359
}
333360

334361
return applyTranslations(ctx, b, t, []func(context.Context, dyn.Value) (dyn.Value, error){

bundle/config/mutator/translate_paths_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,3 +1015,101 @@ func TestTranslatePathsWithSourceLinkedDeployment(t *testing.T) {
10151015
b.Config.Resources.Pipelines["pipeline"].Libraries[1].Notebook.Path,
10161016
)
10171017
}
1018+
1019+
func TestTranslatePathsWithSkipLocalFileValidation(t *testing.T) {
1020+
dir := t.TempDir()
1021+
// Intentionally do NOT create any files — paths are stale/missing.
1022+
1023+
b := &bundle.Bundle{
1024+
SyncRootPath: dir,
1025+
BundleRootPath: dir,
1026+
SyncRoot: vfs.MustNew(dir),
1027+
SkipLocalFileValidation: true,
1028+
Config: config.Root{
1029+
Workspace: config.Workspace{
1030+
FilePath: "/bundle",
1031+
},
1032+
Resources: config.Resources{
1033+
Jobs: map[string]*resources.Job{
1034+
"job": {
1035+
JobSettings: jobs.JobSettings{
1036+
Tasks: []jobs.Task{
1037+
{
1038+
NotebookTask: &jobs.NotebookTask{
1039+
NotebookPath: "./src/notebook.py",
1040+
},
1041+
},
1042+
{
1043+
SparkPythonTask: &jobs.SparkPythonTask{
1044+
PythonFile: "./src/main.py",
1045+
},
1046+
},
1047+
},
1048+
},
1049+
},
1050+
},
1051+
Pipelines: map[string]*resources.Pipeline{
1052+
"pipeline": {
1053+
CreatePipeline: pipelines.CreatePipeline{
1054+
Libraries: []pipelines.PipelineLibrary{
1055+
{
1056+
Notebook: &pipelines.NotebookLibrary{
1057+
Path: "./src/pipeline_notebook.py",
1058+
},
1059+
},
1060+
},
1061+
},
1062+
},
1063+
},
1064+
},
1065+
},
1066+
}
1067+
1068+
bundletest.SetLocation(b, ".", []dyn.Location{{File: filepath.Join(dir, "databricks.yml")}})
1069+
1070+
diags := bundle.ApplySeq(t.Context(), b, mutator.NormalizePaths(), mutator.TranslatePaths())
1071+
require.NoError(t, diags.Error())
1072+
1073+
// Notebook path should be translated (extension stripped) even though file doesn't exist.
1074+
assert.Equal(t, "/bundle/src/notebook", b.Config.Resources.Jobs["job"].Tasks[0].NotebookTask.NotebookPath)
1075+
1076+
// File path should be translated even though file doesn't exist.
1077+
assert.Equal(t, "/bundle/src/main.py", b.Config.Resources.Jobs["job"].Tasks[1].SparkPythonTask.PythonFile)
1078+
1079+
// Pipeline notebook path should be translated even though file doesn't exist.
1080+
assert.Equal(t, "/bundle/src/pipeline_notebook", b.Config.Resources.Pipelines["pipeline"].Libraries[0].Notebook.Path)
1081+
}
1082+
1083+
func TestTranslatePathsWithSkipLocalFileValidationDirectory(t *testing.T) {
1084+
dir := t.TempDir()
1085+
// Intentionally do NOT create pipeline_root directory.
1086+
1087+
b := &bundle.Bundle{
1088+
SyncRootPath: dir,
1089+
BundleRootPath: dir,
1090+
SyncRoot: vfs.MustNew(dir),
1091+
SkipLocalFileValidation: true,
1092+
Config: config.Root{
1093+
Workspace: config.Workspace{
1094+
FilePath: "/bundle",
1095+
},
1096+
Resources: config.Resources{
1097+
Pipelines: map[string]*resources.Pipeline{
1098+
"pipeline": {
1099+
CreatePipeline: pipelines.CreatePipeline{
1100+
RootPath: "./pipeline_root",
1101+
},
1102+
},
1103+
},
1104+
},
1105+
},
1106+
}
1107+
1108+
bundletest.SetLocation(b, ".", []dyn.Location{{File: filepath.Join(dir, "databricks.yml")}})
1109+
1110+
diags := bundle.ApplySeq(t.Context(), b, mutator.NormalizePaths(), mutator.TranslatePaths())
1111+
require.NoError(t, diags.Error())
1112+
1113+
// Directory path should be translated even though directory doesn't exist.
1114+
assert.Equal(t, "/bundle/pipeline_root", b.Config.Resources.Pipelines["pipeline"].RootPath)
1115+
}

cmd/bundle/config_remote_sync.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"runtime"
88

9+
"github.com/databricks/cli/bundle"
910
"github.com/databricks/cli/bundle/configsync"
1011
"github.com/databricks/cli/cmd/bundle/utils"
1112
"github.com/databricks/cli/cmd/root"
@@ -46,6 +47,9 @@ Examples:
4647
ReadState: true,
4748
Build: true,
4849
AlwaysPull: true,
50+
InitFunc: func(b *bundle.Bundle) {
51+
b.SkipLocalFileValidation = true
52+
},
4953
})
5054
if err != nil {
5155
return err

0 commit comments

Comments
 (0)