Skip to content

Commit 5bc8054

Browse files
aledbfclaude
andcommitted
test(parity): serialize the cases that share the Feature content image
Without BuildKit both CLIs build Features from the fixed tag dev_container_feature_content_temp, so two such cases at once can take each other's Features. On the docker8 fleet the TS side failed that way twice (an up in setup_cmd failing silently, then exec or verify finding no container). A case can now name a lock; cases with the same lock run one at a time and stay parallel with the rest. The 24 cases that build Features without BuildKit hold feature-content-image, and TestParityMatrixLocksTheFeatureContentImage keeps that list exact. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 8d4be1b commit 5bc8054

3 files changed

Lines changed: 122 additions & 0 deletions

File tree

‎docs/parity/parity-matrix.yaml‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1375,6 +1375,7 @@ initial_cases:
13751375
current_status: match
13761376

13771377
- id: build.buildkit-never-platform-failure
1378+
lock: feature-content-image
13781379
lane: contract
13791380
command: build
13801381
priority: p1
@@ -1593,6 +1594,7 @@ initial_cases:
15931594
current_status: match
15941595

15951596
- id: exec.workspace-image-with-features-hello-success
1597+
lock: feature-content-image
15961598
lane: runtime
15971599
command: exec
15981600
priority: p1
@@ -1606,6 +1608,7 @@ initial_cases:
16061608
current_status: match
16071609

16081610
- id: exec.workspace-dockerfile-with-features-hello-success
1611+
lock: feature-content-image
16091612
lane: runtime
16101613
command: exec
16111614
priority: p1
@@ -1619,6 +1622,7 @@ initial_cases:
16191622
current_status: match
16201623

16211624
- id: exec.workspace-dockerfile-with-target-marker-success
1625+
lock: feature-content-image
16221626
lane: runtime
16231627
command: exec
16241628
priority: p1
@@ -1632,6 +1636,7 @@ initial_cases:
16321636
current_status: match
16331637

16341638
- id: exec.workspace-compose-image-with-features-hello-success
1639+
lock: feature-content-image
16351640
no_compose_isolation: true
16361641
lane: runtime
16371642
command: exec
@@ -1971,6 +1976,7 @@ initial_cases:
19711976
current_status: match
19721977

19731978
- id: up.image-with-features-omit-config-remote-env-from-metadata-success
1979+
lock: feature-content-image
19741980
lane: runtime
19751981
command: up
19761982
priority: p1
@@ -2009,6 +2015,7 @@ initial_cases:
20092015
current_status: match
20102016

20112017
- id: up.compose-image-with-features-omit-config-remote-env-from-metadata-success
2018+
lock: feature-content-image
20122019
lane: runtime
20132020
command: up
20142021
priority: p1
@@ -2024,6 +2031,7 @@ initial_cases:
20242031
notes: "Unblocked by pinning the harness setup with `docker pull --platform linux/amd64 redis:alpine` (the fixture's db service) before compose."
20252032

20262033
- id: up.compose-dockerfile-with-features-omit-config-remote-env-from-metadata-success
2034+
lock: feature-content-image
20272035
lane: runtime
20282036
command: up
20292037
priority: p1
@@ -2051,6 +2059,7 @@ initial_cases:
20512059
current_status: match
20522060

20532061
- id: up.image-metadata-container-env-success
2062+
lock: feature-content-image
20542063
lane: runtime
20552064
command: up
20562065
priority: p1
@@ -2063,6 +2072,7 @@ initial_cases:
20632072
current_status: match
20642073

20652074
- id: up.image-with-features-success
2075+
lock: feature-content-image
20662076
lane: runtime
20672077
command: up
20682078
priority: p1
@@ -2077,6 +2087,7 @@ initial_cases:
20772087
current_status: match
20782088

20792089
- id: up.image-with-git-feature-success
2090+
lock: feature-content-image
20802091
lane: runtime
20812092
command: up
20822093
priority: p1
@@ -2182,6 +2193,7 @@ initial_cases:
21822193
current_status: match
21832194

21842195
- id: up.compose-dockerfile-with-features-success
2196+
lock: feature-content-image
21852197
lane: runtime
21862198
command: up
21872199
priority: p1
@@ -2196,6 +2208,7 @@ initial_cases:
21962208
notes: "Closed out by fixing the compose entrypoint generated by Go so that it uses real line breaks and keeps the container alive"
21972209

