v2.31.0-dev22 8d342c4

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

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