1// SPDX-License-Identifier: MIT
2
3package e2e
4
5import (
6 "bytes"
7 "encoding/json"
8 "net/http"
9 "strings"
10 "sync"
11 "testing"
12 "time"
13
14 "github.com/bnema/zerowrap"
15
16 "bnema.dev/ermite-runner/internal/cachesvc"
17 "bnema.dev/ermite-runner/internal/execstream/execpb"
18 "bnema.dev/ermite-runner/internal/registryproxy"
19)
20
21type registryLogs struct {
22 mu sync.Mutex
23 b bytes.Buffer
24}
25
26func (b *registryLogs) Write(p []byte) (int, error) {
27 b.mu.Lock()
28 defer b.mu.Unlock()
29 return b.b.Write(p)
30}
31func (b *registryLogs) text() string { b.mu.Lock(); defer b.mu.Unlock(); return b.b.String() }
32func (b *registryLogs) reset() { b.mu.Lock(); defer b.mu.Unlock(); b.b.Reset() }
33
34func TestDockerHubRegistryAcrossJobs(t *testing.T) {
35 image := needE2E(t)
36 cl := &http.Client{Timeout: 10 * time.Second}
37 resp, err := cl.Get("https://registry-1.docker.io/v2/")
38 if err != nil {
39 t.Skipf("Docker Hub unreachable: %v", err)
40 }
41 _ = resp.Body.Close()
42 if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusOK {
43 t.Skipf("Docker Hub unavailable: HTTP %d", resp.StatusCode)
44 }
45 var logs registryLogs
46 p, err := registryproxy.New(registryproxy.Config{Dir: t.TempDir(), Max: 256 << 20, Log: zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs})})
47 if err != nil {
48 t.Fatal(err)
49 }
50 svc, err := cachesvc.New(cachesvc.Config{Dir: t.TempDir(), Registry: p})
51 if err != nil {
52 t.Fatal(err)
53 }
54 defer func() { _ = svc.CloseAll() }()
55 d := newCacheDriver(t, image, svc)
56 probe := runJob(t, d, jobSpec(`on: push
57jobs:
58 j:
59 runs-on: x
60 steps:
61 - run: if docker info >/dev/null 2>&1; then echo nested-dockerd-ready; else echo nested-dockerd-unavailable; fi
62`), nil)
63 if !strings.Contains(strings.Join(probe.Logs, "\n"), "nested-dockerd-ready") {
64 t.Skip("nested dockerd unavailable in sandbox; rootless Docker harness cannot run job containers")
65 }
66 for i := 0; i < 2; i++ {
67 logs.reset()
68 spec := jobSpec(`on: push
69jobs:
70 j:
71 runs-on: x
72 container: alpine:3
73 steps:
74 - run: echo alpine-job
75`)
76 // A registry-only job: the host sends the port and no token.
77 spec.CachePort = cachesvc.DefaultPort
78 s := runJob(t, d, spec, nil)
79 if s.Result == nil || s.Result.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_SUCCESS {
80 t.Fatalf("pull job %d: %v\n%s", i, s.Result, strings.Join(s.Logs, "\n"))
81 }
82 all := logs.text()
83 manifestHit := false
84 for _, line := range strings.Split(all, "\n") {
85 var event struct {
86 Message string `json:"message"`
87 Hit bool `json:"hit"`
88 }
89 if json.Unmarshal([]byte(line), &event) == nil && event.Message == "registry manifest" && event.Hit {
90 manifestHit = true
91 }
92 }
93 if i == 1 && !manifestHit {
94 t.Fatalf("second job missing manifest hit:\n%s", all)
95 }
96 t.Logf("pull job %d proxy logs:\n%s", i+1, all)
97 if i == 1 && strings.Contains(all, `"message":"registry blob"`) {
98 t.Fatalf("second job fetched upstream blobs:\n%s", all)
99 }
100 }
101 // The mirror argv is unconditional: a job without the runtime channel
102 // must still pull directly from Docker Hub.
103 off := runJob(t, d, jobSpec(`on: push
104jobs:
105 j:
106 runs-on: x
107 container: alpine:3
108 steps:
109 - run: echo direct-pull
110`), nil)
111 if off.Result == nil || off.Result.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_SUCCESS {
112 t.Fatalf("proxy-off pull: %v\n%s", off.Result, strings.Join(off.Logs, "\n"))
113 }
114}