21982210
- id: up.compose-dockerfile-with-features-reuse-stopped-container-with-persisted-overrides-success
2211+
lock: feature-content-image
21992212
no_compose_isolation: true
22002213
serial: true
22012214
lane: runtime
@@ -2213,6 +2226,7 @@ initial_cases:
22132226
notes: "Reuse of a stopped compose container with persisted overrides. The divergence observed before (TS failed the second `docker compose up --no-recreate`) was an artifact of the setup with the old TS path; with the setup pointing to the TS submodule, Go and TS match."
22142227

22152228
- id: up.compose-dockerfile-with-features-reuse-stopped-container-without-persisted-overrides-success
2229+
lock: feature-content-image
22162230
no_compose_isolation: true
22172231
serial: true
22182232
lane: runtime
@@ -2292,6 +2306,7 @@ initial_cases:
22922306
current_status: match
22932307

22942308
- id: up.compose-dockerfile-with-target-post-commands-success
2309+
lock: feature-content-image
22952310
lane: runtime
22962311
command: up
22972312
priority: p1
@@ -2458,6 +2473,7 @@ initial_cases:
24582473
current_status: match
24592474

24602475
- id: build.image-metadata-local-features-success
2476+
lock: feature-content-image
24612477
lane: runtime
24622478
command: build
24632479
priority: p1
@@ -2471,6 +2487,7 @@ initial_cases:
24712487
current_status: match
24722488

24732489
- id: build.skip-persisting-customizations-from-features-success
2490+
lock: feature-content-image
24742491
lane: runtime
24752492
command: build
24762493
priority: p1
@@ -2484,6 +2501,7 @@ initial_cases:
24842501
current_status: match
24852502

24862503
- id: build.labels-success
2504+
lock: feature-content-image
24872505
lane: runtime
24882506
command: build
24892507
priority: p0
@@ -2495,6 +2513,7 @@ initial_cases:
24952513
current_status: match
24962514

24972515
- id: build.local-feature-failure
2516+
lock: feature-content-image
24982517
lane: runtime
24992518
command: build
25002519
priority: p0
@@ -2531,6 +2550,7 @@ initial_cases:
25312550
current_status: match
25322551

25332552
- id: build.dockerfile-multi-image-success
2553+
lock: feature-content-image
25342554
lane: runtime
25352555
command: build
25362556
priority: p1
@@ -2607,6 +2627,7 @@ initial_cases:
26072627
notes: "Passes in parity runtime by pinning DOCKER_DEFAULT_PLATFORM=linux/amd64 from the harness"
26082628

26092629
- id: build.apply-build-options-success
2630+
lock: feature-content-image
26102631
lane: runtime
26112632
command: build
26122633
priority: p1
@@ -2635,6 +2656,7 @@ initial_cases:
26352656
notes: "Closed out using a valid cache source (`ubuntu:latest`); the nonexistent remote ref blocked TS before comparing Go."
26362657

26372658
- id: build.image-metadata-container-env-success
2659+
lock: feature-content-image
26382660
lane: runtime
26392661
command: build
26402662
priority: p1
@@ -2647,6 +2669,7 @@ initial_cases:
26472669
current_status: match
26482670

26492671
- id: build.compose-image-extra-cache-from-success
2672+
lock: feature-content-image
26502673
lane: runtime
26512674
command: build
26522675
priority: p1
@@ -2746,6 +2769,7 @@ initial_cases:
27462769
expect_infra_failure the harness compares exit codes instead of skipping via isInfraError."
27472770

27482771
- id: build.compose-image-with-features-success
2772+
lock: feature-content-image
27492773
lane: runtime
27502774
command: build
27512775
priority: p1

