1package cli
2
3import (
4 "context"
5 "fmt"
6 "io"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11 "testing"
12
13 "github.com/bnema/gordon/internal/adapters/dto"
14 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
15 "github.com/bnema/gordon/internal/domain"
16 "github.com/stretchr/testify/assert"
17)
18
19type resolveFromImageTestControlPlane struct {
20 findRoutesByImage func(context.Context, string) ([]domain.Route, error)
21}
22
23var _ ControlPlane = (*resolveFromImageTestControlPlane)(nil)
24
25func (c *resolveFromImageTestControlPlane) ListRoutesWithDetails(context.Context) ([]remote.RouteInfo, error) {
26 panic("unexpected call")
27}
28
29func (c *resolveFromImageTestControlPlane) GetHealth(context.Context) (map[string]*remote.RouteHealth, error) {
30 panic("unexpected call")
31}
32
33func (c *resolveFromImageTestControlPlane) GetRoute(context.Context, string) (*domain.Route, error) {
34 panic("unexpected call")
35}
36
37func (c *resolveFromImageTestControlPlane) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) {
38 if c.findRoutesByImage != nil {
39 return c.findRoutesByImage(ctx, imageName)
40 }
41 return nil, nil
42}
43
44func (c *resolveFromImageTestControlPlane) AddRoute(context.Context, domain.Route) error {
45 panic("unexpected call")
46}
47
48func (c *resolveFromImageTestControlPlane) UpdateRoute(context.Context, domain.Route) error {
49 panic("unexpected call")
50}
51
52func (c *resolveFromImageTestControlPlane) RemoveRoute(context.Context, string) error {
53 panic("unexpected call")
54}
55
56func (c *resolveFromImageTestControlPlane) Bootstrap(context.Context, dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
57 panic("unexpected call")
58}
59
60func (c *resolveFromImageTestControlPlane) ListSecretsWithAttachments(context.Context, string) (*remote.SecretsListResult, error) {
61 panic("unexpected call")
62}
63
64func (c *resolveFromImageTestControlPlane) SetSecrets(context.Context, string, map[string]string) error {
65 panic("unexpected call")
66}
67
68func (c *resolveFromImageTestControlPlane) DeleteSecret(context.Context, string, string) error {
69 panic("unexpected call")
70}
71
72func (c *resolveFromImageTestControlPlane) SetAttachmentSecrets(context.Context, string, string, map[string]string) error {
73 panic("unexpected call")
74}
75
76func (c *resolveFromImageTestControlPlane) DeleteAttachmentSecret(context.Context, string, string, string) error {
77 panic("unexpected call")
78}
79
80func (c *resolveFromImageTestControlPlane) GetAllAttachmentsConfig(context.Context) (map[string][]string, error) {
81 panic("unexpected call")
82}
83
84func (c *resolveFromImageTestControlPlane) GetAttachmentsConfig(context.Context, string) ([]string, error) {
85 panic("unexpected call")
86}
87
88func (c *resolveFromImageTestControlPlane) FindAttachmentTargetsByImage(context.Context, string) ([]string, error) {
89 panic("unexpected call")
90}
91
92func (c *resolveFromImageTestControlPlane) AddAttachment(context.Context, string, string) error {
93 panic("unexpected call")
94}
95
96func (c *resolveFromImageTestControlPlane) RemoveAttachment(context.Context, string, string) error {
97 panic("unexpected call")
98}
99
100func (c *resolveFromImageTestControlPlane) GetAutoRouteAllowedDomains(context.Context) ([]string, error) {
101 panic("unexpected call")
102}
103
104func (c *resolveFromImageTestControlPlane) AddAutoRouteAllowedDomain(context.Context, string) error {
105 panic("unexpected call")
106}
107
108func (c *resolveFromImageTestControlPlane) RemoveAutoRouteAllowedDomain(context.Context, string) error {
109 panic("unexpected call")
110}
111
112func (c *resolveFromImageTestControlPlane) GetStatus(context.Context) (*remote.Status, error) {
113 panic("unexpected call")
114}
115
116func (c *resolveFromImageTestControlPlane) Reload(context.Context) error {
117 panic("unexpected call")
118}
119
120func (c *resolveFromImageTestControlPlane) ListNetworks(context.Context) ([]*domain.NetworkInfo, error) {
121 panic("unexpected call")
122}
123
124func (c *resolveFromImageTestControlPlane) GetConfig(context.Context) (*remote.Config, error) {
125 panic("unexpected call")
126}
127
128func (c *resolveFromImageTestControlPlane) DeployIntent(context.Context, string) error {
129 panic("unexpected call")
130}
131
132func (c *resolveFromImageTestControlPlane) Deploy(context.Context, string) (*remote.DeployResult, error) {
133 panic("unexpected call")
134}
135
136func (c *resolveFromImageTestControlPlane) Restart(context.Context, string, bool) (*remote.RestartResult, error) {
137 panic("unexpected call")
138}
139
140func (c *resolveFromImageTestControlPlane) ListTags(context.Context, string) ([]string, error) {
141 panic("unexpected call")
142}
143
144func (c *resolveFromImageTestControlPlane) ListBackups(context.Context, string) ([]dto.BackupJob, error) {
145 panic("unexpected call")
146}
147
148func (c *resolveFromImageTestControlPlane) BackupStatus(context.Context) ([]dto.BackupJob, error) {
149 panic("unexpected call")
150}
151
152func (c *resolveFromImageTestControlPlane) RunBackup(context.Context, string, string) (*dto.BackupRunResponse, error) {
153 panic("unexpected call")
154}
155
156func (c *resolveFromImageTestControlPlane) DetectDatabases(context.Context, string) ([]dto.DatabaseInfo, error) {
157 panic("unexpected call")
158}
159
160func (c *resolveFromImageTestControlPlane) GetProcessLogs(context.Context, int) ([]string, error) {
161 panic("unexpected call")
162}
163
164func (c *resolveFromImageTestControlPlane) GetContainerLogs(context.Context, string, int) ([]string, error) {
165 panic("unexpected call")
166}
167
168func (c *resolveFromImageTestControlPlane) StreamProcessLogs(context.Context, int) (<-chan string, error) {
169 panic("unexpected call")
170}
171
172func (c *resolveFromImageTestControlPlane) StreamContainerLogs(context.Context, string, int) (<-chan string, error) {
173 panic("unexpected call")
174}
175
176func (c *resolveFromImageTestControlPlane) ListVolumes(context.Context) ([]dto.Volume, error) {
177 panic("unexpected call")
178}
179
180func (c *resolveFromImageTestControlPlane) PruneVolumes(context.Context, dto.VolumePruneRequest) (*dto.VolumePruneResponse, error) {
181 panic("unexpected call")
182}
183
184func TestValidateBuildArg(t *testing.T) {
185 tests := []struct {
186 name string
187 arg string
188 wantErr bool
189 }{
190 {"valid simple", "FOO=bar", false},
191 {"valid with underscore key", "_FOO=bar", false},
192 {"valid with numbers in key", "FOO123=bar", false},
193 {"valid empty value", "FOO=", false},
194 {"valid complex value", "FOO=bar baz=qux", false},
195 {"invalid no equals", "FOO", true},
196 {"invalid starts with number", "1FOO=bar", true},
197 {"invalid starts with dash", "-FOO=bar", true},
198 {"invalid special chars in key", "FOO-BAR=baz", true},
199 {"invalid empty string", "", true},
200 {"invalid equals only", "=value", true},
201 }
202
203 for _, tt := range tests {
204 t.Run(tt.name, func(t *testing.T) {
205 err := validateBuildArg(tt.arg)
206 if tt.wantErr {
207 assert.Error(t, err)
208 } else {
209 assert.NoError(t, err)
210 }
211 })
212 }
213}
214
215func TestParseImageRef(t *testing.T) {
216 tests := []struct {
217 name string
218 image string
219 wantRegistry string
220 wantName string
221 wantTag string
222 }{
223 {"full ref", "reg.example.com/myapp:v1.0.0", "reg.example.com", "myapp", "v1.0.0"},
224 {"no tag", "reg.example.com/myapp", "reg.example.com", "myapp", "latest"},
225 {"latest tag", "reg.example.com/myapp:latest", "reg.example.com", "myapp", "latest"},
226 {"no slash", "myapp:v1.0.0", "", "", ""},
227 }
228
229 for _, tt := range tests {
230 t.Run(tt.name, func(t *testing.T) {
231 registry, name, tag := parseImageRef(tt.image)
232 assert.Equal(t, tt.wantRegistry, registry)
233 assert.Equal(t, tt.wantName, name)
234 assert.Equal(t, tt.wantTag, tag)
235 })
236 }
237}
238
239func TestBuildAndPush_BuildArgs(t *testing.T) {
240 // Verify buildImageArgs produces --load instead of --push
241 args := buildImageArgs(context.Background(), "v1.0.0", "linux/amd64", "Dockerfile", []string{"CGO_ENABLED=0"}, "reg.example.com/app:v1.0.0", "reg.example.com/app:latest")
242
243 assert.Contains(t, args, "--load")
244 assert.NotContains(t, args, "--push")
245 assert.Contains(t, args, "--platform")
246 assert.Contains(t, args, "-f")
247 assert.Contains(t, args, "Dockerfile")
248 assert.Contains(t, args, "VERSION=v1.0.0")
249}
250
251func TestBuildImageArgsInjectsGitBuildArgs(t *testing.T) {
252 args := buildImageArgs(context.Background(), "v1.2.3", "linux/amd64", "Dockerfile", nil, "registry/img:v1.2.3", "registry/img:latest")
253
254 // Must contain explicit KEY=VALUE for all standard git build args
255 argStr := strings.Join(args, " ")
256 for _, key := range []string{"VERSION=v1.2.3", "GIT_TAG=v1.2.3", "GIT_SHA=", "BUILD_TIME="} {
257 if !strings.Contains(argStr, key) {
258 t.Errorf("expected args to contain %q, got: %s", key, argStr)
259 }
260 }
261
262 // Must NOT contain bare "--build-arg VERSION" (without =value)
263 for i, a := range args {
264 if a == "--build-arg" && i+1 < len(args) && args[i+1] == "VERSION" {
265 t.Error("found bare '--build-arg VERSION' (without =value); should be '--build-arg VERSION=v1.2.3'")
266 }
267 }
268}
269
270func TestBuildImageArgsUserArgsOverrideDefaults(t *testing.T) {
271 userArgs := []string{"GIT_TAG=custom-override"}
272 args := buildImageArgs(context.Background(), "v1.2.3", "linux/amd64", "Dockerfile", userArgs, "r/i:v1.2.3", "r/i:latest")
273
274 // Count how many times GIT_TAG appears and track the last occurrence index.
275 // Docker uses the last occurrence of a duplicate --build-arg key, so the user
276 // override must come after the default injected value.
277 count := 0
278 lastIdx := -1
279 for i, a := range args {
280 if a == "--build-arg" && i+1 < len(args) && strings.HasPrefix(args[i+1], "GIT_TAG=") {
281 count++
282 lastIdx = i + 1
283 }
284 }
285 if count < 2 {
286 t.Errorf("expected GIT_TAG to appear twice (default + override), got %d", count)
287 }
288 if lastIdx < 0 || args[lastIdx] != "GIT_TAG="+userArgs[0][len("GIT_TAG="):] {
289 t.Errorf("expected last GIT_TAG= arg to be the user override %q, got %q", "GIT_TAG=custom-override", args[lastIdx])
290 }
291}
292
293func TestParseTagRef(t *testing.T) {
294 tests := []struct {
295 name string
296 ref string
297 want string
298 }{
299 {name: "github tag ref", ref: "refs/tags/v1.2.3", want: "v1.2.3"},
300 {name: "peeled tag ref", ref: "refs/tags/v1.2.3^{}", want: "v1.2.3"},
301 {name: "branch ref", ref: "refs/heads/main", want: ""},
302 {name: "empty", ref: "", want: ""},
303 }
304
305 for _, tt := range tests {
306 t.Run(tt.name, func(t *testing.T) {
307 assert.Equal(t, tt.want, parseTagRef(tt.ref))
308 })
309 }
310}
311
312func TestVersionFromTagRefs(t *testing.T) {
313 tests := []struct {
314 name string
315 env map[string]string
316 want string
317 }{
318 {
319 name: "github ref tag",
320 env: map[string]string{"GITHUB_REF": "refs/tags/v2.0.0"},
321 want: "v2.0.0",
322 },
323 {
324 name: "github ref type and name",
325 env: map[string]string{
326 "GITHUB_REF_TYPE": "tag",
327 "GITHUB_REF_NAME": "v2.1.0",
328 },
329 want: "v2.1.0",
330 },
331 {
332 name: "gitlab commit tag",
333 env: map[string]string{"CI_COMMIT_TAG": "v3.0.0"},
334 want: "v3.0.0",
335 },
336 {
337 name: "azure source branch tag ref",
338 env: map[string]string{"BUILD_SOURCEBRANCH": "refs/tags/v4.0.0"},
339 want: "v4.0.0",
340 },
341 {
342 name: "no tag refs",
343 env: map[string]string{"GITHUB_REF": "refs/heads/main"},
344 want: "",
345 },
346 }
347
348 for _, tt := range tests {
349 t.Run(tt.name, func(t *testing.T) {
350 got := versionFromTagRefs(func(key string) string {
351 return tt.env[key]
352 })
353 assert.Equal(t, tt.want, got)
354 })
355 }
356}
357
358func TestBuildImageArgs_CustomDockerfile(t *testing.T) {
359 args := buildImageArgs(context.Background(), "v1.0.0", "linux/amd64", "docker/app/Dockerfile", nil, "reg.example.com/app:v1.0.0", "reg.example.com/app:latest")
360
361 assert.Contains(t, args, "-f")
362 assert.Contains(t, args, "docker/app/Dockerfile")
363}
364
365func TestParseDockerfileLabels(t *testing.T) {
366 tests := []struct {
367 name string
368 content string
369 expected map[string]string
370 }{
371 {
372 name: "simple label",
373 content: `LABEL gordon.domain="myapp.example.com"`,
374 expected: map[string]string{
375 "gordon.domain": "myapp.example.com",
376 },
377 },
378 {
379 name: "multiple labels",
380 content: `LABEL gordon.domain="myapp.example.com"
381LABEL gordon.proxy.port="8080"
382LABEL gordon.health="/health"`,
383 expected: map[string]string{
384 "gordon.domain": "myapp.example.com",
385 "gordon.proxy.port": "8080",
386 "gordon.health": "/health",
387 },
388 },
389 {
390 name: "multi-label on one line",
391 content: `LABEL gordon.domain="myapp.example.com" gordon.proxy.port="8080"`,
392 expected: map[string]string{
393 "gordon.domain": "myapp.example.com",
394 "gordon.proxy.port": "8080",
395 },
396 },
397 {
398 name: "unquoted value",
399 content: `LABEL gordon.domain=myapp.example.com`,
400 expected: map[string]string{
401 "gordon.domain": "myapp.example.com",
402 },
403 },
404 {
405 name: "single-quoted value",
406 content: `LABEL gordon.domain='myapp.example.com'`,
407 expected: map[string]string{
408 "gordon.domain": "myapp.example.com",
409 },
410 },
411 {
412 name: "non-gordon labels ignored",
413 content: `LABEL maintainer="John" gordon.domain="myapp.example.com"`,
414 expected: map[string]string{
415 "gordon.domain": "myapp.example.com",
416 },
417 },
418 {
419 name: "comma-separated domains",
420 content: `LABEL gordon.domains="app1.example.com,app2.example.com"`,
421 expected: map[string]string{
422 "gordon.domains": "app1.example.com,app2.example.com",
423 },
424 },
425 {
426 name: "no labels",
427 content: `FROM alpine:latest`,
428 expected: map[string]string{},
429 },
430 {
431 name: "comments and empty lines",
432 content: "# This is a comment\n\nLABEL gordon.domain=\"test.com\"",
433 expected: map[string]string{"gordon.domain": "test.com"},
434 },
435 }
436
437 for _, tt := range tests {
438 t.Run(tt.name, func(t *testing.T) {
439 // Write a temp Dockerfile
440 dir := t.TempDir()
441 dockerfile := filepath.Join(dir, "Dockerfile")
442 err := os.WriteFile(dockerfile, []byte(tt.content), 0644)
443 assert.NoError(t, err)
444
445 labels := parseDockerfileLabels(dockerfile)
446 assert.Equal(t, tt.expected, labels)
447 })
448 }
449}
450
451func TestParseDockerfileLabels_NonExistent(t *testing.T) {
452 labels := parseDockerfileLabels("/nonexistent/Dockerfile")
453 assert.Empty(t, labels)
454}
455
456func TestDetectImageName_FromDockerfileLabels(t *testing.T) {
457 dir := t.TempDir()
458 dockerfile := filepath.Join(dir, "Dockerfile")
459 err := os.WriteFile(dockerfile, []byte(`FROM alpine
460LABEL gordon.domain="myapp.example.com"
461`), 0644)
462 assert.NoError(t, err)
463
464 name, err := detectImageName(dockerfile)
465 assert.NoError(t, err)
466 assert.Equal(t, "myapp.example.com", name)
467}
468
469func TestDetectImageName_FallbackToDir(t *testing.T) {
470 dir := t.TempDir()
471 dockerfile := filepath.Join(dir, "Dockerfile")
472 err := os.WriteFile(dockerfile, []byte(`FROM alpine
473`), 0644)
474 assert.NoError(t, err)
475
476 // When no gordon.domain label, falls back to cwd basename
477 name, err := detectImageName(dockerfile)
478 assert.NoError(t, err)
479 assert.NotEmpty(t, name)
480}
481
482func TestSplitLabelPairs(t *testing.T) {
483 tests := []struct {
484 name string
485 content string
486 expected []string
487 }{
488 {
489 name: "single pair",
490 content: `gordon.domain="test.com"`,
491 expected: []string{`gordon.domain="test.com"`},
492 },
493 {
494 name: "two pairs",
495 content: `gordon.domain="test.com" gordon.proxy.port="8080"`,
496 expected: []string{`gordon.domain="test.com"`, `gordon.proxy.port="8080"`},
497 },
498 {
499 name: "unquoted",
500 content: `gordon.domain=test.com gordon.proxy.port=8080`,
501 expected: []string{`gordon.domain=test.com`, `gordon.proxy.port=8080`},
502 },
503 {
504 name: "quoted with spaces in value",
505 content: `gordon.domain="my app.com"`,
506 expected: []string{`gordon.domain="my app.com"`},
507 },
508 }
509
510 for _, tt := range tests {
511 t.Run(tt.name, func(t *testing.T) {
512 result := splitLabelPairs(tt.content)
513 assert.Equal(t, tt.expected, result)
514 })
515 }
516}
517
518func TestGetGitVersionNoTagsReturnsFallbackAndWarns(t *testing.T) {
519 ctx := context.Background()
520
521 // Create a temp dir with a git repo that has no tags
522 tmpDir := t.TempDir()
523 if err := exec.Command("git", "-C", tmpDir, "init").Run(); err != nil { // #nosec G204
524 t.Skipf("git init failed: %v", err)
525 }
526 // Need at least one commit so git describe has something to describe
527 if err := exec.Command("git", "-C", tmpDir, "-c", "user.email=test@test.com", "-c", "user.name=Test", "commit", "--allow-empty", "-m", "init").Run(); err != nil { // #nosec G204
528 t.Skipf("git commit failed: %v", err)
529 }
530
531 // Change to the tmpDir for this test so getGitVersion uses the tag-less repo
532 origDir, err := os.Getwd()
533 if err != nil {
534 t.Fatal(err)
535 }
536 if err := os.Chdir(tmpDir); err != nil { // #nosec G204
537 t.Fatal(err)
538 }
539 defer os.Chdir(origDir) // #nosec G204
540
541 // Redirect stderr to capture the warning
542 origStderr := os.Stderr
543 r, w, err := os.Pipe()
544 if err != nil {
545 t.Fatal(err)
546 }
547 os.Stderr = w
548 defer func() {
549 w.Close()
550 os.Stderr = origStderr
551 }()
552
553 v := getGitVersion(ctx)
554
555 w.Close()
556 os.Stderr = origStderr
557 stderrOutput, _ := io.ReadAll(r)
558
559 // Should return "" (fallback to "latest" handled by determineVersion)
560 assert.Equal(t, "", v, "expected empty string fallback when no git tags exist")
561
562 // Should have printed a warning to stderr
563 assert.Contains(t, string(stderrOutput), "latest",
564 "expected a warning about 'latest' fallback on stderr")
565}
566
567func TestParseLabelPair(t *testing.T) {
568 tests := []struct {
569 name string
570 pair string
571 wantKey string
572 wantValue string
573 wantOk bool
574 }{
575 {"quoted", `gordon.domain="test.com"`, "gordon.domain", "test.com", true},
576 {"unquoted", `gordon.domain=test.com`, "gordon.domain", "test.com", true},
577 {"single quoted", `gordon.domain='test.com'`, "gordon.domain", "test.com", true},
578 {"empty value", `gordon.domain=`, "gordon.domain", "", true},
579 {"no equals", `gordon.domain`, "", "", false},
580 }
581
582 for _, tt := range tests {
583 t.Run(tt.name, func(t *testing.T) {
584 key, value, ok := parseLabelPair(tt.pair)
585 assert.Equal(t, tt.wantOk, ok)
586 if ok {
587 assert.Equal(t, tt.wantKey, key)
588 assert.Equal(t, tt.wantValue, value)
589 }
590 })
591 }
592}
593
594func TestResolveFromImage_NoRouteSuggestsBootstrap(t *testing.T) {
595 cp := &resolveFromImageTestControlPlane{
596 findRoutesByImage: func(context.Context, string) ([]domain.Route, error) {
597 return nil, nil
598 },
599 }
600
601 _, _, _, err := resolveFromImage(context.Background(), cp, "myapp", "Dockerfile")
602
603 assert.Error(t, err)
604 assert.Contains(t, err.Error(), `no route configured for image "myapp"`)
605 assert.Contains(t, err.Error(), "gordon bootstrap")
606}
607
608func TestIsInsufficientScope(t *testing.T) {
609 tests := []struct {
610 name string
611 err error
612 want bool
613 }{
614 {"nil error", nil, false},
615 {"HTTPError 403", &remote.HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "insufficient scope"}, true},
616 {"wrapped HTTPError 403", fmt.Errorf("deploy intent: %w", &remote.HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "scope"}), true},
617 {"HTTPError 500", &remote.HTTPError{StatusCode: 500, Status: "500 Internal Server Error", Body: "broke"}, false},
618 {"plain error", fmt.Errorf("connection refused"), false},
619 }
620 for _, tt := range tests {
621 t.Run(tt.name, func(t *testing.T) {
622 assert.Equal(t, tt.want, isInsufficientScope(tt.err))
623 })
624 }
625}