v2.31.0-dev4 f8fb41e

push_test.go RawHistoryFolder

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

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