ci_socket_test.go
| 1 | package e2e |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "testing" |
| 6 | ) |
| 7 | |
| 8 | // engine_socket lets a step talk to the container engine that runs the |
| 9 | // pipeline. The server must opt in with CI_ENGINE_SOCKET. |
| 10 | |
| 11 | const ciEngineSocketTOML = ` |
| 12 | image = "debian:latest" |
| 13 | |
| 14 | [on] |
| 15 | manual = true |
| 16 | |
| 17 | [[steps]] |
| 18 | name = "build-image" |
| 19 | run_sh = "engine-build ." |
| 20 | engine_socket = true |
| 21 | |
| 22 | [[steps]] |
| 23 | name = "plain" |
| 24 | run_sh = "echo plain" |
| 25 | ` |
| 26 | |
| 27 | // ciBinds returns the HostConfig.Binds of the last created container. |
| 28 | func ciBinds(t *testing.T, m *mockDocker) []string { |
| 29 | t.Helper() |
| 30 | body := m.createBody() |
| 31 | if body == nil { |
| 32 | t.Fatal("no container was created") |
| 33 | } |
| 34 | host, _ := body["HostConfig"].(map[string]any) |
| 35 | raw, _ := host["Binds"].([]any) |
| 36 | out := make([]string, 0, len(raw)) |
| 37 | for _, b := range raw { |
| 38 | s, _ := b.(string) |
| 39 | out = append(out, s) |
| 40 | } |
| 41 | return out |
| 42 | } |
| 43 | |
| 44 | func TestCIEngineSocketDisabled(t *testing.T) { |
| 45 | e, m, admin := ciEnv(t) |
| 46 | sha := ciSeedToml(e, ciEngineSocketTOML) |
| 47 | m.reset() |
| 48 | |
| 49 | runID := ciTrigger(e, admin, sha, nil) |
| 50 | if status := ciWaitForRun(e, runID); status != "failure" { |
| 51 | t.Errorf("run status = %q, want failure", status) |
| 52 | } |
| 53 | |
| 54 | step := ciStep(e, runID, "build-image") |
| 55 | if step.Status != "failure" { |
| 56 | t.Errorf("build-image status = %q, want failure", step.Status) |
| 57 | } |
| 58 | if !strings.Contains(step.Log, "CI_ENGINE_SOCKET") { |
| 59 | t.Errorf("build-image log = %q", step.Log) |
| 60 | } |
| 61 | if got := ciStep(e, runID, "plain").Status; got != "skipped" { |
| 62 | t.Errorf("plain status = %q, want skipped", got) |
| 63 | } |
| 64 | if binds := ciBinds(t, m); contains(binds, "engine.sock") { |
| 65 | t.Errorf("binds = %v, want no engine socket", binds) |
| 66 | } |
| 67 | if strings.Contains(m.allCommandText(), "engine-build") { |
| 68 | t.Error("the disabled step still ran") |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | func TestCIEngineSocketEnabled(t *testing.T) { |
| 73 | e, m, admin := ciEnv(t, "CI_ENGINE_SOCKET", "1") |
| 74 | sha := ciSeedToml(e, ciEngineSocketTOML) |
| 75 | m.reset() |
| 76 | m.queueExec(execResp{output: "built\n"}) |
| 77 | m.queueExec(execResp{output: "plain\n"}) |
| 78 | |
| 79 | runID := ciTrigger(e, admin, sha, nil) |
| 80 | if status := ciWaitForRun(e, runID); status != "success" { |
| 81 | t.Fatalf("run status = %q, want success", status) |
| 82 | } |
| 83 | if got := ciStep(e, runID, "build-image").Status; got != "success" { |
| 84 | t.Errorf("build-image status = %q", got) |
| 85 | } |
| 86 | |
| 87 | want := m.sock + ":/run/hearthforge/engine.sock" |
| 88 | if binds := ciBinds(t, m); !contains(binds, want) { |
| 89 | t.Errorf("binds = %v, want %q", binds, want) |
| 90 | } |
| 91 | |
| 92 | socketEnv := m.envForCommand("engine-build") |
| 93 | if socketEnv == nil { |
| 94 | t.Fatal("the engine_socket step did not run") |
| 95 | } |
| 96 | if !contains(socketEnv, "DOCKER_HOST=unix:///run/hearthforge/engine.sock") { |
| 97 | t.Errorf("engine_socket step env = %v", socketEnv) |
| 98 | } |
| 99 | if !contains(socketEnv, "CONTAINER_HOST=unix:///run/hearthforge/engine.sock") { |
| 100 | t.Errorf("CONTAINER_HOST missing from %v", socketEnv) |
| 101 | } |
| 102 | |
| 103 | // CI_REGISTRY points at this server's registry path for the repo. |
| 104 | wantRegistry := "CI_REGISTRY=" + strings.TrimPrefix(e.Base, "http://") + "/ci-repo" |
| 105 | if !contains(socketEnv, wantRegistry) { |
| 106 | t.Errorf("env has no %q: %v", wantRegistry, socketEnv) |
| 107 | } |
| 108 | |
| 109 | plainEnv := m.envForCommand("echo plain") |
| 110 | if plainEnv == nil { |
| 111 | t.Fatal("the plain step did not run") |
| 112 | } |
| 113 | if contains(plainEnv, "DOCKER_HOST") { |
| 114 | t.Errorf("plain step env = %v, want no DOCKER_HOST", plainEnv) |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | // TestCINetwork checks that CI_NETWORK reaches the container create call. |
| 119 | // An engine network is how a CI container gets IPv6 or an isolated segment. |
| 120 | func TestCINetwork(t *testing.T) { |
| 121 | networkMode := func(m *mockDocker) any { |
| 122 | host, _ := m.createBody()["HostConfig"].(map[string]any) |
| 123 | return host["NetworkMode"] |
| 124 | } |
| 125 | |
| 126 | t.Run("unset leaves the engine default", func(t *testing.T) { |
| 127 | e, m, admin := ciEnv(t) |
| 128 | m.queueExec(execResp{output: "hi\n"}) |
| 129 | sha := ciSeedToml(e, ciSimpleTOML) |
| 130 | runID := ciTrigger(e, admin, sha, nil) |
| 131 | if got := ciWaitForRun(e, runID); got != "success" { |
| 132 | t.Fatalf("status = %q", got) |
| 133 | } |
| 134 | if got := networkMode(m); got != nil { |
| 135 | t.Errorf("NetworkMode = %v, want absent", got) |
| 136 | } |
| 137 | }) |
| 138 | |
| 139 | t.Run("set joins that network", func(t *testing.T) { |
| 140 | e, m, admin := ciEnv(t, "CI_NETWORK", "hearthforge-ci") |
| 141 | m.queueExec(execResp{output: "hi\n"}) |
| 142 | sha := ciSeedToml(e, ciSimpleTOML) |
| 143 | runID := ciTrigger(e, admin, sha, nil) |
| 144 | if got := ciWaitForRun(e, runID); got != "success" { |
| 145 | t.Fatalf("status = %q", got) |
| 146 | } |
| 147 | if got := networkMode(m); got != "hearthforge-ci" { |
| 148 | t.Errorf("NetworkMode = %v", got) |
| 149 | } |
| 150 | }) |
| 151 | } |
| 152 |