‎internal/cli/parity_lock_test.go‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package cli
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"regexp"
7+
"slices"
8+
"strings"
9+
"testing"
10+
11+
"gopkg.in/yaml.v3"
12+
)
13+
14+
var (
15+
parityWorkspaceFlag = regexp.MustCompile(`--workspace-folder\s+(src/test/configs/[^\s"';]+)`)
16+
parityConfigFlag = regexp.MustCompile(`--config\s+(src/test/configs/[^\s"';]+)`)
17+
nonEmptyFeaturesJSON = regexp.MustCompile(`"features"\s*:\s*\{\s*"`)
18+
)
19+
20+
// A case that builds Features without BuildKit goes through the shared
21+
// dev_container_feature_content_temp image, so it must hold the
22+
// feature-content-image lock; a case that does not, must not, so the lock
23+
// serializes no more than it has to.
24+
func TestParityMatrixLocksTheFeatureContentImage(t *testing.T) {
25+
raw, err := os.ReadFile(filepath.Join("..", "..", "docs", "parity", "parity-matrix.yaml"))
26+
if err != nil {
27+
t.Fatal(err)
28+
}
29+
var matrix parityMatrix
30+
if err := yaml.Unmarshal(raw, &matrix); err != nil {
31+
t.Fatal(err)
32+
}
33+
locked := 0
34+
for _, tc := range matrix.InitialCases {
35+
if _, ok := parityLocks[tc.Lock]; tc.Lock != "" && !ok {
36+
t.Errorf("case %q names unknown lock %q", tc.ID, tc.Lock)
37+
}
38+
cmds := tc.TSCmd + "\n" + tc.SetupCmd
39+
need := strings.Contains(cmds, "--buildkit=never") && slices.ContainsFunc(parityCaseConfigs(t, cmds), func(config string) bool {
40+
data, err := os.ReadFile(filepath.Join("..", "..", config))
41+
if err != nil {
42+
t.Fatalf("case %q: %v", tc.ID, err)
43+
}
44+
return nonEmptyFeaturesJSON.Match(data)
45+
})
46+
switch has := tc.Lock == "feature-content-image"; {
47+
case need && !has:
48+
t.Errorf("case %q builds Features without BuildKit; add `lock: feature-content-image`", tc.ID)
49+
case has && !need:
50+
t.Errorf("case %q holds feature-content-image but builds no Features without BuildKit", tc.ID)
51+
case has:
52+
locked++
53+
}
54+
}
55+
if locked == 0 {
56+
t.Error("no case holds feature-content-image")
57+
}
58+
}
59+
60+
// parityCaseConfigs is every devcontainer.json the commands name, by
61+
// --config or as a --workspace-folder's.
62+
func parityCaseConfigs(t *testing.T, cmds string) []string {
63+
var configs []string
64+
for _, m := range parityConfigFlag.FindAllStringSubmatch(cmds, -1) {
65+
configs = append(configs, m[1])
66+
}
67+
for _, m := range parityWorkspaceFlag.FindAllStringSubmatch(cmds, -1) {
68+
for _, c := range []string{".devcontainer/devcontainer.json", ".devcontainer.json"} {
69+
if _, err := os.Stat(filepath.Join("..", "..", m[1], c)); err == nil {
70+
configs = append(configs, filepath.Join(m[1], c))
71+
break
72+
}
73+
}
74+
}
75+
return configs
76+
}

‎internal/cli/parity_matrix_test.go‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ type parityCase struct {
6868
// Serial forces a case to run sequentially (no t.Parallel). Used for the few
6969
// runtime cases that can't be isolated for parallel execution.
7070
Serial bool `yaml:"serial"`
71+
// Lock names a Docker resource the case shares with other cases and cannot
72+
// isolate; cases with the same lock run one at a time, still in parallel
73+
// with every other case. See parityLocks.
74+
Lock string `yaml:"lock"`
7175
// CompareHashes keeps sha256/hex digests UNSCRUBBED so they are compared
7276
// exactly between TS and Go. Set it for cases whose digests are deterministic
7377
// and meaningful (resolve-dependencies installOrder resource@digest,
@@ -189,6 +193,16 @@ func writeParityReport(path string, snapshot map[parityOutcome][]string) error {
189193
return nil
190194
}
191195

196+
// parityLocks are the shared resources a case can name in `lock`.
197+
//
198+
// feature-content-image: without BuildKit, both CLIs build a Feature's files
199+
// into the fixed tag dev_container_feature_content_temp (TS containerFeatures.ts
200+
// getFeaturesBuildOptions) and build FROM it, so two such builds at once can
201+
// take each other's Features.
202+
var parityLocks = map[string]*sync.Mutex{
203+
"feature-content-image": {},
204+
}
205+
192206
// --- Main test ---
193207

194208
func TestParityMatrix(t *testing.T) {
@@ -314,6 +328,14 @@ func TestParityMatrix(t *testing.T) {
314328
outcome = paritySkippedArm64
315329
t.Skip("arm64 experimental (set PARITY_ARM64=true to run; requires arm64 emulation)")
316330
}
331+
if tc.Lock != "" {
332+
mu, ok := parityLocks[tc.Lock]
333+
if !ok {
334+
t.Fatalf("unknown lock %q", tc.Lock)
335+
}
336+
mu.Lock()
337+
defer mu.Unlock()
338+
}
317339
perCaseTimeout := defaultTimeout
318340
if tc.Lane == "runtime" {
319341
perCaseTimeout = runtimeTimeout

0 commit comments

Comments
 (0)