docker_test.go
⎇
Raw
1package ci
2
3import (
4 "context"
5 "encoding/binary"
6 "net"
7 "net/http"
8 "net/http/httptest"
9 "path"
10 "path/filepath"
11 "slices"
12 "strings"
13 "sync"
14 "testing"
15
16 "hearthforge/internal/config"
17)
18
19// muxFrame builds one Docker multiplexed stream frame.
20func muxFrame(text string) []byte {
21 hdr := make([]byte, 8)
22 hdr[0] = 1
23 binary.BigEndian.PutUint32(hdr[4:], uint32(len(text)))
24 return append(hdr, text...)
25}
26
27// newMockEngine serves a fake engine on a unix socket and returns a Runner
28// pointed at it.
29func newMockEngine(t *testing.T, h http.Handler) *Runner {
30 t.Helper()
31 sock := filepath.Join(t.TempDir(), "engine.sock")
32 ln, err := net.Listen("unix", sock)
33 if err != nil {
34 t.Fatalf("listen: %v", err)
35 }
36 srv := httptest.Server{Listener: ln, Config: &http.Server{Handler: h}}
37 srv.Start()
38 t.Cleanup(srv.Close)
39 return New(&config.Config{CIDockerSocket: sock}, nil)
40}
41
42func TestExecReadsLogAndExitCode(t *testing.T) {
43 r := newMockEngine(t, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
44 switch req.URL.Path {
45 case "/v1.47/containers/abc/exec":
46 w.Write([]byte(`{"Id":"exec1"}`))
47 case "/v1.47/exec/exec1/start":
48 w.Write(muxFrame("hello "))
49 w.Write(muxFrame("world\n"))
50 case "/v1.47/exec/exec1/json":
51 w.Write([]byte(`{"ExitCode":3}`))
52 default:
53 t.Errorf("unexpected request %s", req.URL.Path)
54 }
55 }))
56 res, err := r.exec(context.Background(), "abc", []string{"true"}, "/ci", nil, nil)
57 if err != nil {
58 t.Fatalf("exec: %v", err)
59 }
60 if res.log != "hello world\n" || res.exitCode != 3 {
61 t.Fatalf("got %q exit %d", res.log, res.exitCode)
62 }
63}
64
65func TestSocketMissingIsReported(t *testing.T) {
66 r := New(&config.Config{CIDockerSocket: "/nonexistent/engine.sock"}, nil)
67 _, err := r.exec(context.Background(), "abc", []string{"true"}, "", nil, nil)
68 if err == nil || err.Error() != errNoSocket.Error() {
69 t.Fatalf("got %v", err)
70 }
71}
72
73// A crash can leak [[copy]] helpers, which carry no run name. The startup
74// sweep finds them by label.
75func TestCancelStaleRunsSweepsLabelledContainers(t *testing.T) {
76 var mu sync.Mutex
77 var deleted []string
78 r := newMockEngine(t, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
79 switch {
80 case req.Method == http.MethodGet && req.URL.Path == "/v1.47/containers/json":
81 if !strings.Contains(req.URL.Query().Get("filters"), "com.hearthforge.ci") {
82 t.Errorf("filters = %q", req.URL.Query().Get("filters"))
83 }
84 w.Write([]byte(`[{"Id":"helper1"}]`))
85 case req.Method == http.MethodDelete:
86 mu.Lock()
87 deleted = append(deleted, path.Base(req.URL.Path))
88 mu.Unlock()
89 w.WriteHeader(http.StatusNotFound)
90 default:
91 t.Errorf("unexpected request %s %s", req.Method, req.URL.Path)
92 }
93 }))
94 _, d := newRunnerDB(t)
95 r.db = d
96 id := insertRun(t, d, "running")
97 if err := r.CancelStaleRuns(context.Background()); err != nil {
98 t.Fatal(err)
99 }
100 mu.Lock()
101 defer mu.Unlock()
102 want := []string{containerName(id), "helper1"}
103 if !slices.Equal(deleted, want) {
104 t.Fatalf("deleted %v, want %v", deleted, want)
105 }
106}
107