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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 32 | 32 | } | |
| 33 | 33 | ||
| 34 | 34 | func resolveLocalControlPlane(configPath string) (*controlPlaneHandle, error) { | |
| 35 | - | kernel, err := app.NewKernel(configPath) | |
| 35 | + | kernel, err := app.NewKernelQuiet(configPath) | |
| 36 | 36 | if err != nil { | |
| 37 | 37 | return nil, fmt.Errorf("failed to initialize local control plane: %w", err) | |
| 38 | 38 | } | |
| @@ -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, | |||
| 500 | 500 | } | |
| 501 | 501 | ||
| 502 | 502 | func renderRoutesListSections(out io.Writer, sections []routeListSection) error { | |
| 503 | + | sections = renderedRouteListSections(sections) | |
| 504 | + | ||
| 503 | 505 | if err := cliWriteLine(out, cliRenderTitle("Routes")); err != nil { | |
| 504 | 506 | return err | |
| 505 | 507 | } | |
| @@ -558,6 +560,8 @@ func renderRoutesListSection(out io.Writer, section routeListSection) error { | |||
| 558 | 560 | } | |
| 559 | 561 | ||
| 560 | 562 | func renderRoutesStatusSections(out io.Writer, sections []routeStatusSection) error { | |
| 563 | + | sections = renderedRouteStatusSections(sections) | |
| 564 | + | ||
| 561 | 565 | if err := cliWriteLine(out, cliRenderTitle("Route Status")); err != nil { | |
| 562 | 566 | return err | |
| 563 | 567 | } | |
| @@ -579,6 +583,52 @@ func renderRoutesStatusSections(out io.Writer, sections []routeStatusSection) er | |||
| 579 | 583 | 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 | 632 | func renderRoutesStatusSection(out io.Writer, section routeStatusSection) error { | |
| 583 | 633 | if err := cliWriteLine(out, routeSectionHeading(section.Kind, section.Name)); err != nil { | |
| 584 | 634 | 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 err | 506 | 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 err | 566 | return err | ||
| 563 | } | 567 | } | ||
| @@ -579,6 +583,52 @@ func renderRoutesStatusSections(out io.Writer, sections []routeStatusSection) er | |||||
| 579 | return nil | 583 | 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 err | 634 | return err | ||
M internal/adapters/in/cli/routes_test.go +116 -0
| @@ -5,8 +5,10 @@ import ( | |||
| 5 | 5 | "context" | |
| 6 | 6 | "encoding/json" | |
| 7 | 7 | "errors" | |
| 8 | + | "io" | |
| 8 | 9 | "os" | |
| 9 | 10 | "path/filepath" | |
| 11 | + | "strconv" | |
| 10 | 12 | "strings" | |
| 11 | 13 | "sync/atomic" | |
| 12 | 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 | 750 | func TestRenderRoutesStatusSections_IncludesSectionHeadingsAndErrors(t *testing.T) { | |
| 686 | 751 | sections := []routeStatusSection{ | |
| 687 | 752 | { | |
| @@ -738,6 +803,57 @@ func TestRenderRoutesStatusSections_IncludesSectionHeadingsAndErrors(t *testing. | |||
| 738 | 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 | 857 | func TestBuildRouteStatusTree_DeterministicAcrossInputOrder(t *testing.T) { | |
| 742 | 858 | routes := []routeStatusItem{ | |
| 743 | 859 | {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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "io" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/zerowrap" | |
| 8 | 9 | ||
| @@ -29,13 +30,24 @@ type Kernel struct { | |||
| 29 | 30 | ||
| 30 | 31 | // NewKernel initializes local services without starting server listeners. | |
| 31 | 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 | 44 | ctx := context.Background() | |
| 33 | 45 | v, cfg, err := initConfig(configPath) | |
| 34 | 46 | if err != nil { | |
| 35 | 47 | return nil, err | |
| 36 | 48 | } | |
| 37 | 49 | ||
| 38 | - | log, cleanup, err := initLogger(cfg) | |
| 50 | + | log, cleanup, err := initLog(cfg) | |
| 39 | 51 | if err != nil { | |
| 40 | 52 | return nil, err | |
| 41 | 53 | } | |
| @@ -86,6 +98,10 @@ func NewKernel(configPath string) (*Kernel, error) { | |||
| 86 | 98 | }, 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 | 105 | func (k *Kernel) Close() error { | |
| 90 | 106 | if k == nil || k.cleanup == nil { | |
| 91 | 107 | 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, err | 47 | 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, err | 52 | return nil, err | ||
| 41 | } | 53 | } | ||
| @@ -86,6 +98,10 @@ func NewKernel(configPath string) (*Kernel, error) { | |||||
| 86 | }, nil | 98 | }, 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 nil | 107 | return nil | ||