Skip to content
Merged
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
5 changes: 3 additions & 2 deletions acceptance/bin/fault.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
2 changes: 1 addition & 1 deletion acceptance/bundle/dms/operation-upload-fails-once/script
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
27 changes: 18 additions & 9 deletions libs/testserver/fault.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package testserver
import (
"bytes"
"encoding/json"
"regexp"
"strings"
"sync"
)
Expand All @@ -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
Expand Down Expand Up @@ -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{
Expand All @@ -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
Expand All @@ -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,
Expand Down
32 changes: 32 additions & 0 deletions libs/testserver/fault_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "")
Expand Down
Loading