v2.31.0-dev18 7b9923f

routes_test.go RawHistoryFolder

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

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