Skip to content

Commit 36feefa

Browse files
committed
speed up snailmail apply
Signed-off-by: Polina Simonenko <rabarbrablad@gmail.com>
1 parent 43833b0 commit 36feefa

6 files changed

Lines changed: 197 additions & 19 deletions

File tree

‎internal/app/apk_verify.go‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,12 @@ func VerifyAPKClient(ctx context.Context, repository, runner, image string) (bui
5252
if !digestPinnedImage(image) {
5353
return buildgraph.RepositoryManifest{}, 0, errors.New("a digest-pinned Alpine verification image is required")
5454
}
55-
installed := 0
56-
for _, verification := range manifest.VerificationCases {
57-
if err := verifyAPKCase(ctx, runner, image, snapshot, verification); err != nil {
58-
return buildgraph.RepositoryManifest{}, 0, err
59-
}
60-
installed++
55+
if err := verifyCases(ctx, manifest.VerificationCases, func(caseCtx context.Context, verification domain.VerificationCase) error {
56+
return verifyAPKCase(caseCtx, runner, image, snapshot, verification)
57+
}); err != nil {
58+
return buildgraph.RepositoryManifest{}, 0, err
6159
}
62-
return manifest, installed, nil
60+
return manifest, len(manifest.VerificationCases), nil
6361
}
6462

6563
func verifyAPKCase(ctx context.Context, runner, image, snapshot string, verification domain.VerificationCase) error {

‎internal/app/deb_endpoint.go‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,10 @@ func VerifyDebClientEndpointAccess(ctx context.Context, root string, access host
9494
// only reachable with host networking. A public endpoint uses the runner's
9595
// default network and stays isolated from the host.
9696
hostNetwork := isLoopbackHost(endpoint.Hostname())
97-
for _, verification := range verificationCases {
98-
if err := verifyDebEndpointCase(ctx, runner, image, access.Endpoint, trust, hostNetwork, workspaceBytes, manifest, verification); err != nil {
99-
return buildgraph.RepositoryManifest{}, 0, err
100-
}
97+
if err := verifyCases(ctx, verificationCases, func(caseCtx context.Context, verification domain.VerificationCase) error {
98+
return verifyDebEndpointCase(caseCtx, runner, image, access.Endpoint, trust, hostNetwork, workspaceBytes, manifest, verification)
99+
}); err != nil {
100+
return buildgraph.RepositoryManifest{}, 0, err
101101
}
102102
return manifest, len(manifest.VerificationCases), nil
103103
}

‎internal/app/rpm_verify.go‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,12 @@ func VerifyRPMClient(ctx context.Context, repository, runner, image string) (bui
5555
if !digestPinnedImage(image) {
5656
return buildgraph.RepositoryManifest{}, 0, errors.New("a digest-pinned RPM verification image is required")
5757
}
58-
installed := 0
59-
for _, verification := range manifest.VerificationCases {
60-
if err := verifyRPMCase(ctx, runner, image, snapshot, verification); err != nil {
61-
return buildgraph.RepositoryManifest{}, 0, err
62-
}
63-
installed++
58+
if err := verifyCases(ctx, manifest.VerificationCases, func(caseCtx context.Context, verification domain.VerificationCase) error {
59+
return verifyRPMCase(caseCtx, runner, image, snapshot, verification)
60+
}); err != nil {
61+
return buildgraph.RepositoryManifest{}, 0, err
6462
}
65-
return manifest, installed, nil
63+
return manifest, len(manifest.VerificationCases), nil
6664
}
6765

6866
func verifyRPMCase(ctx context.Context, runner, image, snapshot string, verification domain.VerificationCase) error {

‎internal/app/verify_parallel.go‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package app
2+
3+
import (
4+
"context"
5+
"sync"
6+
7+
"github.com/shellcell/snailmail/internal/domain"
8+
)
9+
10+
// maxConcurrentVerifications bounds how many client containers run at once.
11+
//
12+
// Each case is one container installing one package version, and they are
13+
// independent: every one mounts the same repository read-only and writes
14+
// nothing a sibling reads. Running them one at a time made publication cost
15+
// grow with the number of versions a repository holds, which is the number that
16+
// only ever goes up. The bound is small on purpose — verifying a foreign
17+
// architecture runs under emulation, and oversubscribing a two-core runner with
18+
// emulated containers spends more time context-switching than it saves.
19+
const maxConcurrentVerifications = 4
20+
21+
// verifyCases runs one verification per case and reports the earliest failing
22+
// case in the order the manifest lists them.
23+
//
24+
// It deliberately does not stop at the first failure. Cases acquire their slot
25+
// in whatever order the scheduler hands them out, so cancelling the rest would
26+
// leave an earlier case unrun — and a case that never ran cannot be known to
27+
// have failed. The publication would then blame whichever container happened to
28+
// finish first, and the same defect would be reported differently on every run.
29+
// A refused publication is the rare path and is already going to be repeated;
30+
// naming the same case every time is worth more there than finishing sooner.
31+
func verifyCases(ctx context.Context, cases []domain.VerificationCase, verify func(context.Context, domain.VerificationCase) error) error {
32+
if len(cases) == 0 {
33+
return nil
34+
}
35+
if len(cases) == 1 {
36+
return verify(ctx, cases[0])
37+
}
38+
limit := maxConcurrentVerifications
39+
if len(cases) < limit {
40+
limit = len(cases)
41+
}
42+
slots := make(chan struct{}, limit)
43+
failures := make([]error, len(cases))
44+
var wait sync.WaitGroup
45+
for index, verification := range cases {
46+
wait.Add(1)
47+
go func(index int, verification domain.VerificationCase) {
48+
defer wait.Done()
49+
slots <- struct{}{}
50+
defer func() { <-slots }()
51+
// A cancelled parent still stops the run; that cancellation is the
52+
// caller giving up, not a verdict about any case.
53+
if err := ctx.Err(); err != nil {
54+
failures[index] = err
55+
return
56+
}
57+
failures[index] = verify(ctx, verification)
58+
}(index, verification)
59+
}
60+
wait.Wait()
61+
for _, err := range failures {
62+
if err != nil {
63+
return err
64+
}
65+
}
66+
return nil
67+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package app
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"sync"
8+
"sync/atomic"
9+
"testing"
10+
11+
"github.com/shellcell/snailmail/internal/domain"
12+
)
13+
14+
func cases(count int) []domain.VerificationCase {
15+
made := make([]domain.VerificationCase, 0, count)
16+
for index := range count {
17+
made = append(made, domain.VerificationCase{Package: fmt.Sprintf("p%d", index), Version: "1"})
18+
}
19+
return made
20+
}
21+
22+
func TestVerifyCasesRunsEveryCase(t *testing.T) {
23+
var mutex sync.Mutex
24+
seen := map[string]bool{}
25+
if err := verifyCases(context.Background(), cases(9), func(_ context.Context, verification domain.VerificationCase) error {
26+
mutex.Lock()
27+
defer mutex.Unlock()
28+
if seen[verification.Package] {
29+
t.Errorf("case %s was verified twice", verification.Package)
30+
}
31+
seen[verification.Package] = true
32+
return nil
33+
}); err != nil {
34+
t.Fatal(err)
35+
}
36+
if len(seen) != 9 {
37+
t.Fatalf("verified %d cases, want 9", len(seen))
38+
}
39+
}
40+
41+
// A failed publication must name the same case every time, or the same defect
42+
// reads as a different problem on each run.
43+
func TestVerifyCasesReportsTheFirstFailureInOrder(t *testing.T) {
44+
for range 20 {
45+
err := verifyCases(context.Background(), cases(8), func(_ context.Context, verification domain.VerificationCase) error {
46+
if verification.Package == "p3" || verification.Package == "p6" {
47+
return errors.New(verification.Package + " failed")
48+
}
49+
return nil
50+
})
51+
if err == nil || err.Error() != "p3 failed" {
52+
t.Fatalf("got %v, want the earliest failing case p3", err)
53+
}
54+
}
55+
}
56+
57+
// Every case runs even once one has failed. That is what makes the reported
58+
// case deterministic: a case skipped because a sibling failed first cannot be
59+
// compared against the ones that did run, and the earliest failure would depend
60+
// on which container won the race for a slot.
61+
func TestVerifyCasesRunsEveryCaseEvenAfterAFailure(t *testing.T) {
62+
var started atomic.Int64
63+
err := verifyCases(context.Background(), cases(32), func(_ context.Context, verification domain.VerificationCase) error {
64+
started.Add(1)
65+
if verification.Package == "p0" {
66+
return errors.New("refused")
67+
}
68+
return nil
69+
})
70+
if err == nil || err.Error() != "refused" {
71+
t.Fatalf("got %v, want the failing case reported", err)
72+
}
73+
if count := started.Load(); count != 32 {
74+
t.Fatalf("ran %d of 32 cases; skipping any makes the reported failure depend on scheduling", count)
75+
}
76+
}
77+
78+
// Cancelling the parent must not read as every case having passed.
79+
func TestVerifyCasesHonoursACancelledContext(t *testing.T) {
80+
ctx, cancel := context.WithCancel(context.Background())
81+
cancel()
82+
if err := verifyCases(ctx, cases(4), func(context.Context, domain.VerificationCase) error {
83+
return nil
84+
}); !errors.Is(err, context.Canceled) {
85+
t.Fatalf("got %v, want context.Canceled", err)
86+
}
87+
}
88+
89+
// The bound is what keeps an emulated architecture from oversubscribing a small
90+
// runner, so it has to actually hold.
91+
func TestVerifyCasesRespectsItsConcurrencyBound(t *testing.T) {
92+
var running, peak atomic.Int64
93+
if err := verifyCases(context.Background(), cases(32), func(context.Context, domain.VerificationCase) error {
94+
current := running.Add(1)
95+
defer running.Add(-1)
96+
for {
97+
was := peak.Load()
98+
if current <= was || peak.CompareAndSwap(was, current) {
99+
break
100+
}
101+
}
102+
return nil
103+
}); err != nil {
104+
t.Fatal(err)
105+
}
106+
if peak.Load() > maxConcurrentVerifications {
107+
t.Fatalf("%d cases ran at once, above the bound of %d", peak.Load(), maxConcurrentVerifications)
108+
}
109+
}

‎signer/apkrsa/apkrsa_test.go‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,13 @@ func TestVerifyRejectsWhatItShould(t *testing.T) {
6767
},
6868
"altered signature": func() error {
6969
altered := response
70-
altered.Content = append(append([]byte(nil), response.Content[:len(response.Content)-1]...), 0x00)
70+
// Flip a bit rather than write a fixed byte. Setting the last byte
71+
// to zero leaves the signature untouched whenever it already ended
72+
// in zero, which an RSA signature does about once in 256 runs — and
73+
// an unaltered signature verifying is the test passing by accident,
74+
// reported as a failure.
75+
altered.Content = append([]byte(nil), response.Content...)
76+
altered.Content[len(altered.Content)-1] ^= 0x01
7177
return VerifyResponse(request, altered, publicDER(t, generated), generated.Identity.Fingerprint)
7278
},
7379
"another key": func() error {

0 commit comments

Comments
 (0)