config_test.go
⎇
Raw
1package ci
2
3import (
4 "reflect"
5 "strings"
6 "testing"
7)
8
9func 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
18func 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
25func TestParseMinimalConfig(t *testing.T) {
26 cfg := mustParse(t, `
27image = "debian:latest"
28[[steps]]
29name = "build"
30run_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
40func 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
55func TestStepOrderKeepsNumericNames(t *testing.T) {
56 cfg := mustParse(t, `
57image = "alpine"
58[[steps]]
59name = "setup"
60[[steps]]
61name = "2024"
62[[steps]]
63name = "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
71func TestParseCopyEntries(t *testing.T) {
72 cfg := mustParse(t, `
73image = "alpine"
74[[copy]]
75image = "docker.io/oven/bun:1.4.0-alpine"
76from = "/usr/local/bin/bun"
77to = "/usr/local/bin"
78[[steps]]
79name = "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
87func TestParseTopLevelFields(t *testing.T) {
88 cfg := mustParse(t, `
89image = "alpine"
90work_dir = "/ci"
91clone_project_to = "/ci/repo"
92shell_setup = "set -e"
93timeout = 1800
94cpu_limit = 2.0
95memory_limit = "1g"
96cache = ["/root/.npm"]
97[[steps]]
98name = "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
111func TestParseCacheForms(t *testing.T) {
112 cfg := mustParse(t, `
113image = "alpine"
114cache = [
115 { path = "/ci/cache/target", max_size = "12g" },
116 "/usr/local/cargo/registry",
117 { path = "/root/.bun/install/cache" },
118]
119[[steps]]
120name = "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
132func TestParseTriggersAndVariables(t *testing.T) {
133 cfg := mustParse(t, `
134image = "alpine"
135[on]
136push = ["main", "develop"]
137tag = true
138manual = true
139[variables]
140 [variables.DEPLOY_ENV]
141 default = "staging"
142 description = "Target environment"
143 [variables.VERSION]
144 default = "1.0.0"
145[[steps]]
146name = "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
165func TestParseStepFields(t *testing.T) {
166 cfg := mustParse(t, `
167image = "alpine"
168[[steps]]
169name = "test"
170run_sh = "./run_tests"
171run_if = 'test -n "${CI_COMMIT_TAG}"'
172clear = true
173timeout = 300
174publish_file = ["/dist/binary"]
175publish_gzip = "/dist/"
176[[steps]]
177name = "lint"
178warn_on_fail = true
179[[steps]]
180name = "cleanup"
181always = 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
202func 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
232func 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
264func 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
278func 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
294func 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
305func 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
322func 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
329func 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
342func TestParseBuildImage(t *testing.T) {
343 cfg := mustParse(t, `
344image = "debian:latest"
345clone_project_to = "/ci/project"
346[[steps]]
347name = "image"
348build_image = { file = "deploy/Containerfile", target = "app", image = "web", tags = ["$CI_COMMIT_SHORT_SHA", "latest"], args = { VERSION = "$CI_COMMIT_TAG" }, secrets = ["NPM_TOKEN"] }
349`)
350 want := &BuildImage{
351 File: "deploy/Containerfile", Target: "app", Image: "web",
352 Tags: []string{"$CI_COMMIT_SHORT_SHA", "latest"},
353 Args: map[string]string{"VERSION": "$CI_COMMIT_TAG"},
354 Secrets: []string{"NPM_TOKEN"},
355 }
356 if !reflect.DeepEqual(cfg.Steps[0].BuildImage, want) {
357 t.Fatalf("build_image = %+v", cfg.Steps[0].BuildImage)
358 }
359 if got := ValidateCiConfig(cfg); got != "" {
360 t.Fatalf("ValidateCiConfig = %q", got)
361 }
362}
363
364func TestValidateBuildImage(t *testing.T) {
365 bad := map[string]string{
366 "no context": `{}`,
367 "relative context": `{ context = "project" }`,
368 "absolute file": `{ context = "/p", file = "/etc/Containerfile" }`,
369 "file outside": `{ context = "/p", file = "../Containerfile" }`,
370 "bad target": `{ context = "/p", target = "-x" }`,
371 "bad arg": `{ context = "/p", args = { "A-B" = "x" } }`,
372 "bad secret": `{ context = "/p", secrets = ["../x"] }`,
373 "upper image": `{ context = "/p", image = "Web" }`,
374 "reserved image": `{ context = "/p", image = "a/blobs" }`,
375 }
376 for name, spec := range bad {
377 cfg := mustParse(t, "image = \"debian\"\n[[steps]]\nname = \"i\"\nbuild_image = "+spec)
378 if ValidateCiConfig(cfg) == "" {
379 t.Errorf("%s: accepted %s", name, spec)
380 }
381 }
382}
383
384func TestExpandTags(t *testing.T) {
385 vars := map[string]string{"CI_COMMIT_SHORT_SHA": "abc12345", "CI_COMMIT_TAG": ""}
386 got, err := expandTags(nil, vars)
387 if err != nil || !reflect.DeepEqual(got, []string{"abc12345"}) {
388 t.Errorf("default tags = %v, %v", got, err)
389 }
390 got, err = expandTags([]string{"$CI_COMMIT_TAG", "latest", "latest"}, vars)
391 if err != nil || !reflect.DeepEqual(got, []string{"latest"}) {
392 t.Errorf("tags = %v, %v", got, err)
393 }
394 if _, err := expandTags([]string{"$CI_COMMIT_TAG"}, vars); err == nil {
395 t.Error("all-empty tags accepted")
396 }
397 if _, err := expandTags([]string{"a b"}, vars); err == nil {
398 t.Error("invalid tag accepted")
399 }
400}
401