From 230d11eb8ffeb07242ccecf46920b05cfe42cc76 Mon Sep 17 00:00:00 2001 From: Douglas Adolph <116844282+dcadolph@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:04:18 -0600 Subject: [PATCH 1/5] Scan every kubeconfig context when none are named The all-contexts flag, the all_contexts API field, and the chart's allContexts value are gone. One resolver now serves scan, watch, the serve scheduler, the API trigger, the inbound webhook, and the controller. --- client/client_test.go | 5 +- client/types.go | 4 +- cmd/cmd_init.go | 2 - cmd/cmd_scan.go | 22 +--- cmd/cmd_scan_test.go | 109 +++++++++++++----- cmd/cmd_serve.go | 17 +-- cmd/cmd_serve_test.go | 27 ----- cmd/cmd_watch.go | 8 +- cmd/errors.go | 2 - cmd/execute.go | 3 +- cmd/execute_test.go | 4 +- deploy/crds/clusterscan.yaml | 2 +- deploy/examples/serve-config.yaml | 1 - .../templates/crd-clusterscan.yaml | 2 +- .../fleetsweeper/templates/deployment.yaml | 4 +- deploy/helm/fleetsweeper/values.yaml | 2 +- docs/getting-started.md | 10 +- docs/integrations/registry-probing.md | 2 +- docs/integrations/webhooks.md | 4 +- docs/operator/clusterscan.md | 2 +- docs/operator/helm.md | 2 +- docs/operator/server-mode.md | 1 - docs/reference/cli.md | 2 +- internal/integration/fleet_test.go | 2 +- internal/kube/client.go | 18 +++ internal/kube/client_test.go | 51 ++++++++ internal/kube/errors.go | 2 + internal/server/demo_live_test.go | 2 +- internal/server/e2e_demo_test.go | 2 +- internal/server/handlers.go | 22 ++-- internal/server/handlers_trigger_test.go | 31 ++++- internal/server/inbound_webhook.go | 9 +- internal/server/inbound_webhook_test.go | 4 +- internal/server/openapi.yaml | 4 +- internal/server/scan_once.go | 17 ++- internal/server/scan_once_test.go | 11 ++ internal/server/static/js/views/trigger.js | 2 +- 37 files changed, 256 insertions(+), 158 deletions(-) diff --git a/client/client_test.go b/client/client_test.go index 4820d6c..1dede37 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -272,6 +272,7 @@ func TestContract(t *testing.T) { okOrAPIError(t, "contexts", func() error { _, err := c.Contexts(ctx); return err }()) }) + // An empty request scans every kubeconfig context, and this server's kubeconfig holds none. t.Run("trigger scan empty", func(t *testing.T) { _, err := c.TriggerScan(ctx, client.TriggerScanRequest{}) var apiErr *client.APIError @@ -281,8 +282,8 @@ func TestContract(t *testing.T) { if apiErr.StatusCode != http.StatusBadRequest { t.Errorf("status = %d, want %d", apiErr.StatusCode, http.StatusBadRequest) } - if apiErr.Message != "no contexts specified" { - t.Errorf("message = %q, want %q", apiErr.Message, "no contexts specified") + if apiErr.Message != "no contexts in kubeconfig" { + t.Errorf("message = %q, want %q", apiErr.Message, "no contexts in kubeconfig") } }) diff --git a/client/types.go b/client/types.go index b052d37..ad6a363 100644 --- a/client/types.go +++ b/client/types.go @@ -557,10 +557,8 @@ type TimelineResponse struct { // TriggerScanRequest is the body for POST /api/scans. type TriggerScanRequest struct { - // Contexts names the kubeconfig contexts to scan. + // Contexts names the kubeconfig contexts to scan. Empty with no Group scans every context. Contexts []string `json:"contexts,omitempty"` - // AllContexts scans every kubeconfig context when true. - AllContexts bool `json:"all_contexts,omitempty"` // Group scans the clusters in a named group. Group string `json:"group,omitempty"` } diff --git a/cmd/cmd_init.go b/cmd/cmd_init.go index 58f0194..5321bd6 100644 --- a/cmd/cmd_init.go +++ b/cmd/cmd_init.go @@ -100,7 +100,6 @@ cors: scheduler: interval: 30m - allContexts: true persistence: enabled: true @@ -156,7 +155,6 @@ admin-addr: "127.0.0.1:8081" db: "/data/fleet.db" controller: true scan-interval: "30m" -all-contexts: true audit-retention: "720h" ` diff --git a/cmd/cmd_scan.go b/cmd/cmd_scan.go index f3a99cc..3d7e8aa 100644 --- a/cmd/cmd_scan.go +++ b/cmd/cmd_scan.go @@ -48,15 +48,15 @@ import ( var scanCmd = &cobra.Command{ Use: "scan", Short: "Scan clusters and compare configurations.", - Example: " fleetsweeper scan --all-contexts\n" + + Example: " fleetsweeper scan\n" + " fleetsweeper scan --contexts prod-east,prod-west --db fleet.db -o html --html-file report.html", - Long: "Connect to multiple Kubernetes clusters and produce a comparison report highlighting fleet divergence.", + Long: "Connect to every context in the kubeconfig, or the ones --contexts or --group name, and produce " + + "a comparison report highlighting fleet divergence.", RunE: runScan, } func init() { - scanCmd.Flags().StringSlice("contexts", nil, "Kubeconfig context names to scan.") - scanCmd.Flags().Bool("all-contexts", false, "Scan all contexts in the kubeconfig.") + scanCmd.Flags().StringSlice("contexts", nil, "Kubeconfig context names to scan (default: every context).") scanCmd.Flags().Int("workers", 5, "Maximum concurrent operations.") scanCmd.Flags().Duration("scanner-timeout", scanner.DefaultScanTimeout, "Maximum time a single scanner may run against one cluster. 0 disables.") scanCmd.Flags().StringSlice("scanners", nil, "Scanner names to run (default: all).") @@ -109,7 +109,6 @@ func runScan(cmd *cobra.Command, _ []string) error { pretty, _ := cmd.Flags().GetBool("pretty") workers, _ := cmd.Flags().GetInt("workers") scannerTimeout, _ := cmd.Flags().GetDuration("scanner-timeout") - allContexts, _ := cmd.Flags().GetBool("all-contexts") contextNames, _ := cmd.Flags().GetStringSlice("contexts") scannerNames, _ := cmd.Flags().GetStringSlice("scanners") groupName, _ := cmd.Flags().GetString("group") @@ -139,7 +138,7 @@ func runScan(cmd *cobra.Command, _ []string) error { contextNames = g.Clusters } - contexts, err := resolveContexts(kubeconfigPath, contextNames, allContexts) + contexts, err := kube.ResolveContexts(kubeconfigPath, contextNames) if err != nil { return err } @@ -270,17 +269,6 @@ func runScan(cmd *cobra.Command, _ []string) error { return nil } -// resolveContexts determines which kubeconfig contexts to scan. -func resolveContexts(kubeconfigPath string, explicit []string, all bool) ([]string, error) { - if all { - return kube.AvailableContexts(kubeconfigPath) - } - if len(explicit) > 0 { - return explicit, nil - } - return nil, ErrNoContexts -} - // selectScanners filters the registry to the requested scanner names. When names is empty all // scanners are selected. Unknown names return an ErrFlagValue error listing them and the // registered names. diff --git a/cmd/cmd_scan_test.go b/cmd/cmd_scan_test.go index fa68c65..9ab319a 100644 --- a/cmd/cmd_scan_test.go +++ b/cmd/cmd_scan_test.go @@ -1,11 +1,13 @@ package cmd import ( + "bytes" "context" "errors" "fmt" "os" "path/filepath" + "slices" "testing" "github.com/google/go-cmp/cmp" @@ -13,6 +15,7 @@ import ( "github.com/spf13/cobra" "github.com/dcadolph/fleetsweeper/internal/cohort" + "github.com/dcadolph/fleetsweeper/internal/kube" "github.com/dcadolph/fleetsweeper/internal/report" "github.com/dcadolph/fleetsweeper/internal/server" "github.com/dcadolph/fleetsweeper/internal/store" @@ -37,38 +40,6 @@ func TestBuildRegistry(t *testing.T) { } // TestSelectScanners verifies empty selection means all, a subset is honored, -// TestResolveContexts verifies the explicit, none, and all-contexts branches. -func TestResolveContexts(t *testing.T) { - t.Parallel() - - // Test 0: Explicit contexts pass through unchanged. - got, err := resolveContexts("", []string{"a", "b"}, false) - if err != nil { - t.Fatalf("explicit: %v", err) - } - if diff := cmp.Diff([]string{"a", "b"}, got); diff != "" { - t.Errorf("explicit mismatch (-want +got):\n%s", diff) - } - - // Test 1: No contexts and all=false is an error. - if _, err := resolveContexts("", nil, false); !errors.Is(err, ErrNoContexts) { - t.Errorf("want ErrNoContexts, got %v", err) - } - - // Test 2: all=true reads context names from the kubeconfig. - kubeconfig := filepath.Join(t.TempDir(), "config") - if err := os.WriteFile(kubeconfig, []byte(testKubeconfig), 0o600); err != nil { - t.Fatalf("write kubeconfig: %v", err) - } - names, err := resolveContexts(kubeconfig, nil, true) - if err != nil { - t.Fatalf("all-contexts: %v", err) - } - if diff := cmp.Diff([]string{"alpha", "beta"}, names); diff != "" { - t.Errorf("all-contexts mismatch (-want +got):\n%s", diff) - } -} - // testKubeconfig is a minimal two-context kubeconfig fixture. const testKubeconfig = `apiVersion: v1 kind: Config @@ -231,3 +202,77 @@ func TestScanFlagsDefined(t *testing.T) { }) } } + +// unreachableKubeconfig is a two-context kubeconfig whose server refuses connections, so a scan +// resolves and records both contexts without reading anything from them. +const unreachableKubeconfig = `apiVersion: v1 +kind: Config +clusters: +- name: c1 + cluster: + server: https://127.0.0.1:1 +contexts: +- name: alpha + context: {cluster: c1, user: u1} +- name: beta + context: {cluster: c1, user: u1} +users: +- name: u1 + user: {} +` + +// TestScanWithNoFlagsScansEveryContext verifies a bare scan needs no context selection: it +// records every kubeconfig context, and it fails only when the kubeconfig holds none. +func TestScanWithNoFlagsScansEveryContext(t *testing.T) { + tests := []struct { + Kubeconfig string + WantClusters []string + Want error + }{{ // Test 0: Both contexts are scanned with no selection given. + Kubeconfig: unreachableKubeconfig, WantClusters: []string{"alpha", "beta"}, + }, { // Test 1: A kubeconfig with no contexts names that as the failure. + Kubeconfig: "apiVersion: v1\nkind: Config\n", Want: kube.ErrNoContexts, + }} + for testNum, test := range tests { + t.Run(fmt.Sprintf("test %d", testNum), func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config") + if err := os.WriteFile(path, []byte(test.Kubeconfig), 0o600); err != nil { + t.Fatalf("write kubeconfig: %v", err) + } + db := filepath.Join(dir, "fleet.db") + err := execScanCmd(t, "scan", "--kubeconfig="+path, "--db="+db, "--group=", "--contexts=", + "--scanners=version", "--html-file="+filepath.Join(dir, "report.html")) + if !errors.Is(err, test.Want) { + t.Fatalf("error: got %v, want %v", err, test.Want) + } + if test.Want != nil { + return + } + s, err := store.NewSQLite(db) + if err != nil { + t.Fatalf("open store: %v", err) + } + defer s.Close() + scans, err := s.ListScans(context.Background(), 1) + if err != nil || len(scans) != 1 { + t.Fatalf("list scans: %v, %d scans", err, len(scans)) + } + got := slices.Sorted(slices.Values(scans[0].Clusters)) + if diff := cmp.Diff(test.WantClusters, got); diff != "" { + t.Errorf("clusters mismatch (-want +got):\n%s", diff) + } + }) + } +} + +// execScanCmd runs one invocation on rootCmd with output captured. +func execScanCmd(t *testing.T, args ...string) error { + t.Helper() + defer lockRootCmd(t)() + buf := &bytes.Buffer{} + rootCmd.SetOut(buf) + rootCmd.SetErr(buf) + rootCmd.SetArgs(args) + return rootCmd.Execute() +} diff --git a/cmd/cmd_serve.go b/cmd/cmd_serve.go index 9174505..0daceb7 100644 --- a/cmd/cmd_serve.go +++ b/cmd/cmd_serve.go @@ -24,7 +24,7 @@ var serveCmd = &cobra.Command{ Use: "serve", Short: "Start the web UI and API server.", Example: " fleetsweeper serve --demo\n" + - " fleetsweeper serve --db fleet.db --all-contexts --scan-interval 1h --auth-token \"$TOKEN\"", + " fleetsweeper serve --db fleet.db --scan-interval 1h --auth-token \"$TOKEN\"", Long: "Launch a web server with an API and dashboard for browsing scan results, groups, trends, and outliers.", RunE: runServe, } @@ -33,8 +33,7 @@ func init() { serveCmd.Flags().String("addr", ":8080", "Listen address for the public API and UI.") serveCmd.Flags().String("admin-addr", "", "Optional listen address for pprof and metrics (empty disables).") serveCmd.Flags().Duration("scan-interval", 0, "Auto-scan interval (for example 30m, 1h). 0 disables.") - serveCmd.Flags().StringSlice("contexts", nil, "Kubeconfig contexts for scheduled scans.") - serveCmd.Flags().Bool("all-contexts", false, "Use all kubeconfig contexts for scheduled scans.") + serveCmd.Flags().StringSlice("contexts", nil, "Kubeconfig contexts for scheduled scans (default: every context).") serveCmd.Flags().Int("workers", 5, "Maximum concurrent operations for scans.") serveCmd.Flags().String("auth-token", "", "Bearer token required for mutating endpoints (POST, DELETE).") serveCmd.Flags().Bool("insecure", false, "Disable authentication entirely. Required if --auth-token is empty and you accept the risk.") @@ -78,7 +77,6 @@ func init() { serveCmd.Flags().Int("rate-limit-write-rpm", 0, "Per-client budget of mutating requests per minute (60 is a sensible start). 0 disables write limiting.") serveCmd.Flags().Bool("probe-registries", false, "Resolve image manifests against their registries to report image age. Contacts every registry the fleet pulls from.") serveCmd.Flags().String("trusted-proxy-header", "", "Header a trusted reverse proxy sets to the client address (for example X-Forwarded-For). The rate limiter keys on its last entry. Leave empty unless every request passes through that proxy.") - serveCmd.MarkFlagsMutuallyExclusive("all-contexts", "contexts") } // runServe starts the HTTP server and blocks until the parent context is canceled. @@ -112,7 +110,6 @@ func runServe(cmd *cobra.Command, _ []string) error { adminAddr, _ := cmd.Flags().GetString("admin-addr") scanInterval, _ := cmd.Flags().GetDuration("scan-interval") workers, _ := cmd.Flags().GetInt("workers") - allContexts, _ := cmd.Flags().GetBool("all-contexts") contextNames, _ := cmd.Flags().GetStringSlice("contexts") authToken, _ := cmd.Flags().GetString("auth-token") insecure, _ := cmd.Flags().GetBool("insecure") @@ -207,7 +204,7 @@ func runServe(cmd *cobra.Command, _ []string) error { startSideEffects := func(leaderCtx context.Context) { if scanInterval > 0 { - contexts, err := resolveScheduleContexts(kubeconfigPath, contextNames, allContexts) + contexts, err := kube.ResolveContexts(kubeconfigPath, contextNames) if err != nil { log.Warn("scheduler: resolve contexts failed", zap.Error(err)) } else { @@ -383,14 +380,6 @@ func newAdmissionProvider(pinPath string, stableScans int, s store.Store, log *z return p, nil } -// resolveScheduleContexts determines which contexts to use for scheduled scans. -func resolveScheduleContexts(kubeconfigPath string, explicit []string, all bool) ([]string, error) { - if all { - return kube.AvailableContexts(kubeconfigPath) - } - return explicit, nil -} - // controllerKubeconfig returns the kubeconfig path the controller reaches its home cluster with. A // pod with no --controller-context uses its ServiceAccount, so a mounted fleet kubeconfig never // moves the controller off the cluster that holds its ClusterScan objects. diff --git a/cmd/cmd_serve_test.go b/cmd/cmd_serve_test.go index 15546a0..c2080b9 100644 --- a/cmd/cmd_serve_test.go +++ b/cmd/cmd_serve_test.go @@ -17,33 +17,6 @@ import ( "github.com/dcadolph/fleetsweeper/internal/store" ) -// TestResolveScheduleContexts verifies the explicit and all-contexts branches. -func TestResolveScheduleContexts(t *testing.T) { - t.Parallel() - - // Explicit list passes through when all=false. - got, err := resolveScheduleContexts("", []string{"a", "b"}, false) - if err != nil { - t.Fatalf("explicit: %v", err) - } - if diff := cmp.Diff([]string{"a", "b"}, got); diff != "" { - t.Errorf("explicit mismatch (-want +got):\n%s", diff) - } - - // all=true reads the kubeconfig contexts. - kubeconfig := filepath.Join(t.TempDir(), "config") - if err := os.WriteFile(kubeconfig, []byte(testKubeconfig), 0o600); err != nil { - t.Fatalf("write kubeconfig: %v", err) - } - names, err := resolveScheduleContexts(kubeconfig, nil, true) - if err != nil { - t.Fatalf("all: %v", err) - } - if diff := cmp.Diff([]string{"alpha", "beta"}, names); diff != "" { - t.Errorf("all-contexts mismatch (-want +got):\n%s", diff) - } -} - // TestOpenServeStore verifies the demo in-memory store, the demo's refusal of // a --db, the missing-db error, and opening a real path. func TestOpenServeStore(t *testing.T) { diff --git a/cmd/cmd_watch.go b/cmd/cmd_watch.go index 35b0185..50cd6eb 100644 --- a/cmd/cmd_watch.go +++ b/cmd/cmd_watch.go @@ -25,7 +25,7 @@ import ( var watchCmd = &cobra.Command{ Use: "watch", Short: "Repeatedly scan and print a diff between consecutive runs.", - Example: " fleetsweeper watch --all-contexts --interval 10m --on-change-only", + Example: " fleetsweeper watch --interval 10m --on-change-only", Long: "Run a scan on the given interval and print the diff to the " + "previous run. By default no results are persisted; pass --db to " + "also save each scan into the store.", @@ -33,8 +33,7 @@ var watchCmd = &cobra.Command{ } func init() { - watchCmd.Flags().StringSlice("contexts", nil, "Kubeconfig context names to scan.") - watchCmd.Flags().Bool("all-contexts", false, "Scan all contexts in the kubeconfig.") + watchCmd.Flags().StringSlice("contexts", nil, "Kubeconfig context names to scan (default: every context).") watchCmd.Flags().Int("workers", 5, "Maximum concurrent operations.") watchCmd.Flags().StringSlice("scanners", nil, "Scanner names to run (default: all).") watchCmd.Flags().Duration("interval", 5*time.Minute, "Pause between scans.") @@ -51,7 +50,6 @@ func runWatch(cmd *cobra.Command, _ []string) error { log := logutil.UnwrapLogger(ctx) kubeconfigPath, _ := cmd.Flags().GetString("kubeconfig") - allContexts, _ := cmd.Flags().GetBool("all-contexts") contextNames, _ := cmd.Flags().GetStringSlice("contexts") scannerNames, _ := cmd.Flags().GetStringSlice("scanners") workers, _ := cmd.Flags().GetInt("workers") @@ -62,7 +60,7 @@ func runWatch(cmd *cobra.Command, _ []string) error { noColor, _ := cmd.Flags().GetBool("no-color") color := !noColor && term.IsTerminal(int(os.Stdout.Fd())) - contexts, err := resolveContexts(kubeconfigPath, contextNames, allContexts) + contexts, err := kube.ResolveContexts(kubeconfigPath, contextNames) if err != nil { return err } diff --git a/cmd/errors.go b/cmd/errors.go index 1fda0e2..5bd9b1b 100644 --- a/cmd/errors.go +++ b/cmd/errors.go @@ -3,8 +3,6 @@ package cmd import "errors" var ( - // ErrNoContexts indicates no kubeconfig contexts were found or specified. - ErrNoContexts = errors.New("no kubeconfig contexts specified") // ErrNoClients indicates all cluster connections failed. ErrNoClients = errors.New("no clusters reachable") // ErrNoDatabase indicates the --db flag is required but was not provided. diff --git a/cmd/execute.go b/cmd/execute.go index 6e8879c..48ae497 100644 --- a/cmd/execute.go +++ b/cmd/execute.go @@ -9,6 +9,7 @@ import ( "syscall" "time" + "github.com/dcadolph/fleetsweeper/internal/kube" "github.com/dcadolph/fleetsweeper/internal/server" "github.com/dcadolph/fleetsweeper/internal/tracing" ) @@ -56,7 +57,7 @@ const tracingShutdownTimeout = 5 * time.Second // exitCode maps known errors to specific exit codes. func exitCode(err error) int { switch { - case errors.Is(err, ErrNoContexts): + case errors.Is(err, kube.ErrNoContexts): return CodeNoContexts case errors.Is(err, ErrNoClients): return CodeConnectionError diff --git a/cmd/execute_test.go b/cmd/execute_test.go index c2e81ae..a4f6b28 100644 --- a/cmd/execute_test.go +++ b/cmd/execute_test.go @@ -4,6 +4,8 @@ import ( "errors" "fmt" "testing" + + "github.com/dcadolph/fleetsweeper/internal/kube" ) // TestExitCode verifies the error-to-exit-code mapping, including wrapped @@ -14,7 +16,7 @@ func TestExitCode(t *testing.T) { In error WantCode int }{ - {In: ErrNoContexts, WantCode: CodeNoContexts}, // Test 0: No contexts sentinel. + {In: kube.ErrNoContexts, WantCode: CodeNoContexts}, // Test 0: No contexts sentinel. {In: ErrNoClients, WantCode: CodeConnectionError}, // Test 1: Connection sentinel. {In: ErrNoDatabase, WantCode: CodeNoDB}, // Test 2: Missing database sentinel. {In: fmt.Errorf("wrap: %w", ErrNoDatabase), WantCode: CodeNoDB}, // Test 3: Wrapped sentinel still maps. diff --git a/deploy/crds/clusterscan.yaml b/deploy/crds/clusterscan.yaml index 3f4df95..36f273e 100644 --- a/deploy/crds/clusterscan.yaml +++ b/deploy/crds/clusterscan.yaml @@ -33,7 +33,7 @@ spec: properties: contexts: type: array - description: Kubeconfig context names to include in the scan. Use this when fleetsweeper runs outside the target clusters. + description: Kubeconfig context names to include in the scan. Empty with no group scans every context. items: type: string group: diff --git a/deploy/examples/serve-config.yaml b/deploy/examples/serve-config.yaml index 59e0822..c8c2e3b 100644 --- a/deploy/examples/serve-config.yaml +++ b/deploy/examples/serve-config.yaml @@ -25,7 +25,6 @@ cors-origin: # Scheduled scanner. scan-interval: "30m" -all-contexts: true workers: 8 # Controller (declarative scans via ClusterScan CRs). diff --git a/deploy/helm/fleetsweeper/templates/crd-clusterscan.yaml b/deploy/helm/fleetsweeper/templates/crd-clusterscan.yaml index a7a5ac8..d8a75cd 100644 --- a/deploy/helm/fleetsweeper/templates/crd-clusterscan.yaml +++ b/deploy/helm/fleetsweeper/templates/crd-clusterscan.yaml @@ -41,7 +41,7 @@ spec: properties: contexts: type: array - description: Kubeconfig context names to include in the scan. Use this when fleetsweeper runs outside the target clusters. + description: Kubeconfig context names to include in the scan. Empty with no group scans every context. items: type: string group: diff --git a/deploy/helm/fleetsweeper/templates/deployment.yaml b/deploy/helm/fleetsweeper/templates/deployment.yaml index a9ab599..4838ead 100644 --- a/deploy/helm/fleetsweeper/templates/deployment.yaml +++ b/deploy/helm/fleetsweeper/templates/deployment.yaml @@ -39,8 +39,8 @@ spec: {{- if .Values.scheduler.interval }} - --scan-interval={{ .Values.scheduler.interval }} {{- end }} - {{- if .Values.scheduler.allContexts }} - - --all-contexts + {{- range .Values.scheduler.contexts }} + - --contexts={{ . }} {{- end }} {{- if .Values.kubeconfig.existingSecret }} - --kubeconfig=/var/run/fleetsweeper/kubeconfig diff --git a/deploy/helm/fleetsweeper/values.yaml b/deploy/helm/fleetsweeper/values.yaml index 63acaa1..a6b0078 100644 --- a/deploy/helm/fleetsweeper/values.yaml +++ b/deploy/helm/fleetsweeper/values.yaml @@ -22,7 +22,7 @@ cors: scheduler: # Set to a non-zero duration (e.g. 30m, 1h) to run scans automatically. interval: "" - allContexts: true + # Contexts to scan. Empty scans every context in the kubeconfig. contexts: [] # Kubeconfig the server scans with. Without one the server sees only the cluster it runs in, diff --git a/docs/getting-started.md b/docs/getting-started.md index a5839d3..61049e7 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -24,7 +24,7 @@ Swap the synthetic fleet for your own: run a scan into a database, then serve that database. `--insecure` disables auth for local viewing. ```shell -fleetsweeper scan --all-contexts --db /tmp/fleet.db +fleetsweeper scan --db /tmp/fleet.db fleetsweeper serve --db /tmp/fleet.db --insecure ``` @@ -53,7 +53,7 @@ Open . ## Run a real scan ```shell -fleetsweeper scan --db /tmp/fleet.db --all-contexts +fleetsweeper scan --db /tmp/fleet.db ``` Then refresh the dashboard. The Fleet Score updates with the new scan. @@ -96,7 +96,7 @@ each resource after every scan. Generate an HTML report: ```shell -fleetsweeper scan --all-contexts -o html --html-file report.html +fleetsweeper scan -o html --html-file report.html ``` ## Persist scan results @@ -105,7 +105,7 @@ Pass `--db` to store results in a SQLite database. This enables scan history, trend analysis, and cluster grouping. ```shell -fleetsweeper scan --all-contexts --db fleet.db +fleetsweeper scan --db fleet.db ``` ## Cluster groups @@ -152,7 +152,7 @@ MAD-zero gates suppress findings on near-uniform integer data. Lower the threshold to flag more outliers. ```shell -fleetsweeper scan --all-contexts --db fleet.db --outlier-threshold 2.5 +fleetsweeper scan --db fleet.db --outlier-threshold 2.5 ``` See [outliers](concepts/outliers.md) for the full statistical treatment. diff --git a/docs/integrations/registry-probing.md b/docs/integrations/registry-probing.md index 82ca16c..a9541fa 100644 --- a/docs/integrations/registry-probing.md +++ b/docs/integrations/registry-probing.md @@ -9,7 +9,7 @@ work out of the box. ## Enable ```shell -fleetsweeper scan --all-contexts --probe-registries +fleetsweeper scan --probe-registries # or, on a running server: fleetsweeper serve --probe-registries ``` diff --git a/docs/integrations/webhooks.md b/docs/integrations/webhooks.md index 01e2521..5d573e4 100644 --- a/docs/integrations/webhooks.md +++ b/docs/integrations/webhooks.md @@ -17,7 +17,7 @@ serve: Then POST to `/api/webhooks/scan-trigger`: ```shell -BODY='{"all_contexts": true}' +BODY='{}' SIG="sha256=$(printf %s "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -binary | xxd -p -c 256)" curl -X POST \ -H "X-Fleetsweeper-Signature: $SIG" \ @@ -26,7 +26,7 @@ curl -X POST \ https://fleetsweeper.example.com/api/webhooks/scan-trigger ``` -Use the empty body to scan every available context. +A body that names no contexts and no group scans every context in the server's kubeconfig. The endpoint is **disabled** (returns 404) when the secret is empty so an unsigned endpoint cannot be left exposed by mistake. diff --git a/docs/operator/clusterscan.md b/docs/operator/clusterscan.md index 1650db7..a267ac8 100644 --- a/docs/operator/clusterscan.md +++ b/docs/operator/clusterscan.md @@ -11,7 +11,7 @@ interval and writes the outcome back to `.status`. | Field | Type | Description | | --- | --- | --- | | `interval` | duration string | Required. Cadence between scans. Go duration format (`15m`, `1h`, `6h`). | -| `contexts` | string array | Kubeconfig context names to scan. Required when `group` is empty. | +| `contexts` | string array | Kubeconfig context names to scan. Empty with no `group` scans every context. | | `group` | string | Fleetsweeper group name whose members will be scanned. Mutually exclusive with `contexts`. | | `scanners` | string array | Optional allowlist of scanner names. Empty runs all registered scanners. | | `emit.fleetDriftReport` | boolean | When true, emit `FleetDriftReport` CRs after each scan. | diff --git a/docs/operator/helm.md b/docs/operator/helm.md index d2eba32..9dc8445 100644 --- a/docs/operator/helm.md +++ b/docs/operator/helm.md @@ -18,7 +18,7 @@ helm install fleetsweeper deploy/helm/fleetsweeper \ | `auth.insecure` | `false` | Disables auth entirely. Private networks only. | | `cors.origins` | `[]` | CORS allowlist. Empty refuses cross-origin requests. | | `scheduler.interval` | `""` | Auto-scan interval (e.g. `30m`). Empty disables. | -| `scheduler.allContexts` | `true` | Use every kubeconfig context for scheduled scans. | +| `scheduler.contexts` | `[]` | Contexts for scheduled scans. Empty scans every kubeconfig context. | | `kubeconfig.existingSecret` | `""` | Secret holding the kubeconfig to scan with. Empty scans the host cluster only. | | `kubeconfig.key` | `config` | Key in that Secret whose value is the kubeconfig file. | | `persistence.enabled` | `true` | PVC for `/data/fleet.db`. | diff --git a/docs/operator/server-mode.md b/docs/operator/server-mode.md index be1afc5..e20a9ee 100644 --- a/docs/operator/server-mode.md +++ b/docs/operator/server-mode.md @@ -16,7 +16,6 @@ fleetsweeper serve --db fleet.db --addr :8080 \ fleetsweeper serve \ --db fleet.db \ --scan-interval 30m \ - --all-contexts \ --auth-token "$TOKEN" \ --cors-origin https://fleet.internal \ --admin-addr 127.0.0.1:9090 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 68ac9c0..993ea64 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -76,7 +76,7 @@ and does not take `--db`. | `0` | The command succeeded. | | `1` | Any error not listed below. | | `2` | No cluster in the scan could be reached. | -| `3` | No kubeconfig contexts were given or found. | +| `3` | The kubeconfig holds no contexts. | | `4` | The command needs `--db` and none was given. | Errors print to stderr as `error: `. Commands with their own diff --git a/internal/integration/fleet_test.go b/internal/integration/fleet_test.go index 0056a27..c35b8f0 100644 --- a/internal/integration/fleet_test.go +++ b/internal/integration/fleet_test.go @@ -194,7 +194,7 @@ func mustCLI(t *testing.T, f *fleet, args ...string) cliResult { func scanFleet(t *testing.T, f *fleet, db string) *report.Report { t.Helper() start := time.Now() - res := mustCLI(t, f, "scan", "--all-contexts", "--db", db) + res := mustCLI(t, f, "scan", "--db", db) var rpt report.Report if err := json.Unmarshal([]byte(res.Stdout), &rpt); err != nil { t.Fatalf("decode scan report: %v", err) diff --git a/internal/kube/client.go b/internal/kube/client.go index a70544e..4b5567a 100644 --- a/internal/kube/client.go +++ b/internal/kube/client.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "os" + "slices" "sort" "sync" "time" @@ -183,6 +184,23 @@ func AvailableContexts(kubeconfigPath string) ([]string, error) { return contextsOrInCluster(contextNames(cfg), kubeconfigPath, InCluster()), nil } +// ResolveContexts returns the contexts to scan: names when any are given, else every context +// in the kubeconfig. Scanning the whole fleet needs no selection, so an empty selection is +// never an error unless the kubeconfig itself holds no contexts. +func ResolveContexts(kubeconfigPath string, names []string) ([]string, error) { + if len(names) > 0 { + return slices.Clone(names), nil + } + all, err := AvailableContexts(kubeconfigPath) + if err != nil { + return nil, err + } + if len(all) == 0 { + return nil, fmt.Errorf("%w: %s", ErrNoContexts, kubeconfigPath) + } + return all, nil +} + // contextsOrInCluster returns names, or InClusterContext alone when names is empty, no // kubeconfig path was given, and the process runs in a pod. func contextsOrInCluster(names []string, kubeconfigPath string, inCluster bool) []string { diff --git a/internal/kube/client_test.go b/internal/kube/client_test.go index 4ff7b85..fb5affd 100644 --- a/internal/kube/client_test.go +++ b/internal/kube/client_test.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "time" @@ -223,3 +224,53 @@ func TestContextsOrInCluster(t *testing.T) { }) } } + +// TestResolveContexts verifies an empty selection means every kubeconfig context, an explicit +// selection narrows, and a kubeconfig with no contexts is an error that names its path. +func TestResolveContexts(t *testing.T) { + t.Parallel() + full := writeKubeconfig(t) + empty := filepath.Join(t.TempDir(), "empty") + if err := os.WriteFile(empty, []byte("apiVersion: v1\nkind: Config\n"), 0o600); err != nil { + t.Fatalf("write kubeconfig: %v", err) + } + tests := []struct { + Path string + Names []string + WantResult []string + Want error + }{{ // Test 0: No selection scans every context. + Path: full, WantResult: []string{"alpha", "mu", "zeta"}, + }, { // Test 1: An explicit selection narrows. + Path: full, Names: []string{"mu"}, WantResult: []string{"mu"}, + }, { // Test 2: A kubeconfig with no contexts is an error. + Path: empty, Want: ErrNoContexts, + }, { // Test 3: A missing kubeconfig is a load error. + Path: filepath.Join(t.TempDir(), "missing"), Want: ErrLoadConfig, + }} + for testNum, test := range tests { + t.Run(fmt.Sprintf("test %d", testNum), func(t *testing.T) { + t.Parallel() + got, err := ResolveContexts(test.Path, test.Names) + if !errors.Is(err, test.Want) { + t.Fatalf("error: got %v, want %v", err, test.Want) + } + if diff := cmp.Diff(test.WantResult, got); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + }) + } +} + +// TestResolveContextsNamesPath verifies the no-contexts error tells the user which file was read. +func TestResolveContextsNamesPath(t *testing.T) { + t.Parallel() + empty := filepath.Join(t.TempDir(), "empty") + if err := os.WriteFile(empty, []byte("apiVersion: v1\nkind: Config\n"), 0o600); err != nil { + t.Fatalf("write kubeconfig: %v", err) + } + _, err := ResolveContexts(empty, nil) + if err == nil || !strings.Contains(err.Error(), empty) { + t.Errorf("error %v does not name %s", err, empty) + } +} diff --git a/internal/kube/errors.go b/internal/kube/errors.go index 3c353cb..9b87483 100644 --- a/internal/kube/errors.go +++ b/internal/kube/errors.go @@ -7,4 +7,6 @@ var ( ErrLoadConfig = errors.New("load kubeconfig") // ErrConnect indicates a failure connecting to a cluster. ErrConnect = errors.New("connect to cluster") + // ErrNoContexts indicates the kubeconfig defines no contexts to scan. + ErrNoContexts = errors.New("no contexts in kubeconfig") ) diff --git a/internal/server/demo_live_test.go b/internal/server/demo_live_test.go index 3363c12..f34a77e 100644 --- a/internal/server/demo_live_test.go +++ b/internal/server/demo_live_test.go @@ -73,7 +73,7 @@ func TestDemoTriggerScanLive(t *testing.T) { trigger := func() string { t.Helper() - body, _ := json.Marshal(triggerScanRequest{AllContexts: true}) + body, _ := json.Marshal(triggerScanRequest{}) w := httptest.NewRecorder() srv.mux.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/scans", bytes.NewReader(body))) if w.Code != http.StatusAccepted { diff --git a/internal/server/e2e_demo_test.go b/internal/server/e2e_demo_test.go index 7f89e68..80d20e3 100644 --- a/internal/server/e2e_demo_test.go +++ b/internal/server/e2e_demo_test.go @@ -181,7 +181,7 @@ type mutationCase struct { // and a tag write. var demoMutations = []mutationCase{ //nolint:gochecknoglobals // Not modified, simplifies testing. {Name: "trigger", Method: http.MethodPost, Path: "/api/scans", - Body: `{"all_contexts":true}`, WantDemo: http.StatusAccepted}, + Body: `{}`, WantDemo: http.StatusAccepted}, {Name: "ack", Method: http.MethodPost, Path: "/api/findings/" + report.FindingFingerprint(report.Finding{ Cluster: "prod-eu-central-1", Scanner: "deprecated-apis", Rule: "deprecated-apis/in-use", diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 3b0d5b1..e73612a 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -206,10 +206,8 @@ func (s *Server) handleGetScanReport(w http.ResponseWriter, r *http.Request) { // triggerScanRequest is the JSON body for POST /api/scans. type triggerScanRequest struct { - // Contexts is the list of kubeconfig contexts to scan. + // Contexts is the list of kubeconfig contexts to scan. Empty with no Group scans every context. Contexts []string `json:"contexts"` - // AllContexts scans all available contexts when true. - AllContexts bool `json:"all_contexts"` // Group scans only clusters in this group. Group string `json:"group"` } @@ -238,13 +236,17 @@ func (s *Server) handleTriggerScan(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, safeMessage(err, "group not found")) return } + if errors.Is(err, kube.ErrNoContexts) { + writeError(w, http.StatusBadRequest, "no contexts in kubeconfig") + return + } s.log.Error("trigger scan: resolve contexts", zap.Error(err)) writeError(w, http.StatusInternalServerError, "resolve contexts failed") return } if len(contexts) == 0 { - writeError(w, http.StatusBadRequest, "no contexts specified") + writeError(w, http.StatusBadRequest, "group has no clusters") return } @@ -282,10 +284,11 @@ func (s *Server) startBackgroundScan(source string, contexts []string) (scanID s return scanID, true } -// resolveContexts determines which kubeconfig contexts to scan for a trigger request. In demo -// mode every context comes from the synthetic fleet, never from the kubeconfig. +// resolveContexts determines which kubeconfig contexts to scan: the group's clusters, the named +// contexts, or every context when the request names neither. In demo mode an open request scans +// the synthetic fleet, never the kubeconfig. func (s *Server) resolveContexts(ctx context.Context, req triggerScanRequest) ([]string, error) { - if s.demo && req.Group == "" && (req.AllContexts || len(req.Contexts) == 0) { + if s.demo && req.Group == "" && len(req.Contexts) == 0 { return demoClusterNames(), nil } if req.Group != "" { @@ -295,10 +298,7 @@ func (s *Server) resolveContexts(ctx context.Context, req triggerScanRequest) ([ } return g.Clusters, nil } - if req.AllContexts { - return kube.AvailableContexts(s.kubeconfigPath) - } - return req.Contexts, nil + return kube.ResolveContexts(s.kubeconfigPath, req.Contexts) } // handleListClusters returns all known clusters with their tag maps diff --git a/internal/server/handlers_trigger_test.go b/internal/server/handlers_trigger_test.go index ee0a912..76b6fed 100644 --- a/internal/server/handlers_trigger_test.go +++ b/internal/server/handlers_trigger_test.go @@ -40,7 +40,10 @@ func TestHandleTriggerScan_Paths(t *testing.T) { }{{ // Test 0: Malformed JSON body. Name: "bad json", Body: "{", Actor: adminActor(), WantStatus: http.StatusBadRequest, }, { // Test 1: Empty context list. - Name: "no contexts", Body: `{"contexts":[]}`, Actor: adminActor(), + Name: "kubeconfig holds no contexts", Body: `{"contexts":[]}`, Actor: adminActor(), + WantStatus: http.StatusBadRequest, + }, { + Name: "removed all_contexts field", Body: `{"all_contexts":true}`, Actor: adminActor(), WantStatus: http.StatusBadRequest, }, { // Test 2: Group that does not exist. Name: "unknown group", Body: `{"group":"ghost"}`, Actor: adminActor(), @@ -416,3 +419,29 @@ func TestExecuteScanUsesTagsAndGroups(t *testing.T) { t.Errorf("want a tagged cohort named edge, got %+v", rpt.Cohorts) } } + +// TestTriggerScanEmptyBodyScansEveryContext verifies a trigger that names nothing scans every +// kubeconfig context, the same default the CLI scan and the controller use. +func TestTriggerScanEmptyBodyScansEveryContext(t *testing.T) { + t.Parallel() + srv, ss := newScanTestServer(t, "east", "west") + req := httptest.NewRequest(http.MethodPost, "/api/scans", strings.NewReader(`{}`)) + w := httptest.NewRecorder() + srv.mux.ServeHTTP(w, req) + if w.Code != http.StatusAccepted { + t.Fatalf("trigger: want 202, got %d body=%s", w.Code, w.Body.String()) + } + var resp triggerScanResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode trigger response: %v", err) + } + waitForScan(t, srv, resp.ScanID) + rec, err := ss.GetScan(context.Background(), resp.ScanID) + if err != nil { + t.Fatalf("get scan: %v", err) + } + if diff := cmp.Diff([]string{"east", "west"}, rec.Clusters, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" { + t.Errorf("clusters mismatch (-want +got):\n%s", diff) + } + srv.bg.Wait() +} diff --git a/internal/server/inbound_webhook.go b/internal/server/inbound_webhook.go index cdcf4c4..85516a9 100644 --- a/internal/server/inbound_webhook.go +++ b/internal/server/inbound_webhook.go @@ -12,6 +12,7 @@ import ( "go.uber.org/zap" + "github.com/dcadolph/fleetsweeper/internal/kube" "github.com/dcadolph/fleetsweeper/internal/store" ) @@ -55,16 +56,16 @@ func (s *Server) handleWebhookScanTrigger(w http.ResponseWriter, r *http.Request } } - // An empty request scans every available context. - if len(req.Contexts) == 0 && req.Group == "" { - req.AllContexts = true - } contexts, err := s.resolveContexts(r.Context(), req) if err != nil { if errors.Is(err, store.ErrNotFound) { writeError(w, http.StatusBadRequest, safeMessage(err, "group not found")) return } + if errors.Is(err, kube.ErrNoContexts) { + writeError(w, http.StatusBadRequest, "no contexts in kubeconfig") + return + } s.log.Error("inbound webhook: resolve contexts", zap.Error(err)) writeError(w, http.StatusInternalServerError, "resolve contexts failed") return diff --git a/internal/server/inbound_webhook_test.go b/internal/server/inbound_webhook_test.go index ce2dc89..9b1f069 100644 --- a/internal/server/inbound_webhook_test.go +++ b/internal/server/inbound_webhook_test.go @@ -9,7 +9,7 @@ import ( func TestVerifyWebhookSignature_OK(t *testing.T) { t.Parallel() - body := []byte(`{"all_contexts":true}`) + body := []byte(`{}`) sig := signWebhookBody("topsecret", body) if err := verifyWebhookSignature("topsecret", sig, body); err != nil { t.Errorf("expected ok, got %v", err) @@ -70,7 +70,7 @@ func TestInboundWebhook_ValidSignature_AcceptedOrConflict(t *testing.T) { t.Parallel() srv, _ := testServer(t) srv.webhookSecret = "topsecret" - body := []byte(`{"all_contexts":true}`) + body := []byte(`{}`) req := httptest.NewRequest(http.MethodPost, "/api/webhooks/scan-trigger", bytes.NewReader(body)) req.Header.Set(webhookSignatureHeader, signWebhookBody("topsecret", body)) diff --git a/internal/server/openapi.yaml b/internal/server/openapi.yaml index 0f15b74..7b6382a 100644 --- a/internal/server/openapi.yaml +++ b/internal/server/openapi.yaml @@ -638,7 +638,7 @@ paths: contexts: type: array items: {type: string} - all_contexts: {type: boolean} + description: Contexts to scan. Empty with no group scans every kubeconfig context. group: {type: string} responses: "202": @@ -1455,7 +1455,7 @@ paths: contexts: type: array items: {type: string} - all_contexts: {type: boolean} + description: Contexts to scan. Empty with no group scans every kubeconfig context. group: {type: string} responses: "202": diff --git a/internal/server/scan_once.go b/internal/server/scan_once.go index a7010eb..1682966 100644 --- a/internal/server/scan_once.go +++ b/internal/server/scan_once.go @@ -258,18 +258,15 @@ func (s *Server) ScanOnce(ctx context.Context, opts controller.ScanOptions) (con }, nil } -// resolveOptionContexts produces the final context list, expanding a group -// reference when set. A non-empty Group takes precedence over Contexts so -// ClusterScan authors can pick one mode unambiguously. +// resolveOptionContexts produces the final context list through the same resolver as an API +// trigger. A non-empty Group takes precedence over Contexts so ClusterScan authors can pick one +// mode unambiguously, and a spec that names neither scans every context. func (s *Server) resolveOptionContexts(ctx context.Context, opts controller.ScanOptions) ([]string, error) { - if opts.Group != "" { - g, err := s.store.GetGroup(ctx, opts.Group) - if err != nil { - return nil, fmt.Errorf("resolve group %q: %w", opts.Group, err) - } - return g.Clusters, nil + contexts, err := s.resolveContexts(ctx, triggerScanRequest{Contexts: opts.Contexts, Group: opts.Group}) + if err != nil { + return nil, fmt.Errorf("resolve contexts: %w", err) } - return opts.Contexts, nil + return contexts, nil } // selectScanners filters the registered scanners by the supplied allowlist. diff --git a/internal/server/scan_once_test.go b/internal/server/scan_once_test.go index b3d82f4..d2fb938 100644 --- a/internal/server/scan_once_test.go +++ b/internal/server/scan_once_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/google/go-cmp/cmp" + "github.com/dcadolph/fleetsweeper/internal/controller" "github.com/dcadolph/fleetsweeper/internal/kube" "github.com/dcadolph/fleetsweeper/internal/report" @@ -83,4 +85,13 @@ func TestResolveOptionContexts(t *testing.T) { if _, err := srv.resolveOptionContexts(ctx, controller.ScanOptions{Group: "ghost"}); err == nil { t.Error("unknown group should error") } + + srv.kubeconfigPath = writeTestKubeconfig(t, "east", "west") + every, err := srv.resolveOptionContexts(ctx, controller.ScanOptions{}) + if err != nil { + t.Fatalf("empty options: %v", err) + } + if diff := cmp.Diff([]string{"east", "west"}, every); diff != "" { + t.Errorf("empty options mismatch (-want +got):\n%s", diff) + } } diff --git a/internal/server/static/js/views/trigger.js b/internal/server/static/js/views/trigger.js index ea4550b..9080c3c 100644 --- a/internal/server/static/js/views/trigger.js +++ b/internal/server/static/js/views/trigger.js @@ -14,7 +14,7 @@ export function triggerScan() { label: 'Start scan', tone: 'primary', run: async () => { - if (!await send('POST', '/scans', { all_contexts: true }, 'Scan')) return false; + if (!await send('POST', '/scans', {}, 'Scan')) return false; toast(isLive() ? 'Scan started. This page updates when it finishes.' : 'Scan started. Turn on Live or reload when it finishes.', 'ok'); return true; }, From 64e73644afdda38ccceec733851ae55aeaad8a39 Mon Sep 17 00:00:00 2001 From: Douglas Adolph <116844282+dcadolph@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:06:42 -0600 Subject: [PATCH 2/5] Lead the scan summary with coverage, then score, then fixes A scan now tells the terminal how much of the fleet it observed before it prints a score, and names the three fixes with the most leverage. --- cmd/cmd_scan.go | 102 +++++++++++++++++++------------ cmd/cmd_scan_summary_test.go | 114 +++++++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 37 deletions(-) create mode 100644 cmd/cmd_scan_summary_test.go diff --git a/cmd/cmd_scan.go b/cmd/cmd_scan.go index 3d7e8aa..60a23a5 100644 --- a/cmd/cmd_scan.go +++ b/cmd/cmd_scan.go @@ -3,7 +3,9 @@ package cmd import ( "context" "fmt" + "io" "os" + "slices" "strings" "time" @@ -43,6 +45,7 @@ import ( "github.com/dcadolph/fleetsweeper/internal/scanner/workloadsec" "github.com/dcadolph/fleetsweeper/internal/server" "github.com/dcadolph/fleetsweeper/internal/store" + "github.com/dcadolph/fleetsweeper/internal/util" ) var scanCmd = &cobra.Command{ @@ -105,6 +108,7 @@ func runScan(cmd *cobra.Command, _ []string) error { ctx := cmdContext(cmd) log := logutil.UnwrapLogger(ctx) + stdout, stderr := cmd.OutOrStdout(), cmd.ErrOrStderr() kubeconfigPath, _ := cmd.Flags().GetString("kubeconfig") pretty, _ := cmd.Flags().GetBool("pretty") workers, _ := cmd.Flags().GetInt("workers") @@ -175,7 +179,7 @@ func runScan(cmd *cobra.Command, _ []string) error { return fmt.Errorf("save scan: %w", err) } log.Info("scan persisted", zap.String("scan_id", scanID)) - fmt.Fprintf(os.Stderr, "scan %s saved to %s\n", scanID, dbPath) + fmt.Fprintf(stderr, "scan %s saved to %s\n", scanID, dbPath) } outlierThreshold, _ := cmd.Flags().GetFloat64("outlier-threshold") @@ -187,35 +191,9 @@ func runScan(cmd *cobra.Command, _ []string) error { return err } - // Surface degraded coverage so a forbidden or unreachable API never reads - // as a clean, zero-resource cluster. This goes to stderr so stdout stays - // pure report output. - if degraded := rpt.DegradedByCluster(); len(degraded) > 0 { - fmt.Fprintf(os.Stderr, "warning: %d scanner run(s) did not return trustworthy data across %d cluster(s); coverage is partial\n", - len(rpt.Degraded), len(degraded)) - for _, c := range clusterNames { - if d := degraded[c]; d > 0 { - fmt.Fprintf(os.Stderr, " %s: %d of %d scanners degraded\n", c, d, len(selected)) - } - } - } - - // Surface cohort partitioning and the within-cohort outliers, which catch - // drift the fleet-wide view drowns out. - if len(rpt.Cohorts) > 0 { - var cohortOutliers int - for _, c := range rpt.Cohorts { - cohortOutliers += len(c.Outliers) - } - fmt.Fprintf(os.Stderr, "cohorts: %d group(s), %d within-cohort outlier(s)\n", - len(rpt.Cohorts), cohortOutliers) - } - if len(rpt.Incidents) > 0 { - fmt.Fprintf(os.Stderr, "incidents: %d fused from correlated findings\n", len(rpt.Incidents)) - } - if rpt.Brief.Headline != "" { - fmt.Fprintf(os.Stderr, "brief: %s\n", rpt.Brief.Headline) - } + // Coverage leads, so a scan that saw little of the fleet never reads as a clean one. This goes + // to stderr so stdout stays pure report output. + writeScanSummary(stderr, rpt) // Emit FleetDriftReport YAMLs when --fleetdrift-output is set so GitOps // pipelines can pick up the drift state. Best-effort: a write failure logs @@ -224,18 +202,18 @@ func runScan(cmd *cobra.Command, _ []string) error { if dir, _ := cmd.Flags().GetString("fleetdrift-output"); dir != "" { reports := fleetdrift.ReportsFor(rpt, scanIDForFD, "") if err := fleetdrift.Write(reports, dir); err != nil { - fmt.Fprintf(os.Stderr, "warning: fleetdrift write failed: %s\n", err) + fmt.Fprintf(stderr, "warning: fleetdrift write failed: %s\n", err) } else { - fmt.Fprintf(os.Stderr, "fleetdrift: wrote %d reports to %s\n", len(reports), dir) + fmt.Fprintf(stderr, "fleetdrift: wrote %d reports to %s\n", len(reports), dir) } } if dir, _ := cmd.Flags().GetString("policy-report-output"); dir != "" { ns, _ := cmd.Flags().GetString("policy-report-namespace") reports := policyreport.ReportsFor(rpt, scanIDForFD, ns) if err := policyreport.Write(reports, dir); err != nil { - fmt.Fprintf(os.Stderr, "warning: policyreport write failed: %s\n", err) + fmt.Fprintf(stderr, "warning: policyreport write failed: %s\n", err) } else { - fmt.Fprintf(os.Stderr, "policyreport: wrote %d reports to %s\n", len(reports), dir) + fmt.Fprintf(stderr, "policyreport: wrote %d reports to %s\n", len(reports), dir) } } @@ -254,21 +232,71 @@ func runScan(cmd *cobra.Command, _ []string) error { if err := os.WriteFile(htmlFile, htmlBytes, 0o644); err != nil { return fmt.Errorf("write html: %w", err) } - fmt.Fprintf(os.Stderr, "report written to %s\n", htmlFile) + fmt.Fprintf(stderr, "report written to %s\n", htmlFile) } else { - fmt.Fprintln(os.Stdout, string(htmlBytes)) + fmt.Fprintln(stdout, string(htmlBytes)) } default: out, err := jsonutil.Marshal(rpt, pretty) if err != nil { return fmt.Errorf("marshal report: %w", err) } - fmt.Fprintln(os.Stdout, string(out)) + fmt.Fprintln(stdout, string(out)) } return nil } +// scanSummaryFixes is how many recommendations the terminal summary prints. +const scanSummaryFixes = 3 + +// writeScanSummary prints the human summary of a scan to w in a fixed order: what the scan +// observed, then the score, then the fixes with the most leverage. Coverage comes first because a +// score is only as good as the runs behind it. +func writeScanSummary(w io.Writer, rpt *report.Report) { + cov := rpt.FleetScore.Coverage + clusters := len(rpt.Clusters) + line := fmt.Sprintf("Coverage: %d of %d scanner runs observed across %d cluster%s.", + cov.ObservedRuns, cov.ExpectedRuns, clusters, util.Plural(clusters)) + if cov.BlindClusters > 0 { + line += fmt.Sprintf(" %d cluster%s had no complete run.", cov.BlindClusters, util.Plural(cov.BlindClusters)) + } + fmt.Fprintln(w, line) + degraded := rpt.DegradedByCluster() + for _, c := range slices.Sorted(slices.Values(rpt.Clusters)) { + if d := degraded[c]; d > 0 { + fmt.Fprintf(w, " %s: %d of %d scanners degraded\n", c, d, rpt.Summary.ScannerCount) + } + } + + score := fmt.Sprintf("Score: %d (%s).", rpt.FleetScore.Score, rpt.FleetScore.Grade) + if !rpt.FleetScore.Graded { + score = "Score: not graded." + } + if h := rpt.FleetScore.Headline; h != "" { + score += " " + h + } + fmt.Fprintln(w, score) + + recs := buildRecommendations(rpt.Findings, "") + for i, r := range recs[:min(len(recs), scanSummaryFixes)] { + fmt.Fprintf(w, "Fix %d: %s, %s, %d cluster%s (%s).\n", i+1, r.Title, r.Severity, r.Leverage, + util.Plural(r.Leverage), joinTrim(r.Clusters, 5)) + } + + if len(rpt.Cohorts) > 0 { + var cohortOutliers int + for _, c := range rpt.Cohorts { + cohortOutliers += len(c.Outliers) + } + fmt.Fprintf(w, "Cohorts: %d group%s, %d within-cohort outlier%s.\n", len(rpt.Cohorts), + util.Plural(len(rpt.Cohorts)), cohortOutliers, util.Plural(cohortOutliers)) + } + if len(rpt.Incidents) > 0 { + fmt.Fprintf(w, "Incidents: %d fused from correlated findings.\n", len(rpt.Incidents)) + } +} + // selectScanners filters the registry to the requested scanner names. When names is empty all // scanners are selected. Unknown names return an ErrFlagValue error listing them and the // registered names. diff --git a/cmd/cmd_scan_summary_test.go b/cmd/cmd_scan_summary_test.go new file mode 100644 index 0000000..5258d0b --- /dev/null +++ b/cmd/cmd_scan_summary_test.go @@ -0,0 +1,114 @@ +package cmd + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/dcadolph/fleetsweeper/internal/report" +) + +// summaryFinding returns a finding with a remediation, so it ranks as a recommendation. +func summaryFinding(cluster, rule, severity string) report.Finding { + return report.Finding{ + Cluster: cluster, Scanner: "workload-security", Rule: rule, Severity: severity, + Remediation: &report.Remediation{Command: "kubectl get pods -A"}, + } +} + +// TestWriteScanSummary verifies the terminal summary leads with coverage, then the score, then +// the three fixes with the most leverage, and that an ungraded score says so instead of a grade. +func TestWriteScanSummary(t *testing.T) { + t.Parallel() + findings := []report.Finding{ + summaryFinding("a", "workload-security/privileged", "critical"), + summaryFinding("b", "workload-security/privileged", "critical"), + summaryFinding("a", "workload-security/host-network", "warning"), + summaryFinding("c", "workload-security/host-pid", "warning"), + summaryFinding("c", "workload-security/run-as-root", "info"), + } + tests := []struct { + Report *report.Report + WantPrefix []string + }{{ // Test 0: Graded scan prints coverage, score, then three fixes by leverage. + Report: &report.Report{ + Clusters: []string{"a", "b", "c"}, + Findings: findings, + FleetScore: report.FleetScore{ + Score: 72, Grade: "C", Graded: true, Headline: "Two clusters drifted.", + Coverage: report.FleetCoverage{ExpectedRuns: 72, ObservedRuns: 70, BlindRuns: 2}, + }, + }, + WantPrefix: []string{ + "Coverage: 70 of 72 scanner runs observed across 3 clusters.", + "Score: 72 (C). Two clusters drifted.", + "Fix 1:", + "Fix 2:", + "Fix 3:", + }, + }, { // Test 1: Ungraded scan names the blind clusters and gives no grade. + Report: &report.Report{ + Clusters: []string{"a", "b"}, + FleetScore: report.FleetScore{ + Score: 100, Graded: false, Headline: "Not enough data.", + Coverage: report.FleetCoverage{ExpectedRuns: 48, ObservedRuns: 0, BlindRuns: 48, BlindClusters: 2}, + }, + }, + WantPrefix: []string{ + "Coverage: 0 of 48 scanner runs observed across 2 clusters. 2 clusters had no complete run.", + "Score: not graded. Not enough data.", + }, + }} + for testNum, test := range tests { + t.Run(fmt.Sprintf("test %d", testNum), func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + writeScanSummary(&buf, test.Report) + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + if len(lines) < len(test.WantPrefix) { + t.Fatalf("got %d lines, want at least %d:\n%s", len(lines), len(test.WantPrefix), buf.String()) + } + got := make([]string, len(test.WantPrefix)) + for i, want := range test.WantPrefix { + got[i] = lines[i] + if strings.HasPrefix(lines[i], want) { + got[i] = want + } + } + if diff := cmp.Diff(test.WantPrefix, got); diff != "" { + t.Errorf("line order mismatch (-want +got):\n%s", diff) + } + }) + } +} + +// TestWriteScanSummaryFixOrder verifies the first fix is the one that reaches the most clusters +// at the highest severity, and that no more than three are printed. +func TestWriteScanSummaryFixOrder(t *testing.T) { + t.Parallel() + rpt := &report.Report{ + Clusters: []string{"a", "b", "c"}, + Findings: []report.Finding{ + summaryFinding("a", "workload-security/privileged", "critical"), + summaryFinding("b", "workload-security/privileged", "critical"), + summaryFinding("a", "workload-security/host-network", "warning"), + summaryFinding("c", "workload-security/host-pid", "warning"), + summaryFinding("c", "workload-security/run-as-root", "info"), + }, + FleetScore: report.FleetScore{Score: 72, Grade: "C", Graded: true}, + } + var buf bytes.Buffer + writeScanSummary(&buf, rpt) + out := buf.String() + if n := strings.Count(out, "Fix "); n != 3 { + t.Errorf("want 3 fixes, got %d:\n%s", n, out) + } + first := out[strings.Index(out, "Fix 1:"):] + first = first[:strings.Index(first, "\n")] + if !strings.Contains(first, report.RuleTitle("workload-security/privileged")) || !strings.Contains(first, "2 clusters") { + t.Errorf("first fix should be privileged on 2 clusters, got %q", first) + } +} From 2811466601c242fe73629d1596dc0f010b820bb4 Mon Sep 17 00:00:00 2001 From: Douglas Adolph <116844282+dcadolph@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:13:01 -0600 Subject: [PATCH 3/5] Store every scan in a default local database With no --db, scans go to a SQLite file under the XDG state directory, so history, trends, and serve work on day two with nothing configured. The path prints once on first use. --- cmd/cmd_apikey_test.go | 8 --- cmd/cmd_compare_test.go | 7 --- cmd/cmd_doctor.go | 22 +++++--- cmd/cmd_group_test.go | 9 ---- cmd/cmd_history_test.go | 7 --- cmd/cmd_location_test.go | 9 ---- cmd/cmd_root.go | 3 +- cmd/cmd_scan.go | 44 +++++++--------- cmd/cmd_serve.go | 7 ++- cmd/cmd_serve_test.go | 5 -- cmd/errors.go | 4 +- cmd/main_test.go | 26 ++++++++++ cmd/store.go | 66 +++++++++++++++++++++--- cmd/store_default_test.go | 100 ++++++++++++++++++++++++++++++++++++ docs/getting-started.md | 6 ++- docs/reference/cli.md | 4 +- internal/util/state.go | 33 ++++++++++++ internal/util/state_test.go | 42 +++++++++++++++ 18 files changed, 308 insertions(+), 94 deletions(-) create mode 100644 cmd/main_test.go create mode 100644 cmd/store_default_test.go create mode 100644 internal/util/state.go create mode 100644 internal/util/state_test.go diff --git a/cmd/cmd_apikey_test.go b/cmd/cmd_apikey_test.go index 17c7580..e163df0 100644 --- a/cmd/cmd_apikey_test.go +++ b/cmd/cmd_apikey_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "encoding/json" - "errors" "path/filepath" "strings" "testing" @@ -140,10 +139,3 @@ func TestAPIKeyCreateValidation(t *testing.T) { t.Error("expected error for invalid role") } } - -// TestAPIKeyRequiresDB verifies list without --db returns the sentinel. -func TestAPIKeyRequiresDB(t *testing.T) { - if _, err := runAPIKey(t, "", "list"); !errors.Is(err, ErrNoDatabase) { - t.Errorf("want ErrNoDatabase, got %v", err) - } -} diff --git a/cmd/cmd_compare_test.go b/cmd/cmd_compare_test.go index 63a49ea..9e7205f 100644 --- a/cmd/cmd_compare_test.go +++ b/cmd/cmd_compare_test.go @@ -37,10 +37,3 @@ func TestCompareFormats(t *testing.T) { } } } - -// TestCompareRequiresDB verifies the missing-db path surfaces an error. -func TestCompareRequiresDB(t *testing.T) { - if _, err := execCompare(t, "", "a", "b", "--format=text"); err == nil { - t.Error("expected error without --db") - } -} diff --git a/cmd/cmd_doctor.go b/cmd/cmd_doctor.go index 2575798..1f9296a 100644 --- a/cmd/cmd_doctor.go +++ b/cmd/cmd_doctor.go @@ -162,11 +162,11 @@ func runCheck(parent context.Context, name string, timeout time.Duration, fn fun return res } -// doctorCheckDatabase opens the configured --db and pings it. +// doctorCheckDatabase opens --db, or the default state store when one exists, and pings it. func doctorCheckDatabase(ctx context.Context, cmd *cobra.Command) CheckResult { - dbPath, _ := cmd.Flags().GetString("db") - if dbPath == "" { - return CheckResult{Status: StatusSkip, Detail: "no --db configured"} + dbPath, ok := existingDBPath(cmd) + if !ok { + return CheckResult{Status: StatusSkip, Detail: noStoreDetail(dbPath)} } driver, _ := cmd.Flags().GetString("db-driver") if driver == "" { @@ -403,14 +403,22 @@ func doctorCheckAdmissionWebhook( return CheckResult{Status: StatusOK, Detail: fmt.Sprintf("%d webhook(s) registered", len(cfg.Webhooks))} } +// noStoreDetail explains a skipped store check: no store yet at the default path, or no default. +func noStoreDetail(defaultPath string) string { + if defaultPath == "" { + return "no --db and no default state directory" + } + return "no store yet at " + defaultPath + "; the first scan creates it" +} + // doctorCheckScanFreshness reads the most recent scan from the // configured store and warns when its timestamp is older than // --scan-freshness. Distinguishes "no scans yet" (warn) from "stale // scans" (fail) so first-install behavior isn't reported as broken. func doctorCheckScanFreshness(ctx context.Context, cmd *cobra.Command) CheckResult { - dbPath, _ := cmd.Flags().GetString("db") - if dbPath == "" { - return CheckResult{Status: StatusSkip, Detail: "no --db configured"} + dbPath, ok := existingDBPath(cmd) + if !ok { + return CheckResult{Status: StatusSkip, Detail: noStoreDetail(dbPath)} } maxAge, _ := cmd.Flags().GetDuration("scan-freshness") driver, _ := cmd.Flags().GetString("db-driver") diff --git a/cmd/cmd_group_test.go b/cmd/cmd_group_test.go index 6051aec..58530b9 100644 --- a/cmd/cmd_group_test.go +++ b/cmd/cmd_group_test.go @@ -2,7 +2,6 @@ package cmd import ( "context" - "errors" "path/filepath" "testing" @@ -69,14 +68,6 @@ func TestGroupLifecycle(t *testing.T) { } } -// TestGroupRequiresDB verifies the missing-db sentinel from a group run. -func TestGroupRequiresDB(t *testing.T) { - t.Parallel() - if err := runGroupList(newGroupCmd(""), nil); !errors.Is(err, ErrNoDatabase) { - t.Errorf("want ErrNoDatabase, got %v", err) - } -} - // groupMembers reopens the store and returns the named group's cluster list. func groupMembers(t *testing.T, dbPath, name string) []string { t.Helper() diff --git a/cmd/cmd_history_test.go b/cmd/cmd_history_test.go index 3434f99..a7f2687 100644 --- a/cmd/cmd_history_test.go +++ b/cmd/cmd_history_test.go @@ -256,13 +256,6 @@ func TestHistoryTrend(t *testing.T) { } } -// TestHistoryRequiresDB verifies the missing-db sentinel surfaces. -func TestHistoryRequiresDB(t *testing.T) { - if err := runHistory(t, "", "list"); !errors.Is(err, ErrNoDatabase) { - t.Errorf("want ErrNoDatabase, got %v", err) - } -} - // countScans reopens the store and returns the number of persisted scans. func countScans(t *testing.T, dbPath string) int { t.Helper() diff --git a/cmd/cmd_location_test.go b/cmd/cmd_location_test.go index f13d04c..e089220 100644 --- a/cmd/cmd_location_test.go +++ b/cmd/cmd_location_test.go @@ -2,7 +2,6 @@ package cmd import ( "context" - "errors" "path/filepath" "testing" @@ -74,11 +73,3 @@ func TestLocationSetValidation(t *testing.T) { t.Error("expected error for longitude > 180") } } - -// TestLocationRequiresDB verifies the missing-db sentinel from a location run. -func TestLocationRequiresDB(t *testing.T) { - t.Parallel() - if err := runLocationList(newLocationCmd("", 0, 0, "", ""), nil); !errors.Is(err, ErrNoDatabase) { - t.Errorf("want ErrNoDatabase, got %v", err) - } -} diff --git a/cmd/cmd_root.go b/cmd/cmd_root.go index 9855f28..9a0edb8 100644 --- a/cmd/cmd_root.go +++ b/cmd/cmd_root.go @@ -35,7 +35,8 @@ func init() { rootCmd.PersistentFlags().String("kubeconfig", defaultKubeconfig(), "Path to the kubeconfig file.") rootCmd.PersistentFlags().Bool("pretty", false, "Pretty-print JSON output.") rootCmd.PersistentFlags().String("log-level", "warn", "Log level (debug, info, warn, error).") - rootCmd.PersistentFlags().String("db", "", "Database DSN. SQLite: a filesystem path. Postgres: a postgres:// URL.") + rootCmd.PersistentFlags().String("db", "", "Database DSN. SQLite: a filesystem path. Postgres: a postgres:// URL. "+ + "Empty uses $XDG_STATE_HOME/fleetsweeper/fleet.db, else ~/.local/state/fleetsweeper/fleet.db.") rootCmd.PersistentFlags().String("db-driver", "", "Backend driver. One of sqlite, postgres. Empty auto-detects from the DSN prefix.") rootCmd.AddCommand(scanCmd) diff --git a/cmd/cmd_scan.go b/cmd/cmd_scan.go index 60a23a5..ae1ace9 100644 --- a/cmd/cmd_scan.go +++ b/cmd/cmd_scan.go @@ -65,7 +65,7 @@ func init() { scanCmd.Flags().StringSlice("scanners", nil, "Scanner names to run (default: all).") scanCmd.Flags().StringP("output", "o", "json", "Output format: json or html.") scanCmd.Flags().String("html-file", "", "Write HTML report to this file path (implies --output html).") - scanCmd.Flags().String("group", "", "Scan only clusters in this group (requires --db).") + scanCmd.Flags().String("group", "", "Scan only clusters in this group.") scanCmd.Flags().Float64("outlier-threshold", 3.5, "Outlier detection sensitivity (lower=more sensitive).") scanCmd.Flags().String("fleetdrift-output", "", "Local directory to write FleetDriftReport YAMLs to (one file per cluster). Empty disables.") scanCmd.Flags().String("policy-report-output", "", "Local directory to write wgpolicyk8s.io PolicyReport YAMLs (one file per cluster). Empty disables.") @@ -128,14 +128,20 @@ func runScan(cmd *cobra.Command, _ []string) error { return err } - // Resolve contexts from group if specified. + // Every scan persists, to --db or the default state store, so history and trends work on + // the second scan with nothing configured. + dbPath, err := resolveDBPath(cmd) + if err != nil { + return err + } + st, err := openStorePath(cmd, dbPath) + if err != nil { + return fmt.Errorf("open store: %w", err) + } + defer st.Close() + if groupName != "" { - s, err := openAnyStore(cmd) - if err != nil { - return err - } - defer s.Close() - g, err := s.GetGroup(ctx, groupName) + g, err := st.GetGroup(ctx, groupName) if err != nil { return fmt.Errorf("get group %q: %w", groupName, err) } @@ -163,24 +169,12 @@ func runScan(cmd *cobra.Command, _ []string) error { clusterNames[i] = c.Context } - // Persist results to database when --db is set. - var st store.Store - dbPath, _ := cmd.Flags().GetString("db") - if dbPath != "" { - s, err := openAnyStore(cmd) - if err != nil { - return fmt.Errorf("open store: %w", err) - } - defer s.Close() - st = s - - scanID, err := s.SaveScanRecord(ctx, store.ScanRecord{Timestamp: scannedAt, Clusters: clusterNames}, results) - if err != nil { - return fmt.Errorf("save scan: %w", err) - } - log.Info("scan persisted", zap.String("scan_id", scanID)) - fmt.Fprintf(stderr, "scan %s saved to %s\n", scanID, dbPath) + scanID, err := st.SaveScanRecord(ctx, store.ScanRecord{Timestamp: scannedAt, Clusters: clusterNames}, results) + if err != nil { + return fmt.Errorf("save scan: %w", err) } + log.Info("scan persisted", zap.String("scan_id", scanID)) + fmt.Fprintf(stderr, "Saved scan %s to %s.\n", scanID, dbPath) outlierThreshold, _ := cmd.Flags().GetFloat64("outlier-threshold") rpt, err := buildScanReport(ctx, st, clusterNames, results, report.BuildOptions{ diff --git a/cmd/cmd_serve.go b/cmd/cmd_serve.go index 0daceb7..ab7a630 100644 --- a/cmd/cmd_serve.go +++ b/cmd/cmd_serve.go @@ -390,10 +390,9 @@ func controllerKubeconfig(kubeconfigPath, controllerContext string, inCluster bo return kubeconfigPath } -// openServeStore opens whichever backend --db / --db-driver point at. --demo -// runs on an in-memory SQLite database the server seeds with the demo fleet, -// so it refuses a --db rather than write the demo into a real database. -// Outside demo mode a missing --db is still an error. +// openServeStore opens whichever backend --db / --db-driver point at, or the default state store +// the scan command writes to. --demo runs on an in-memory SQLite database the server seeds with +// the demo fleet, so it refuses a --db rather than write the demo into a real database. func openServeStore(cmd *cobra.Command, demo bool) (store.Store, error) { dbPath, _ := cmd.Flags().GetString("db") if demo { diff --git a/cmd/cmd_serve_test.go b/cmd/cmd_serve_test.go index c2080b9..6d81b2f 100644 --- a/cmd/cmd_serve_test.go +++ b/cmd/cmd_serve_test.go @@ -35,11 +35,6 @@ func TestOpenServeStore(t *testing.T) { t.Errorf("demo with --db: want ErrDemoDatabase, got %v", err) } - // Non-demo with no --db is an error. - if _, err := openServeStore(newStoreCmd(""), false); !errors.Is(err, ErrNoDatabase) { - t.Errorf("no-db non-demo: want ErrNoDatabase, got %v", err) - } - // A real path opens successfully. dbPath := filepath.Join(t.TempDir(), "serve.db") if s, err := store.NewSQLite(dbPath); err != nil { diff --git a/cmd/errors.go b/cmd/errors.go index 5bd9b1b..09e44b0 100644 --- a/cmd/errors.go +++ b/cmd/errors.go @@ -5,8 +5,8 @@ import "errors" var ( // ErrNoClients indicates all cluster connections failed. ErrNoClients = errors.New("no clusters reachable") - // ErrNoDatabase indicates the --db flag is required but was not provided. - ErrNoDatabase = errors.New("--db is required for this command") + // ErrNoDatabase indicates no --db was given and no default state store can be derived. + ErrNoDatabase = errors.New("no database: pass --db or set HOME") // ErrDemoDatabase indicates --demo was given a --db, which would seed the demo fleet into a // real database. ErrDemoDatabase = errors.New("--demo runs on its own in-memory store and does not take --db") diff --git a/cmd/main_test.go b/cmd/main_test.go new file mode 100644 index 0000000..f1f47b9 --- /dev/null +++ b/cmd/main_test.go @@ -0,0 +1,26 @@ +package cmd + +import ( + "fmt" + "os" + "testing" +) + +// TestMain points the default store at a temporary state directory, so a command run without +// --db never writes into the real home directory. No test may create the default store in that +// shared directory: the doctor checks read its absence as "no store yet". A test that needs a +// default store sets XDG_STATE_HOME to its own temporary directory. +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "fleetsweeper-cmd-state-") + if err != nil { + fmt.Fprintf(os.Stderr, "state dir: %v\n", err) + os.Exit(1) + } + if err := os.Setenv("XDG_STATE_HOME", dir); err != nil { + fmt.Fprintf(os.Stderr, "set XDG_STATE_HOME: %v\n", err) + os.Exit(1) + } + code := m.Run() + os.RemoveAll(dir) + os.Exit(code) +} diff --git a/cmd/store.go b/cmd/store.go index 06e26a7..68d4887 100644 --- a/cmd/store.go +++ b/cmd/store.go @@ -2,7 +2,11 @@ package cmd import ( "context" + "errors" "fmt" + "io/fs" + "os" + "path/filepath" "github.com/spf13/cobra" @@ -10,16 +14,23 @@ import ( "github.com/dcadolph/fleetsweeper/internal/scanner" "github.com/dcadolph/fleetsweeper/internal/server" "github.com/dcadolph/fleetsweeper/internal/store" + "github.com/dcadolph/fleetsweeper/internal/util" ) -// openAnyStore opens whichever backend the --db / --db-driver flag pair points -// at. Returns the broader Store interface so callers can use either backend -// transparently. +// openAnyStore opens whichever backend the --db / --db-driver flag pair points at, or the default +// state store when --db is empty. Returns the broader Store interface so callers can use either +// backend transparently. func openAnyStore(cmd *cobra.Command) (store.Store, error) { - dbPath, _ := cmd.Flags().GetString("db") - if dbPath == "" { - return nil, ErrNoDatabase + dbPath, err := resolveDBPath(cmd) + if err != nil { + return nil, err } + return openStorePath(cmd, dbPath) +} + +// openStorePath opens the store at dbPath with the driver --db-driver names, or the one its +// prefix implies. +func openStorePath(cmd *cobra.Command, dbPath string) (store.Store, error) { driver, _ := cmd.Flags().GetString("db-driver") if driver == "" { driver = string(store.DetectDriver(dbPath)) @@ -27,6 +38,49 @@ func openAnyStore(cmd *cobra.Command) (store.Store, error) { return store.Open(driver, dbPath) } +// resolveDBPath returns --db, or the default state store when --db is empty. The default store's +// directory is created 0700 and its file 0600, and the first use prints the path to stderr once so +// the user learns where history lives without having chosen it. +func resolveDBPath(cmd *cobra.Command) (string, error) { + if dbPath, _ := cmd.Flags().GetString("db"); dbPath != "" { + return dbPath, nil + } + path, err := util.DefaultStatePath() + if err != nil { + return "", fmt.Errorf("%w: %w", ErrNoDatabase, err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return "", fmt.Errorf("create state dir: %w", err) + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + switch { + case err == nil: + if err := f.Close(); err != nil { + return "", fmt.Errorf("create store %s: %w", path, err) + } + fmt.Fprintf(cmd.ErrOrStderr(), "Storing scans in %s. Pass --db to use another store.\n", path) + case !errors.Is(err, fs.ErrExist): + return "", fmt.Errorf("create store %s: %w", path, err) + } + return path, nil +} + +// existingDBPath returns --db, or the default state store when --db is empty and that store +// already exists. It creates nothing, so a read-only check never leaves a store behind. +func existingDBPath(cmd *cobra.Command) (string, bool) { + if dbPath, _ := cmd.Flags().GetString("db"); dbPath != "" { + return dbPath, true + } + path, err := util.DefaultStatePath() + if err != nil { + return "", false + } + if _, err := os.Stat(path); err != nil { + return path, false + } + return path, true +} + // resolveScanRef maps a scan reference to a stored scan ID. It accepts latest, previous or // second-latest, and returns any other ref unchanged as a scan ID. func resolveScanRef(ctx context.Context, st store.Store, ref string) (string, error) { diff --git a/cmd/store_default_test.go b/cmd/store_default_test.go new file mode 100644 index 0000000..137470b --- /dev/null +++ b/cmd/store_default_test.go @@ -0,0 +1,100 @@ +package cmd + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestResolveDBPathDefault verifies an empty --db resolves to the default state store, creates +// it private to the user, and announces the path on first use only. +func TestResolveDBPathDefault(t *testing.T) { + state := t.TempDir() + t.Setenv("XDG_STATE_HOME", state) + want := filepath.Join(state, "fleetsweeper", "fleet.db") + + var first bytes.Buffer + cmd := newStoreCmd("") + cmd.SetErr(&first) + got, err := resolveDBPath(cmd) + if err != nil || got != want { + t.Fatalf("resolve: got %q, %v, want %q", got, err, want) + } + if n := strings.Count(first.String(), want); n != 1 { + t.Errorf("first use: path printed %d times, want 1: %q", n, first.String()) + } + info, err := os.Stat(want) + if err != nil || info.Mode().Perm() != 0o600 { + t.Errorf("store file: %v, mode %v, want 0600", err, info) + } + dirInfo, err := os.Stat(filepath.Dir(want)) + if err != nil || dirInfo.Mode().Perm() != 0o700 { + t.Errorf("store dir: %v, mode %v, want 0700", err, dirInfo) + } + + var second bytes.Buffer + cmd = newStoreCmd("") + cmd.SetErr(&second) + if _, err := resolveDBPath(cmd); err != nil { + t.Fatalf("second resolve: %v", err) + } + if second.Len() != 0 { + t.Errorf("second use printed %q, want nothing", second.String()) + } +} + +// TestResolveDBPathExplicit verifies --db wins over the default and creates nothing. +func TestResolveDBPathExplicit(t *testing.T) { + state := t.TempDir() + t.Setenv("XDG_STATE_HOME", state) + explicit := filepath.Join(t.TempDir(), "mine.db") + got, err := resolveDBPath(newStoreCmd(explicit)) + if err != nil || got != explicit { + t.Fatalf("resolve: got %q, %v, want %q", got, err, explicit) + } + if _, err := os.Stat(filepath.Join(state, "fleetsweeper")); !errors.Is(err, os.ErrNotExist) { + t.Errorf("explicit --db created the default state dir: %v", err) + } +} + +// TestResolveDBPathNoHome verifies a process with no home and no XDG_STATE_HOME has no store. +func TestResolveDBPathNoHome(t *testing.T) { + t.Setenv("XDG_STATE_HOME", "") + t.Setenv("HOME", "") + if _, err := resolveDBPath(newStoreCmd("")); !errors.Is(err, ErrNoDatabase) { + t.Errorf("want ErrNoDatabase, got %v", err) + } +} + +// TestScanAndServeShareDefaultStore verifies a scan with no --db persists to the default store +// and serve with no --db opens that same store. +func TestScanAndServeShareDefaultStore(t *testing.T) { + state := t.TempDir() + t.Setenv("XDG_STATE_HOME", state) + dir := t.TempDir() + kubeconfig := filepath.Join(dir, "config") + if err := os.WriteFile(kubeconfig, []byte(unreachableKubeconfig), 0o600); err != nil { + t.Fatalf("write kubeconfig: %v", err) + } + if err := execScanCmd(t, "scan", "--kubeconfig="+kubeconfig, "--db=", "--group=", "--contexts=", + "--scanners=version", "--html-file="+filepath.Join(dir, "report.html")); err != nil { + t.Fatalf("scan: %v", err) + } + if _, err := os.Stat(filepath.Join(state, "fleetsweeper", "fleet.db")); err != nil { + t.Fatalf("default store not written: %v", err) + } + + st, err := openServeStore(newStoreCmd(""), false) + if err != nil { + t.Fatalf("serve store: %v", err) + } + defer st.Close() + scans, err := st.ListScans(context.Background(), 5) + if err != nil || len(scans) != 1 { + t.Fatalf("serve sees %d scans, %v; want the 1 scan run wrote", len(scans), err) + } +} diff --git a/docs/getting-started.md b/docs/getting-started.md index 61049e7..40998ce 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -101,8 +101,10 @@ fleetsweeper scan -o html --html-file report.html ## Persist scan results -Pass `--db` to store results in a SQLite database. This enables scan -history, trend analysis, and cluster grouping. +Every scan is stored. With no `--db`, scans go to a SQLite file at +`$XDG_STATE_HOME/fleetsweeper/fleet.db`, else `~/.local/state/fleetsweeper/fleet.db`, and the +first scan prints the path. Scan history, trends, and cluster grouping work from the second scan +with nothing configured. Pass `--db` to use another SQLite path or a Postgres URL. ```shell fleetsweeper scan --db fleet.db diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 993ea64..c873747 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -61,7 +61,7 @@ each command's own help line; `fleetsweeper --help` lists its flags. | Flag | Default | Description | | --- | --- | --- | | `--kubeconfig` | `$KUBECONFIG` or `~/.kube/config` | Path to the kubeconfig used by scanners. | -| `--db` | `""` | Database DSN: a SQLite file path, or a `postgres://` URL. Required for stateful commands. | +| `--db` | `""` | Database DSN: a SQLite file path, or a `postgres://` URL. Empty uses `$XDG_STATE_HOME/fleetsweeper/fleet.db`, else `~/.local/state/fleetsweeper/fleet.db`. | | `--db-driver` | `""` | `sqlite` or `postgres`. Empty picks from the DSN: a `postgres://` or `postgresql://` prefix means Postgres, anything else SQLite. | | `--pretty` | `false` | Indent JSON output. | | `--log-level` | `warn` | One of `debug`, `info`, `warn`, `error`. Logs go to stderr. | @@ -77,7 +77,7 @@ and does not take `--db`. | `1` | Any error not listed below. | | `2` | No cluster in the scan could be reached. | | `3` | The kubeconfig holds no contexts. | -| `4` | The command needs `--db` and none was given. | +| `4` | No `--db` was given and no default store path exists (no `HOME` or `XDG_STATE_HOME`). | Errors print to stderr as `error: `. Commands with their own pass and fail semantics, such as `doctor` and `drift --fail-on-drift`, exit diff --git a/internal/util/state.go b/internal/util/state.go new file mode 100644 index 0000000..35c3ced --- /dev/null +++ b/internal/util/state.go @@ -0,0 +1,33 @@ +package util + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" +) + +// ErrStatePath indicates no default state directory can be derived for this user. +var ErrStatePath = errors.New("no default state directory") + +// DefaultStatePath returns the file fleetsweeper keeps its scan store in when none is named: +// $XDG_STATE_HOME/fleetsweeper/fleet.db when XDG_STATE_HOME is absolute, else +// ~/.local/state/fleetsweeper/fleet.db, and the user config directory on Windows. +func DefaultStatePath() (string, error) { + if dir := os.Getenv("XDG_STATE_HOME"); filepath.IsAbs(dir) { + return filepath.Join(dir, "fleetsweeper", "fleet.db"), nil + } + if runtime.GOOS == "windows" { + dir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("%w: %w", ErrStatePath, err) + } + return filepath.Join(dir, "fleetsweeper", "fleet.db"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("%w: %w", ErrStatePath, err) + } + return filepath.Join(home, ".local", "state", "fleetsweeper", "fleet.db"), nil +} diff --git a/internal/util/state_test.go b/internal/util/state_test.go new file mode 100644 index 0000000..f5d30d7 --- /dev/null +++ b/internal/util/state_test.go @@ -0,0 +1,42 @@ +package util + +import ( + "errors" + "fmt" + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" +) + +// TestDefaultStatePath verifies XDG_STATE_HOME wins when absolute, the home directory is the +// fallback, and a process with neither has no default. +func TestDefaultStatePath(t *testing.T) { + tests := []struct { + XDG string + Home string + WantResult string + Want error + }{{ // Test 0: An absolute XDG_STATE_HOME wins. + XDG: "/xdg", Home: "/home/u", WantResult: filepath.Join("/xdg", "fleetsweeper", "fleet.db"), + }, { // Test 1: No XDG_STATE_HOME falls back to the home directory. + Home: "/home/u", WantResult: filepath.Join("/home/u", ".local", "state", "fleetsweeper", "fleet.db"), + }, { // Test 2: A relative XDG_STATE_HOME is ignored, as the XDG spec requires. + XDG: "rel", Home: "/home/u", WantResult: filepath.Join("/home/u", ".local", "state", "fleetsweeper", "fleet.db"), + }, { // Test 3: Neither set has no default. + Want: ErrStatePath, + }} + for testNum, test := range tests { + t.Run(fmt.Sprintf("test %d", testNum), func(t *testing.T) { + t.Setenv("XDG_STATE_HOME", test.XDG) + t.Setenv("HOME", test.Home) + got, err := DefaultStatePath() + if !errors.Is(err, test.Want) { + t.Fatalf("error: got %v, want %v", err, test.Want) + } + if diff := cmp.Diff(test.WantResult, got); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + }) + } +} From ce06e9ca1deb58b9e9f539ac61af01883faa1f99 Mon Sep 17 00:00:00 2001 From: Douglas Adolph <116844282+dcadolph@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:14:11 -0600 Subject: [PATCH 4/5] Read the first fix line without unchecked indexes --- cmd/cmd_scan_summary_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cmd/cmd_scan_summary_test.go b/cmd/cmd_scan_summary_test.go index 5258d0b..a6c7055 100644 --- a/cmd/cmd_scan_summary_test.go +++ b/cmd/cmd_scan_summary_test.go @@ -106,8 +106,11 @@ func TestWriteScanSummaryFixOrder(t *testing.T) { if n := strings.Count(out, "Fix "); n != 3 { t.Errorf("want 3 fixes, got %d:\n%s", n, out) } - first := out[strings.Index(out, "Fix 1:"):] - first = first[:strings.Index(first, "\n")] + _, rest, found := strings.Cut(out, "Fix 1:") + if !found { + t.Fatalf("no first fix in:\n%s", out) + } + first, _, _ := strings.Cut(rest, "\n") if !strings.Contains(first, report.RuleTitle("workload-security/privileged")) || !strings.Contains(first, "2 clusters") { t.Errorf("first fix should be privileged on 2 clusters, got %q", first) } From 3729bf551760a974b56b46802e1baa5c8e2c44fc Mon Sep 17 00:00:00 2001 From: Douglas Adolph <116844282+dcadolph@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:00:53 -0600 Subject: [PATCH 5/5] Prepare the v0.16.0 upgrade notes and chart version --- UPGRADING.md | 36 +++++++++++++++++++++++++++++ cmd/codes.go | 2 +- deploy/helm/fleetsweeper/Chart.yaml | 4 ++-- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/UPGRADING.md b/UPGRADING.md index fc2cda1..9726fba 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -14,6 +14,42 @@ stability contract and the GitHub release notes for the full feature list. 5. Verify `kubectl logs deploy/fleetsweeper | grep migrate` shows no errors and `/readyz` returns 200. +## v0.16.0 + +No schema migration. + +- **A bare scan scans every context.** `fleetsweeper scan`, `watch`, and the + `serve` scheduler with no `--contexts` or `--group` now scan every context in + the kubeconfig. `--all-contexts` is removed from all three and from the + `init` scaffold. Drop it from scripts and config files, since an unknown flag + is an error. A kubeconfig with no contexts exits with code 3 and names the + file. + +- **The scan API takes an empty request.** `POST /api/scan` with `{}` scans + every context. The `all_contexts` field is removed and a request that sends + it gets 400. A ClusterScan with no `contexts` and no `group` also scans every + context, where before it scanned nothing. + +- **The chart's scheduler renders contexts.** `scheduler.allContexts` is + removed. `scheduler.contexts` now renders one `--contexts` argument per + entry. Leave it empty to scan every context. + +- **Scans are stored by default.** With no `--db`, every scan is saved to + `$XDG_STATE_HOME/fleetsweeper/fleet.db` when `XDG_STATE_HOME` is an absolute + path, else `~/.local/state/fleetsweeper/fleet.db` (the user config directory + on Windows). The directory is created 0700 and the file 0600, and the path is + printed once on first use. `--db` still overrides it. Commands that needed + `--db` (`group`, `history`, `compare`, `location`, `apikey`, and `scan + --group`) now use the default store. Exit code 4 now means no store path + could be resolved, i.e. no `--db` and no home directory. `watch` still stores + scans only when `--db` is set. + +- **The terminal summary leads with coverage.** After a scan, stderr shows + coverage first (scanner runs observed, then any cluster with no complete + run), then the score or "not graded", then the top three fixes, then cohorts + and incidents. Scripts should read the JSON report on stdout, not this + summary. + ## v0.15.0 No schema migration. diff --git a/cmd/codes.go b/cmd/codes.go index 4a4933f..edf8310 100644 --- a/cmd/codes.go +++ b/cmd/codes.go @@ -9,6 +9,6 @@ const ( CodeConnectionError = 2 // CodeNoContexts indicates no kubeconfig contexts were resolved. CodeNoContexts = 3 - // CodeNoDB indicates the --db flag was required but not provided. + // CodeNoDB indicates no store path resolved: no --db and no home directory. CodeNoDB = 4 ) diff --git a/deploy/helm/fleetsweeper/Chart.yaml b/deploy/helm/fleetsweeper/Chart.yaml index 5170ec4..2a3d934 100644 --- a/deploy/helm/fleetsweeper/Chart.yaml +++ b/deploy/helm/fleetsweeper/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: fleetsweeper description: Multi-cluster Kubernetes fleet comparison and audit type: application -version: 0.15.0 -appVersion: "0.15.0" +version: 0.16.0 +appVersion: "0.16.0" home: https://github.com/dcadolph/fleetsweeper sources: - https://github.com/dcadolph/fleetsweeper