config_test.go
| 1 | package ci |
| 2 | |
| 3 | import ( |
| 4 | "reflect" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | ) |
| 8 | |
| 9 | func mustParse(t *testing.T, src string) *Config { |
| 10 | t.Helper() |
| 11 | cfg, err := ParseCiConfig(src) |
| 12 | if err != nil { |
| 13 | t.Fatalf("ParseCiConfig: %v", err) |
| 14 | } |
| 15 | return cfg |
| 16 | } |
| 17 | |
| 18 | func mustFail(t *testing.T, src string) { |
| 19 | t.Helper() |
| 20 | if cfg, err := ParseCiConfig(src); err == nil { |
| 21 | t.Fatalf("expected an error, got %+v", cfg) |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | func TestParseMinimalConfig(t *testing.T) { |
| 26 | cfg := mustParse(t, ` |
| 27 | image = "debian:latest" |
| 28 | [[steps]] |
| 29 | name = "build" |
| 30 | run_sh = "make all" |
| 31 | `) |
| 32 | if cfg.Image != "debian:latest" || len(cfg.Steps) != 1 { |
| 33 | t.Fatalf("unexpected config %+v", cfg) |
| 34 | } |
| 35 | if cfg.Steps[0].Name != "build" || cfg.Steps[0].RunSh != "make all" { |
| 36 | t.Fatalf("unexpected step %+v", cfg.Steps[0]) |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | func TestParseRejects(t *testing.T) { |
| 41 | cases := map[string]string{ |
| 42 | "missing image": "[[steps]]\nname = \"build\"\nrun_sh = \"make\"", |
| 43 | "invalid toml": "image = [unclosed", |
| 44 | "missing steps": `image = "alpine"`, |
| 45 | "step, no name": "image = \"alpine\"\n[[steps]]\nrun_sh = \"make\"", |
| 46 | "partial copy": "image = \"alpine\"\n[[copy]]\nimage = \"busybox\"\nfrom = \"/bin/busybox\"\n[[steps]]\nname = \"build\"", |
| 47 | "bad cache size": "image = \"alpine\"\ncache = [{ path = \"/c\", max_size = \"12 GiB\" }]\n[[steps]]\nname = \"build\"", |
| 48 | "cache no path": "image = \"alpine\"\ncache = [{ max_size = \"1g\" }]\n[[steps]]\nname = \"build\"", |
| 49 | } |
| 50 | for name, src := range cases { |
| 51 | t.Run(name, func(t *testing.T) { mustFail(t, src) }) |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | func TestStepOrderKeepsNumericNames(t *testing.T) { |
| 56 | cfg := mustParse(t, ` |
| 57 | image = "alpine" |
| 58 | [[steps]] |
| 59 | name = "setup" |
| 60 | [[steps]] |
| 61 | name = "2024" |
| 62 | [[steps]] |
| 63 | name = "test" |
| 64 | `) |
| 65 | got := []string{cfg.Steps[0].Name, cfg.Steps[1].Name, cfg.Steps[2].Name} |
| 66 | if !reflect.DeepEqual(got, []string{"setup", "2024", "test"}) { |
| 67 | t.Fatalf("step order lost: %v", got) |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | func TestParseCopyEntries(t *testing.T) { |
| 72 | cfg := mustParse(t, ` |
| 73 | image = "alpine" |
| 74 | [[copy]] |
| 75 | image = "docker.io/oven/bun:1.4.0-alpine" |
| 76 | from = "/usr/local/bin/bun" |
| 77 | to = "/usr/local/bin" |
| 78 | [[steps]] |
| 79 | name = "build" |
| 80 | `) |
| 81 | want := []Copy{{Image: "docker.io/oven/bun:1.4.0-alpine", From: "/usr/local/bin/bun", To: "/usr/local/bin"}} |
| 82 | if !reflect.DeepEqual(cfg.Copy, want) { |
| 83 | t.Fatalf("got %+v", cfg.Copy) |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | func TestParseTopLevelFields(t *testing.T) { |
| 88 | cfg := mustParse(t, ` |
| 89 | image = "alpine" |
| 90 | work_dir = "/ci" |
| 91 | clone_project_to = "/ci/repo" |
| 92 | shell_setup = "set -e" |
| 93 | timeout = 1800 |
| 94 | cpu_limit = 2.0 |
| 95 | memory_limit = "1g" |
| 96 | cache = ["/root/.npm"] |
| 97 | [[steps]] |
| 98 | name = "build" |
| 99 | `) |
| 100 | if cfg.WorkDir != "/ci" || cfg.CloneProjectTo != "/ci/repo" || cfg.ShellSetup != "set -e" { |
| 101 | t.Fatalf("got %+v", cfg) |
| 102 | } |
| 103 | if cfg.Timeout != 1800 || cfg.CPULimit != 2.0 || cfg.MemoryLimit != "1g" { |
| 104 | t.Fatalf("got %+v", cfg) |
| 105 | } |
| 106 | if !reflect.DeepEqual(cfg.Cache, []Cache{{Path: "/root/.npm"}}) { |
| 107 | t.Fatalf("got %+v", cfg.Cache) |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | func TestParseCacheForms(t *testing.T) { |
| 112 | cfg := mustParse(t, ` |
| 113 | image = "alpine" |
| 114 | cache = [ |
| 115 | { path = "/ci/cache/target", max_size = "12g" }, |
| 116 | "/usr/local/cargo/registry", |
| 117 | { path = "/root/.bun/install/cache" }, |
| 118 | ] |
| 119 | [[steps]] |
| 120 | name = "build" |
| 121 | `) |
| 122 | want := []Cache{ |
| 123 | {Path: "/ci/cache/target", MaxSize: 12 * 1024 * 1024 * 1024}, |
| 124 | {Path: "/usr/local/cargo/registry"}, |
| 125 | {Path: "/root/.bun/install/cache"}, |
| 126 | } |
| 127 | if !reflect.DeepEqual(cfg.Cache, want) { |
| 128 | t.Fatalf("got %+v", cfg.Cache) |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | func TestParseTriggersAndVariables(t *testing.T) { |
| 133 | cfg := mustParse(t, ` |
| 134 | image = "alpine" |
| 135 | [on] |
| 136 | push = ["main", "develop"] |
| 137 | tag = true |
| 138 | manual = true |
| 139 | [variables] |
| 140 | [variables.DEPLOY_ENV] |
| 141 | default = "staging" |
| 142 | description = "Target environment" |
| 143 | [variables.VERSION] |
| 144 | default = "1.0.0" |
| 145 | [[steps]] |
| 146 | name = "build" |
| 147 | `) |
| 148 | if !reflect.DeepEqual(cfg.On.Push.Patterns, []string{"main", "develop"}) { |
| 149 | t.Fatalf("got %+v", cfg.On.Push) |
| 150 | } |
| 151 | if !cfg.On.Tag || !cfg.On.Manual { |
| 152 | t.Fatalf("got %+v", cfg.On) |
| 153 | } |
| 154 | if cfg.Variables["DEPLOY_ENV"] != (VariableDef{Default: "staging", Description: "Target environment"}) { |
| 155 | t.Fatalf("got %+v", cfg.Variables) |
| 156 | } |
| 157 | if cfg.Variables["VERSION"].Default != "1.0.0" { |
| 158 | t.Fatalf("got %+v", cfg.Variables) |
| 159 | } |
| 160 | if !reflect.DeepEqual(cfg.VariableOrder, []string{"DEPLOY_ENV", "VERSION"}) { |
| 161 | t.Fatalf("variable order lost: %v", cfg.VariableOrder) |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | func TestParseStepFields(t *testing.T) { |
| 166 | cfg := mustParse(t, ` |
| 167 | image = "alpine" |
| 168 | [[steps]] |
| 169 | name = "test" |
| 170 | run_sh = "./run_tests" |
| 171 | run_if = 'test -n "${CI_COMMIT_TAG}"' |
| 172 | clear = true |
| 173 | timeout = 300 |
| 174 | publish_file = ["/dist/binary"] |
| 175 | publish_gzip = "/dist/" |
| 176 | [[steps]] |
| 177 | name = "lint" |
| 178 | warn_on_fail = true |
| 179 | [[steps]] |
| 180 | name = "cleanup" |
| 181 | always = true |
| 182 | `) |
| 183 | s := cfg.Steps[0] |
| 184 | if s.RunSh != "./run_tests" || s.RunIf != `test -n "${CI_COMMIT_TAG}"` || !s.Clear || s.Timeout != 300 { |
| 185 | t.Fatalf("got %+v", s) |
| 186 | } |
| 187 | if !reflect.DeepEqual([]string(s.PublishFile), []string{"/dist/binary"}) { |
| 188 | t.Fatalf("got %+v", s.PublishFile) |
| 189 | } |
| 190 | // A bare string is accepted where a list is. |
| 191 | if !reflect.DeepEqual([]string(s.PublishGzip), []string{"/dist/"}) { |
| 192 | t.Fatalf("got %+v", s.PublishGzip) |
| 193 | } |
| 194 | if !cfg.Steps[1].WarnOnFail || cfg.Steps[1].Always { |
| 195 | t.Fatalf("got %+v", cfg.Steps[1]) |
| 196 | } |
| 197 | if !cfg.Steps[2].Always { |
| 198 | t.Fatalf("got %+v", cfg.Steps[2]) |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | func TestShouldTriggerPush(t *testing.T) { |
| 203 | cfg := func(push string) *Config { |
| 204 | return mustParse(t, "image=\"alpine\"\n[[steps]]\nname=\"b\"\n[on]\npush="+push) |
| 205 | } |
| 206 | cases := []struct { |
| 207 | push string |
| 208 | branch string |
| 209 | want bool |
| 210 | }{ |
| 211 | {`["main"]`, "main", true}, |
| 212 | {`["main"]`, "develop", false}, |
| 213 | {`["*"]`, "anything", true}, |
| 214 | {`["main","release"]`, "release", true}, |
| 215 | {`["main","release"]`, "feature/x", false}, |
| 216 | {`["release/*"]`, "release/1.0", true}, |
| 217 | {`["release/*"]`, "main", false}, |
| 218 | {`true`, "anything", true}, |
| 219 | {`false`, "main", false}, |
| 220 | } |
| 221 | for _, c := range cases { |
| 222 | if got := shouldTriggerPush(cfg(c.push), c.branch); got != c.want { |
| 223 | t.Errorf("push=%s branch=%s: got %v", c.push, c.branch, got) |
| 224 | } |
| 225 | } |
| 226 | noOn := mustParse(t, "image=\"alpine\"\n[[steps]]\nname=\"b\"") |
| 227 | if shouldTriggerPush(noOn, "main") { |
| 228 | t.Error("absent on.push must not trigger") |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | func TestValidateCachePaths(t *testing.T) { |
| 233 | parse := func(clone, cache string) *Config { |
| 234 | return mustParse(t, "image=\"alpine\"\nclone_project_to=\""+clone+"\"\ncache="+cache+ |
| 235 | "\n[[steps]]\nname=\"build\"") |
| 236 | } |
| 237 | if got := ValidateCiConfig(parse("/ci/build/project", `["/ci/cache/target","/usr/local/cargo/registry"]`)); got != "" { |
| 238 | t.Errorf("outside paths rejected: %s", got) |
| 239 | } |
| 240 | if got := ValidateCiConfig(parse("/ci/build/project", `["/ci/build/project/target"]`)); !strings.Contains(got, "/ci/build/project/target") { |
| 241 | t.Errorf("inside path accepted: %q", got) |
| 242 | } |
| 243 | if got := ValidateCiConfig(parse("/ci/build/project", `["/ci/build/project"]`)); got == "" { |
| 244 | t.Error("equal path accepted") |
| 245 | } |
| 246 | if got := ValidateCiConfig(parse("/ci/build/project", `["/ci/build/project-cache"]`)); got != "" { |
| 247 | t.Errorf("sibling prefix rejected: %s", got) |
| 248 | } |
| 249 | if got := ValidateCiConfig(parse("/ci/build/project", `[{path="/ci/build/project/target",max_size="1g"}]`)); !strings.Contains(got, "/ci/build/project/target") { |
| 250 | t.Errorf("capped inside path accepted: %q", got) |
| 251 | } |
| 252 | // Cache alone is fine when nothing is cloned. |
| 253 | noClone := mustParse(t, "image=\"alpine\"\ncache=[\"/anything\"]\n[[steps]]\nname=\"build\"") |
| 254 | if got := ValidateCiConfig(noClone); got != "" { |
| 255 | t.Errorf("cache without clone rejected: %s", got) |
| 256 | } |
| 257 | // A relative clone path names two different directories. |
| 258 | rel := mustParse(t, "image=\"alpine\"\nclone_project_to=\"project\"\n[[steps]]\nname=\"build\"") |
| 259 | if ValidateCiConfig(rel) == "" { |
| 260 | t.Error("relative clone_project_to accepted") |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | func TestValidateCopyDestination(t *testing.T) { |
| 265 | parse := func(to string) *Config { |
| 266 | return mustParse(t, "image=\"alpine\"\n[[copy]]\nimage=\"busybox\"\nfrom=\"/bin/busybox\"\nto=\""+ |
| 267 | to+"\"\n[[steps]]\nname=\"build\"") |
| 268 | } |
| 269 | if got := ValidateCiConfig(parse("/usr/local/bin")); got != "" { |
| 270 | t.Errorf("directory destination rejected: %s", got) |
| 271 | } |
| 272 | // Looks like a rename, silently produces /usr/local/bin/busybox/busybox. |
| 273 | if got := ValidateCiConfig(parse("/usr/local/bin/busybox")); !strings.Contains(got, "/usr/local/bin/busybox/busybox") { |
| 274 | t.Errorf("repeated basename accepted: %q", got) |
| 275 | } |
| 276 | } |
| 277 | |
| 278 | func TestParseMemoryBytes(t *testing.T) { |
| 279 | cases := map[string]int64{ |
| 280 | "1024": 1024, |
| 281 | "1k": 1024, |
| 282 | "2M": 2 * 1024 * 1024, |
| 283 | "1.5g": 1536 * 1024 * 1024, |
| 284 | "12 GiB": 0, |
| 285 | "": 0, |
| 286 | } |
| 287 | for in, want := range cases { |
| 288 | if got := parseMemoryBytes(in); got != want { |
| 289 | t.Errorf("parseMemoryBytes(%q) = %d, want %d", in, got, want) |
| 290 | } |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | func TestCacheVolumeNameDiffersPerPath(t *testing.T) { |
| 295 | a := cacheVolumeName("repo", "/ci/cache/target") |
| 296 | b := cacheVolumeName("repo", "/ci/build/project/target") |
| 297 | if a == b { |
| 298 | t.Fatal("two paths share a volume name") |
| 299 | } |
| 300 | if !strings.HasPrefix(a, "hearthforge-ci-cache-") || len(a) != len("hearthforge-ci-cache-")+24 { |
| 301 | t.Fatalf("unexpected volume name %q", a) |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | func TestSplitImageRef(t *testing.T) { |
| 306 | cases := []struct{ in, name, tag string }{ |
| 307 | {"debian:stable", "debian", "stable"}, |
| 308 | {"docker.io/library/debian", "docker.io/library/debian", "latest"}, |
| 309 | {"registry:5000/img", "registry:5000/img", "latest"}, |
| 310 | {"registry:5000/img:v2", "registry:5000/img", "v2"}, |
| 311 | {"debian@sha256:abc", "debian@sha256:abc", ""}, |
| 312 | {"registry:5000/img@sha256:abc", "registry:5000/img@sha256:abc", ""}, |
| 313 | } |
| 314 | for _, c := range cases { |
| 315 | name, tag := splitImageRef(c.in) |
| 316 | if name != c.name || tag != c.tag { |
| 317 | t.Errorf("splitImageRef(%q) = %q,%q", c.in, name, tag) |
| 318 | } |
| 319 | } |
| 320 | } |
| 321 | |
| 322 | func TestMaskSecrets(t *testing.T) { |
| 323 | got := maskSecrets("token=abc123 again abc123", []string{"abc123", ""}) |
| 324 | if got != "token=[MASKED] again [MASKED]" { |
| 325 | t.Fatalf("got %q", got) |
| 326 | } |
| 327 | } |
| 328 | |
| 329 | func TestLogBufferTruncates(t *testing.T) { |
| 330 | var b logBuffer |
| 331 | b.append(strings.Repeat("x", ciMaxLogBytes+10)) |
| 332 | if !b.truncated || !strings.Contains(b.String(), "[log truncated at") { |
| 333 | t.Fatal("log not truncated") |
| 334 | } |
| 335 | before := b.String() |
| 336 | b.append("more") |
| 337 | if b.String() != before { |
| 338 | t.Fatal("appended after truncation") |
| 339 | } |
| 340 | } |
| 341 | |
| 342 | func TestParseEngineSocket(t *testing.T) { |
| 343 | cfg := mustParse(t, ` |
| 344 | image = "debian:latest" |
| 345 | [[steps]] |
| 346 | name = "test" |
| 347 | run_sh = "true" |
| 348 | [[steps]] |
| 349 | name = "image" |
| 350 | engine_socket = true |
| 351 | run_sh = "docker build ." |
| 352 | `) |
| 353 | if cfg.Steps[0].EngineSocket || !cfg.Steps[1].EngineSocket { |
| 354 | t.Fatalf("engine_socket parsed wrong: %+v", cfg.Steps) |
| 355 | } |
| 356 | if !cfg.WantsEngineSocket() { |
| 357 | t.Error("WantsEngineSocket = false") |
| 358 | } |
| 359 | cfg.Steps[1].EngineSocket = false |
| 360 | if cfg.WantsEngineSocket() { |
| 361 | t.Error("WantsEngineSocket = true with no step asking") |
| 362 | } |
| 363 | } |
| 364 |