v2.31.0-dev17 bd898f0

push_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close