push_remote_inference_test.go RawHistoryFolder

1package cli
2
3import (
4	"context"
5	"encoding/json"
6	"net/http"
7	"net/http/httptest"
8	"net/url"
9	"os"
10	"path/filepath"
11	"strings"
12	"testing"
13
14	"github.com/stretchr/testify/assert"
15	"github.com/stretchr/testify/require"
16)
17
18func TestInferPushRemote_UsesUniqueMatchingSavedRemote(t *testing.T) {
19	prod := newTagsTestServer(t, func(image string) ([]string, int) {
20		assert.Equal(t, "myapp", image)
21		return []string{"latest"}, http.StatusOK
22	})
23	staging := newTagsTestServer(t, func(image string) ([]string, int) {
24		assert.Equal(t, "myapp", image)
25		return nil, http.StatusOK
26	})
27
28	configurePushRemoteInferenceTestEnv(t, `
29[remotes.prod]
30url = "`+prod.URL+`"
31
32[remotes.staging]
33url = "`+staging.URL+`"
34`)
35
36	resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
37	require.NoError(t, err)
38	require.NotNil(t, resolved)
39	assert.Equal(t, "prod", resolved.Name)
40	assert.Equal(t, prod.URL, resolved.URL)
41}
42
43func TestInferPushRemote_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
44	prod := newTagsTestServer(t, func(image string) ([]string, int) {
45		return []string{"latest"}, http.StatusOK
46	})
47	staging := newTagsTestServer(t, func(image string) ([]string, int) {
48		return []string{"latest"}, http.StatusOK
49	})
50
51	configurePushRemoteInferenceTestEnv(t, `
52[remotes.prod]
53url = "`+prod.URL+`"
54
55[remotes.staging]
56url = "`+staging.URL+`"
57`)
58
59	resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
60	require.Error(t, err)
61	assert.Nil(t, resolved)
62	assert.Contains(t, err.Error(), `multiple saved remotes match image "myapp"`)
63	assert.Contains(t, err.Error(), "prod")
64	assert.Contains(t, err.Error(), "staging")
65}
66
67func TestInferPushRemote_SkipsGuessWhenExplicitRemoteSelected(t *testing.T) {
68	originalRemoteFlag := remoteFlag
69	t.Cleanup(func() {
70		remoteFlag = originalRemoteFlag
71	})
72
73	called := false
74	prod := newTagsTestServer(t, func(image string) ([]string, int) {
75		called = true
76		return []string{"latest"}, http.StatusOK
77	})
78
79	configurePushRemoteInferenceTestEnv(t, `
80[remotes.prod]
81url = "`+prod.URL+`"
82`)
83	t.Setenv("GORDON_REMOTE", "")
84	remoteFlag = "prod"
85
86	resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
87	require.NoError(t, err)
88	assert.Nil(t, resolved)
89	assert.False(t, called)
90}
91
92func TestInferPushRemote_SkipsGuessWhenActiveRemoteConfigured(t *testing.T) {
93	called := false
94	prod := newTagsTestServer(t, func(image string) ([]string, int) {
95		called = true
96		return []string{"latest"}, http.StatusOK
97	})
98
99	configurePushRemoteInferenceTestEnv(t, `
100active = "prod"
101
102[remotes.prod]
103url = "`+prod.URL+`"
104`)
105
106	resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
107	require.NoError(t, err)
108	assert.Nil(t, resolved)
109	assert.False(t, called)
110}
111
112func TestInferPushRemote_FailsSafeWhenSomeRemotesCannotBeProbed(t *testing.T) {
113	prod := newTagsTestServer(t, func(image string) ([]string, int) {
114		return []string{"latest"}, http.StatusOK
115	})
116	broken := newTagsTestServer(t, func(image string) ([]string, int) {
117		return nil, http.StatusInternalServerError
118	})
119
120	configurePushRemoteInferenceTestEnv(t, `
121[remotes.prod]
122url = "`+prod.URL+`"
123
124[remotes.broken]
125url = "`+broken.URL+`"
126`)
127
128	resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
129	require.Error(t, err)
130	assert.Nil(t, resolved)
131	assert.Contains(t, err.Error(), `could not safely infer remote for image "myapp"`)
132	assert.Contains(t, err.Error(), "broken")
133}
134
135func TestInferRemoteForRepository_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
136	prod := newTagsTestServer(t, func(repository string) ([]string, int) {
137		return []string{"latest", "v1.0.0"}, http.StatusOK
138	})
139	staging := newTagsTestServer(t, func(repository string) ([]string, int) {
140		return []string{"latest"}, http.StatusOK
141	})
142
143	configurePushRemoteInferenceTestEnv(t, `
144[remotes.prod]
145url = "`+prod.URL+`"
146
147[remotes.staging]
148url = "`+staging.URL+`"
149`)
150
151	resolved, err := inferRemoteForRepository(context.Background(), "myapp")
152	require.Error(t, err)
153	assert.Nil(t, resolved)
154	assert.Contains(t, err.Error(), `multiple saved remotes match repository "myapp"`)
155}
156
157func configurePushRemoteInferenceTestEnv(t *testing.T, remotesTOML string) {
158	t.Helper()
159
160	originalRemoteFlag := remoteFlag
161	originalTokenFlag := tokenFlag
162	originalInsecureTLSFlag := insecureTLSFlag
163	t.Cleanup(func() {
164		remoteFlag = originalRemoteFlag
165		tokenFlag = originalTokenFlag
166		insecureTLSFlag = originalInsecureTLSFlag
167	})
168
169	remoteFlag = ""
170	tokenFlag = ""
171	insecureTLSFlag = false
172	t.Setenv("GORDON_REMOTE", "")
173	t.Setenv("GORDON_TOKEN", "")
174	t.Setenv("GORDON_INSECURE", "")
175	t.Setenv("HOME", t.TempDir())
176
177	configHome := t.TempDir()
178	t.Setenv("XDG_CONFIG_HOME", configHome)
179	cliConfigPath := filepath.Join(configHome, "gordon", "remotes.toml")
180	require.NoError(t, os.MkdirAll(filepath.Dir(cliConfigPath), 0o755))
181	require.NoError(t, os.WriteFile(cliConfigPath, []byte(strings.TrimSpace(remotesTOML)), 0o600))
182}
183
184func newTagsTestServer(t *testing.T, handler func(repository string) ([]string, int)) *httptest.Server {
185	t.Helper()
186	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
187		handleListTagsProbe(t, w, r, handler)
188	}))
189	t.Cleanup(server.Close)
190	return server
191}
192
193func handleListTagsProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(repository string) ([]string, int)) {
194	t.Helper()
195	if handler == nil {
196		http.NotFound(w, r)
197		return
198	}
199	repository, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/tags/"))
200	if !assert.NoError(t, err) {
201		return
202	}
203	tags, status := handler(repository)
204	if status >= http.StatusBadRequest {
205		w.Header().Set("Content-Type", "application/json")
206		w.WriteHeader(status)
207		if status == http.StatusNotFound {
208			if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "repository not found"})) {
209				return
210			}
211			return
212		}
213		if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
214			return
215		}
216		return
217	}
218	w.Header().Set("Content-Type", "application/json")
219	if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"repository": repository, "tags": tags})) {
220		return
221	}
222}

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