diff --git a/acceptance/bundle/state/directory_at_state_path/databricks.yml.tmpl b/acceptance/bundle/state/directory_at_state_path/databricks.yml.tmpl new file mode 100644 index 00000000000..ea466a3f18d --- /dev/null +++ b/acceptance/bundle/state/directory_at_state_path/databricks.yml.tmpl @@ -0,0 +1,7 @@ +bundle: + name: directory-at-state-path-$UNIQUE_NAME + +resources: + jobs: + foo: + name: foo diff --git a/acceptance/bundle/state/directory_at_state_path/out.test.toml b/acceptance/bundle/state/directory_at_state_path/out.test.toml new file mode 100644 index 00000000000..0f167effef1 --- /dev/null +++ b/acceptance/bundle/state/directory_at_state_path/out.test.toml @@ -0,0 +1,2 @@ +Cloud = true +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/state/directory_at_state_path/output.txt b/acceptance/bundle/state/directory_at_state_path/output.txt new file mode 100644 index 00000000000..9acde0d0b1a --- /dev/null +++ b/acceptance/bundle/state/directory_at_state_path/output.txt @@ -0,0 +1,4 @@ + +>>> musterr [CLI] bundle plan +Error: reading resources.json: not a file: the workspace has a directory at this path + diff --git a/acceptance/bundle/state/directory_at_state_path/script b/acceptance/bundle/state/directory_at_state_path/script new file mode 100644 index 00000000000..f7ff13bc41f --- /dev/null +++ b/acceptance/bundle/state/directory_at_state_path/script @@ -0,0 +1,14 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +# Something else created a directory where the CLI keeps its state file. +state_path=$($CLI bundle validate -o json | jq -r .workspace.state_path) +# MSYS_NO_PATHCONV stops Git Bash on Windows from rewriting the /Workspace/... argument into a local path. +MSYS_NO_PATHCONV=1 $CLI workspace mkdirs "$state_path/resources.json" + +cleanup() { + MSYS_NO_PATHCONV=1 $CLI workspace delete --recursive "$($CLI bundle validate -o json | jq -r .workspace.root_path)" + rm -f "$OUT_REQUESTS" +} +trap cleanup EXIT + +trace musterr $CLI bundle plan diff --git a/acceptance/bundle/state/directory_at_state_path/test.toml b/acceptance/bundle/state/directory_at_state_path/test.toml new file mode 100644 index 00000000000..c489ad5f0c8 --- /dev/null +++ b/acceptance/bundle/state/directory_at_state_path/test.toml @@ -0,0 +1,2 @@ +# Exercises the real workspace's export of a directory, which returns a ZIP archive. +Cloud = true diff --git a/bundle/statemgmt/state_pull.go b/bundle/statemgmt/state_pull.go index 4ce9494c894..616cd8af197 100644 --- a/bundle/statemgmt/state_pull.go +++ b/bundle/statemgmt/state_pull.go @@ -1,6 +1,7 @@ package statemgmt import ( + "bytes" "context" "encoding/json" "errors" @@ -26,6 +27,9 @@ import ( type AlwaysPull bool +// zipMagic starts every ZIP archive; a state file is JSON and never does. +var zipMagic = []byte("PK\x03\x04") + type StateDesc struct { Serial int `json:"serial"` Lineage string `json:"lineage"` @@ -113,6 +117,11 @@ func _filerRead(ctx context.Context, f filer.Filer, path string) (*StateDesc, er state := &StateDesc{} err = json.Unmarshal(content, state) if err != nil { + // The workspace exports a directory as a ZIP archive rather than failing, so a directory + // at the state path reads fine and only shows up here. + if bytes.HasPrefix(content, zipMagic) { + return nil, errors.New("not a file: the workspace has a directory at this path") + } return nil, fmt.Errorf("parsing state: %w", err) } diff --git a/libs/testserver/fake_workspace.go b/libs/testserver/fake_workspace.go index e0073bd656a..a51bd231c7d 100644 --- a/libs/testserver/fake_workspace.go +++ b/libs/testserver/fake_workspace.go @@ -845,9 +845,27 @@ func (s *FakeWorkspace) WorkspaceMkdirs(request workspace.Mkdirs) { func (s *FakeWorkspace) WorkspaceExport(path string) []byte { defer s.LockUnlock()() + if _, isDir := s.directories[path]; isDir { + return exportDirectory(path) + } return s.files[path].Data } +// exportDirectory mimics the real API, which exports a directory as a ZIP archive even with +// direct_download=true. Only the directory's own entry is included: callers just need to see +// that they got an archive rather than a file. +func exportDirectory(dirPath string) []byte { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + if _, err := zw.Create(path.Base(dirPath) + "/"); err != nil { + panic(err) + } + if err := zw.Close(); err != nil { + panic(err) + } + return buf.Bytes() +} + // WorkspaceDelete implements POST /api/2.0/workspace/delete. As in the real API, a // non-recursive delete of a directory that still has children fails instead of removing // it, which is what lets a caller delete a directory only if it is empty.