v2.31.0-dev10 f49cffc

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) 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}

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