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 TestInferRemoteForRouteCleanupDomain_UsesCleanupPreviewWhenRouteConfigMissing(t *testing.T) {
197 prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
198 getRoute: func(domainName string) (*domain.Route, int) {
199 return nil, http.StatusNotFound
200 },
201 getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
202 return &domain.CleanupReport{
203 Domain: domainName,
204 OrphanedEntities: []domain.CleanupOrphanedEntity{{
205 Kind: "route_container",
206 ID: "abc123",
207 Status: "running",
208 }},
209 }, http.StatusOK
210 },
211 })
212 staging := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
213 getRoute: func(domainName string) (*domain.Route, int) {
214 return nil, http.StatusNotFound
215 },
216 getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
217 return &domain.CleanupReport{Domain: domainName}, http.StatusOK
218 },
219 })
220
221 configurePushRemoteInferenceTestEnv(t, `
222[remotes.prod]
223url = "`+prod.URL+`"
224
225[remotes.staging]
226url = "`+staging.URL+`"
227`)
228
229 resolved, err := inferRemoteForRouteCleanupDomain(context.Background(), "app.example.com")
230 require.NoError(t, err)
231 require.NotNil(t, resolved)
232 assert.Equal(t, "prod", resolved.Name)
233}
234
235func TestInferRemoteForRouteCleanupDomain_UsesCleanupPreviewVolumesWhenRouteConfigMissing(t *testing.T) {
236 prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
237 getRoute: func(domainName string) (*domain.Route, int) {
238 return nil, http.StatusNotFound
239 },
240 getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
241 return &domain.CleanupReport{
242 Domain: domainName,
243 PreservedVolumes: []domain.CleanupVolume{{Name: "gordon-app-example-com-data"}},
244 }, http.StatusOK
245 },
246 })
247 staging := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
248 getRoute: func(domainName string) (*domain.Route, int) {
249 return nil, http.StatusNotFound
250 },
251 getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
252 return &domain.CleanupReport{Domain: domainName}, http.StatusOK
253 },
254 })
255
256 configurePushRemoteInferenceTestEnv(t, `
257[remotes.prod]
258url = "`+prod.URL+`"
259
260[remotes.staging]
261url = "`+staging.URL+`"
262`)
263
264 resolved, err := inferRemoteForRouteCleanupDomain(context.Background(), "app.example.com")
265 require.NoError(t, err)
266 require.NotNil(t, resolved)
267 assert.Equal(t, "prod", resolved.Name)
268}
269
270func TestInferRemoteForRouteCleanupDomain_FailsSafeWhenCleanupPreviewProbeFails(t *testing.T) {
271 prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
272 getRoute: func(domainName string) (*domain.Route, int) {
273 return nil, http.StatusNotFound
274 },
275 getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
276 return nil, http.StatusForbidden
277 },
278 })
279
280 configurePushRemoteInferenceTestEnv(t, `
281[remotes.prod]
282url = "`+prod.URL+`"
283`)
284
285 resolved, err := inferRemoteForRouteCleanupDomain(context.Background(), "app.example.com")
286 require.Error(t, err)
287 assert.Nil(t, resolved)
288 assert.Contains(t, err.Error(), `could not safely infer remote for route cleanup "app.example.com"`)
289}
290
291func TestInferRemoteForAttachmentImage_UsesUniqueMatchingSavedRemote(t *testing.T) {
292 prod := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) {
293 return []string{"app.example.com"}, http.StatusOK
294 })
295 staging := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) {
296 return nil, http.StatusOK
297 })
298
299 configurePushRemoteInferenceTestEnv(t, `
300[remotes.prod]
301url = "`+prod.URL+`"
302
303[remotes.staging]
304url = "`+staging.URL+`"
305`)
306
307 resolved, err := inferRemoteForAttachmentImage(context.Background(), "postgres:18")
308 require.NoError(t, err)
309 require.NotNil(t, resolved)
310 assert.Equal(t, "prod", resolved.Name)
311}
312
313func TestInferRemoteForAttachmentTarget_FallsBackToRouteLookup(t *testing.T) {
314 prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
315 getRoute: func(domainName string) (*domain.Route, int) {
316 return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
317 },
318 getAttachmentsConfig: func(target string) ([]string, int) {
319 return nil, http.StatusNotFound
320 },
321 })
322 staging := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
323 getRoute: func(domainName string) (*domain.Route, int) {
324 return nil, http.StatusNotFound
325 },
326 getAttachmentsConfig: func(target string) ([]string, int) {
327 return nil, http.StatusNotFound
328 },
329 })
330
331 configurePushRemoteInferenceTestEnv(t, `
332[remotes.prod]
333url = "`+prod.URL+`"
334
335[remotes.staging]
336url = "`+staging.URL+`"
337`)
338
339 resolved, err := inferRemoteForAttachmentTarget(context.Background(), "app.example.com")
340 require.NoError(t, err)
341 require.NotNil(t, resolved)
342 assert.Equal(t, "prod", resolved.Name)
343}
344
345func TestInferRemoteForRepository_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
346 prod := newTagsTestServer(t, func(repository string) ([]string, int) {
347 return []string{"latest", "v1.0.0"}, http.StatusOK
348 })
349 staging := newTagsTestServer(t, func(repository string) ([]string, int) {
350 return []string{"latest"}, http.StatusOK
351 })
352
353 configurePushRemoteInferenceTestEnv(t, `
354[remotes.prod]
355url = "`+prod.URL+`"
356
357[remotes.staging]
358url = "`+staging.URL+`"
359`)
360
361 resolved, err := inferRemoteForRepository(context.Background(), "myapp")
362 require.Error(t, err)
363 assert.Nil(t, resolved)
364 assert.Contains(t, err.Error(), `multiple saved remotes match repository "myapp"`)
365}
366
367func configurePushRemoteInferenceTestEnv(t *testing.T, remotesTOML string) {
368 t.Helper()
369
370 originalRemoteFlag := remoteFlag
371 originalTokenFlag := tokenFlag
372 originalInsecureTLSFlag := insecureTLSFlag
373 t.Cleanup(func() {
374 remoteFlag = originalRemoteFlag
375 tokenFlag = originalTokenFlag
376 insecureTLSFlag = originalInsecureTLSFlag
377 })
378
379 remoteFlag = ""
380 tokenFlag = ""
381 insecureTLSFlag = false
382 t.Setenv("GORDON_REMOTE", "")
383 t.Setenv("GORDON_TOKEN", "")
384 t.Setenv("GORDON_INSECURE", "")
385 t.Setenv("HOME", t.TempDir())
386
387 configHome := t.TempDir()
388 t.Setenv("XDG_CONFIG_HOME", configHome)
389 configPath := filepath.Join(configHome, "gordon", "remotes.toml")
390 require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
391 require.NoError(t, os.WriteFile(configPath, []byte(strings.TrimSpace(remotesTOML)), 0o600))
392}
393
394func newFindRoutesByImageTestServer(t *testing.T, handler func(image string) ([]domain.Route, int)) *httptest.Server {
395 t.Helper()
396
397 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
398 if !strings.HasPrefix(r.URL.Path, "/admin/routes/by-image/") {
399 http.NotFound(w, r)
400 return
401 }
402
403 image, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/routes/by-image/"))
404 if !assert.NoError(t, err) {
405 return
406 }
407
408 routes, status := handler(image)
409 if status >= http.StatusBadRequest {
410 w.Header().Set("Content-Type", "application/json")
411 w.WriteHeader(status)
412 if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
413 return
414 }
415 return
416 }
417
418 w.Header().Set("Content-Type", "application/json")
419 if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{
420 "image": image,
421 "routes": routes,
422 })) {
423 return
424 }
425 }))
426 t.Cleanup(server.Close)
427 return server
428}
429
430func newGetRouteTestServer(t *testing.T, handler func(domainName string) (*domain.Route, int)) *httptest.Server {
431 t.Helper()
432 return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{getRoute: handler})
433}
434
435type multiAdminProbeHandlers struct {
436 getRoute func(domainName string) (*domain.Route, int)
437 getRouteCleanup func(domainName string) (*domain.CleanupReport, int)
438 getAttachmentsConfig func(target string) ([]string, int)
439 listTags func(repository string) ([]string, int)
440 findAttachmentTarget func(image string) ([]string, int)
441}
442
443func newMultiAdminProbeTestServer(t *testing.T, handlers multiAdminProbeHandlers) *httptest.Server {
444 t.Helper()
445
446 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
447 switch {
448 case strings.HasSuffix(r.URL.Path, "/cleanup") && strings.HasPrefix(r.URL.Path, "/admin/routes/"):
449 handleGetRouteCleanupProbe(t, w, r, handlers.getRouteCleanup)
450 case strings.HasPrefix(r.URL.Path, "/admin/routes/"):
451 handleGetRouteProbe(t, w, r, handlers.getRoute)
452 case strings.HasPrefix(r.URL.Path, "/admin/attachments/by-image/"):
453 handleAttachmentTargetsByImageProbe(t, w, r, handlers.findAttachmentTarget)
454 case strings.HasPrefix(r.URL.Path, "/admin/attachments/"):
455 handleGetAttachmentsConfigProbe(t, w, r, handlers.getAttachmentsConfig)
456 case strings.HasPrefix(r.URL.Path, "/admin/tags/"):
457 handleListTagsProbe(t, w, r, handlers.listTags)
458 default:
459 http.NotFound(w, r)
460 }
461 }))
462 t.Cleanup(server.Close)
463 return server
464}
465
466func newAttachmentTargetsByImageTestServer(t *testing.T, handler func(image string) ([]string, int)) *httptest.Server {
467 t.Helper()
468 return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{findAttachmentTarget: handler})
469}
470
471func newTagsTestServer(t *testing.T, handler func(repository string) ([]string, int)) *httptest.Server {
472 t.Helper()
473 return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{listTags: handler})
474}
475
476func handleGetRouteProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(domainName string) (*domain.Route, int)) {
477 t.Helper()
478 if handler == nil {
479 http.NotFound(w, r)
480 return
481 }
482 domainName, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/routes/"))
483 if !assert.NoError(t, err) {
484 return
485 }
486 route, status := handler(domainName)
487 if status >= http.StatusBadRequest {
488 w.Header().Set("Content-Type", "application/json")
489 w.WriteHeader(status)
490 if status == http.StatusNotFound {
491 if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": domain.ErrRouteNotFound.Error()})) {
492 return
493 }
494 return
495 }
496 if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
497 return
498 }
499 return
500 }
501 w.Header().Set("Content-Type", "application/json")
502 if !assert.NoError(t, json.NewEncoder(w).Encode(route)) {
503 return
504 }
505}
506
507func handleGetRouteCleanupProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(domainName string) (*domain.CleanupReport, int)) {
508 t.Helper()
509 if handler == nil {
510 http.NotFound(w, r)
511 return
512 }
513 domainName, err := url.PathUnescape(strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/admin/routes/"), "/cleanup"))
514 if !assert.NoError(t, err) {
515 return
516 }
517 report, status := handler(domainName)
518 if status >= http.StatusBadRequest {
519 w.Header().Set("Content-Type", "application/json")
520 w.WriteHeader(status)
521 if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
522 return
523 }
524 return
525 }
526 w.Header().Set("Content-Type", "application/json")
527 if !assert.NoError(t, json.NewEncoder(w).Encode(report)) {
528 return
529 }
530}
531
532func handleAttachmentTargetsByImageProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(image string) ([]string, int)) {
533 t.Helper()
534 if handler == nil {
535 http.NotFound(w, r)
536 return
537 }
538 image, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/attachments/by-image/"))
539 if !assert.NoError(t, err) {
540 return
541 }
542 targets, status := handler(image)
543 if status >= http.StatusBadRequest {
544 w.Header().Set("Content-Type", "application/json")
545 w.WriteHeader(status)
546 if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
547 return
548 }
549 return
550 }
551 w.Header().Set("Content-Type", "application/json")
552 if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"image": image, "targets": targets})) {
553 return
554 }
555}
556
557func handleGetAttachmentsConfigProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(target string) ([]string, int)) {
558 t.Helper()
559 if handler == nil {
560 http.NotFound(w, r)
561 return
562 }
563 target, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/attachments/"))
564 if !assert.NoError(t, err) {
565 return
566 }
567 images, status := handler(target)
568 if status >= http.StatusBadRequest {
569 w.Header().Set("Content-Type", "application/json")
570 w.WriteHeader(status)
571 if status == http.StatusNotFound {
572 if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "no attachments found for target"})) {
573 return
574 }
575 return
576 }
577 if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
578 return
579 }
580 return
581 }
582 w.Header().Set("Content-Type", "application/json")
583 if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"target": target, "images": images})) {
584 return
585 }
586}
587
588func handleListTagsProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(repository string) ([]string, int)) {
589 t.Helper()
590 if handler == nil {
591 http.NotFound(w, r)
592 return
593 }
594 repository, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/tags/"))
595 if !assert.NoError(t, err) {
596 return
597 }
598 tags, status := handler(repository)
599 if status >= http.StatusBadRequest {
600 w.Header().Set("Content-Type", "application/json")
601 w.WriteHeader(status)
602 if status == http.StatusNotFound {
603 if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "repository not found"})) {
604 return
605 }
606 return
607 }
608 if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
609 return
610 }
611 return
612 }
613 w.Header().Set("Content-Type", "application/json")
614 if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"repository": repository, "tags": tags})) {
615 return
616 }
617}