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