1package cli
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "io"
9 "net/http"
10 "net/http/httptest"
11 "os"
12 "path/filepath"
13 "strconv"
14 "strings"
15 "sync/atomic"
16 "testing"
17
18 "github.com/bnema/gordon/internal/adapters/dto"
19 climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
20 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
21 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
22 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
23 "github.com/bnema/gordon/internal/domain"
24 "github.com/stretchr/testify/assert"
25 "github.com/stretchr/testify/require"
26)
27
28func TestTruncateImage(t *testing.T) {
29 tests := []struct {
30 name string
31 image string
32 maxLen int
33 expected string
34 }{
35 // Basic cases - no truncation needed
36 {
37 name: "short image fits",
38 image: "nginx:latest",
39 maxLen: 20,
40 expected: "nginx:latest",
41 },
42 {
43 name: "exact fit",
44 image: "nginx:latest",
45 maxLen: 12,
46 expected: "nginx:latest",
47 },
48
49 // Regular tag truncation
50 {
51 name: "truncate long tag with ellipsis",
52 image: "registry.test.com/test:v1234567890",
53 maxLen: 30,
54 expected: "registry.test.com/test:v123...",
55 },
56 {
57 name: "truncate very long image",
58 image: "registry.example.com/organization/project/image:v1.2.3-beta.4",
59 maxLen: 35,
60 expected: "registry.example.com/organizatio...",
61 },
62
63 // Digest truncation
64 {
65 name: "digest shortened to 12 chars",
66 image: "myapp@sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4",
67 maxLen: 50,
68 expected: "myapp@sha256:a3ed95caeb02",
69 },
70 {
71 name: "digest truncated with ellipsis when too long",
72 image: "registry.example.com/org/app@sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4",
73 maxLen: 35,
74 expected: "registry.example.com/org/app@sha...",
75 },
76 {
77 name: "short digest fits",
78 image: "app@sha256:abc123",
79 maxLen: 30,
80 expected: "app@sha256:abc123",
81 },
82
83 // Edge cases
84 {
85 name: "maxLen zero returns empty",
86 image: "nginx:latest",
87 maxLen: 0,
88 expected: "",
89 },
90 {
91 name: "maxLen negative returns empty",
92 image: "nginx:latest",
93 maxLen: -1,
94 expected: "",
95 },
96 {
97 name: "maxLen 3 or less - no ellipsis",
98 image: "nginx:latest",
99 maxLen: 3,
100 expected: "ngi",
101 },
102 {
103 name: "maxLen 4 - truncate with ellipsis",
104 image: "nginx:latest",
105 maxLen: 4,
106 expected: "n...",
107 },
108 {
109 name: "empty image",
110 image: "",
111 maxLen: 10,
112 expected: "",
113 },
114 }
115
116 for _, tt := range tests {
117 t.Run(tt.name, func(t *testing.T) {
118 result := truncateImage(tt.image, tt.maxLen)
119 assert.Equal(t, tt.expected, result)
120 })
121 }
122}
123
124func TestHTTPHealthToStatus(t *testing.T) {
125 tests := []struct {
126 name string
127 health *remote.RouteHealth
128 expected components.Status
129 }{
130 {
131 name: "nil health returns unknown",
132 health: nil,
133 expected: components.StatusUnknown,
134 },
135 {
136 name: "zero status no error returns unknown",
137 health: &remote.RouteHealth{HTTPStatus: 0},
138 expected: components.StatusUnknown,
139 },
140 {
141 name: "zero status with error returns error",
142 health: &remote.RouteHealth{HTTPStatus: 0, Error: "connection refused"},
143 expected: components.StatusError,
144 },
145 {
146 name: "200 returns success",
147 health: &remote.RouteHealth{HTTPStatus: 200},
148 expected: components.StatusSuccess,
149 },
150 {
151 name: "301 returns success",
152 health: &remote.RouteHealth{HTTPStatus: 301},
153 expected: components.StatusSuccess,
154 },
155 {
156 name: "500 returns error",
157 health: &remote.RouteHealth{HTTPStatus: 500},
158 expected: components.StatusError,
159 },
160 {
161 name: "404 returns error",
162 health: &remote.RouteHealth{HTTPStatus: 404},
163 expected: components.StatusError,
164 },
165 {
166 name: "100 informational returns error",
167 health: &remote.RouteHealth{HTTPStatus: 100},
168 expected: components.StatusError,
169 },
170 }
171
172 for _, tt := range tests {
173 t.Run(tt.name, func(t *testing.T) {
174 result := httpHealthToStatus(tt.health)
175 assert.Equal(t, tt.expected, result)
176 })
177 }
178}
179
180func TestGroupRoutesByNetwork(t *testing.T) {
181 routes := []remote.RouteInfo{
182 {Domain: "alpha.dev", Network: "gordon-alpha-dev"},
183 {Domain: "beta.dev", Network: "gordon-shared"},
184 {Domain: "gamma.dev", Network: "gordon-shared"},
185 {Domain: "delta.dev", Network: "gordon-delta-dev"},
186 }
187
188 groups, solo := groupRoutesByNetwork(routes)
189
190 assert.Len(t, solo, 2)
191 assert.Equal(t, "alpha.dev", solo[0].Domain)
192 assert.Equal(t, "delta.dev", solo[1].Domain)
193
194 assert.Len(t, groups, 1)
195 assert.Equal(t, "shared", groups[0].name)
196 assert.Len(t, groups[0].routes, 2)
197}
198
199func TestStripNetworkPrefix(t *testing.T) {
200 tests := []struct {
201 network string
202 expected string
203 }{
204 {"gordon-shared-services", "shared-services"},
205 {"gordon-my-app-dev", "my-app-dev"},
206 {"custom-network", "custom-network"},
207 {"gordon-", ""},
208 {"", ""},
209 }
210
211 for _, tt := range tests {
212 t.Run(tt.network, func(t *testing.T) {
213 assert.Equal(t, tt.expected, stripNetworkPrefix(tt.network))
214 })
215 }
216}
217
218func TestResolveRoutesExplicitRemote_AllowsAdHocURLWhenSavedRemotesConfigUnreadable(t *testing.T) {
219 originalRemoteFlag := remoteFlag
220 originalTokenFlag := tokenFlag
221 originalInsecureTLSFlag := insecureTLSFlag
222 t.Cleanup(func() {
223 remoteFlag = originalRemoteFlag
224 tokenFlag = originalTokenFlag
225 insecureTLSFlag = originalInsecureTLSFlag
226 })
227
228 remoteFlag = ""
229 tokenFlag = ""
230 insecureTLSFlag = false
231
232 configHome := t.TempDir()
233 t.Setenv("XDG_CONFIG_HOME", configHome)
234 t.Setenv("HOME", t.TempDir())
235 t.Setenv("GORDON_REMOTE", "https://ad-hoc.example.com")
236
237 configPath := filepath.Join(configHome, "gordon", "remotes.toml")
238 require.NoError(t, os.MkdirAll(configPath, 0o755))
239
240 resolved, ok, err := resolveRoutesExplicitRemote()
241 require.True(t, ok)
242 require.NoError(t, err)
243 require.NotNil(t, resolved)
244 assert.Equal(t, "https://ad-hoc.example.com", resolved.URL)
245 assert.Empty(t, resolved.Name)
246}
247
248func TestResolveRoutesExplicitRemote_ReturnsErrorForUnreadableNamedRemoteConfig(t *testing.T) {
249 originalRemoteFlag := remoteFlag
250 originalTokenFlag := tokenFlag
251 originalInsecureTLSFlag := insecureTLSFlag
252 t.Cleanup(func() {
253 remoteFlag = originalRemoteFlag
254 tokenFlag = originalTokenFlag
255 insecureTLSFlag = originalInsecureTLSFlag
256 })
257
258 remoteFlag = "missing-remote"
259 tokenFlag = ""
260 insecureTLSFlag = false
261
262 configHome := t.TempDir()
263 t.Setenv("XDG_CONFIG_HOME", configHome)
264 t.Setenv("HOME", t.TempDir())
265 t.Setenv("GORDON_REMOTE", "")
266
267 configPath := filepath.Join(configHome, "gordon", "remotes.toml")
268 require.NoError(t, os.MkdirAll(configPath, 0o755))
269
270 resolved, ok, err := resolveRoutesExplicitRemote()
271 require.Error(t, err)
272 require.False(t, ok)
273 require.Nil(t, resolved)
274 assert.Contains(t, err.Error(), "failed to read remotes")
275}
276
277func TestRouteStatusTitle_PreservesProbeFailureError(t *testing.T) {
278 item := routeStatusItem{
279 Domain: "app.example.com",
280 Image: "app:latest",
281 ContainerStatus: "running",
282 HTTPStatus: 0,
283 HealthError: "connection refused",
284 }
285
286 expected := components.StatusIcon(styles.IconHTTPStatus, components.StatusError) + " " +
287 components.StatusIcon(styles.IconContainerStatus, components.ParseStatus("running")) +
288 " app.example.com"
289
290 assert.Equal(t, expected, routeStatusTitle(item))
291}
292
293func TestRunRoutesShow_JSONIncludesHealthError(t *testing.T) {
294 cpMock := climocks.NewMockControlPlane(t)
295 cpMock.EXPECT().GetRoute(context.Background(), "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil).Once()
296 cpMock.EXPECT().GetHealth(context.Background()).Return(nil, errors.New("probe failed")).Once()
297
298 var out bytes.Buffer
299 err := runRoutesShow(context.Background(), cpMock, &out, "app.example.com", true)
300
301 require.NoError(t, err)
302
303 var got map[string]any
304 require.NoError(t, json.Unmarshal(out.Bytes(), &got))
305 assert.Equal(t, "app.example.com", got["domain"])
306 assert.Equal(t, "app:latest", got["image"])
307 assert.Equal(t, "unknown", got["container_status"])
308 assert.Equal(t, float64(0), got["http_status"])
309 assert.Equal(t, "probe failed", got["health_error"])
310}
311
312func TestRunRoutesShow_TextIncludesHealthWarning(t *testing.T) {
313 cpMock := climocks.NewMockControlPlane(t)
314 cpMock.EXPECT().GetRoute(context.Background(), "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil).Once()
315 cpMock.EXPECT().GetHealth(context.Background()).Return(nil, errors.New("probe failed")).Once()
316
317 var out bytes.Buffer
318 err := runRoutesShow(context.Background(), cpMock, &out, "app.example.com", false)
319
320 require.NoError(t, err)
321
322 text := stripANSI(out.String())
323 assert.Contains(t, text, "Route: app.example.com")
324 assert.Contains(t, text, "Domain:")
325 assert.Contains(t, text, "Image:")
326 assert.Contains(t, text, "Container:")
327 assert.Contains(t, text, "probe failed")
328}
329
330func TestRunRoutesShow_JSONIncludesRouteProbeFailureAndUnknownContainerStatus(t *testing.T) {
331 cpMock := climocks.NewMockControlPlane(t)
332 cpMock.EXPECT().GetRoute(context.Background(), "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil).Once()
333 cpMock.EXPECT().GetHealth(context.Background()).Return(map[string]*remote.RouteHealth{
334 "app.example.com": {
335 HTTPStatus: 503,
336 ContainerStatus: "",
337 Error: "probe failed",
338 },
339 }, nil).Once()
340
341 var out bytes.Buffer
342 err := runRoutesShow(context.Background(), cpMock, &out, "app.example.com", true)
343
344 require.NoError(t, err)
345
346 var got map[string]any
347 require.NoError(t, json.Unmarshal(out.Bytes(), &got))
348 assert.Equal(t, "app.example.com", got["domain"])
349 assert.Equal(t, "app:latest", got["image"])
350 assert.Equal(t, "unknown", got["container_status"])
351 assert.Equal(t, float64(503), got["http_status"])
352 assert.Equal(t, "probe failed", got["health_error"])
353}
354
355func TestRunRoutesShow_TextIncludesRouteProbeFailureAndUnknownContainerStatus(t *testing.T) {
356 cpMock := climocks.NewMockControlPlane(t)
357 cpMock.EXPECT().GetRoute(context.Background(), "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil).Once()
358 cpMock.EXPECT().GetHealth(context.Background()).Return(map[string]*remote.RouteHealth{
359 "app.example.com": {
360 HTTPStatus: 503,
361 ContainerStatus: "",
362 Error: "probe failed",
363 },
364 }, nil).Once()
365
366 var out bytes.Buffer
367 err := runRoutesShow(context.Background(), cpMock, &out, "app.example.com", false)
368
369 require.NoError(t, err)
370
371 text := stripANSI(out.String())
372 assert.Contains(t, text, "Route: app.example.com")
373 assert.Contains(t, text, "Container:")
374 assert.Contains(t, text, "unknown")
375 assert.Contains(t, text, "probe failed")
376}
377
378func TestCollectRoutesListSections_DefaultModeIncludesLocalThenSortedRemotes(t *testing.T) {
379 testsDeps := routesListDeps{
380 explicitRemote: func() (*remote.ResolvedRemote, bool, error) {
381 return nil, false, nil
382 },
383 loadLocal: func(context.Context, string) (routeListSection, error) {
384 return routeListSection{
385 Kind: "local",
386 Name: "local",
387 Routes: []routeListItem{{
388 Domain: "app.local",
389 Image: "myapp:latest",
390 }},
391 }, nil
392 },
393 listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
394 return map[string]remote.RemoteEntry{
395 "remote-a": {URL: "https://remote-a.example.com"},
396 "remote-b": {URL: "https://remote-b.example.com"},
397 }, "remote-a", nil
398 },
399 loadRemote: func(_ context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) {
400 if name == "remote-b" {
401 return routeListSection{
402 Kind: "remote",
403 Name: name,
404 URL: entry.URL,
405 Error: "remote unavailable",
406 }, nil
407 }
408 return routeListSection{
409 Kind: "remote",
410 Name: name,
411 URL: entry.URL,
412 Routes: []routeListItem{{
413 Domain: "dashboard.example.com",
414 Image: "dashboard",
415 }},
416 }, nil
417 },
418 }
419
420 sections, err := collectRoutesListSections(context.Background(), "", testsDeps)
421
422 require.NoError(t, err)
423 require.Len(t, sections, 3)
424 assert.Equal(t, "local", sections[0].Name)
425 assert.Equal(t, "remote-a", sections[1].Name)
426 assert.Equal(t, "remote-b", sections[2].Name)
427 assert.Equal(t, "dashboard.example.com", sections[1].Routes[0].Domain)
428 assert.Equal(t, "remote unavailable", sections[2].Error)
429}
430
431func TestCollectRoutesListSections_ExplicitRemoteSkipsAggregate(t *testing.T) {
432 testsDeps := routesListDeps{
433 explicitRemote: func() (*remote.ResolvedRemote, bool, error) {
434 return &remote.ResolvedRemote{
435 Name: "remote-a",
436 URL: "https://remote-a.example.com",
437 }, true, nil
438 },
439 loadLocal: func(context.Context, string) (routeListSection, error) {
440 t.Fatal("loadLocal should not be called when an explicit remote is selected")
441 return routeListSection{}, nil
442 },
443 listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
444 t.Fatal("listRemotes should not be called when an explicit remote is selected")
445 return nil, "", nil
446 },
447 loadRemote: func(_ context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) {
448 return routeListSection{
449 Kind: "remote",
450 Name: name,
451 URL: entry.URL,
452 Routes: []routeListItem{{
453 Domain: "test.example.com",
454 Image: "hello-app",
455 }},
456 }, nil
457 },
458 }
459
460 sections, err := collectRoutesListSections(context.Background(), "", testsDeps)
461
462 require.NoError(t, err)
463 require.Len(t, sections, 1)
464 assert.Equal(t, "remote-a", sections[0].Name)
465 assert.Equal(t, "test.example.com", sections[0].Routes[0].Domain)
466}
467
468func TestCollectRoutesSections_ExplicitRemoteResolutionErrorReturnsError(t *testing.T) {
469
470 t.Run("list", func(t *testing.T) {
471 var loadLocalCalls atomic.Int32
472 var listRemotesCalls atomic.Int32
473 var loadRemoteCalls atomic.Int32
474
475 deps := routesListDeps{
476 explicitRemote: func() (*remote.ResolvedRemote, bool, error) {
477 return nil, false, errors.New("failed to read remotes: boom")
478 },
479 loadLocal: func(context.Context, string) (routeListSection, error) {
480 loadLocalCalls.Add(1)
481 return routeListSection{}, nil
482 },
483 listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
484 listRemotesCalls.Add(1)
485 return map[string]remote.RemoteEntry{}, "", nil
486 },
487 loadRemote: func(context.Context, string, remote.RemoteEntry) (routeListSection, error) {
488 loadRemoteCalls.Add(1)
489 return routeListSection{}, nil
490 },
491 }
492
493 sections, err := collectRoutesListSections(context.Background(), "", deps)
494
495 require.Error(t, err)
496 require.Nil(t, sections)
497 assert.Contains(t, err.Error(), "failed to read remotes")
498 assert.Zero(t, loadLocalCalls.Load())
499 assert.Zero(t, listRemotesCalls.Load())
500 assert.Zero(t, loadRemoteCalls.Load())
501 })
502
503 t.Run("status", func(t *testing.T) {
504 var loadLocalCalls atomic.Int32
505 var listRemotesCalls atomic.Int32
506 var loadRemoteCalls atomic.Int32
507
508 deps := routesStatusDeps{
509 explicitRemote: func() (*remote.ResolvedRemote, bool, error) {
510 return nil, false, errors.New("failed to read remotes: boom")
511 },
512 loadLocal: func(context.Context, string) (routeStatusSection, error) {
513 loadLocalCalls.Add(1)
514 return routeStatusSection{}, nil
515 },
516 listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
517 listRemotesCalls.Add(1)
518 return map[string]remote.RemoteEntry{}, "", nil
519 },
520 loadRemote: func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error) {
521 loadRemoteCalls.Add(1)
522 return routeStatusSection{}, nil
523 },
524 }
525
526 sections, err := collectRoutesStatusSections(context.Background(), "", deps)
527
528 require.Error(t, err)
529 require.Nil(t, sections)
530 assert.Contains(t, err.Error(), "failed to read remotes")
531 assert.Zero(t, loadLocalCalls.Load())
532 assert.Zero(t, listRemotesCalls.Load())
533 assert.Zero(t, loadRemoteCalls.Load())
534 })
535}
536
537func TestLoadRoutesListExplicitRemoteSection_PropagatesLoaderError(t *testing.T) {
538 section := loadRoutesListExplicitRemoteSection(context.Background(), routesListDeps{
539 loadRemote: func(context.Context, string, remote.RemoteEntry) (routeListSection, error) {
540 return routeListSection{}, errors.New("boom")
541 },
542 }, &remote.ResolvedRemote{Name: "remote-a", URL: "https://remote-a.example.com"})
543
544 assert.Equal(t, "remote", section.Kind)
545 assert.Equal(t, "remote-a", section.Name)
546 assert.Equal(t, "https://remote-a.example.com", section.URL)
547 assert.Equal(t, "boom", section.Error)
548}
549
550func TestLoadRoutesStatusExplicitRemoteSection_PropagatesLoaderError(t *testing.T) {
551 section := loadRoutesStatusExplicitRemoteSection(context.Background(), routesStatusDeps{
552 loadRemote: func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error) {
553 return routeStatusSection{}, errors.New("boom")
554 },
555 }, &remote.ResolvedRemote{Name: "remote-a", URL: "https://remote-a.example.com"})
556
557 assert.Equal(t, "remote", section.Kind)
558 assert.Equal(t, "remote-a", section.Name)
559 assert.Equal(t, "https://remote-a.example.com", section.URL)
560 assert.Equal(t, "boom", section.Error)
561}
562
563func TestCollectRoutesListAggregateSections_EncodesLoaderErrors(t *testing.T) {
564 t.Run("local loader error", func(t *testing.T) {
565 sections, err := collectRoutesListAggregateSections(context.Background(), "config.toml", routesListDeps{
566 loadLocal: func(context.Context, string) (routeListSection, error) {
567 return routeListSection{Kind: "local", Name: "local"}, errors.New("local failed")
568 },
569 listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
570 return map[string]remote.RemoteEntry{}, "", nil
571 },
572 loadRemote: func(context.Context, string, remote.RemoteEntry) (routeListSection, error) {
573 return routeListSection{Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"}, nil
574 },
575 })
576
577 require.NoError(t, err)
578 require.Len(t, sections, 1)
579 assert.Equal(t, "local", sections[0].Kind)
580 assert.Equal(t, "local failed", sections[0].Error)
581 })
582
583 t.Run("remote loader error", func(t *testing.T) {
584 sections, err := collectRoutesListAggregateSections(context.Background(), "config.toml", routesListDeps{
585 loadLocal: func(context.Context, string) (routeListSection, error) {
586 return routeListSection{Kind: "local", Name: "local"}, nil
587 },
588 listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
589 return map[string]remote.RemoteEntry{"remote-a": {URL: "https://remote-a.example.com"}}, "", nil
590 },
591 loadRemote: func(context.Context, string, remote.RemoteEntry) (routeListSection, error) {
592 return routeListSection{Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"}, errors.New("remote failed")
593 },
594 })
595
596 require.NoError(t, err)
597 require.Len(t, sections, 2)
598 assert.Equal(t, "local", sections[0].Kind)
599 assert.Equal(t, "remote", sections[1].Kind)
600 assert.Equal(t, "remote failed", sections[1].Error)
601 })
602}
603
604func TestCollectRoutesStatusAggregateSections_EncodesLoaderErrors(t *testing.T) {
605 t.Run("local loader error", func(t *testing.T) {
606 sections, err := collectRoutesStatusAggregateSections(context.Background(), "config.toml", routesStatusDeps{
607 loadLocal: func(context.Context, string) (routeStatusSection, error) {
608 return routeStatusSection{Kind: "local", Name: "local"}, errors.New("local failed")
609 },
610 listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
611 return map[string]remote.RemoteEntry{}, "", nil
612 },
613 loadRemote: func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error) {
614 return routeStatusSection{Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"}, nil
615 },
616 })
617
618 require.NoError(t, err)
619 require.Len(t, sections, 1)
620 assert.Equal(t, "local", sections[0].Kind)
621 assert.Equal(t, "local failed", sections[0].Error)
622 })
623
624 t.Run("remote loader error", func(t *testing.T) {
625 sections, err := collectRoutesStatusAggregateSections(context.Background(), "config.toml", routesStatusDeps{
626 loadLocal: func(context.Context, string) (routeStatusSection, error) {
627 return routeStatusSection{Kind: "local", Name: "local"}, nil
628 },
629 listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
630 return map[string]remote.RemoteEntry{"remote-a": {URL: "https://remote-a.example.com"}}, "", nil
631 },
632 loadRemote: func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error) {
633 return routeStatusSection{Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"}, errors.New("remote failed")
634 },
635 })
636
637 require.NoError(t, err)
638 require.Len(t, sections, 2)
639 assert.Equal(t, "local", sections[0].Kind)
640 assert.Equal(t, "remote", sections[1].Kind)
641 assert.Equal(t, "remote failed", sections[1].Error)
642 })
643}
644
645func TestLoadRoutesStatusLocalSection_DoesNotLeakKernelInitLogs(t *testing.T) {
646 tmpDir := t.TempDir()
647 cfgPath := filepath.Join(tmpDir, "gordon.toml")
648 require.NoError(t, os.WriteFile(cfgPath, []byte(`[server]
649gordon_domain = "gordon.local"
650data_dir = `+strconv.Quote(filepath.Join(tmpDir, "data"))+`
651
652[auth]
653enabled = true
654`), 0o600))
655
656 stdout, stderr := captureOutput(t, func() {
657 section, err := loadRoutesStatusLocalSection(context.Background(), cfgPath)
658 require.NoError(t, err)
659 assert.Equal(t, "local", section.Kind)
660 assert.Equal(t, "local", section.Name)
661 assert.Contains(t, section.Error, "failed to initialize local control plane")
662 assert.Contains(t, section.Error, "auth.secrets_backend is required")
663 })
664
665 combined := stdout + stderr
666 assert.NotContains(t, combined, "failed to resolve secrets backend")
667 assert.NotContains(t, combined, "local kernel running in minimal mode")
668}
669
670func captureOutput(t *testing.T, fn func()) (string, string) {
671 t.Helper()
672
673 oldStdout, oldStderr := os.Stdout, os.Stderr
674 outR, outW, err := os.Pipe()
675 require.NoError(t, err)
676 errR, errW, err := os.Pipe()
677 require.NoError(t, err)
678
679 os.Stdout = outW
680 os.Stderr = errW
681
682 stdoutCh := make(chan string, 1)
683 stderrCh := make(chan string, 1)
684 go func() {
685 var buf bytes.Buffer
686 _, _ = io.Copy(&buf, outR)
687 stdoutCh <- buf.String()
688 }()
689 go func() {
690 var buf bytes.Buffer
691 _, _ = io.Copy(&buf, errR)
692 stderrCh <- buf.String()
693 }()
694
695 defer func() {
696 os.Stdout = oldStdout
697 os.Stderr = oldStderr
698 }()
699
700 fn()
701
702 require.NoError(t, outW.Close())
703 require.NoError(t, errW.Close())
704
705 return <-stdoutCh, <-stderrCh
706}
707
708func TestRenderRoutesStatusSections_IncludesSectionHeadingsAndErrors(t *testing.T) {
709 sections := []routeStatusSection{
710 {
711 Kind: "local",
712 Name: "local",
713 Routes: []routeStatusItem{{
714 Domain: "app.local",
715 Image: "myapp:latest",
716 ContainerStatus: "running",
717 }},
718 },
719 {
720 Kind: "remote",
721 Name: "remote-a",
722 URL: "https://remote-a.example.com",
723 Error: "dial tcp timeout",
724 },
725 }
726
727 var out bytes.Buffer
728 err := renderRoutesStatusSections(&out, sections)
729
730 require.NoError(t, err)
731 rendered := stripANSI(out.String())
732 lines := strings.Split(rendered, "\n")
733
734 lineIndex := func(substr string) int {
735 for i, line := range lines {
736 if strings.Contains(line, substr) {
737 return i
738 }
739 }
740 return -1
741 }
742
743 titleIdx := lineIndex("Route Status")
744 require.NotEqual(t, -1, titleIdx)
745
746 localIdx := lineIndex("Local")
747 require.NotEqual(t, -1, localIdx)
748
749 routeIdx := lineIndex("app.local")
750 require.NotEqual(t, -1, routeIdx)
751
752 remoteIdx := lineIndex("Remote: remote-a")
753 require.NotEqual(t, -1, remoteIdx)
754
755 errorIdx := lineIndex("dial tcp timeout")
756 require.NotEqual(t, -1, errorIdx)
757
758 assert.Less(t, titleIdx, localIdx)
759 assert.Less(t, localIdx, routeIdx)
760 assert.Less(t, routeIdx, remoteIdx)
761 assert.Less(t, remoteIdx, errorIdx)
762}
763
764func TestRenderRoutesStatusSections_DoesNotAddExtraBlankLineBetweenSections(t *testing.T) {
765 sections := []routeStatusSection{
766 {
767 Kind: "remote",
768 Name: "remote-b",
769 Routes: []routeStatusItem{{
770 Domain: "app.example.com",
771 Image: "app:latest",
772 ContainerStatus: "running",
773 }},
774 },
775 {
776 Kind: "remote",
777 Name: "remote-a",
778 Routes: []routeStatusItem{{
779 Domain: "dashboard.example.com",
780 Image: "dashboard",
781 ContainerStatus: "running",
782 }},
783 },
784 }
785
786 var out bytes.Buffer
787 err := renderRoutesStatusSections(&out, sections)
788
789 require.NoError(t, err)
790 rendered := stripANSI(out.String())
791 assert.NotContains(t, rendered, "app:latest\n\n\nRemote: remote-a")
792 assert.Contains(t, rendered, "app:latest\n\nRemote: remote-a")
793}
794
795func TestRenderRoutesListSections_HidesLocalErrorWhenRemoteIsUsable(t *testing.T) {
796 sections := []routeListSection{
797 {Kind: "local", Name: "local", Error: "failed to initialize local control plane"},
798 {Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"},
799 }
800
801 var out bytes.Buffer
802 err := renderRoutesListSections(&out, sections)
803
804 require.NoError(t, err)
805 rendered := stripANSI(out.String())
806 assert.NotContains(t, rendered, "Local")
807 assert.NotContains(t, rendered, "failed to initialize local control plane")
808 assert.Contains(t, rendered, "Remote: remote-a")
809 assert.Contains(t, rendered, "No routes configured")
810}
811
812func TestRenderRoutesStatusSections_HidesLocalErrorWhenRemoteIsUsable(t *testing.T) {
813 sections := []routeStatusSection{
814 {Kind: "local", Name: "local", Error: "failed to initialize local control plane"},
815 {Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"},
816 }
817
818 var out bytes.Buffer
819 err := renderRoutesStatusSections(&out, sections)
820
821 require.NoError(t, err)
822 rendered := stripANSI(out.String())
823 assert.NotContains(t, rendered, "Local")
824 assert.NotContains(t, rendered, "failed to initialize local control plane")
825 assert.Contains(t, rendered, "Remote: remote-a")
826 assert.Contains(t, rendered, "No routes configured")
827}
828
829func TestRenderRoutesStatusSections_KeepsLocalErrorWhenAllRemotesFail(t *testing.T) {
830 sections := []routeStatusSection{
831 {Kind: "local", Name: "local", Error: "failed to initialize local control plane"},
832 {Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com", Error: "dial tcp timeout"},
833 }
834
835 var out bytes.Buffer
836 err := renderRoutesStatusSections(&out, sections)
837
838 require.NoError(t, err)
839 rendered := stripANSI(out.String())
840 assert.Contains(t, rendered, "Local")
841 assert.Contains(t, rendered, "failed to initialize local control plane")
842 assert.Contains(t, rendered, "Remote: remote-a")
843 assert.Contains(t, rendered, "dial tcp timeout")
844}
845
846func TestWriteRouteRemoveTextShowsCleanupReport(t *testing.T) {
847 var out bytes.Buffer
848 report := &domain.CleanupReport{
849 Domain: "app.example.com",
850 RemovedContainers: []domain.CleanupContainer{{
851 ID: "1234567890abcdef",
852 Name: "gordon-app.example.com",
853 }},
854 PreservedAttachments: []domain.CleanupAttachment{{
855 Name: "postgres",
856 ContainerID: "attachment-1",
857 Status: "running",
858 }},
859 Warnings: []string{"volumes preserved"},
860 Hints: []string{"run diagnose"},
861 }
862
863 err := writeRouteRemoveText(&out, "app.example.com", dto.CleanupReportFromDomain(report))
864 require.NoError(t, err)
865 rendered := stripANSI(out.String())
866 assert.Contains(t, rendered, "Route removed: app.example.com")
867 assert.Contains(t, rendered, "Removed containers:")
868 assert.Contains(t, rendered, "gordon-app.example.com (1234567890ab)")
869 assert.Contains(t, rendered, "Preserved attachments:")
870 assert.Contains(t, rendered, "postgres (running)")
871 assert.Contains(t, rendered, "volumes preserved")
872 assert.Contains(t, rendered, "run diagnose")
873}
874
875func TestBuildRouteStatusTree_DeterministicAcrossInputOrder(t *testing.T) {
876 routes := []routeStatusItem{
877 {Domain: "zulu.example", Image: "zulu:v1", Network: "gordon-zulu", ContainerStatus: "running"},
878 {Domain: "alpha.example", Image: "alpha:v1", Network: "gordon-alpha", ContainerStatus: "running"},
879 {Domain: "mike.example", Image: "solo:v1", ContainerStatus: "running"},
880 }
881
882 var outA bytes.Buffer
883 _, _ = outA.WriteString(stripANSI(buildRouteStatusTree(routes).Render()))
884
885 var outB bytes.Buffer
886 reordered := []routeStatusItem{routes[2], routes[0], routes[1]}
887 _, _ = outB.WriteString(stripANSI(buildRouteStatusTree(reordered).Render()))
888
889 assert.Equal(t, outA.String(), outB.String())
890}
891
892func TestAttachmentsForRouteDiagnosisUsesOwner(t *testing.T) {
893 attachments := []domain.CleanupAttachment{
894 {Name: "unexpected-name", Owner: "app.example.com", ContainerID: "match"},
895 {Name: "gordon-other-example-com-postgres", Owner: "other.example.com", ContainerID: "miss"},
896 }
897
898 matched := attachmentsForRouteDiagnosis(attachments, "app.example.com")
899
900 require.Len(t, matched, 1)
901 assert.Equal(t, "match", matched[0].ContainerID)
902}
903
904func TestBuildRouteDiagnosis_VolumesAreScopedToExactRouteContainers(t *testing.T) {
905 cpMock := climocks.NewMockControlPlane(t)
906 cpMock.EXPECT().GetRoute(context.Background(), "example.test").Return(&domain.Route{Domain: "example.test", Image: "site:latest"}, nil).Once()
907 cpMock.EXPECT().ListRoutesWithDetails(context.Background()).Return([]remote.RouteInfo{{
908 Domain: "example.test",
909 ContainerID: "route-1",
910 ContainerStatus: "running",
911 }}, nil).Once()
912 cpMock.EXPECT().GetHealth(context.Background()).Return(nil, nil).Once()
913 cpMock.EXPECT().ListVolumes(context.Background()).Return([]dto.Volume{
914 {Name: "gordon-example-test-data-docs", Containers: []string{"gordon-example.test"}},
915 {Name: "gordon-api-example-test-cache", Containers: []string{"gordon-api.example.test"}},
916 }, nil).Once()
917 cpMock.EXPECT().GetConfig(context.Background()).Return(&remote.Config{}, nil).Once()
918 cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return(nil, nil).Once()
919
920 diag, err := buildRouteDiagnosis(context.Background(), cpMock, "example.test")
921
922 require.NoError(t, err)
923 require.Len(t, diag.Volumes, 1)
924 assert.Equal(t, "gordon-example-test-data-docs", diag.Volumes[0].Name)
925}
926
927func TestBuildRouteDiagnosis_UsesConfiguredVolumePrefixForPreservedVolumes(t *testing.T) {
928 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
929 w.Header().Set("Content-Type", "application/json")
930 switch r.URL.Path {
931 case "/admin/routes/example.test":
932 w.WriteHeader(http.StatusNotFound)
933 _, _ = w.Write([]byte(`{"error":"route not found"}`))
934 case "/admin/routes":
935 require.Equal(t, "detailed=true", r.URL.RawQuery)
936 _, _ = w.Write([]byte(`{"routes":[]}`))
937 case "/admin/health":
938 _, _ = w.Write([]byte(`{"health":{}}`))
939 case "/admin/volumes":
940 _, _ = w.Write([]byte(`[{"name":"custom-example-test-data-docs","containers":[]}]`))
941 case "/admin/attachments/orphans":
942 _, _ = w.Write([]byte(`{"attachments":[]}`))
943 case "/admin/config":
944 _, _ = w.Write([]byte(`{"server":{"port":80,"registry_port":5000,"registry_domain":"registry.example.test"},"auto_route":{"enabled":false},"network_isolation":{"enabled":true,"prefix":"custom-network"},"volumes":{"auto_create":true,"prefix":"custom","preserve":true},"routes":[],"external_routes":[]}`))
945 default:
946 t.Fatalf("unexpected path: %s", r.URL.Path)
947 }
948 }))
949 defer srv.Close()
950
951 cp := NewRemoteControlPlane(remote.NewClient(srv.URL))
952 diag, err := buildRouteDiagnosis(context.Background(), cp, "example.test")
953
954 require.NoError(t, err)
955 require.Len(t, diag.Volumes, 1)
956 assert.Equal(t, "custom-example-test-data-docs", diag.Volumes[0].Name)
957}
958
959func TestBuildRouteDiagnosis_IncludesAttachmentVolumesWithDoubleUnderscoreOwnerEncoding(t *testing.T) {
960 cpMock := climocks.NewMockControlPlane(t)
961 cpMock.EXPECT().GetRoute(context.Background(), "app.example.test").Return(&domain.Route{Domain: "app.example.test", Image: "app:latest"}, nil).Once()
962 cpMock.EXPECT().ListRoutesWithDetails(context.Background()).Return([]remote.RouteInfo{{
963 Domain: "app.example.test",
964 ContainerID: "route-1",
965 ContainerStatus: "running",
966 Attachments: []dto.Attachment{{
967 Name: "postgres",
968 Image: "postgres:16",
969 ContainerID: "attachment-1",
970 Status: "running",
971 }},
972 }}, nil).Once()
973 cpMock.EXPECT().GetHealth(context.Background()).Return(nil, nil).Once()
974 cpMock.EXPECT().ListVolumes(context.Background()).Return([]dto.Volume{
975 {Name: "gordon-gordon-app__example__test-postgres-var-lib-postgresql", Containers: []string{"gordon-app__example__test-postgres"}},
976 }, nil).Once()
977 cpMock.EXPECT().GetConfig(context.Background()).Return(&remote.Config{}, nil).Once()
978 cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return(nil, nil).Once()
979
980 diag, err := buildRouteDiagnosis(context.Background(), cpMock, "app.example.test")
981
982 require.NoError(t, err)
983 require.Len(t, diag.Volumes, 1)
984 assert.Equal(t, "gordon-gordon-app__example__test-postgres-var-lib-postgresql", diag.Volumes[0].Name)
985}
986
987func TestBuildRouteDiagnosis_RemoteMissingRouteDoesNotWarnOnExpectedNotFound(t *testing.T) {
988 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
989 w.Header().Set("Content-Type", "application/json")
990 switch r.URL.Path {
991 case "/admin/routes/missing.example.test":
992 w.WriteHeader(http.StatusNotFound)
993 _, _ = w.Write([]byte(`{"error":"route not found"}`))
994 case "/admin/routes":
995 require.Equal(t, "detailed=true", r.URL.RawQuery)
996 _, _ = w.Write([]byte(`{"routes":[]}`))
997 case "/admin/health":
998 _, _ = w.Write([]byte(`{"health":{}}`))
999 case "/admin/volumes":
1000 _, _ = w.Write([]byte(`[]`))
1001 case "/admin/attachments/orphans":
1002 _, _ = w.Write([]byte(`{"attachments":[]}`))
1003 default:
1004 t.Fatalf("unexpected path: %s", r.URL.Path)
1005 }
1006 }))
1007 defer srv.Close()
1008
1009 cp := NewRemoteControlPlane(remote.NewClient(srv.URL))
1010 diag, err := buildRouteDiagnosis(context.Background(), cp, "missing.example.test")
1011
1012 require.NoError(t, err)
1013 assert.False(t, diag.Configured)
1014 assert.Empty(t, diag.Warnings)
1015}
1016
1017func TestRunRoutePurge_VolumesUsesConfiguredVolumePrefix(t *testing.T) {
1018 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1019 w.Header().Set("Content-Type", "application/json")
1020 switch r.URL.Path {
1021 case "/admin/routes/example.test":
1022 w.WriteHeader(http.StatusNotFound)
1023 _, _ = w.Write([]byte(`{"error":"route not found"}`))
1024 case "/admin/routes":
1025 require.Equal(t, "detailed=true", r.URL.RawQuery)
1026 _, _ = w.Write([]byte(`{"routes":[]}`))
1027 case "/admin/health":
1028 _, _ = w.Write([]byte(`{"health":{}}`))
1029 case "/admin/volumes":
1030 _, _ = w.Write([]byte(`[{"name":"custom-example-test-data-docs","containers":[]}]`))
1031 case "/admin/attachments/orphans":
1032 _, _ = w.Write([]byte(`{"attachments":[]}`))
1033 case "/admin/config":
1034 _, _ = w.Write([]byte(`{"server":{"port":80,"registry_port":5000,"registry_domain":"registry.example.test"},"auto_route":{"enabled":false},"network_isolation":{"enabled":true,"prefix":"custom-network"},"volumes":{"auto_create":true,"prefix":"custom","preserve":true},"routes":[],"external_routes":[]}`))
1035 default:
1036 t.Fatalf("unexpected path: %s", r.URL.Path)
1037 }
1038 }))
1039 defer srv.Close()
1040
1041 cp := NewRemoteControlPlane(remote.NewClient(srv.URL))
1042 report, err := runRoutePurge(context.Background(), cp, "example.test", routePurgeOptions{Volumes: true})
1043
1044 require.NoError(t, err)
1045 require.Len(t, report.PreservedVolumes, 1)
1046 assert.Equal(t, "custom-example-test-data-docs", report.PreservedVolumes[0].Name)
1047}
1048
1049func TestBuildRouteDiagnosis_UsesRouteScopedReviewHintForOrphanedAttachments(t *testing.T) {
1050 cpMock := climocks.NewMockControlPlane(t)
1051 cpMock.EXPECT().GetRoute(context.Background(), "app.example.test").Return(&domain.Route{Domain: "app.example.test", Image: "app:latest"}, nil).Once()
1052 cpMock.EXPECT().ListRoutesWithDetails(context.Background()).Return([]remote.RouteInfo{{
1053 Domain: "app.example.test",
1054 ContainerID: "route-1",
1055 ContainerStatus: "running",
1056 }}, nil).Once()
1057 cpMock.EXPECT().GetHealth(context.Background()).Return(nil, nil).Once()
1058 cpMock.EXPECT().ListVolumes(context.Background()).Return(nil, nil).Once()
1059 cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return([]domain.CleanupAttachment{{
1060 Name: "old-postgres",
1061 ContainerID: "attachment-1",
1062 Owner: "app.example.test",
1063 }}, nil).Once()
1064
1065 diag, err := buildRouteDiagnosis(context.Background(), cpMock, "app.example.test")
1066
1067 require.NoError(t, err)
1068 assert.Contains(t, diag.Hints, "run 'gordon routes purge app.example.test --attachments' to review orphaned attachment cleanup for this route")
1069}
1070
1071func TestWriteRouteDiagnosisText_UsesNeutralVolumeLabelForConfiguredRoutes(t *testing.T) {
1072 var out bytes.Buffer
1073 diag := &routeDiagnosis{
1074 Domain: "app.example.test",
1075 Configured: true,
1076 Route: &domain.Route{Domain: "app.example.test", Image: "app:latest"},
1077 Runtime: &remote.RouteInfo{
1078 Domain: "app.example.test",
1079 ContainerID: "route-1",
1080 ContainerStatus: "running",
1081 },
1082 Volumes: []dto.Volume{{Name: "gordon-app-example-test-data"}},
1083 }
1084
1085 err := writeRouteDiagnosisText(&out, diag)
1086 require.NoError(t, err)
1087 rendered := stripANSI(out.String())
1088 assert.Contains(t, rendered, "Volume: gordon-app-example-test-data")
1089 assert.NotContains(t, rendered, "Preserved volume:")
1090}
1091
1092func TestWriteRouteDiagnosisText_ShowsActiveAttachmentsSeparatelyFromOrphans(t *testing.T) {
1093 var out bytes.Buffer
1094 diag := &routeDiagnosis{
1095 Domain: "app.example.test",
1096 Configured: true,
1097 Route: &domain.Route{Domain: "app.example.test", Image: "app:latest"},
1098 Runtime: &remote.RouteInfo{
1099 Domain: "app.example.test",
1100 ContainerID: "route-1",
1101 ContainerStatus: "running",
1102 Attachments: []dto.Attachment{{
1103 Name: "postgres",
1104 Image: "postgres:16",
1105 ContainerID: "attachment-1",
1106 Status: "running",
1107 }},
1108 },
1109 OrphanedAttachments: []domain.CleanupAttachment{{
1110 Name: "old-postgres",
1111 ContainerID: "attachment-2",
1112 Owner: "app.example.test",
1113 }},
1114 }
1115
1116 err := writeRouteDiagnosisText(&out, diag)
1117 require.NoError(t, err)
1118 rendered := stripANSI(out.String())
1119 assert.Contains(t, rendered, "Active attachments:")
1120 assert.Contains(t, rendered, "postgres (running)")
1121 assert.Contains(t, rendered, "Orphaned attachment: old-postgres")
1122}
1123
1124func TestRunRoutePurge_AttachmentsForceIsScopedToRoute(t *testing.T) {
1125 cpMock := climocks.NewMockControlPlane(t)
1126 orphanedAttachments := []domain.CleanupAttachment{
1127 {ContainerID: "app-attachment", Owner: "app.example.com", Name: "postgres"},
1128 {ContainerID: "other-attachment", Owner: "other.example.com", Name: "redis"},
1129 }
1130 cpMock.EXPECT().GetRoute(context.Background(), "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil).Once()
1131 cpMock.EXPECT().ListRoutesWithDetails(context.Background()).Return(nil, nil).Once()
1132 cpMock.EXPECT().GetHealth(context.Background()).Return(nil, nil).Once()
1133 cpMock.EXPECT().ListVolumes(context.Background()).Return(nil, nil).Once()
1134 cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return(orphanedAttachments, nil).Once()
1135 cpMock.EXPECT().CleanupOrphanedAttachments(context.Background(), "app.example.com", true).Return(&domain.CleanupReport{
1136 RemovedContainers: []domain.CleanupContainer{{ID: "app-attachment", Name: "postgres"}},
1137 }, nil).Once()
1138
1139 report, err := runRoutePurge(context.Background(), cpMock, "app.example.com", routePurgeOptions{Force: true, Attachments: true})
1140
1141 require.NoError(t, err)
1142 require.NotNil(t, report)
1143 require.Len(t, report.RemovedContainers, 1)
1144 assert.Equal(t, "app-attachment", report.RemovedContainers[0].ID)
1145}