From de48caec9cba159e0200a91ffe4007af0564ffc8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 01:30:19 +0000 Subject: [PATCH 1/7] feat(diagnose): add --format json flag with structured DiagnoseReport output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add DiagnoseReport struct with HealthScore (0-100) and Findings slice to pkg/k8s/diagnostics.go. Introduce DiagnoseClusterReport() alongside the existing DiagnoseCluster() so callers can request structured output without breaking the default text path. Wire --format flag (text|json) into ksail cluster diagnose. When --format json is passed, the command marshals a DiagnoseReport to stdout, making the cluster_read MCP tool useful for AI assistants that need numeric health signals and structured finding data rather than free-form text. Each critical finding (not-ready node, failing pod) deducts 25 points from the 100-point HealthScore; warnings deduct 10 points; score floors at 0. Closes #4422 (partial — structured scoring + JSON output path) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cli/cmd/cluster/cluster.go | 29 ++++++- pkg/cli/cmd/cluster/cluster_test.go | 4 + pkg/k8s/diagnostics.go | 112 ++++++++++++++++++++++++++ pkg/k8s/diagnostics_test.go | 118 ++++++++++++++++++++++++++++ 4 files changed, 259 insertions(+), 4 deletions(-) diff --git a/pkg/cli/cmd/cluster/cluster.go b/pkg/cli/cmd/cluster/cluster.go index 25845abbe3..be1cfcb472 100644 --- a/pkg/cli/cmd/cluster/cluster.go +++ b/pkg/cli/cmd/cluster/cluster.go @@ -2843,6 +2843,7 @@ func NewDiagnoseCmd(_ *di.Runtime) *cobra.Command { var ( nameFlag string providerFlag v1alpha1.Provider + formatFlag string ) cmd := &cobra.Command{ @@ -2851,7 +2852,7 @@ func NewDiagnoseCmd(_ *di.Runtime) *cobra.Command { Long: diagnoseLongDesc, SilenceUsage: true, RunE: func(cmd *cobra.Command, _ []string) error { - return runDiagnoseCmd(cmd, nameFlag, providerFlag) + return runDiagnoseCmd(cmd, nameFlag, providerFlag, formatFlag) }, } @@ -2870,16 +2871,24 @@ func NewDiagnoseCmd(_ *di.Runtime) *cobra.Command { fmt.Sprintf("Provider to use (%s)", providerFlag.ValidValues()), ) + cmd.Flags().StringVar( + &formatFlag, + "format", + "text", + "Output format: text or json. Use json for machine-readable structured output.", + ) + return cmd } // runDiagnoseCmd inspects the live cluster via the Kubernetes API and writes -// a human-readable diagnostic report to the command's stdout. When every +// a human-readable or JSON diagnostic report to the command's stdout. When every // resource looks healthy the command writes a short "all healthy" banner. func runDiagnoseCmd( cmd *cobra.Command, nameFlag string, providerFlag v1alpha1.Provider, + formatFlag string, ) error { resolved, err := lifecycle.ResolveClusterInfo(cmd, nameFlag, providerFlag, "") if err != nil { @@ -2891,13 +2900,25 @@ func runDiagnoseCmd( return fmt.Errorf("build kubernetes client: %w", err) } + writer := cmd.OutOrStdout() + + if formatFlag == "json" { + report, diagErr := k8s.DiagnoseClusterReport(cmd.Context(), clientset, resolved.ClusterName) + if diagErr != nil { + return fmt.Errorf("diagnose cluster %q: %w", resolved.ClusterName, diagErr) + } + + enc := json.NewEncoder(writer) + enc.SetIndent("", " ") + + return enc.Encode(report) + } + report, err := k8s.DiagnoseCluster(cmd.Context(), clientset) if err != nil { return fmt.Errorf("diagnose cluster %q: %w", resolved.ClusterName, err) } - writer := cmd.OutOrStdout() - if report == "" { _, _ = fmt.Fprintf( writer, diff --git a/pkg/cli/cmd/cluster/cluster_test.go b/pkg/cli/cmd/cluster/cluster_test.go index b3174f1b30..2420110ac3 100644 --- a/pkg/cli/cmd/cluster/cluster_test.go +++ b/pkg/cli/cmd/cluster/cluster_test.go @@ -6729,6 +6729,10 @@ func TestNewDiagnoseCmd(t *testing.T) { providerFlag := diagnoseCmd.Flags().Lookup("provider") require.NotNil(t, providerFlag) assert.Equal(t, "p", providerFlag.Shorthand) + + formatFlag := diagnoseCmd.Flags().Lookup("format") + require.NotNil(t, formatFlag) + assert.Equal(t, "text", formatFlag.DefValue) } // TestClusterCmd_RegistersDiagnoseSubcommand verifies that NewClusterCmd wires diff --git a/pkg/k8s/diagnostics.go b/pkg/k8s/diagnostics.go index 53a7ac7224..87e4f8f20e 100644 --- a/pkg/k8s/diagnostics.go +++ b/pkg/k8s/diagnostics.go @@ -10,6 +10,118 @@ import ( "k8s.io/client-go/kubernetes" ) +// DiagnoseSeverity represents the severity level of a diagnostic finding. +type DiagnoseSeverity string + +const ( + // DiagnoseSeverityCritical indicates a resource is failing and requires immediate attention. + DiagnoseSeverityCritical DiagnoseSeverity = "critical" + // DiagnoseSeverityWarning indicates a resource is degraded but not yet failing. + DiagnoseSeverityWarning DiagnoseSeverity = "warning" +) + +// DiagnoseFinding describes a single unhealthy resource detected during diagnosis. +type DiagnoseFinding struct { + // Severity is the impact level: critical or warning. + Severity DiagnoseSeverity `json:"severity"` + // Resource is a short identifier, e.g. "node/node-1" or "pod/boom (default)". + Resource string `json:"resource"` + // Reason is a one-line description of the failure. + Reason string `json:"reason"` +} + +// DiagnoseReport is the structured result of DiagnoseClusterReport. It is +// the JSON-serialisable form of the cluster health snapshot produced by +// DiagnoseCluster. The HealthScore field (0–100) gives AI assistants and +// automation a single numeric signal; Findings carry the details. +type DiagnoseReport struct { + // ClusterName is the name of the inspected cluster. + ClusterName string `json:"clusterName"` + // HealthScore is an integer from 0 (completely broken) to 100 (fully healthy). + // Each critical finding deducts 25 points; each warning deducts 10 points. + HealthScore int `json:"healthScore"` + // Findings lists every unhealthy resource discovered. + Findings []DiagnoseFinding `json:"findings"` +} + +// DiagnoseClusterReport is the structured equivalent of DiagnoseCluster. It +// returns a DiagnoseReport suitable for JSON serialisation and AI consumption +// via the cluster_read MCP tool. The plain-text representation produced by +// DiagnoseCluster remains the default; this function is used when the caller +// requests --format json. +func DiagnoseClusterReport(ctx context.Context, clientset kubernetes.Interface, clusterName string) (DiagnoseReport, error) { + report := DiagnoseReport{ + ClusterName: clusterName, + HealthScore: 100, + Findings: []DiagnoseFinding{}, + } + + nodes, err := clientset.CoreV1().Nodes().List(ctx, metav1.ListOptions{}) + if err != nil { + return report, fmt.Errorf("list nodes: %w", err) + } + + for i := range nodes.Items { + node := &nodes.Items[i] + if reason := describeNotReadyNode(node); reason != "" { + report.Findings = append(report.Findings, DiagnoseFinding{ + Severity: DiagnoseSeverityCritical, + Resource: "node/" + node.Name, + Reason: reason, + }) + } + } + + namespaces, err := listNamespaceNames(ctx, clientset) + if err != nil { + return report, err + } + + for _, namespace := range namespaces { + pods, err := clientset.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + report.Findings = append(report.Findings, DiagnoseFinding{ + Severity: DiagnoseSeverityWarning, + Resource: "namespace/" + namespace, + Reason: fmt.Sprintf("failed to list pods: %v", err), + }) + + continue + } + + for j := range pods.Items { + pod := &pods.Items[j] + if isPodHealthy(pod) { + continue + } + + report.Findings = append(report.Findings, DiagnoseFinding{ + Severity: DiagnoseSeverityCritical, + Resource: fmt.Sprintf("pod/%s (%s)", pod.Name, namespace), + Reason: describePodFailure(pod), + }) + } + } + + score := 100 + for _, f := range report.Findings { + switch f.Severity { + case DiagnoseSeverityCritical: + score -= 25 + case DiagnoseSeverityWarning: + score -= 10 + } + } + + if score < 0 { + score = 0 + } + + report.HealthScore = score + + return report, nil +} + // DiagnoseCluster produces a combined human-readable diagnostic report for // a running Kubernetes cluster. It enumerates every namespace, surfaces any // failing pods via DiagnosePodFailures, and reports any nodes that are not diff --git a/pkg/k8s/diagnostics_test.go b/pkg/k8s/diagnostics_test.go index aae1ba7d64..db29b0d6ef 100644 --- a/pkg/k8s/diagnostics_test.go +++ b/pkg/k8s/diagnostics_test.go @@ -3,6 +3,7 @@ package k8s_test import ( "context" "errors" + "fmt" "testing" "github.com/devantler-tech/ksail/v7/pkg/k8s" @@ -502,3 +503,120 @@ func TestDiagnoseCluster_NodeListErrorIsSurfaced(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "list nodes") } + +func TestDiagnoseClusterReport_HealthyClusterReturns100(t *testing.T) { + t.Parallel() + + clientset := k8sfake.NewClientset( + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "node-1"}, + Status: corev1.NodeStatus{Conditions: []corev1.NodeCondition{ + {Type: corev1.NodeReady, Status: corev1.ConditionTrue}, + }}, + }, + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "healthy", Namespace: "default"}, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + ContainerStatuses: []corev1.ContainerStatus{{Ready: true}}, + }, + }, + ) + + report, err := k8s.DiagnoseClusterReport(context.Background(), clientset, "my-cluster") + + require.NoError(t, err) + assert.Equal(t, "my-cluster", report.ClusterName) + assert.Equal(t, 100, report.HealthScore) + assert.Empty(t, report.Findings) +} + +func TestDiagnoseClusterReport_FailingPodReducesScore(t *testing.T) { + t.Parallel() + + clientset := k8sfake.NewClientset( + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "node-ok"}, + Status: corev1.NodeStatus{Conditions: []corev1.NodeCondition{ + {Type: corev1.NodeReady, Status: corev1.ConditionTrue}, + }}, + }, + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "crasher", Namespace: "default"}, + Status: corev1.PodStatus{Phase: corev1.PodFailed}, + }, + ) + + report, err := k8s.DiagnoseClusterReport(context.Background(), clientset, "test") + + require.NoError(t, err) + assert.Equal(t, 75, report.HealthScore) + require.Len(t, report.Findings, 1) + assert.Equal(t, k8s.DiagnoseSeverityCritical, report.Findings[0].Severity) + assert.Contains(t, report.Findings[0].Resource, "crasher") +} + +func TestDiagnoseClusterReport_NotReadyNodeReducesScore(t *testing.T) { + t.Parallel() + + clientset := k8sfake.NewClientset( + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "broken-node"}, + Status: corev1.NodeStatus{Conditions: []corev1.NodeCondition{ + {Type: corev1.NodeReady, Status: corev1.ConditionFalse, Message: "disk pressure"}, + }}, + }, + ) + + report, err := k8s.DiagnoseClusterReport(context.Background(), clientset, "test") + + require.NoError(t, err) + assert.Equal(t, 75, report.HealthScore) + require.Len(t, report.Findings, 1) + assert.Equal(t, k8s.DiagnoseSeverityCritical, report.Findings[0].Severity) + assert.Equal(t, "node/broken-node", report.Findings[0].Resource) +} + +func TestDiagnoseClusterReport_ScoreFloorsAtZero(t *testing.T) { + t.Parallel() + + pods := make([]runtime.Object, 0, 5) + pods = append(pods, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, + ) + + for i := range 5 { + pods = append(pods, &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("fail-%d", i), Namespace: "default"}, + Status: corev1.PodStatus{Phase: corev1.PodFailed}, + }) + } + + clientset := k8sfake.NewClientset(pods...) + + report, err := k8s.DiagnoseClusterReport(context.Background(), clientset, "overloaded") + + require.NoError(t, err) + assert.Equal(t, 0, report.HealthScore) +} + +func TestDiagnoseClusterReport_NodeListErrorIsSurfaced(t *testing.T) { + t.Parallel() + + clientset := k8sfake.NewClientset() + clientset.PrependReactor( + "list", + "nodes", + func(_ k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, errConnectionRefused + }, + ) + + _, err := k8s.DiagnoseClusterReport(context.Background(), clientset, "test") + + require.Error(t, err) + assert.Contains(t, err.Error(), "list nodes") +} From c67e47c46287046f1789ab12fba77350143fbe1b Mon Sep 17 00:00:00 2001 From: devantler <26203420+devantler@users.noreply.github.com> Date: Mon, 4 May 2026 05:45:27 +0000 Subject: [PATCH 2/7] chore: sync modules and update generated files --- docs/src/content/docs/cli-flags/cluster/cluster-diagnose.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/src/content/docs/cli-flags/cluster/cluster-diagnose.mdx b/docs/src/content/docs/cli-flags/cluster/cluster-diagnose.mdx index 34d3d6f1c6..5c91fb8b80 100644 --- a/docs/src/content/docs/cli-flags/cluster/cluster-diagnose.mdx +++ b/docs/src/content/docs/cli-flags/cluster/cluster-diagnose.mdx @@ -33,6 +33,7 @@ Usage: ksail cluster diagnose [flags] Flags: + --format string Output format: text or json. Use json for machine-readable structured output. (default "text") -n, --name string Name of the cluster to target -p, --provider Provider Provider to use ([Docker Hetzner Omni AWS]) From 79521127743728816b234e56639430c06101eab6 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 21:09:36 +0200 Subject: [PATCH 3/7] fix(diagnose): validate --format flag and reject unknown values early Add early validation for the --format flag in runDiagnoseCmd that normalises the value to lower-case and returns ErrUnsupportedOutputFormat for any value other than "text" or "json". This prevents typos like --format jsn from silently falling back to the text path, which would cause MCP/automation callers to receive prose when they expected JSON. Reuses the existing ErrUnsupportedOutputFormat sentinel and outputFormatText/outputFormatJSON constants for consistency with the --output flag validation pattern already in this package. Also add: - TestDiagnoseCmd_InvalidFormatRejectsEarly to cover the new guard - TestDiagnoseClusterReport_PodListErrorCreatesWarningFinding to cover the DiagnoseSeverityWarning path in DiagnoseClusterReport Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cli/cmd/cluster/cluster.go | 13 +++++++++- pkg/cli/cmd/cluster/cluster_test.go | 37 +++++++++++++++++++++++++++++ pkg/k8s/diagnostics_test.go | 24 +++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/pkg/cli/cmd/cluster/cluster.go b/pkg/cli/cmd/cluster/cluster.go index be1cfcb472..89b6efd28a 100644 --- a/pkg/cli/cmd/cluster/cluster.go +++ b/pkg/cli/cmd/cluster/cluster.go @@ -2890,6 +2890,17 @@ func runDiagnoseCmd( providerFlag v1alpha1.Provider, formatFlag string, ) error { + format := strings.ToLower(formatFlag) + if format != outputFormatText && format != outputFormatJSON { + return fmt.Errorf( + "%w: %q (expected %q or %q)", + ErrUnsupportedOutputFormat, + format, + outputFormatText, + outputFormatJSON, + ) + } + resolved, err := lifecycle.ResolveClusterInfo(cmd, nameFlag, providerFlag, "") if err != nil { return fmt.Errorf("resolve cluster info: %w", err) @@ -2902,7 +2913,7 @@ func runDiagnoseCmd( writer := cmd.OutOrStdout() - if formatFlag == "json" { + if format == outputFormatJSON { report, diagErr := k8s.DiagnoseClusterReport(cmd.Context(), clientset, resolved.ClusterName) if diagErr != nil { return fmt.Errorf("diagnose cluster %q: %w", resolved.ClusterName, diagErr) diff --git a/pkg/cli/cmd/cluster/cluster_test.go b/pkg/cli/cmd/cluster/cluster_test.go index 2420110ac3..ac41d53874 100644 --- a/pkg/cli/cmd/cluster/cluster_test.go +++ b/pkg/cli/cmd/cluster/cluster_test.go @@ -6735,6 +6735,43 @@ func TestNewDiagnoseCmd(t *testing.T) { assert.Equal(t, "text", formatFlag.DefValue) } +// TestDiagnoseCmd_InvalidFormatRejectsEarly verifies that an unknown --format +// value is rejected before any cluster interaction takes place. +// This guards against typos like "--format jsn" silently falling back to the +// text path instead of returning an actionable error. +func TestDiagnoseCmd_InvalidFormatRejectsEarly(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + format string + }{ + {name: "typo jsn", format: "jsn"}, + {name: "empty format", format: ""}, + {name: "xml", format: "xml"}, + {name: "pretty", format: "pretty"}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + diagnoseCmd := cluster.NewDiagnoseCmd(nil) + diagnoseCmd.SetOut(io.Discard) + diagnoseCmd.SetErr(io.Discard) + diagnoseCmd.SetArgs([]string{"--format", testCase.format}) + + err := diagnoseCmd.Execute() + + require.Error(t, err) + assert.ErrorIs(t, err, cluster.ErrUnsupportedOutputFormat, + "expected ErrUnsupportedOutputFormat for format %q, got: %v", + testCase.format, err, + ) + }) + } +} + // TestClusterCmd_RegistersDiagnoseSubcommand verifies that NewClusterCmd wires // the diagnose subcommand into the cluster command tree so that toolgen can // expose it as part of the cluster_read tool. diff --git a/pkg/k8s/diagnostics_test.go b/pkg/k8s/diagnostics_test.go index db29b0d6ef..f7823c24ea 100644 --- a/pkg/k8s/diagnostics_test.go +++ b/pkg/k8s/diagnostics_test.go @@ -620,3 +620,27 @@ func TestDiagnoseClusterReport_NodeListErrorIsSurfaced(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "list nodes") } + +func TestDiagnoseClusterReport_PodListErrorCreatesWarningFinding(t *testing.T) { +t.Parallel() + +clientset := k8sfake.NewClientset( +&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "broken-ns"}}, +) +clientset.PrependReactor( +"list", +"pods", +func(_ k8stesting.Action) (bool, runtime.Object, error) { +return true, nil, errConnectionRefused +}, +) + +report, err := k8s.DiagnoseClusterReport(context.Background(), clientset, "test") + +require.NoError(t, err) +require.Len(t, report.Findings, 1) +assert.Equal(t, k8s.DiagnoseSeverityWarning, report.Findings[0].Severity) +assert.Equal(t, "namespace/broken-ns", report.Findings[0].Resource) +assert.Contains(t, report.Findings[0].Reason, "failed to list pods") +assert.Equal(t, 90, report.HealthScore) +} From 12027d53e50da5b998307181eb6b27b4433b4913 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 22:04:41 +0200 Subject: [PATCH 4/7] fix(diagnose): resolve golangci-lint issues in structured report code - Extract score calculation into diagnoseComputeScore to reduce cyclomatic complexity of DiagnoseClusterReport from 13 to below the max of 10 - Extract pod-listing loop into appendNamespacePodFindings for the same reason - Replace magic numbers 100/25/10 with named constants (diagnoseMaxHealthScore, diagnoseCriticalPenalty, diagnoseWarningPenalty) - Wrap json.Encoder.Encode error to satisfy wrapcheck linter Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cli/cmd/cluster/cluster.go | 6 ++- pkg/k8s/diagnostics.go | 76 ++++++++++++++++++++++------------ 2 files changed, 54 insertions(+), 28 deletions(-) diff --git a/pkg/cli/cmd/cluster/cluster.go b/pkg/cli/cmd/cluster/cluster.go index 89b6efd28a..6bfafda73a 100644 --- a/pkg/cli/cmd/cluster/cluster.go +++ b/pkg/cli/cmd/cluster/cluster.go @@ -2922,7 +2922,11 @@ func runDiagnoseCmd( enc := json.NewEncoder(writer) enc.SetIndent("", " ") - return enc.Encode(report) + if err := enc.Encode(report); err != nil { + return fmt.Errorf("encode diagnose report: %w", err) + } + + return nil } report, err := k8s.DiagnoseCluster(cmd.Context(), clientset) diff --git a/pkg/k8s/diagnostics.go b/pkg/k8s/diagnostics.go index 87e4f8f20e..0b7e0aa896 100644 --- a/pkg/k8s/diagnostics.go +++ b/pkg/k8s/diagnostics.go @@ -20,6 +20,15 @@ const ( DiagnoseSeverityWarning DiagnoseSeverity = "warning" ) +const ( + // diagnoseMaxHealthScore is the starting score for a fully healthy cluster. + diagnoseMaxHealthScore = 100 + // diagnoseCriticalPenalty is the score deduction for each critical finding. + diagnoseCriticalPenalty = 25 + // diagnoseWarningPenalty is the score deduction for each warning finding. + diagnoseWarningPenalty = 10 +) + // DiagnoseFinding describes a single unhealthy resource detected during diagnosis. type DiagnoseFinding struct { // Severity is the impact level: critical or warning. @@ -52,7 +61,7 @@ type DiagnoseReport struct { func DiagnoseClusterReport(ctx context.Context, clientset kubernetes.Interface, clusterName string) (DiagnoseReport, error) { report := DiagnoseReport{ ClusterName: clusterName, - HealthScore: 100, + HealthScore: diagnoseMaxHealthScore, Findings: []DiagnoseFinding{}, } @@ -78,38 +87,53 @@ func DiagnoseClusterReport(ctx context.Context, clientset kubernetes.Interface, } for _, namespace := range namespaces { - pods, err := clientset.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{}) - if err != nil { - report.Findings = append(report.Findings, DiagnoseFinding{ - Severity: DiagnoseSeverityWarning, - Resource: "namespace/" + namespace, - Reason: fmt.Sprintf("failed to list pods: %v", err), - }) + appendNamespacePodFindings(ctx, clientset, namespace, &report.Findings) + } - continue - } + report.HealthScore = diagnoseComputeScore(report.Findings) - for j := range pods.Items { - pod := &pods.Items[j] - if isPodHealthy(pod) { - continue - } + return report, nil +} - report.Findings = append(report.Findings, DiagnoseFinding{ - Severity: DiagnoseSeverityCritical, - Resource: fmt.Sprintf("pod/%s (%s)", pod.Name, namespace), - Reason: describePodFailure(pod), - }) +// appendNamespacePodFindings lists all pods in namespace and appends a finding +// for each unhealthy pod (or a warning finding when the pod list itself fails). +func appendNamespacePodFindings(ctx context.Context, clientset kubernetes.Interface, namespace string, findings *[]DiagnoseFinding) { + pods, err := clientset.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + *findings = append(*findings, DiagnoseFinding{ + Severity: DiagnoseSeverityWarning, + Resource: "namespace/" + namespace, + Reason: fmt.Sprintf("failed to list pods: %v", err), + }) + + return + } + + for j := range pods.Items { + pod := &pods.Items[j] + if isPodHealthy(pod) { + continue } + + *findings = append(*findings, DiagnoseFinding{ + Severity: DiagnoseSeverityCritical, + Resource: fmt.Sprintf("pod/%s (%s)", pod.Name, namespace), + Reason: describePodFailure(pod), + }) } +} - score := 100 - for _, f := range report.Findings { +// diagnoseComputeScore returns a health score in [0, diagnoseMaxHealthScore] +// by deducting penalty points for each finding. +func diagnoseComputeScore(findings []DiagnoseFinding) int { + score := diagnoseMaxHealthScore + + for _, f := range findings { switch f.Severity { case DiagnoseSeverityCritical: - score -= 25 + score -= diagnoseCriticalPenalty case DiagnoseSeverityWarning: - score -= 10 + score -= diagnoseWarningPenalty } } @@ -117,9 +141,7 @@ func DiagnoseClusterReport(ctx context.Context, clientset kubernetes.Interface, score = 0 } - report.HealthScore = score - - return report, nil + return score } // DiagnoseCluster produces a combined human-readable diagnostic report for From a2ebd8cd45c8e019aa140465def359a3a3498a52 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 22:24:58 +0200 Subject: [PATCH 5/7] fix(diagnose): resolve second round of golangci-lint issues - Split long function signatures (>120 chars) for DiagnoseClusterReport and appendNamespacePodFindings across multiple lines (lll) - Extract runDiagnoseJSONReport helper to keep runDiagnoseCmd within the 60-line limit (funlen) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cli/cmd/cluster/cluster.go | 23 +++++++++++++++-------- pkg/k8s/diagnostics.go | 13 +++++++++++-- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/pkg/cli/cmd/cluster/cluster.go b/pkg/cli/cmd/cluster/cluster.go index 6bfafda73a..05ef19ed0c 100644 --- a/pkg/cli/cmd/cluster/cluster.go +++ b/pkg/cli/cmd/cluster/cluster.go @@ -2919,14 +2919,7 @@ func runDiagnoseCmd( return fmt.Errorf("diagnose cluster %q: %w", resolved.ClusterName, diagErr) } - enc := json.NewEncoder(writer) - enc.SetIndent("", " ") - - if err := enc.Encode(report); err != nil { - return fmt.Errorf("encode diagnose report: %w", err) - } - - return nil + return runDiagnoseJSONReport(report, writer) } report, err := k8s.DiagnoseCluster(cmd.Context(), clientset) @@ -2950,6 +2943,20 @@ func runDiagnoseCmd( return nil } +// runDiagnoseJSONReport serialises the structured DiagnoseReport for clusterName +// as indented JSON to w. It is extracted from runDiagnoseCmd to keep that +// function within the allowed line-count limit. +func runDiagnoseJSONReport(report k8s.DiagnoseReport, w io.Writer) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + + if err := enc.Encode(report); err != nil { + return fmt.Errorf("encode diagnose report: %w", err) + } + + return nil +} + // runInfoCmd orchestrates the cluster info command flow: // 1. Resolve cluster identity (name, provider, kubeconfig) // 2. Query provider API for cluster status diff --git a/pkg/k8s/diagnostics.go b/pkg/k8s/diagnostics.go index 0b7e0aa896..333be5ec73 100644 --- a/pkg/k8s/diagnostics.go +++ b/pkg/k8s/diagnostics.go @@ -58,7 +58,11 @@ type DiagnoseReport struct { // via the cluster_read MCP tool. The plain-text representation produced by // DiagnoseCluster remains the default; this function is used when the caller // requests --format json. -func DiagnoseClusterReport(ctx context.Context, clientset kubernetes.Interface, clusterName string) (DiagnoseReport, error) { +func DiagnoseClusterReport( + ctx context.Context, + clientset kubernetes.Interface, + clusterName string, +) (DiagnoseReport, error) { report := DiagnoseReport{ ClusterName: clusterName, HealthScore: diagnoseMaxHealthScore, @@ -97,7 +101,12 @@ func DiagnoseClusterReport(ctx context.Context, clientset kubernetes.Interface, // appendNamespacePodFindings lists all pods in namespace and appends a finding // for each unhealthy pod (or a warning finding when the pod list itself fails). -func appendNamespacePodFindings(ctx context.Context, clientset kubernetes.Interface, namespace string, findings *[]DiagnoseFinding) { +func appendNamespacePodFindings( + ctx context.Context, + clientset kubernetes.Interface, + namespace string, + findings *[]DiagnoseFinding, +) { pods, err := clientset.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{}) if err != nil { *findings = append(*findings, DiagnoseFinding{ From 3381e9c522c066b478e2666178310ef13a6c1032 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 22:39:23 +0200 Subject: [PATCH 6/7] style(diagnose): apply gci import ordering to cluster.go golangci-lint auto-formats imports on each run; pre-applying this avoids the git-auto-commit-action conflicting during CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cli/cmd/cluster/cluster.go | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/pkg/cli/cmd/cluster/cluster.go b/pkg/cli/cmd/cluster/cluster.go index 05ef19ed0c..4e2af39d25 100644 --- a/pkg/cli/cmd/cluster/cluster.go +++ b/pkg/cli/cmd/cluster/cluster.go @@ -19,6 +19,21 @@ import ( "syscall" "time" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" + "github.com/hetznercloud/hcloud-go/v2/hcloud" + "github.com/k3d-io/k3d/v5/pkg/config/v1alpha5" + omniclient "github.com/siderolabs/omni/client/pkg/client" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "golang.org/x/sync/errgroup" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/cli-runtime/pkg/genericiooptions" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + "sigs.k8s.io/kind/pkg/apis/config/v1alpha4" + sigsyaml "sigs.k8s.io/yaml" + "github.com/devantler-tech/ksail/v7/internal/buildmeta" v1alpha1 "github.com/devantler-tech/ksail/v7/pkg/apis/cluster/v1alpha1" "github.com/devantler-tech/ksail/v7/pkg/cli/annotations" @@ -64,20 +79,6 @@ import ( "github.com/devantler-tech/ksail/v7/pkg/svc/state" "github.com/devantler-tech/ksail/v7/pkg/svc/versionresolver" "github.com/devantler-tech/ksail/v7/pkg/timer" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/client" - "github.com/hetznercloud/hcloud-go/v2/hcloud" - "github.com/k3d-io/k3d/v5/pkg/config/v1alpha5" - omniclient "github.com/siderolabs/omni/client/pkg/client" - "github.com/spf13/cobra" - "github.com/spf13/pflag" - "golang.org/x/sync/errgroup" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/cli-runtime/pkg/genericiooptions" - "k8s.io/client-go/tools/clientcmd" - clientcmdapi "k8s.io/client-go/tools/clientcmd/api" - "sigs.k8s.io/kind/pkg/apis/config/v1alpha4" - sigsyaml "sigs.k8s.io/yaml" ) const ( From 2fbcc296c15ce60c769485f683ca5623ee5e891b Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 22:54:47 +0200 Subject: [PATCH 7/7] style(diagnose): apply golangci-lint formatter fixes (gci order, wsl_v5, gofmt) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix import ordering in cluster.go to match gci's expected section order (standard → devantler-tech prefix → external), split enc.Encode assignment per wsl_v5 requirement, and fix indentation in diagnostics_test.go. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cli/cmd/cluster/cluster.go | 32 +++++++++++++++--------------- pkg/k8s/diagnostics_test.go | 36 +++++++++++++++++----------------- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/pkg/cli/cmd/cluster/cluster.go b/pkg/cli/cmd/cluster/cluster.go index 4e2af39d25..bbb38e1cb0 100644 --- a/pkg/cli/cmd/cluster/cluster.go +++ b/pkg/cli/cmd/cluster/cluster.go @@ -19,21 +19,6 @@ import ( "syscall" "time" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/client" - "github.com/hetznercloud/hcloud-go/v2/hcloud" - "github.com/k3d-io/k3d/v5/pkg/config/v1alpha5" - omniclient "github.com/siderolabs/omni/client/pkg/client" - "github.com/spf13/cobra" - "github.com/spf13/pflag" - "golang.org/x/sync/errgroup" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/cli-runtime/pkg/genericiooptions" - "k8s.io/client-go/tools/clientcmd" - clientcmdapi "k8s.io/client-go/tools/clientcmd/api" - "sigs.k8s.io/kind/pkg/apis/config/v1alpha4" - sigsyaml "sigs.k8s.io/yaml" - "github.com/devantler-tech/ksail/v7/internal/buildmeta" v1alpha1 "github.com/devantler-tech/ksail/v7/pkg/apis/cluster/v1alpha1" "github.com/devantler-tech/ksail/v7/pkg/cli/annotations" @@ -79,6 +64,20 @@ import ( "github.com/devantler-tech/ksail/v7/pkg/svc/state" "github.com/devantler-tech/ksail/v7/pkg/svc/versionresolver" "github.com/devantler-tech/ksail/v7/pkg/timer" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" + "github.com/hetznercloud/hcloud-go/v2/hcloud" + "github.com/k3d-io/k3d/v5/pkg/config/v1alpha5" + omniclient "github.com/siderolabs/omni/client/pkg/client" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "golang.org/x/sync/errgroup" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/cli-runtime/pkg/genericiooptions" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + "sigs.k8s.io/kind/pkg/apis/config/v1alpha4" + sigsyaml "sigs.k8s.io/yaml" ) const ( @@ -2951,7 +2950,8 @@ func runDiagnoseJSONReport(report k8s.DiagnoseReport, w io.Writer) error { enc := json.NewEncoder(w) enc.SetIndent("", " ") - if err := enc.Encode(report); err != nil { + err := enc.Encode(report) + if err != nil { return fmt.Errorf("encode diagnose report: %w", err) } diff --git a/pkg/k8s/diagnostics_test.go b/pkg/k8s/diagnostics_test.go index f7823c24ea..d382417672 100644 --- a/pkg/k8s/diagnostics_test.go +++ b/pkg/k8s/diagnostics_test.go @@ -622,25 +622,25 @@ func TestDiagnoseClusterReport_NodeListErrorIsSurfaced(t *testing.T) { } func TestDiagnoseClusterReport_PodListErrorCreatesWarningFinding(t *testing.T) { -t.Parallel() + t.Parallel() -clientset := k8sfake.NewClientset( -&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "broken-ns"}}, -) -clientset.PrependReactor( -"list", -"pods", -func(_ k8stesting.Action) (bool, runtime.Object, error) { -return true, nil, errConnectionRefused -}, -) + clientset := k8sfake.NewClientset( + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "broken-ns"}}, + ) + clientset.PrependReactor( + "list", + "pods", + func(_ k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, errConnectionRefused + }, + ) -report, err := k8s.DiagnoseClusterReport(context.Background(), clientset, "test") + report, err := k8s.DiagnoseClusterReport(context.Background(), clientset, "test") -require.NoError(t, err) -require.Len(t, report.Findings, 1) -assert.Equal(t, k8s.DiagnoseSeverityWarning, report.Findings[0].Severity) -assert.Equal(t, "namespace/broken-ns", report.Findings[0].Resource) -assert.Contains(t, report.Findings[0].Reason, "failed to list pods") -assert.Equal(t, 90, report.HealthScore) + require.NoError(t, err) + require.Len(t, report.Findings, 1) + assert.Equal(t, k8s.DiagnoseSeverityWarning, report.Findings[0].Severity) + assert.Equal(t, "namespace/broken-ns", report.Findings[0].Resource) + assert.Contains(t, report.Findings[0].Reason, "failed to list pods") + assert.Equal(t, 90, report.HealthScore) }