v2.31.0-dev22 8d342c4

routes_test.go RawHistoryFolder

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 TestLoadRoutesListRemoteSection_UsesInventoryEndpoint(t *testing.T) {
813	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
814		w.Header().Set("Content-Type", "application/json")
815		switch {
816		case r.URL.Path == "/admin/routes" && r.URL.RawQuery == "":
817			_, _ = w.Write([]byte(`{"routes":[{"domain":"app.example.com","image":"app:latest"}]}`))
818		case r.URL.Path == "/admin/routes" && r.URL.RawQuery == "detailed=true":
819			w.WriteHeader(http.StatusInternalServerError)
820			_, _ = w.Write([]byte(`{"error":"detailed route status unavailable"}`))
821		default:
822			t.Fatalf("unexpected request: %s?%s", r.URL.Path, r.URL.RawQuery)
823		}
824	}))
825	defer srv.Close()
826
827	section, err := loadRoutesListRemoteSection(context.Background(), "prod", remote.RemoteEntry{URL: srv.URL})
828
829	require.NoError(t, err)
830	require.Empty(t, section.Error)
831	require.Len(t, section.Routes, 1)
832	assert.Equal(t, "app.example.com", section.Routes[0].Domain)
833	assert.Equal(t, "app:latest", section.Routes[0].Image)
834}
835
836func TestRenderRoutesStatusSections_HidesLocalErrorWhenRemoteIsUsable(t *testing.T) {
837	sections := []routeStatusSection{
838		{Kind: "local", Name: "local", Error: "failed to initialize local control plane"},
839		{Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com"},
840	}
841
842	var out bytes.Buffer
843	err := renderRoutesStatusSections(&out, sections)
844
845	require.NoError(t, err)
846	rendered := stripANSI(out.String())
847	assert.NotContains(t, rendered, "Local")
848	assert.NotContains(t, rendered, "failed to initialize local control plane")
849	assert.Contains(t, rendered, "Remote: remote-a")
850	assert.Contains(t, rendered, "No routes configured")
851}
852
853func TestRenderRoutesStatusSections_KeepsLocalErrorWhenAllRemotesFail(t *testing.T) {
854	sections := []routeStatusSection{
855		{Kind: "local", Name: "local", Error: "failed to initialize local control plane"},
856		{Kind: "remote", Name: "remote-a", URL: "https://remote-a.example.com", Error: "dial tcp timeout"},
857	}
858
859	var out bytes.Buffer
860	err := renderRoutesStatusSections(&out, sections)
861
862	require.NoError(t, err)
863	rendered := stripANSI(out.String())
864	assert.Contains(t, rendered, "Local")
865	assert.Contains(t, rendered, "failed to initialize local control plane")
866	assert.Contains(t, rendered, "Remote: remote-a")
867	assert.Contains(t, rendered, "dial tcp timeout")
868}
869
870func TestWriteRouteRemoveTextShowsCleanupReport(t *testing.T) {
871	var out bytes.Buffer
872	report := &domain.CleanupReport{
873		Domain: "app.example.com",
874		RemovedContainers: []domain.CleanupContainer{{
875			ID:   "1234567890abcdef",
876			Name: "gordon-app.example.com",
877		}},
878		PreservedAttachments: []domain.CleanupAttachment{{
879			Name:        "postgres",
880			ContainerID: "attachment-1",
881			Status:      "running",
882		}},
883		Warnings: []string{"volumes preserved"},
884		Hints:    []string{"run diagnose"},
885	}
886
887	err := writeRouteRemoveText(&out, "app.example.com", dto.CleanupReportFromDomain(report))
888	require.NoError(t, err)
889	rendered := stripANSI(out.String())
890	assert.Contains(t, rendered, "Route removed: app.example.com")
891	assert.Contains(t, rendered, "Removed containers:")
892	assert.Contains(t, rendered, "gordon-app.example.com (1234567890ab)")
893	assert.Contains(t, rendered, "Preserved attachments:")
894	assert.Contains(t, rendered, "postgres (running)")
895	assert.Contains(t, rendered, "volumes preserved")
896	assert.Contains(t, rendered, "run diagnose")
897}
898
899func TestBuildRouteStatusTree_DeterministicAcrossInputOrder(t *testing.T) {
900	routes := []routeStatusItem{
901		{Domain: "zulu.example", Image: "zulu:v1", Network: "gordon-zulu", ContainerStatus: "running"},
902		{Domain: "alpha.example", Image: "alpha:v1", Network: "gordon-alpha", ContainerStatus: "running"},
903		{Domain: "mike.example", Image: "solo:v1", ContainerStatus: "running"},
904	}
905
906	var outA bytes.Buffer
907	_, _ = outA.WriteString(stripANSI(buildRouteStatusTree(routes).Render()))
908
909	var outB bytes.Buffer
910	reordered := []routeStatusItem{routes[2], routes[0], routes[1]}
911	_, _ = outB.WriteString(stripANSI(buildRouteStatusTree(reordered).Render()))
912
913	assert.Equal(t, outA.String(), outB.String())
914}
915
916func TestAttachmentsForRouteDiagnosisUsesOwner(t *testing.T) {
917	attachments := []domain.CleanupAttachment{
918		{Name: "unexpected-name", Owner: "app.example.com", ContainerID: "match"},
919		{Name: "gordon-other-example-com-postgres", Owner: "other.example.com", ContainerID: "miss"},
920	}
921
922	matched := attachmentsForRouteDiagnosis(attachments, "app.example.com")
923
924	require.Len(t, matched, 1)
925	assert.Equal(t, "match", matched[0].ContainerID)
926}
927
928func TestBuildRouteDiagnosis_VolumesAreScopedToExactRouteContainers(t *testing.T) {
929	cpMock := climocks.NewMockControlPlane(t)
930	cpMock.EXPECT().GetRoute(context.Background(), "example.test").Return(&domain.Route{Domain: "example.test", Image: "site:latest"}, nil).Once()
931	cpMock.EXPECT().ListRoutesWithDetails(context.Background()).Return([]remote.RouteInfo{{
932		Domain:          "example.test",
933		ContainerID:     "route-1",
934		ContainerStatus: "running",
935	}}, nil).Once()
936	cpMock.EXPECT().GetHealth(context.Background()).Return(nil, nil).Once()
937	cpMock.EXPECT().ListVolumes(context.Background()).Return([]dto.Volume{
938		{Name: "gordon-example-test-data-docs", Containers: []string{"gordon-example.test"}},
939		{Name: "gordon-api-example-test-cache", Containers: []string{"gordon-api.example.test"}},
940	}, nil).Once()
941	cpMock.EXPECT().GetConfig(context.Background()).Return(&remote.Config{}, nil).Once()
942	cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return(nil, nil).Once()
943
944	diag, err := buildRouteDiagnosis(context.Background(), cpMock, "example.test")
945
946	require.NoError(t, err)
947	require.Len(t, diag.Volumes, 1)
948	assert.Equal(t, "gordon-example-test-data-docs", diag.Volumes[0].Name)
949}
950
951func TestBuildRouteDiagnosis_UsesConfiguredVolumePrefixForPreservedVolumes(t *testing.T) {
952	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
953		w.Header().Set("Content-Type", "application/json")
954		switch r.URL.Path {
955		case "/admin/routes/example.test":
956			w.WriteHeader(http.StatusNotFound)
957			_, _ = w.Write([]byte(`{"error":"route not found"}`))
958		case "/admin/routes/example.test/cleanup":
959			_, _ = w.Write([]byte(`{"domain":"example.test","preserved_volumes":[{"name":"custom-example-test-data-docs"}]}`))
960		case "/admin/routes":
961			require.Equal(t, "detailed=true", r.URL.RawQuery)
962			_, _ = w.Write([]byte(`{"routes":[]}`))
963		case "/admin/health":
964			_, _ = w.Write([]byte(`{"health":{}}`))
965		case "/admin/volumes":
966			_, _ = w.Write([]byte(`[{"name":"custom-example-test-data-docs","containers":[]}]`))
967		case "/admin/attachments/orphans":
968			_, _ = w.Write([]byte(`{"attachments":[]}`))
969		case "/admin/config":
970			_, _ = 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":[]}`))
971		default:
972			t.Fatalf("unexpected path: %s", r.URL.Path)
973		}
974	}))
975	defer srv.Close()
976
977	cp := NewRemoteControlPlane(remote.NewClient(srv.URL))
978	diag, err := buildRouteDiagnosis(context.Background(), cp, "example.test")
979
980	require.NoError(t, err)
981	require.Len(t, diag.Volumes, 1)
982	assert.Equal(t, "custom-example-test-data-docs", diag.Volumes[0].Name)
983}
984
985func TestBuildRouteDiagnosis_IncludesAttachmentVolumesWithDoubleUnderscoreOwnerEncoding(t *testing.T) {
986	cpMock := climocks.NewMockControlPlane(t)
987	cpMock.EXPECT().GetRoute(context.Background(), "app.example.test").Return(&domain.Route{Domain: "app.example.test", Image: "app:latest"}, nil).Once()
988	cpMock.EXPECT().ListRoutesWithDetails(context.Background()).Return([]remote.RouteInfo{{
989		Domain:          "app.example.test",
990		ContainerID:     "route-1",
991		ContainerStatus: "running",
992		Attachments: []dto.Attachment{{
993			Name:        "postgres",
994			Image:       "postgres:16",
995			ContainerID: "attachment-1",
996			Status:      "running",
997		}},
998	}}, nil).Once()
999	cpMock.EXPECT().GetHealth(context.Background()).Return(nil, nil).Once()
1000	cpMock.EXPECT().ListVolumes(context.Background()).Return([]dto.Volume{
1001		{Name: "gordon-gordon-app__example__test-postgres-var-lib-postgresql", Containers: []string{"gordon-app__example__test-postgres"}},
1002	}, nil).Once()
1003	cpMock.EXPECT().GetConfig(context.Background()).Return(&remote.Config{}, nil).Once()
1004	cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return(nil, nil).Once()
1005
1006	diag, err := buildRouteDiagnosis(context.Background(), cpMock, "app.example.test")
1007
1008	require.NoError(t, err)
1009	require.Len(t, diag.Volumes, 1)
1010	assert.Equal(t, "gordon-gordon-app__example__test-postgres-var-lib-postgresql", diag.Volumes[0].Name)
1011}
1012
1013func TestBuildRouteDiagnosis_UsesCleanupPreviewForRemovedRouteRuntimeState(t *testing.T) {
1014	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1015		w.Header().Set("Content-Type", "application/json")
1016		switch r.URL.Path {
1017		case "/admin/routes/example.test":
1018			w.WriteHeader(http.StatusNotFound)
1019			_, _ = w.Write([]byte(`{"error":"route not found"}`))
1020		case "/admin/routes/example.test/cleanup":
1021			_, _ = w.Write([]byte(`{"domain":"example.test","orphaned_entities":[{"kind":"route_container","id":"ctr-1","name":"gordon-example.test","status":"running"}],"preserved_attachments":[{"name":"old-postgres","container_id":"att-1","owner":"example.test"}],"preserved_volumes":[{"name":"gordon-example-test-data"}]}`))
1022		case "/admin/routes":
1023			require.Equal(t, "detailed=true", r.URL.RawQuery)
1024			_, _ = w.Write([]byte(`{"routes":[]}`))
1025		case "/admin/health":
1026			_, _ = w.Write([]byte(`{"health":{}}`))
1027		default:
1028			t.Fatalf("unexpected path: %s", r.URL.Path)
1029		}
1030	}))
1031	defer srv.Close()
1032
1033	cp := NewRemoteControlPlane(remote.NewClient(srv.URL))
1034	diag, err := buildRouteDiagnosis(context.Background(), cp, "example.test")
1035
1036	require.NoError(t, err)
1037	assert.False(t, diag.Configured)
1038	require.Len(t, diag.OrphanedEntities, 1)
1039	assert.Equal(t, "route_container", diag.OrphanedEntities[0].Kind)
1040	require.Len(t, diag.OrphanedAttachments, 1)
1041	assert.Equal(t, "old-postgres", diag.OrphanedAttachments[0].Name)
1042	require.Len(t, diag.Volumes, 1)
1043	assert.Equal(t, "gordon-example-test-data", diag.Volumes[0].Name)
1044	assert.Contains(t, diag.Warnings, "route is not configured but runtime state still exists")
1045}
1046
1047func TestBuildRouteDiagnosis_RemoteMissingRouteDoesNotWarnOnExpectedNotFound(t *testing.T) {
1048	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1049		w.Header().Set("Content-Type", "application/json")
1050		switch r.URL.Path {
1051		case "/admin/routes/missing.example.test":
1052			w.WriteHeader(http.StatusNotFound)
1053			_, _ = w.Write([]byte(`{"error":"route not found"}`))
1054		case "/admin/routes/missing.example.test/cleanup":
1055			_, _ = w.Write([]byte(`{"domain":"missing.example.test"}`))
1056		case "/admin/routes":
1057			require.Equal(t, "detailed=true", r.URL.RawQuery)
1058			_, _ = w.Write([]byte(`{"routes":[]}`))
1059		case "/admin/health":
1060			_, _ = w.Write([]byte(`{"health":{}}`))
1061		case "/admin/volumes":
1062			_, _ = w.Write([]byte(`[]`))
1063		case "/admin/attachments/orphans":
1064			_, _ = w.Write([]byte(`{"attachments":[]}`))
1065		default:
1066			t.Fatalf("unexpected path: %s", r.URL.Path)
1067		}
1068	}))
1069	defer srv.Close()
1070
1071	cp := NewRemoteControlPlane(remote.NewClient(srv.URL))
1072	diag, err := buildRouteDiagnosis(context.Background(), cp, "missing.example.test")
1073
1074	require.NoError(t, err)
1075	assert.False(t, diag.Configured)
1076	assert.Empty(t, diag.Warnings)
1077}
1078
1079func TestRunRoutePurge_VolumesUsesConfiguredVolumePrefix(t *testing.T) {
1080	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1081		w.Header().Set("Content-Type", "application/json")
1082		switch r.URL.Path {
1083		case "/admin/routes/example.test":
1084			w.WriteHeader(http.StatusNotFound)
1085			_, _ = w.Write([]byte(`{"error":"route not found"}`))
1086		case "/admin/routes/example.test/cleanup":
1087			_, _ = w.Write([]byte(`{"domain":"example.test","preserved_volumes":[{"name":"custom-example-test-data-docs"}]}`))
1088		case "/admin/routes":
1089			require.Equal(t, "detailed=true", r.URL.RawQuery)
1090			_, _ = w.Write([]byte(`{"routes":[]}`))
1091		case "/admin/health":
1092			_, _ = w.Write([]byte(`{"health":{}}`))
1093		case "/admin/volumes":
1094			_, _ = w.Write([]byte(`[{"name":"custom-example-test-data-docs","containers":[]}]`))
1095		case "/admin/attachments/orphans":
1096			_, _ = w.Write([]byte(`{"attachments":[]}`))
1097		case "/admin/config":
1098			_, _ = 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":[]}`))
1099		default:
1100			t.Fatalf("unexpected path: %s", r.URL.Path)
1101		}
1102	}))
1103	defer srv.Close()
1104
1105	cp := NewRemoteControlPlane(remote.NewClient(srv.URL))
1106	report, err := runRoutePurge(context.Background(), cp, "example.test", routePurgeOptions{Volumes: true})
1107
1108	require.NoError(t, err)
1109	require.Len(t, report.PreservedVolumes, 1)
1110	assert.Equal(t, "custom-example-test-data-docs", report.PreservedVolumes[0].Name)
1111}
1112
1113func TestBuildRouteDiagnosis_UsesRouteScopedReviewHintForOrphanedAttachments(t *testing.T) {
1114	cpMock := climocks.NewMockControlPlane(t)
1115	cpMock.EXPECT().GetRoute(context.Background(), "app.example.test").Return(&domain.Route{Domain: "app.example.test", Image: "app:latest"}, nil).Once()
1116	cpMock.EXPECT().ListRoutesWithDetails(context.Background()).Return([]remote.RouteInfo{{
1117		Domain:          "app.example.test",
1118		ContainerID:     "route-1",
1119		ContainerStatus: "running",
1120	}}, nil).Once()
1121	cpMock.EXPECT().GetHealth(context.Background()).Return(nil, nil).Once()
1122	cpMock.EXPECT().ListVolumes(context.Background()).Return(nil, nil).Once()
1123	cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return([]domain.CleanupAttachment{{
1124		Name:        "old-postgres",
1125		ContainerID: "attachment-1",
1126		Owner:       "app.example.test",
1127	}}, nil).Once()
1128
1129	diag, err := buildRouteDiagnosis(context.Background(), cpMock, "app.example.test")
1130
1131	require.NoError(t, err)
1132	assert.Contains(t, diag.Hints, "run 'gordon routes purge app.example.test --attachments' to review orphaned attachment cleanup for this route")
1133}
1134
1135func TestWriteRouteDiagnosisText_UsesNeutralVolumeLabelForConfiguredRoutes(t *testing.T) {
1136	var out bytes.Buffer
1137	diag := &routeDiagnosis{
1138		Domain:     "app.example.test",
1139		Configured: true,
1140		Route:      &domain.Route{Domain: "app.example.test", Image: "app:latest"},
1141		Runtime: &remote.RouteInfo{
1142			Domain:          "app.example.test",
1143			ContainerID:     "route-1",
1144			ContainerStatus: "running",
1145		},
1146		Volumes: []dto.Volume{{Name: "gordon-app-example-test-data"}},
1147	}
1148
1149	err := writeRouteDiagnosisText(&out, diag)
1150	require.NoError(t, err)
1151	rendered := stripANSI(out.String())
1152	assert.Contains(t, rendered, "Volume: gordon-app-example-test-data")
1153	assert.NotContains(t, rendered, "Preserved volume:")
1154}
1155
1156func TestWriteRouteDiagnosisText_ShowsActiveAttachmentsSeparatelyFromOrphans(t *testing.T) {
1157	var out bytes.Buffer
1158	diag := &routeDiagnosis{
1159		Domain:     "app.example.test",
1160		Configured: true,
1161		Route:      &domain.Route{Domain: "app.example.test", Image: "app:latest"},
1162		Runtime: &remote.RouteInfo{
1163			Domain:          "app.example.test",
1164			ContainerID:     "route-1",
1165			ContainerStatus: "running",
1166			Attachments: []dto.Attachment{{
1167				Name:        "postgres",
1168				Image:       "postgres:16",
1169				ContainerID: "attachment-1",
1170				Status:      "running",
1171			}},
1172		},
1173		OrphanedAttachments: []domain.CleanupAttachment{{
1174			Name:        "old-postgres",
1175			ContainerID: "attachment-2",
1176			Owner:       "app.example.test",
1177		}},
1178	}
1179
1180	err := writeRouteDiagnosisText(&out, diag)
1181	require.NoError(t, err)
1182	rendered := stripANSI(out.String())
1183	assert.Contains(t, rendered, "Active attachments:")
1184	assert.Contains(t, rendered, "postgres (running)")
1185	assert.Contains(t, rendered, "Orphaned attachment: old-postgres")
1186}
1187
1188func TestRunRoutePurge_AttachmentsForceIsScopedToRoute(t *testing.T) {
1189	cpMock := climocks.NewMockControlPlane(t)
1190	orphanedAttachments := []domain.CleanupAttachment{
1191		{ContainerID: "app-attachment", Owner: "app.example.com", Name: "postgres"},
1192		{ContainerID: "other-attachment", Owner: "other.example.com", Name: "redis"},
1193	}
1194	cpMock.EXPECT().GetRoute(context.Background(), "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil).Once()
1195	cpMock.EXPECT().ListRoutesWithDetails(context.Background()).Return(nil, nil).Once()
1196	cpMock.EXPECT().GetHealth(context.Background()).Return(nil, nil).Once()
1197	cpMock.EXPECT().ListVolumes(context.Background()).Return(nil, nil).Once()
1198	cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return(orphanedAttachments, nil).Once()
1199	cpMock.EXPECT().CleanupOrphanedAttachments(context.Background(), "app.example.com", true).Return(&domain.CleanupReport{
1200		RemovedContainers: []domain.CleanupContainer{{ID: "app-attachment", Name: "postgres"}},
1201	}, nil).Once()
1202
1203	report, err := runRoutePurge(context.Background(), cpMock, "app.example.com", routePurgeOptions{Force: true, Attachments: true})
1204
1205	require.NoError(t, err)
1206	require.NotNil(t, report)
1207	require.Len(t, report.RemovedContainers, 1)
1208	assert.Equal(t, "app-attachment", report.RemovedContainers[0].ID)
1209}

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