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