detached 187ed78

fix(cli): clean up aggregate routes output

Silence local kernel init logs and hide the local error section in text output when usable remote route data is available.
commit
187ed782f3196b5eb35ebd49364e9dd1624d832d
author
brice · · 2026-04-15 13:50 UTC
parent
cc460f4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +184 -2
M internal/adapters/in/cli/controlplane_resolver.go +1 -1
@@ -32,7 +32,7 @@ func resolveControlPlane(configPath string) (*controlPlaneHandle, error) {
3232 }
3333
3434 func resolveLocalControlPlane(configPath string) (*controlPlaneHandle, error) {
35- kernel, err := app.NewKernel(configPath)
35+ kernel, err := app.NewKernelQuiet(configPath)
3636 if err != nil {
3737 return nil, fmt.Errorf("failed to initialize local control plane: %w", err)
3838 }
@@ -32,7 +32,7 @@ func resolveControlPlane(configPath string) (*controlPlaneHandle, error) {
32 }32 }
33 33
34 func resolveLocalControlPlane(configPath string) (*controlPlaneHandle, error) {34 func resolveLocalControlPlane(configPath string) (*controlPlaneHandle, error) {
35- kernel, err := app.NewKernel(configPath)35+ kernel, err := app.NewKernelQuiet(configPath)
36 if err != nil {36 if err != nil {
37 return nil, fmt.Errorf("failed to initialize local control plane: %w", err)37 return nil, fmt.Errorf("failed to initialize local control plane: %w", err)
38 }38 }
M internal/adapters/in/cli/routes.go +50 -0
@@ -500,6 +500,8 @@ func normalizeRouteStatusRemoteSection(section routeStatusSection, name string,
500500 }
501501
502502 func renderRoutesListSections(out io.Writer, sections []routeListSection) error {
503+ sections = renderedRouteListSections(sections)
504+
503505 if err := cliWriteLine(out, cliRenderTitle("Routes")); err != nil {
504506 return err
505507 }
@@ -558,6 +560,8 @@ func renderRoutesListSection(out io.Writer, section routeListSection) error {
558560 }
559561
560562 func renderRoutesStatusSections(out io.Writer, sections []routeStatusSection) error {
563+ sections = renderedRouteStatusSections(sections)
564+
561565 if err := cliWriteLine(out, cliRenderTitle("Route Status")); err != nil {
562566 return err
563567 }
@@ -579,6 +583,52 @@ func renderRoutesStatusSections(out io.Writer, sections []routeStatusSection) er
579583 return nil
580584 }
581585
586+func renderedRouteListSections(sections []routeListSection) []routeListSection {
587+ if !shouldHideLocalRouteListSection(sections) {
588+ return sections
589+ }
590+ return sections[1:]
591+}
592+
593+func shouldHideLocalRouteListSection(sections []routeListSection) bool {
594+ if len(sections) < 2 {
595+ return false
596+ }
597+ local := sections[0]
598+ if local.Kind != "local" || local.Error == "" {
599+ return false
600+ }
601+ for _, section := range sections[1:] {
602+ if section.Kind == "remote" && section.Error == "" {
603+ return true
604+ }
605+ }
606+ return false
607+}
608+
609+func renderedRouteStatusSections(sections []routeStatusSection) []routeStatusSection {
610+ if !shouldHideLocalRouteStatusSection(sections) {
611+ return sections
612+ }
613+ return sections[1:]
614+}
615+
616+func shouldHideLocalRouteStatusSection(sections []routeStatusSection) bool {
617+ if len(sections) < 2 {
618+ return false
619+ }
620+ local := sections[0]
621+ if local.Kind != "local" || local.Error == "" {
622+ return false
623+ }
624+ for _, section := range sections[1:] {
625+ if section.Kind == "remote" && section.Error == "" {
626+ return true
627+ }
628+ }
629+ return false
630+}
631+
582632 func renderRoutesStatusSection(out io.Writer, section routeStatusSection) error {
583633 if err := cliWriteLine(out, routeSectionHeading(section.Kind, section.Name)); err != nil {
584634 return err
@@ -500,6 +500,8 @@ func normalizeRouteStatusRemoteSection(section routeStatusSection, name string,
500 }500 }
501 501
502 func renderRoutesListSections(out io.Writer, sections []routeListSection) error {502 func renderRoutesListSections(out io.Writer, sections []routeListSection) error {
503+ sections = renderedRouteListSections(sections)
504+
503 if err := cliWriteLine(out, cliRenderTitle("Routes")); err != nil {505 if err := cliWriteLine(out, cliRenderTitle("Routes")); err != nil {
504 return err506 return err
505 }507 }
@@ -558,6 +560,8 @@ func renderRoutesListSection(out io.Writer, section routeListSection) error {
558 }560 }
559 561
560 func renderRoutesStatusSections(out io.Writer, sections []routeStatusSection) error {562 func renderRoutesStatusSections(out io.Writer, sections []routeStatusSection) error {
563+ sections = renderedRouteStatusSections(sections)
564+
561 if err := cliWriteLine(out, cliRenderTitle("Route Status")); err != nil {565 if err := cliWriteLine(out, cliRenderTitle("Route Status")); err != nil {
562 return err566 return err
563 }567 }
@@ -579,6 +583,52 @@ func renderRoutesStatusSections(out io.Writer, sections []routeStatusSection) er
579 return nil583 return nil
580 }584 }
581 585
586+func renderedRouteListSections(sections []routeListSection) []routeListSection {
587+ if !shouldHideLocalRouteListSection(sections) {
588+ return sections
589+ }
590+ return sections[1:]
591+}
592+
593+func shouldHideLocalRouteListSection(sections []routeListSection) bool {
594+ if len(sections) < 2 {
595+ return false
596+ }
597+ local := sections[0]
598+ if local.Kind != "local" || local.Error == "" {
599+ return false
600+ }
601+ for _, section := range sections[1:] {
602+ if section.Kind == "remote" && section.Error == "" {
603+ return true
604+ }
605+ }
606+ return false
607+}
608+
609+func renderedRouteStatusSections(sections []routeStatusSection) []routeStatusSection {
610+ if !shouldHideLocalRouteStatusSection(sections) {
611+ return sections
612+ }
613+ return sections[1:]
614+}
615+
616+func shouldHideLocalRouteStatusSection(sections []routeStatusSection) bool {
617+ if len(sections) < 2 {
618+ return false
619+ }
620+ local := sections[0]
621+ if local.Kind != "local" || local.Error == "" {
622+ return false
623+ }
624+ for _, section := range sections[1:] {
625+ if section.Kind == "remote" && section.Error == "" {
626+ return true
627+ }
628+ }
629+ return false
630+}
631+
582 func renderRoutesStatusSection(out io.Writer, section routeStatusSection) error {632 func renderRoutesStatusSection(out io.Writer, section routeStatusSection) error {
583 if err := cliWriteLine(out, routeSectionHeading(section.Kind, section.Name)); err != nil {633 if err := cliWriteLine(out, routeSectionHeading(section.Kind, section.Name)); err != nil {
584 return err634 return err
M internal/adapters/in/cli/routes_test.go +116 -0
@@ -5,8 +5,10 @@ import (
55 "context"
66 "encoding/json"
77 "errors"
8+ "io"
89 "os"
910 "path/filepath"
11+ "strconv"
1012 "strings"
1113 "sync/atomic"
1214 "testing"
@@ -682,6 +684,69 @@ func TestCollectRoutesStatusAggregateSections_EncodesLoaderErrors(t *testing.T)
682684 })
683685 }
684686
687+func 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]
691+gordon_domain = "gordon.local"
692+data_dir = `+strconv.Quote(filepath.Join(tmpDir, "data"))+`
693+
694+[auth]
695+enabled = 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+
712+func 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+
685750 func TestRenderRoutesStatusSections_IncludesSectionHeadingsAndErrors(t *testing.T) {
686751 sections := []routeStatusSection{
687752 {
@@ -738,6 +803,57 @@ func TestRenderRoutesStatusSections_IncludesSectionHeadingsAndErrors(t *testing.
738803 assert.Less(t, remoteIdx, errorIdx)
739804 }
740805
806+func TestRenderRoutesListSections_HidesLocalErrorWhenRemoteIsUsable(t *testing.T) {
807+ sections := []routeListSection{
808+ {Kind: "local", Name: "local", Error: "failed to initialize local control plane"},
809+ {Kind: "remote", Name: "igor", URL: "https://gordon.supri.xyz"},
810+ }
811+
812+ var out bytes.Buffer
813+ err := renderRoutesListSections(&out, sections)
814+
815+ require.NoError(t, err)
816+ rendered := stripANSI(out.String())
817+ assert.NotContains(t, rendered, "Local")
818+ assert.NotContains(t, rendered, "failed to initialize local control plane")
819+ assert.Contains(t, rendered, "Remote: igor")
820+ assert.Contains(t, rendered, "No routes configured")
821+}
822+
823+func TestRenderRoutesStatusSections_HidesLocalErrorWhenRemoteIsUsable(t *testing.T) {
824+ sections := []routeStatusSection{
825+ {Kind: "local", Name: "local", Error: "failed to initialize local control plane"},
826+ {Kind: "remote", Name: "igor", URL: "https://gordon.supri.xyz"},
827+ }
828+
829+ var out bytes.Buffer
830+ err := renderRoutesStatusSections(&out, sections)
831+
832+ require.NoError(t, err)
833+ rendered := stripANSI(out.String())
834+ assert.NotContains(t, rendered, "Local")
835+ assert.NotContains(t, rendered, "failed to initialize local control plane")
836+ assert.Contains(t, rendered, "Remote: igor")
837+ assert.Contains(t, rendered, "No routes configured")
838+}
839+
840+func TestRenderRoutesStatusSections_KeepsLocalErrorWhenAllRemotesFail(t *testing.T) {
841+ sections := []routeStatusSection{
842+ {Kind: "local", Name: "local", Error: "failed to initialize local control plane"},
843+ {Kind: "remote", Name: "igor", URL: "https://gordon.supri.xyz", Error: "dial tcp timeout"},
844+ }
845+
846+ var out bytes.Buffer
847+ err := renderRoutesStatusSections(&out, sections)
848+
849+ require.NoError(t, err)
850+ rendered := stripANSI(out.String())
851+ assert.Contains(t, rendered, "Local")
852+ assert.Contains(t, rendered, "failed to initialize local control plane")
853+ assert.Contains(t, rendered, "Remote: igor")
854+ assert.Contains(t, rendered, "dial tcp timeout")
855+}
856+
741857 func TestBuildRouteStatusTree_DeterministicAcrossInputOrder(t *testing.T) {
742858 routes := []routeStatusItem{
743859 {Domain: "zulu.example", Image: "zulu:v1", Network: "gordon-zulu", ContainerStatus: "running"},
@@ -5,8 +5,10 @@ import (
5 "context"5 "context"
6 "encoding/json"6 "encoding/json"
7 "errors"7 "errors"
8+ "io"
8 "os"9 "os"
9 "path/filepath"10 "path/filepath"
11+ "strconv"
10 "strings"12 "strings"
11 "sync/atomic"13 "sync/atomic"
12 "testing"14 "testing"
@@ -682,6 +684,69 @@ func TestCollectRoutesStatusAggregateSections_EncodesLoaderErrors(t *testing.T)
682 })684 })
683 }685 }
684 686
687+func 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]
691+gordon_domain = "gordon.local"
692+data_dir = `+strconv.Quote(filepath.Join(tmpDir, "data"))+`
693+
694+[auth]
695+enabled = 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+
712+func 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+
685 func TestRenderRoutesStatusSections_IncludesSectionHeadingsAndErrors(t *testing.T) {750 func TestRenderRoutesStatusSections_IncludesSectionHeadingsAndErrors(t *testing.T) {
686 sections := []routeStatusSection{751 sections := []routeStatusSection{
687 {752 {
@@ -738,6 +803,57 @@ func TestRenderRoutesStatusSections_IncludesSectionHeadingsAndErrors(t *testing.
738 assert.Less(t, remoteIdx, errorIdx)803 assert.Less(t, remoteIdx, errorIdx)
739 }804 }
740 805
806+func TestRenderRoutesListSections_HidesLocalErrorWhenRemoteIsUsable(t *testing.T) {
807+ sections := []routeListSection{
808+ {Kind: "local", Name: "local", Error: "failed to initialize local control plane"},
809+ {Kind: "remote", Name: "igor", URL: "https://gordon.supri.xyz"},
810+ }
811+
812+ var out bytes.Buffer
813+ err := renderRoutesListSections(&out, sections)
814+
815+ require.NoError(t, err)
816+ rendered := stripANSI(out.String())
817+ assert.NotContains(t, rendered, "Local")
818+ assert.NotContains(t, rendered, "failed to initialize local control plane")
819+ assert.Contains(t, rendered, "Remote: igor")
820+ assert.Contains(t, rendered, "No routes configured")
821+}
822+
823+func TestRenderRoutesStatusSections_HidesLocalErrorWhenRemoteIsUsable(t *testing.T) {
824+ sections := []routeStatusSection{
825+ {Kind: "local", Name: "local", Error: "failed to initialize local control plane"},
826+ {Kind: "remote", Name: "igor", URL: "https://gordon.supri.xyz"},
827+ }
828+
829+ var out bytes.Buffer
830+ err := renderRoutesStatusSections(&out, sections)
831+
832+ require.NoError(t, err)
833+ rendered := stripANSI(out.String())
834+ assert.NotContains(t, rendered, "Local")
835+ assert.NotContains(t, rendered, "failed to initialize local control plane")
836+ assert.Contains(t, rendered, "Remote: igor")
837+ assert.Contains(t, rendered, "No routes configured")
838+}
839+
840+func TestRenderRoutesStatusSections_KeepsLocalErrorWhenAllRemotesFail(t *testing.T) {
841+ sections := []routeStatusSection{
842+ {Kind: "local", Name: "local", Error: "failed to initialize local control plane"},
843+ {Kind: "remote", Name: "igor", URL: "https://gordon.supri.xyz", Error: "dial tcp timeout"},
844+ }
845+
846+ var out bytes.Buffer
847+ err := renderRoutesStatusSections(&out, sections)
848+
849+ require.NoError(t, err)
850+ rendered := stripANSI(out.String())
851+ assert.Contains(t, rendered, "Local")
852+ assert.Contains(t, rendered, "failed to initialize local control plane")
853+ assert.Contains(t, rendered, "Remote: igor")
854+ assert.Contains(t, rendered, "dial tcp timeout")
855+}
856+
741 func TestBuildRouteStatusTree_DeterministicAcrossInputOrder(t *testing.T) {857 func TestBuildRouteStatusTree_DeterministicAcrossInputOrder(t *testing.T) {
742 routes := []routeStatusItem{858 routes := []routeStatusItem{
743 {Domain: "zulu.example", Image: "zulu:v1", Network: "gordon-zulu", ContainerStatus: "running"},859 {Domain: "zulu.example", Image: "zulu:v1", Network: "gordon-zulu", ContainerStatus: "running"},
M internal/app/kernel.go +17 -1
@@ -3,6 +3,7 @@ package app
33 import (
44 "context"
55 "fmt"
6+ "io"
67
78 "github.com/bnema/zerowrap"
89
@@ -29,13 +30,24 @@ type Kernel struct {
2930
3031 // NewKernel initializes local services without starting server listeners.
3132 func NewKernel(configPath string) (*Kernel, error) {
33+ return newKernel(configPath, initLogger)
34+}
35+
36+// NewKernelQuiet initializes local services without emitting console logs.
37+func NewKernelQuiet(configPath string) (*Kernel, error) {
38+ return newKernel(configPath, quietInitLogger)
39+}
40+
41+type kernelLoggerInit func(cfg Config) (zerowrap.Logger, func(), error)
42+
43+func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) {
3244 ctx := context.Background()
3345 v, cfg, err := initConfig(configPath)
3446 if err != nil {
3547 return nil, err
3648 }
3749
38- log, cleanup, err := initLogger(cfg)
50+ log, cleanup, err := initLog(cfg)
3951 if err != nil {
4052 return nil, err
4153 }
@@ -86,6 +98,10 @@ func NewKernel(configPath string) (*Kernel, error) {
8698 }, nil
8799 }
88100
101+func quietInitLogger(Config) (zerowrap.Logger, func(), error) {
102+ return zerowrap.New(zerowrap.Config{Level: "disabled", Output: io.Discard}), func() {}, nil
103+}
104+
89105 func (k *Kernel) Close() error {
90106 if k == nil || k.cleanup == nil {
91107 return nil
@@ -3,6 +3,7 @@ package app
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "io"
6 7
7 "github.com/bnema/zerowrap"8 "github.com/bnema/zerowrap"
8 9
@@ -29,13 +30,24 @@ type Kernel struct {
29 30
30 // NewKernel initializes local services without starting server listeners.31 // NewKernel initializes local services without starting server listeners.
31 func NewKernel(configPath string) (*Kernel, error) {32 func NewKernel(configPath string) (*Kernel, error) {
33+ return newKernel(configPath, initLogger)
34+}
35+
36+// NewKernelQuiet initializes local services without emitting console logs.
37+func NewKernelQuiet(configPath string) (*Kernel, error) {
38+ return newKernel(configPath, quietInitLogger)
39+}
40+
41+type kernelLoggerInit func(cfg Config) (zerowrap.Logger, func(), error)
42+
43+func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) {
32 ctx := context.Background()44 ctx := context.Background()
33 v, cfg, err := initConfig(configPath)45 v, cfg, err := initConfig(configPath)
34 if err != nil {46 if err != nil {
35 return nil, err47 return nil, err
36 }48 }
37 49
38- log, cleanup, err := initLogger(cfg)50+ log, cleanup, err := initLog(cfg)
39 if err != nil {51 if err != nil {
40 return nil, err52 return nil, err
41 }53 }
@@ -86,6 +98,10 @@ func NewKernel(configPath string) (*Kernel, error) {
86 }, nil98 }, nil
87 }99 }
88 100
101+func quietInitLogger(Config) (zerowrap.Logger, func(), error) {
102+ return zerowrap.New(zerowrap.Config{Level: "disabled", Output: io.Discard}), func() {}, nil
103+}
104+
89 func (k *Kernel) Close() error {105 func (k *Kernel) Close() error {
90 if k == nil || k.cleanup == nil {106 if k == nil || k.cleanup == nil {
91 return nil107 return nil

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