v2.31.0-dev9 062dcae

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

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