Skip to content

Commit 359cfd6

Browse files
authored
Add custom testrunner that skips known failures in integration tests (#3733)
## Changes New testrunner that reads https://github.com/databricks/cli/blob/ciconfig/known_failures.txt and allows failures based on that config. Oncall can quickly (PR & approvals not required) push updated config to that branch, this will immediately be in effect for all not yet started integration tests. The test is not blocked by known_failures.txt download, if that takes more time then the test suite then check is skipped (we never wait for download). ## Why Allows to quickly prevent failing tests from blocking PRs while still running the test (so that we know when it becomes good) and without requiring PR on main. ## Tests go/deco/tests/18315637433 Manually test: ``` % deco env run -i -n aws-prod-ucws -- go run -modfile=tools/go.mod ./tools/testrunner/main.go go tool -modfile=tools/go.mod gotestsum --format github-actions --jsonfile output.json --packages "./integration/libs/telemetry/..." -- -parallel 4 -timeout=2h ... normal output skipped ... DONE 1 tests, 1 failure in 2.132s integration/libs/telemetry TestTelemetryEndpoint failure is allowed, matches rule "integration/libs/telemetry TestTelemetryEndpoint" % echo $? 0 ```
1 parent 6b14704 commit 359cfd6

File tree

3 files changed

+403
-1
lines changed

3 files changed

+403
-1
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ schema:
107107
docs:
108108
go run ./bundle/docsgen ./bundle/internal/schema ./bundle/docsgen
109109

110-
INTEGRATION = ${GO_TOOL} gotestsum --format github-actions --rerun-fails --jsonfile output.json --packages "./acceptance ./integration/..." -- -parallel 4 -timeout=2h
110+
INTEGRATION = go run -modfile=tools/go.mod ./tools/testrunner/main.go ${GO_TOOL} gotestsum --format github-actions --rerun-fails --jsonfile output.json --packages "./acceptance ./integration/..." -- -parallel 4 -timeout=2h
111111

112112
integration:
113113
$(INTEGRATION)

tools/testrunner/main.go

Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
/*
2+
Start the test runner (gotestsum) as usual.
3+
4+
Download https://github.com/databricks/cli/blob/ciconfig/known_failures.txt in parallel.
5+
6+
If download was successful by the time test runner finishes and test runner finishes with non-zero code,
7+
analyze failures in the test output identified by --jsonfile option. If all failures are expected (listed in known_failures.txt)
8+
then the failure is masked, the process exits with 0.
9+
*/
10+
package main
11+
12+
import (
13+
"bufio"
14+
"context"
15+
"encoding/json"
16+
"fmt"
17+
"io"
18+
"net/http"
19+
"os"
20+
"os/exec"
21+
"strings"
22+
"sync/atomic"
23+
"syscall"
24+
)
25+
26+
const (
27+
maxConfigSize = 10 * 1024 // 10KB
28+
repoConfigURL = "https://raw.githubusercontent.com/databricks/cli/ciconfig/known_failures.txt"
29+
cutPrefix = "github.com/databricks/cli/"
30+
)
31+
32+
type TestResult struct {
33+
Action string `json:"Action,omitempty"`
34+
Package string `json:"Package,omitempty"`
35+
Test string `json:"Test,omitempty"`
36+
}
37+
38+
func getExitCode(err error) (int, error) {
39+
if err == nil {
40+
return 0, nil
41+
}
42+
if exitError, ok := err.(*exec.ExitError); ok {
43+
if status, ok := exitError.Sys().(syscall.WaitStatus); ok {
44+
return status.ExitStatus(), nil
45+
}
46+
}
47+
return 1, err
48+
}
49+
50+
func main() {
51+
if len(os.Args) < 2 {
52+
fmt.Fprintf(os.Stderr, "Usage: %s <command> [args...]\n", os.Args[0])
53+
os.Exit(1)
54+
}
55+
56+
// Find --jsonfile argument
57+
jsonFile := ""
58+
for i, arg := range os.Args {
59+
if arg == "--jsonfile" && i+1 < len(os.Args) {
60+
jsonFile = os.Args[i+1]
61+
break
62+
}
63+
}
64+
65+
if jsonFile == "" {
66+
fmt.Println("No --jsonfile argument found")
67+
}
68+
69+
// Start the main subprocess
70+
cmd := exec.Command(os.Args[1], os.Args[2:]...)
71+
cmd.Stdout = os.Stdout
72+
cmd.Stderr = os.Stderr
73+
cmd.Stdin = os.Stdin
74+
75+
var configContent atomic.Value
76+
77+
// Start background config download
78+
go func() {
79+
content, err := downloadConfig(context.Background())
80+
if err != nil {
81+
fmt.Printf("testrunner: Failed to download %s: %v\n", repoConfigURL, err)
82+
} else {
83+
configContent.Store(content)
84+
}
85+
}()
86+
87+
// Run the main command
88+
err := cmd.Run()
89+
90+
exitCode, err := getExitCode(err)
91+
if err != nil {
92+
fmt.Printf("testrunner: Failed to run command: %v\n", err)
93+
os.Exit(1)
94+
}
95+
96+
// Success case, exit early
97+
if exitCode == 0 || jsonFile == "" {
98+
os.Exit(exitCode)
99+
}
100+
101+
// Check if config is ready
102+
content := configContent.Load()
103+
104+
if content == "" {
105+
fmt.Printf("CI config download not completed, propagating exit code %d", exitCode)
106+
os.Exit(exitCode)
107+
}
108+
109+
config, err := parseConfig(content.(string))
110+
if err != nil {
111+
fmt.Printf("Error parsing CI config: %v\n", err)
112+
os.Exit(exitCode)
113+
}
114+
115+
finalExitCode := checkFailures(config, jsonFile, exitCode)
116+
os.Exit(finalExitCode)
117+
}
118+
119+
func downloadConfig(ctx context.Context) (string, error) {
120+
req, err := http.NewRequestWithContext(ctx, "GET", repoConfigURL, nil)
121+
if err != nil {
122+
return "", err
123+
}
124+
125+
client := &http.Client{}
126+
resp, err := client.Do(req)
127+
if err != nil {
128+
return "", err
129+
}
130+
defer resp.Body.Close()
131+
132+
if resp.StatusCode != http.StatusOK {
133+
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
134+
}
135+
136+
// Read limited body
137+
limitedReader := io.LimitReader(resp.Body, maxConfigSize+1)
138+
body, err := io.ReadAll(limitedReader)
139+
if err != nil {
140+
return "", err
141+
}
142+
143+
if len(body) > maxConfigSize {
144+
fmt.Printf("Warning: CI config body was truncated at %d bytes", maxConfigSize)
145+
body = body[:maxConfigSize]
146+
}
147+
148+
return string(body), nil
149+
}
150+
151+
func checkFailures(config *Config, jsonFile string, originalExitCode int) int {
152+
// Parse JSON test results
153+
file, err := os.Open(jsonFile)
154+
if err != nil {
155+
fmt.Printf("testrunner: failed to open JSON file %s: %v\n", jsonFile, err)
156+
return originalExitCode
157+
}
158+
defer file.Close()
159+
160+
scanner := bufio.NewScanner(file)
161+
unexpectedFailures := map[string]bool{}
162+
163+
for scanner.Scan() {
164+
line := scanner.Text()
165+
if line == "" {
166+
continue
167+
}
168+
169+
var result TestResult
170+
if err := json.Unmarshal([]byte(line), &result); err != nil {
171+
fmt.Printf("failed to parse json: %q: %s\n", line, err)
172+
return originalExitCode
173+
}
174+
175+
if result.Test == "" {
176+
continue
177+
}
178+
179+
result.Package, _ = strings.CutPrefix(result.Package, cutPrefix)
180+
181+
key := result.Package + " " + result.Test
182+
183+
if result.Action == "fail" {
184+
matchedRule := config.matches(result.Package, result.Test)
185+
if matchedRule != "" {
186+
fmt.Printf("%s %s failure is allowed, matches rule %q\n", result.Package, result.Test, matchedRule)
187+
} else {
188+
fmt.Printf("%s %s failure is not allowed\n", result.Package, result.Test)
189+
unexpectedFailures[key] = true
190+
}
191+
} else if result.Action == "pass" {
192+
// We run gotestsum with --rerun-fails, so we need to account for intermittent failures
193+
delete(unexpectedFailures, key)
194+
}
195+
}
196+
197+
if err := scanner.Err(); err != nil {
198+
fmt.Printf("testrunner: error reading JSON file: %v\n", err)
199+
return originalExitCode
200+
}
201+
202+
if len(unexpectedFailures) == 0 {
203+
return 0
204+
} else {
205+
fmt.Printf("testrunner: %d test failures were not expected\n", len(unexpectedFailures))
206+
return originalExitCode
207+
}
208+
}
209+
210+
// CI Config Format
211+
//
212+
// The CI config is downloaded from the "ciconfig" branch of the repository.
213+
// It's a text file with the following format:
214+
//
215+
// package testcase
216+
//
217+
// Where:
218+
// - Lines with whitespace only are ignored
219+
// - Everything after '#' is a comment and is ignored
220+
// - Both package and testcase can be '*' meaning any package or any testcase
221+
// - Both can end with '/' which means it's a prefix match
222+
//
223+
// Examples:
224+
// "libs/ *" - all packages starting with "libs/" and all testcases are allowed to fail
225+
// "* TestAccept/" - all testcases starting with "TestAccept/" are allowed to fail
226+
// "bundle TestDeploy" - exact match for package "bundle" and testcase "TestDeploy"
227+
//
228+
// Parse errors for individual lines are logged but do not abort processing.
229+
230+
type Config struct {
231+
rules []ConfigRule
232+
}
233+
234+
func (c *Config) matches(packageName, testName string) string {
235+
for _, rule := range c.rules {
236+
if rule.matches(packageName, testName) {
237+
return rule.OriginalLine
238+
}
239+
}
240+
return ""
241+
}
242+
243+
type ConfigRule struct {
244+
PackagePattern string
245+
TestPattern string
246+
PackagePrefix bool
247+
TestPrefix bool
248+
OriginalLine string
249+
}
250+
251+
func parseConfig(content string) (*Config, error) {
252+
config := &Config{}
253+
scanner := bufio.NewScanner(strings.NewReader(content))
254+
lineNum := 0
255+
256+
for scanner.Scan() {
257+
lineNum++
258+
line := strings.TrimSpace(scanner.Text())
259+
260+
// Skip empty lines
261+
if line == "" {
262+
continue
263+
}
264+
265+
// Remove comments
266+
if idx := strings.Index(line, "#"); idx >= 0 {
267+
line = strings.TrimSpace(line[:idx])
268+
if line == "" {
269+
continue
270+
}
271+
}
272+
273+
// Parse rule
274+
rule, err := parseConfigRule(line, scanner.Text())
275+
if err != nil {
276+
fmt.Printf("Error parsing config line %d: %q - %v", lineNum, line, err)
277+
continue
278+
}
279+
280+
config.rules = append(config.rules, rule)
281+
}
282+
283+
return config, scanner.Err()
284+
}
285+
286+
// parsePattern returns the pattern and whether it's a prefix match.
287+
func parsePattern(pattern string) (string, bool) {
288+
if pattern == "*" {
289+
return "", true
290+
}
291+
return strings.CutSuffix(pattern, "/")
292+
}
293+
294+
func parseConfigRule(line, originalLine string) (ConfigRule, error) {
295+
parts := strings.Fields(line)
296+
if len(parts) != 2 {
297+
return ConfigRule{}, fmt.Errorf("expected 2 fields, got %d", len(parts))
298+
}
299+
300+
packagePattern := parts[0]
301+
testPattern := parts[1]
302+
303+
rule := ConfigRule{
304+
PackagePattern: packagePattern,
305+
TestPattern: testPattern,
306+
OriginalLine: strings.TrimSpace(originalLine),
307+
}
308+
309+
// Check for wildcard or prefix
310+
rule.PackagePattern, rule.PackagePrefix = parsePattern(packagePattern)
311+
rule.TestPattern, rule.TestPrefix = parsePattern(testPattern)
312+
313+
return rule, nil
314+
}
315+
316+
func (r ConfigRule) matches(packageName, testName string) bool {
317+
// Check package pattern
318+
var packageMatch bool
319+
if r.PackagePrefix {
320+
packageMatch = matchesPathPrefix(packageName, r.PackagePattern)
321+
} else {
322+
packageMatch = packageName == r.PackagePattern
323+
}
324+
325+
if !packageMatch {
326+
return false
327+
}
328+
329+
// Check test pattern
330+
if r.TestPrefix {
331+
return matchesPathPrefix(testName, r.TestPattern)
332+
} else {
333+
return testName == r.TestPattern
334+
}
335+
}
336+
337+
// matchesPathPrefix returns true if s matches pattern or starts with pattern + "/"
338+
// If pattern is empty (wildcard "*"), it matches any string
339+
func matchesPathPrefix(s, pattern string) bool {
340+
if pattern == "" {
341+
return true
342+
}
343+
if s == pattern {
344+
return true
345+
}
346+
return strings.HasPrefix(s, pattern+"/")
347+
}

0 commit comments

Comments
 (0)