diff --git a/acceptance/bin/fault.py b/acceptance/bin/fault.py index 5b75c0a5907..20d8be4d405 100755 --- a/acceptance/bin/fault.py +++ b/acceptance/bin/fault.py @@ -6,8 +6,9 @@ --after-handler run the handler first; keep its effect and replace only the response --body-contains only fire when the request body contains SUBSTR, e.g. to target one file's /workspace/import upload (all uploads share that path) - PATTERN HTTP method and path, supports trailing * wildcard, - e.g. "PUT /api/2.0/permissions/pipelines/*" + PATTERN HTTP method and path; * matches within one path segment, ** spans /, + e.g. "POST /api/2.0/bundle/deployments/*/versions/*/complete" + or "PATCH /api/2.0/bundle/**" STATUS_CODE HTTP status code to return, e.g. 504 OFFSET number of requests to let through before fault starts TIMES number of times to return the fault response diff --git a/acceptance/bundle/dms/operation-upload-fails-once/output.txt b/acceptance/bundle/dms/operation-upload-fails-once/output.txt index d17e9ee26fb..042e9d0669e 100644 --- a/acceptance/bundle/dms/operation-upload-fails-once/output.txt +++ b/acceptance/bundle/dms/operation-upload-fails-once/output.txt @@ -54,7 +54,7 @@ Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged } === A recreate records twice, and the first one fails: the second must still carry the sequence id staging left, because a rejected update earned no new one ->>> fault.py PATCH /api/2.0/bundle/* 500 0 1 +>>> fault.py PATCH /api/2.0/bundle/** 500 0 1 >>> update_file.py databricks.yml catalog_name: main catalog_name: dms_other_[UNIQUE_NAME] diff --git a/acceptance/bundle/dms/operation-upload-fails-once/script b/acceptance/bundle/dms/operation-upload-fails-once/script index 69dab957164..87bf33251a4 100644 --- a/acceptance/bundle/dms/operation-upload-fails-once/script +++ b/acceptance/bundle/dms/operation-upload-fails-once/script @@ -16,7 +16,7 @@ title "The raw recorded state after deploy" trace print_state.py --no-dms title "A recreate records twice, and the first one fails: the second must still carry the sequence id staging left, because a rejected update earned no new one" -trace fault.py "PATCH /api/2.0/bundle/*" 500 0 1 +trace fault.py "PATCH /api/2.0/bundle/**" 500 0 1 # Unity Catalog has no catalog called "other", so make one to move the schema into. other_catalog="dms_other_${UNIQUE_NAME}" $CLI catalogs create "$other_catalog" &> LOG.catalog diff --git a/acceptance/bundle/resources/dashboards/publish-failure-stale-content/script b/acceptance/bundle/resources/dashboards/publish-failure-stale-content/script index 325b4afb422..b521988df03 100644 --- a/acceptance/bundle/resources/dashboards/publish-failure-stale-content/script +++ b/acceptance/bundle/resources/dashboards/publish-failure-stale-content/script @@ -20,7 +20,7 @@ trace $CLI bundle plan -o json | nostamp | gron.py | grep -E "etag|published" rm "$OUT_REQUESTS" # Inject a single publish failure for the update below. -fault.py "POST /api/2.0/lakeview/dashboards/*" 400 0 1 +fault.py "POST /api/2.0/lakeview/dashboards/*/published" 400 0 1 # Rename the dashboard to trigger an Update. update_file.py databricks.yml "my dashboard" "my dashboard renamed" diff --git a/bundle/generate/job.go b/bundle/generate/job.go index 801eee1f5bd..f2da37a5dc9 100644 --- a/bundle/generate/job.go +++ b/bundle/generate/job.go @@ -41,7 +41,7 @@ func ConvertJobToValue(job *jobs.Job) (dyn.Value, error) { value["parameters"] = dyn.NewValue(params, []diag.Location{{Line: jobOrder.Get("parameters")}}) } - return yamlsaver.ConvertToMapValue(job.Settings, jobOrder, []string{"format", "new_cluster", "existing_cluster_id"}, value) + return yamlsaver.ConvertToMapValue(job.Settings, jobOrder, []string{"format", "new_cluster", "existing_cluster_id", "deployment"}, value) } func convertTaskToValue(task jobs.Task, order *yamlsaver.Order) (dyn.Value, error) { diff --git a/bundle/generate/job_test.go b/bundle/generate/job_test.go new file mode 100644 index 00000000000..d2870475fbe --- /dev/null +++ b/bundle/generate/job_test.go @@ -0,0 +1,26 @@ +package generate_test + +import ( + "testing" + + "github.com/databricks/cli/bundle/generate" + "github.com/databricks/databricks-sdk-go/service/jobs" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConvertJobToValueOmitsDeployment(t *testing.T) { + job := &jobs.Job{Settings: &jobs.JobSettings{ + Name: "generated-job", + Deployment: &jobs.JobDeployment{ + Kind: "BUNDLE", + DeploymentId: "123", + VersionId: "4", + MetadataFilePath: "/Workspace/state/metadata.json", + }, + }} + + value, err := generate.ConvertJobToValue(job) + require.NoError(t, err) + assert.Equal(t, map[string]any{"name": "generated-job"}, value.AsAny()) +} diff --git a/bundle/generate/pipeline.go b/bundle/generate/pipeline.go index 115330ea0f6..8b6b8278c9d 100644 --- a/bundle/generate/pipeline.go +++ b/bundle/generate/pipeline.go @@ -30,5 +30,5 @@ func ConvertPipelineToValue(pipeline *pipelines.PipelineSpec, rootPath, remoteRo // - storage: changes to this field are rare because changing the storage recreates pipeline-related resources // - edition: this field is rarely changed // - development: this field is specific to the mode where it's used and does not need to be saved to the bundle configuration. - return yamlsaver.ConvertToMapValue(pipeline, pipelineOrder, []string{"id", "storage", "edition", "development"}, value) + return yamlsaver.ConvertToMapValue(pipeline, pipelineOrder, []string{"id", "storage", "edition", "development", "deployment"}, value) } diff --git a/bundle/generate/pipeline_test.go b/bundle/generate/pipeline_test.go new file mode 100644 index 00000000000..66773c78d90 --- /dev/null +++ b/bundle/generate/pipeline_test.go @@ -0,0 +1,26 @@ +package generate_test + +import ( + "testing" + + "github.com/databricks/cli/bundle/generate" + "github.com/databricks/databricks-sdk-go/service/pipelines" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConvertPipelineToValueOmitsDeployment(t *testing.T) { + pipeline := &pipelines.PipelineSpec{ + Name: "generated-pipeline", + Deployment: &pipelines.PipelineDeployment{ + Kind: "BUNDLE", + DeploymentId: "123", + VersionId: "4", + MetadataFilePath: "/Workspace/state/metadata.json", + }, + } + + value, err := generate.ConvertPipelineToValue(pipeline, "", "") + require.NoError(t, err) + assert.Equal(t, map[string]any{"name": "generated-pipeline"}, value.AsAny()) +} diff --git a/libs/testserver/fault.go b/libs/testserver/fault.go index 43d46f3f28c..2ee2e83dbd2 100644 --- a/libs/testserver/fault.go +++ b/libs/testserver/fault.go @@ -3,6 +3,7 @@ package testserver import ( "bytes" "encoding/json" + "regexp" "strings" "sync" ) @@ -20,6 +21,7 @@ type FaultRule struct { AfterHandler bool offset int times int + pattern *regexp.Regexp // bodyContains, if non-empty, additionally requires the request body to // contain this substring for the rule to fire. /workspace/import routes @@ -50,6 +52,18 @@ func (fr *FaultRules) SetAfterHandler(token, pattern string, statusCode int, bod } func (fr *FaultRules) set(token, pattern string, statusCode int, body string, offset, times int, afterHandler bool, bodyContains string) { + // Escape regex characters so the pattern starts as literal text. + escapedPattern := regexp.QuoteMeta(pattern) + + // Replace ** before * so a recursive wildcard stays intact. + wildcards := strings.NewReplacer( + `\*\*`, ".*", // ** can cross path segments. + `\*`, "[^/]*", // * stays within one path segment. + ) + + // Anchor both ends to match the whole request. + matcher := regexp.MustCompile("^" + wildcards.Replace(escapedPattern) + "$") + fr.mu.Lock() defer fr.mu.Unlock() fr.rules[faultRuleKey{token: token, pattern: pattern}] = &FaultRule{ @@ -58,12 +72,14 @@ func (fr *FaultRules) set(token, pattern string, statusCode int, body string, of AfterHandler: afterHandler, offset: offset, times: times, + pattern: matcher, bodyContains: bodyContains, } } // Check returns a matching fault rule and advances its counters, or nil if no rule matches. -// Pattern supports a trailing "*" wildcard, e.g. "PUT /api/2.0/permissions/pipelines/*". +// Pattern supports "*" within a path segment and "**" across path segments. +// Both wildcards match zero or more characters. // A rule with a non-empty bodyContains only matches when body contains that substring. func (fr *FaultRules) Check(method, path, token string, body []byte) *FaultRule { requestPattern := method + " " + path @@ -75,14 +91,7 @@ func (fr *FaultRules) Check(method, path, token string, body []byte) *FaultRule if key.token != token { continue } - rulePattern := key.pattern - var matched bool - if strings.HasSuffix(rulePattern, "*") { - matched = strings.HasPrefix(requestPattern, rulePattern[:len(rulePattern)-1]) - } else { - matched = requestPattern == rulePattern - } - if !matched { + if !rule.pattern.MatchString(requestPattern) { continue } // A non-matching body must not consume the rule's offset/times budget, diff --git a/libs/testserver/fault_test.go b/libs/testserver/fault_test.go index ae27b954f93..776c4e63bbf 100644 --- a/libs/testserver/fault_test.go +++ b/libs/testserver/fault_test.go @@ -40,6 +40,38 @@ func TestFaultRulesWildcardMatch(t *testing.T) { assert.Nil(t, fr.Check("PUT", "/api/2.0/permissions/pipelines/xyz", "tok", nil)) // exhausted } +func TestFaultRulesWildcardPatterns(t *testing.T) { + for _, tc := range []struct { + name string + pattern string + path string + match bool + }{ + {"middle", "POST /versions/*/complete", "/versions/42/complete", true}, + {"middle_empty", "POST /versions/*/complete", "/versions//complete", true}, + {"middle_does_not_span_slashes", "POST /versions/*/complete", "/versions/42/other/complete", false}, + {"wrong_endpoint", "POST /versions/*/complete", "/versions/42/heartbeat", false}, + {"extra_prefix", "POST /versions/*/complete", "/other/versions/42/complete", false}, + {"extra_suffix", "POST /versions/*/complete", "/versions/42/complete/extra", false}, + {"multiple", "POST /deployments/*/versions/*/complete", "/deployments/123/versions/42/complete", true}, + {"trailing", "POST /versions/*", "/versions/42", true}, + {"trailing_does_not_span_slashes", "POST /versions/*", "/versions/42/operations/1", false}, + {"trailing_empty", "POST /versions/*", "/versions/", true}, + {"recursive_trailing", "POST /versions/**", "/versions/42/operations/1", true}, + {"recursive_empty", "POST /versions/**", "/versions/", true}, + {"recursive_middle", "POST /**/complete", "/deployments/123/versions/42/complete", true}, + {"recursive_wrong_suffix", "POST /**/complete", "/deployments/123/versions/42/heartbeat", false}, + {"literal_metacharacters", "POST /v1.0/[id]?", "/v1.0/[id]?", true}, + {"metacharacters_do_not_match", "POST /v1.0/[id]?", "/v1x0/i", false}, + } { + t.Run(tc.name, func(t *testing.T) { + fr := testserver.NewFaultRules() + fr.Set("tok", tc.pattern, 500, "body", 0, 1, "") + assert.Equal(t, tc.match, fr.Check("POST", tc.path, "tok", nil) != nil) + }) + } +} + func TestFaultRulesOffset(t *testing.T) { fr := testserver.NewFaultRules() fr.Set("tok", "GET /foo", 504, "body", 2, 1, "")