v2.31.0-dev19 35467d7

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	"github.com/bnema/gordon/internal/domain"
18)
19
20func TestInferPushRemote_UsesUniqueMatchingSavedRemote(t *testing.T) {
21	prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
22		assert.Equal(t, "myapp", image)
23		return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
24	})
25	staging := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
26		assert.Equal(t, "myapp", image)
27		return nil, http.StatusOK
28	})
29
30	configurePushRemoteInferenceTestEnv(t, `
31[remotes.prod]
32url = "`+prod.URL+`"
33
34[remotes.staging]
35url = "`+staging.URL+`"
36`)
37
38	resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
39	require.NoError(t, err)
40	require.NotNil(t, resolved)
41	assert.Equal(t, "prod", resolved.Name)
42	assert.Equal(t, prod.URL, resolved.URL)
43}
44
45func TestInferPushRemote_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
46	prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
47		return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
48	})
49	staging := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
50		return []domain.Route{{Domain: "staging.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
51	})
52
53	configurePushRemoteInferenceTestEnv(t, `
54[remotes.prod]
55url = "`+prod.URL+`"
56
57[remotes.staging]
58url = "`+staging.URL+`"
59`)
60
61	resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
62	require.Error(t, err)
63	assert.Nil(t, resolved)
64	assert.Contains(t, err.Error(), `multiple saved remotes match image "myapp"`)
65	assert.Contains(t, err.Error(), "prod")
66	assert.Contains(t, err.Error(), "staging")
67}
68
69func TestInferPushRemote_SkipsGuessWhenExplicitRemoteSelected(t *testing.T) {
70	originalRemoteFlag := remoteFlag
71	t.Cleanup(func() {
72		remoteFlag = originalRemoteFlag
73	})
74
75	called := false
76	prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
77		called = true
78		return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
79	})
80
81	configurePushRemoteInferenceTestEnv(t, `
82[remotes.prod]
83url = "`+prod.URL+`"
84`)
85	t.Setenv("GORDON_REMOTE", "")
86	remoteFlag = "prod"
87
88	resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
89	require.NoError(t, err)
90	assert.Nil(t, resolved)
91	assert.False(t, called)
92}
93
94func TestInferPushRemote_SkipsGuessWhenActiveRemoteConfigured(t *testing.T) {
95	called := false
96	prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
97		called = true
98		return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
99	})
100
101	configurePushRemoteInferenceTestEnv(t, `
102active = "prod"
103
104[remotes.prod]
105url = "`+prod.URL+`"
106`)
107
108	resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
109	require.NoError(t, err)
110	assert.Nil(t, resolved)
111	assert.False(t, called)
112}
113
114func TestInferPushRemote_IgnoresPreviewOnlyMatches(t *testing.T) {
115	prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
116		return []domain.Route{{Domain: "preview--pr-42.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
117	})
118
119	configurePushRemoteInferenceTestEnv(t, `
120[remotes.prod]
121url = "`+prod.URL+`"
122`)
123
124	resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
125	require.NoError(t, err)
126	assert.Nil(t, resolved)
127}
128
129func TestInferPushRemote_FailsSafeWhenSomeRemotesCannotBeProbed(t *testing.T) {
130	prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
131		return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
132	})
133	broken := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
134		return nil, http.StatusInternalServerError
135	})
136
137	configurePushRemoteInferenceTestEnv(t, `
138[remotes.prod]
139url = "`+prod.URL+`"
140
141[remotes.broken]
142url = "`+broken.URL+`"
143`)
144
145	resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
146	require.Error(t, err)
147	assert.Nil(t, resolved)
148	assert.Contains(t, err.Error(), `could not safely infer remote for image "myapp"`)
149	assert.Contains(t, err.Error(), "broken")
150}
151
152func TestInferRemoteForRouteDomain_UsesUniqueMatchingSavedRemote(t *testing.T) {
153	prod := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
154		return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
155	})
156	staging := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
157		return nil, http.StatusNotFound
158	})
159
160	configurePushRemoteInferenceTestEnv(t, `
161[remotes.prod]
162url = "`+prod.URL+`"
163
164[remotes.staging]
165url = "`+staging.URL+`"
166`)
167
168	resolved, err := inferRemoteForRouteDomain(context.Background(), "app.example.com")
169	require.NoError(t, err)
170	require.NotNil(t, resolved)
171	assert.Equal(t, "prod", resolved.Name)
172}
173
174func TestInferRemoteForRouteDomain_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
175	prod := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
176		return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
177	})
178	staging := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
179		return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
180	})
181
182	configurePushRemoteInferenceTestEnv(t, `
183[remotes.prod]
184url = "`+prod.URL+`"
185
186[remotes.staging]
187url = "`+staging.URL+`"
188`)
189
190	resolved, err := inferRemoteForRouteDomain(context.Background(), "app.example.com")
191	require.Error(t, err)
192	assert.Nil(t, resolved)
193	assert.Contains(t, err.Error(), `multiple saved remotes match route "app.example.com"`)
194}
195
196func TestInferRemoteForAttachmentImage_UsesUniqueMatchingSavedRemote(t *testing.T) {
197	prod := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) {
198		return []string{"app.example.com"}, http.StatusOK
199	})
200	staging := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) {
201		return nil, http.StatusOK
202	})
203
204	configurePushRemoteInferenceTestEnv(t, `
205[remotes.prod]
206url = "`+prod.URL+`"
207
208[remotes.staging]
209url = "`+staging.URL+`"
210`)
211
212	resolved, err := inferRemoteForAttachmentImage(context.Background(), "postgres:18")
213	require.NoError(t, err)
214	require.NotNil(t, resolved)
215	assert.Equal(t, "prod", resolved.Name)
216}
217
218func TestInferRemoteForAttachmentTarget_FallsBackToRouteLookup(t *testing.T) {
219	prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
220		getRoute: func(domainName string) (*domain.Route, int) {
221			return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
222		},
223		getAttachmentsConfig: func(target string) ([]string, int) {
224			return nil, http.StatusNotFound
225		},
226	})
227	staging := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
228		getRoute: func(domainName string) (*domain.Route, int) {
229			return nil, http.StatusNotFound
230		},
231		getAttachmentsConfig: func(target string) ([]string, int) {
232			return nil, http.StatusNotFound
233		},
234	})
235
236	configurePushRemoteInferenceTestEnv(t, `
237[remotes.prod]
238url = "`+prod.URL+`"
239
240[remotes.staging]
241url = "`+staging.URL+`"
242`)
243
244	resolved, err := inferRemoteForAttachmentTarget(context.Background(), "app.example.com")
245	require.NoError(t, err)
246	require.NotNil(t, resolved)
247	assert.Equal(t, "prod", resolved.Name)
248}
249
250func TestInferRemoteForRepository_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
251	prod := newTagsTestServer(t, func(repository string) ([]string, int) {
252		return []string{"latest", "v1.0.0"}, http.StatusOK
253	})
254	staging := newTagsTestServer(t, func(repository string) ([]string, int) {
255		return []string{"latest"}, http.StatusOK
256	})
257
258	configurePushRemoteInferenceTestEnv(t, `
259[remotes.prod]
260url = "`+prod.URL+`"
261
262[remotes.staging]
263url = "`+staging.URL+`"
264`)
265
266	resolved, err := inferRemoteForRepository(context.Background(), "myapp")
267	require.Error(t, err)
268	assert.Nil(t, resolved)
269	assert.Contains(t, err.Error(), `multiple saved remotes match repository "myapp"`)
270}
271
272func configurePushRemoteInferenceTestEnv(t *testing.T, remotesTOML string) {
273	t.Helper()
274
275	originalRemoteFlag := remoteFlag
276	originalTokenFlag := tokenFlag
277	originalInsecureTLSFlag := insecureTLSFlag
278	t.Cleanup(func() {
279		remoteFlag = originalRemoteFlag
280		tokenFlag = originalTokenFlag
281		insecureTLSFlag = originalInsecureTLSFlag
282	})
283
284	remoteFlag = ""
285	tokenFlag = ""
286	insecureTLSFlag = false
287	t.Setenv("GORDON_REMOTE", "")
288	t.Setenv("GORDON_TOKEN", "")
289	t.Setenv("GORDON_INSECURE", "")
290	t.Setenv("HOME", t.TempDir())
291
292	configHome := t.TempDir()
293	t.Setenv("XDG_CONFIG_HOME", configHome)
294	configPath := filepath.Join(configHome, "gordon", "remotes.toml")
295	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
296	require.NoError(t, os.WriteFile(configPath, []byte(strings.TrimSpace(remotesTOML)), 0o600))
297}
298
299func newFindRoutesByImageTestServer(t *testing.T, handler func(image string) ([]domain.Route, int)) *httptest.Server {
300	t.Helper()
301
302	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
303		if !strings.HasPrefix(r.URL.Path, "/admin/routes/by-image/") {
304			http.NotFound(w, r)
305			return
306		}
307
308		image, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/routes/by-image/"))
309		if !assert.NoError(t, err) {
310			return
311		}
312
313		routes, status := handler(image)
314		if status >= http.StatusBadRequest {
315			w.Header().Set("Content-Type", "application/json")
316			w.WriteHeader(status)
317			if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
318				return
319			}
320			return
321		}
322
323		w.Header().Set("Content-Type", "application/json")
324		if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{
325			"image":  image,
326			"routes": routes,
327		})) {
328			return
329		}
330	}))
331	t.Cleanup(server.Close)
332	return server
333}
334
335func newGetRouteTestServer(t *testing.T, handler func(domainName string) (*domain.Route, int)) *httptest.Server {
336	t.Helper()
337	return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{getRoute: handler})
338}
339
340type multiAdminProbeHandlers struct {
341	getRoute             func(domainName string) (*domain.Route, int)
342	getAttachmentsConfig func(target string) ([]string, int)
343	listTags             func(repository string) ([]string, int)
344	findAttachmentTarget func(image string) ([]string, int)
345}
346
347func newMultiAdminProbeTestServer(t *testing.T, handlers multiAdminProbeHandlers) *httptest.Server {
348	t.Helper()
349
350	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
351		switch {
352		case strings.HasPrefix(r.URL.Path, "/admin/routes/"):
353			handleGetRouteProbe(t, w, r, handlers.getRoute)
354		case strings.HasPrefix(r.URL.Path, "/admin/attachments/by-image/"):
355			handleAttachmentTargetsByImageProbe(t, w, r, handlers.findAttachmentTarget)
356		case strings.HasPrefix(r.URL.Path, "/admin/attachments/"):
357			handleGetAttachmentsConfigProbe(t, w, r, handlers.getAttachmentsConfig)
358		case strings.HasPrefix(r.URL.Path, "/admin/tags/"):
359			handleListTagsProbe(t, w, r, handlers.listTags)
360		default:
361			http.NotFound(w, r)
362		}
363	}))
364	t.Cleanup(server.Close)
365	return server
366}
367
368func newAttachmentTargetsByImageTestServer(t *testing.T, handler func(image string) ([]string, int)) *httptest.Server {
369	t.Helper()
370	return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{findAttachmentTarget: handler})
371}
372
373func newTagsTestServer(t *testing.T, handler func(repository string) ([]string, int)) *httptest.Server {
374	t.Helper()
375	return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{listTags: handler})
376}
377
378func handleGetRouteProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(domainName string) (*domain.Route, int)) {
379	t.Helper()
380	if handler == nil {
381		http.NotFound(w, r)
382		return
383	}
384	domainName, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/routes/"))
385	if !assert.NoError(t, err) {
386		return
387	}
388	route, status := handler(domainName)
389	if status >= http.StatusBadRequest {
390		w.Header().Set("Content-Type", "application/json")
391		w.WriteHeader(status)
392		if status == http.StatusNotFound {
393			if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": domain.ErrRouteNotFound.Error()})) {
394				return
395			}
396			return
397		}
398		if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
399			return
400		}
401		return
402	}
403	w.Header().Set("Content-Type", "application/json")
404	if !assert.NoError(t, json.NewEncoder(w).Encode(route)) {
405		return
406	}
407}
408
409func handleAttachmentTargetsByImageProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(image string) ([]string, int)) {
410	t.Helper()
411	if handler == nil {
412		http.NotFound(w, r)
413		return
414	}
415	image, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/attachments/by-image/"))
416	if !assert.NoError(t, err) {
417		return
418	}
419	targets, status := handler(image)
420	if status >= http.StatusBadRequest {
421		w.Header().Set("Content-Type", "application/json")
422		w.WriteHeader(status)
423		if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
424			return
425		}
426		return
427	}
428	w.Header().Set("Content-Type", "application/json")
429	if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"image": image, "targets": targets})) {
430		return
431	}
432}
433
434func handleGetAttachmentsConfigProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(target string) ([]string, int)) {
435	t.Helper()
436	if handler == nil {
437		http.NotFound(w, r)
438		return
439	}
440	target, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/attachments/"))
441	if !assert.NoError(t, err) {
442		return
443	}
444	images, status := handler(target)
445	if status >= http.StatusBadRequest {
446		w.Header().Set("Content-Type", "application/json")
447		w.WriteHeader(status)
448		if status == http.StatusNotFound {
449			if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "no attachments found for target"})) {
450				return
451			}
452			return
453		}
454		if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
455			return
456		}
457		return
458	}
459	w.Header().Set("Content-Type", "application/json")
460	if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"target": target, "images": images})) {
461		return
462	}
463}
464
465func handleListTagsProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(repository string) ([]string, int)) {
466	t.Helper()
467	if handler == nil {
468		http.NotFound(w, r)
469		return
470	}
471	repository, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/tags/"))
472	if !assert.NoError(t, err) {
473		return
474	}
475	tags, status := handler(repository)
476	if status >= http.StatusBadRequest {
477		w.Header().Set("Content-Type", "application/json")
478		w.WriteHeader(status)
479		if status == http.StatusNotFound {
480			if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "repository not found"})) {
481				return
482			}
483			return
484		}
485		if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
486			return
487		}
488		return
489	}
490	w.Header().Set("Content-Type", "application/json")
491	if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"repository": repository, "tags": tags})) {
492		return
493	}
494}

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