From 518a73c21a63ddcd6f9c8154b6d2e2196446e77e Mon Sep 17 00:00:00 2001 From: Bradley Jones Date: Thu, 2 May 2024 13:25:46 +0100 Subject: [PATCH] feat: add account routing for namespaces Allow account routing by config for namespaces to specific accounts by namespaces name using either exact match or by regex pattern Signed-off-by: Bradley Jones --- Makefile | 2 +- anchore-k8s-inventory.yaml | 9 ++ cmd/root.go | 12 +- internal/config/config.go | 10 ++ .../snapshot/TestDefaultConfigString.golden | 1 + .../snapshot/TestEmptyConfigString.golden | 1 + .../snapshot/TestSensitiveConfigString.golden | 1 + pkg/lib.go | 153 +++++++++++++++--- pkg/lib_test.go | 104 ++++++++++++ test/integration/get_images_test.go | 50 +++--- 10 files changed, 290 insertions(+), 53 deletions(-) create mode 100644 pkg/lib_test.go diff --git a/Makefile b/Makefile index 5ed141c..10278ef 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ RESET := $(shell tput -T linux sgr0) TITLE := $(BOLD)$(PURPLE) SUCCESS := $(BOLD)$(GREEN) # the quality gate lower threshold for unit test total % coverage (by function statements) -COVERAGE_THRESHOLD := 50 +COVERAGE_THRESHOLD := 48 CLUSTER_NAME=anchore-k8s-inventory-testing diff --git a/anchore-k8s-inventory.yaml b/anchore-k8s-inventory.yaml index 77a2de6..c354214 100644 --- a/anchore-k8s-inventory.yaml +++ b/anchore-k8s-inventory.yaml @@ -43,6 +43,15 @@ namespace-selectors: ignore-empty: false +account-routes: + # Example + # account: + # user: username + # password: password + # namespaces: + # - default + # - ^kube-* + # Kubernetes API configuration parameters (should not need tuning) kubernetes: # Sets the request timeout for kubernetes API requests diff --git a/cmd/root.go b/cmd/root.go index 00b8460..d255944 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -47,7 +47,7 @@ var rootCmd = &cobra.Command{ case mode.PeriodicPolling: pkg.PeriodicallyGetInventoryReport(appConfig) default: - report, err := pkg.GetInventoryReport(appConfig) + reports, err := pkg.GetInventoryReports(appConfig) if appConfig.Dev.ProfileCPU { pprof.StopCPUProfile() } @@ -55,10 +55,12 @@ var rootCmd = &cobra.Command{ log.Errorf("Failed to get Image Results: %+v", err) os.Exit(1) } - err = pkg.HandleReport(report, appConfig) - if err != nil { - log.Errorf("Failed to handle Image Results: %+v", err) - os.Exit(1) + for account, report := range reports { + err = pkg.HandleReport(report, appConfig, account) + if err != nil { + log.Errorf("Failed to handle Image Results: %+v", err) + os.Exit(1) + } } } }, diff --git a/internal/config/config.go b/internal/config/config.go index 5d88fad..aebd0cf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -46,6 +46,7 @@ type Application struct { Namespaces []string `mapstructure:"namespaces"` KubernetesRequestTimeoutSeconds int64 `mapstructure:"kubernetes-request-timeout-seconds"` NamespaceSelectors NamespaceSelector `mapstructure:"namespace-selectors"` + AccountRoutes AccountRoutes `mapstructure:"account-routes"` MissingRegistryOverride string `mapstructure:"missing-registry-override"` MissingTagPolicy MissingTagConf `mapstructure:"missing-tag-policy"` RunMode mode.Mode @@ -69,6 +70,14 @@ type NamespaceSelector struct { IgnoreEmpty bool `mapstructure:"ignore-empty"` } +type AccountRoutes map[string]AccountRouteDetails + +type AccountRouteDetails struct { + User string `mapstructure:"user"` + Password string `mapstructure:"password"` + Namespaces []string `mapstructure:"namespaces"` +} + // KubernetesAPI details the configuration for interacting with the k8s api server type KubernetesAPI struct { RequestTimeoutSeconds int64 `mapstructure:"request-timeout-seconds"` @@ -128,6 +137,7 @@ func setNonCliDefaultValues(v *viper.Viper) { v.SetDefault("missing-registry-override", "") v.SetDefault("missing-tag-policy.policy", "digest") v.SetDefault("missing-tag-policy.tag", "UNKNOWN") + v.SetDefault("account-routes", AccountRoutes{}) v.SetDefault("namespaces", []string{}) v.SetDefault("namespace-selectors.include", []string{}) v.SetDefault("namespace-selectors.exclude", []string{}) diff --git a/internal/config/test-fixtures/snapshot/TestDefaultConfigString.golden b/internal/config/test-fixtures/snapshot/TestDefaultConfigString.golden index a6e6cd1..eac6d20 100644 --- a/internal/config/test-fixtures/snapshot/TestDefaultConfigString.golden +++ b/internal/config/test-fixtures/snapshot/TestDefaultConfigString.golden @@ -31,6 +31,7 @@ namespaceselectors: include: [] exclude: [] ignoreempty: false +accountroutes: {} missingregistryoverride: "" missingtagpolicy: policy: digest diff --git a/internal/config/test-fixtures/snapshot/TestEmptyConfigString.golden b/internal/config/test-fixtures/snapshot/TestEmptyConfigString.golden index 8986b0a..6c59c18 100644 --- a/internal/config/test-fixtures/snapshot/TestEmptyConfigString.golden +++ b/internal/config/test-fixtures/snapshot/TestEmptyConfigString.golden @@ -31,6 +31,7 @@ namespaceselectors: include: [] exclude: [] ignoreempty: false +accountroutes: {} missingregistryoverride: "" missingtagpolicy: policy: "" diff --git a/internal/config/test-fixtures/snapshot/TestSensitiveConfigString.golden b/internal/config/test-fixtures/snapshot/TestSensitiveConfigString.golden index 4ad3a2b..ce1c5f4 100644 --- a/internal/config/test-fixtures/snapshot/TestSensitiveConfigString.golden +++ b/internal/config/test-fixtures/snapshot/TestSensitiveConfigString.golden @@ -31,6 +31,7 @@ namespaceselectors: include: [] exclude: [] ignoreempty: false +accountroutes: {} missingregistryoverride: "" missingtagpolicy: policy: digest diff --git a/pkg/lib.go b/pkg/lib.go index d27cfef..ecfe2a4 100644 --- a/pkg/lib.go +++ b/pkg/lib.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "os" + "regexp" "time" "github.com/anchore/k8s-inventory/pkg/reporter" @@ -33,6 +34,8 @@ type channels struct { stopper chan struct{} } +type AccountRoutedReports map[string]inventory.Report + func reportToStdout(report inventory.Report) error { enc := json.NewEncoder(os.Stdout) // prevent > and < from being escaped in the payload @@ -44,7 +47,7 @@ func reportToStdout(report inventory.Report) error { return nil } -func HandleReport(report inventory.Report, cfg *config.Application) error { +func HandleReport(report inventory.Report, cfg *config.Application, account string) error { if cfg.VerboseInventoryReports { err := reportToStdout(report) if err != nil { @@ -52,11 +55,29 @@ func HandleReport(report inventory.Report, cfg *config.Application) error { } } - if cfg.AnchoreDetails.IsValid() { - if err := reporter.Post(report, cfg.AnchoreDetails); err != nil { - return fmt.Errorf("unable to report Inventory to Anchore: %w", err) + anchoreDetails := cfg.AnchoreDetails + // Look for account credentials in the account routes first then fall back to the global anchore credentials + if account == "" { + return fmt.Errorf("account name is required") + } + anchoreDetails.Account = account + if cfg.AccountRoutes != nil { + if route, ok := cfg.AccountRoutes[account]; ok { + log.Debugf("Using account details specified from account-routes config for account %s", account) + anchoreDetails.User = route.User + anchoreDetails.Password = route.Password + } else { + log.Debugf("Using default account details for account %s", account) + } + } else { + log.Debugf("Using default account details for account %s", account) + } + + if anchoreDetails.IsValid() { + if err := reporter.Post(report, anchoreDetails); err != nil { + return fmt.Errorf("unable to report Inventory to Anchore account %s: %w", account, err) } - log.Info("Inventory report sent to Anchore") + log.Infof("Inventory report sent to Anchore account %s", account) } else { log.Info("Anchore details not specified, not reporting inventory") } @@ -70,13 +91,15 @@ func PeriodicallyGetInventoryReport(cfg *config.Application) { ticker := time.NewTicker(time.Duration(cfg.PollingIntervalSeconds) * time.Second) for { - report, err := GetInventoryReport(cfg) + reports, err := GetInventoryReports(cfg) if err != nil { log.Errorf("Failed to get Inventory Report: %w", err) } else { - err := HandleReport(report, cfg) - if err != nil { - log.Errorf("Failed to handle Inventory Report: %w", err) + for account, report := range reports { + err := HandleReport(report, cfg, account) + if err != nil { + log.Errorf("Failed to handle Inventory Report: %w", err) + } } } @@ -117,23 +140,21 @@ func launchWorkerPool( } } -// GetInventoryReport is an atomic method for getting in-use image results, in parallel for multiple namespaces +// GetInventoryReportForNamespaces is an atomic method for getting in-use image results, in parallel for multiple namespaces // //nolint:funlen -func GetInventoryReport(cfg *config.Application) (inventory.Report, error) { - log.Info("Starting image inventory collection") +func GetInventoryReportForNamespaces( + cfg *config.Application, + namespaces []inventory.Namespace, +) (inventory.Report, error) { + log.Info("Starting image inventory collection for specific namespaces") + log.Debugf("Namespaces: %v", namespaces) kubeconfig, err := client.GetKubeConfig(cfg) if err != nil { return inventory.Report{}, err } - ch := channels{ - reportItem: make(chan ReportItem), - errors: make(chan error), - stopper: make(chan struct{}, 1), - } - clientset, err := client.GetClientSet(kubeconfig) if err != nil { return inventory.Report{}, fmt.Errorf("failed to get k8s client set: %w", err) @@ -142,11 +163,10 @@ func GetInventoryReport(cfg *config.Application) (inventory.Report, error) { Clientset: clientset, } - namespaces, err := inventory.FetchNamespaces(client, - cfg.Kubernetes.RequestBatchSize, cfg.Kubernetes.RequestTimeoutSeconds, - cfg.NamespaceSelectors.Exclude, cfg.NamespaceSelectors.Include) - if err != nil { - return inventory.Report{}, err + ch := channels{ + reportItem: make(chan ReportItem), + errors: make(chan error), + stopper: make(chan struct{}, 1), } queue := make(chan inventory.Namespace, len(namespaces)) // fill the queue of namespaces to process @@ -215,6 +235,93 @@ func GetInventoryReport(cfg *config.Application) (inventory.Report, error) { }, nil } +func GetAllNamespaces(cfg *config.Application) ([]inventory.Namespace, error) { + kubeconfig, err := client.GetKubeConfig(cfg) + if err != nil { + return []inventory.Namespace{}, err + } + + clientset, err := client.GetClientSet(kubeconfig) + if err != nil { + return []inventory.Namespace{}, fmt.Errorf("failed to get k8s client set: %w", err) + } + client := client.Client{ + Clientset: clientset, + } + + namespaces, err := inventory.FetchNamespaces(client, + cfg.Kubernetes.RequestBatchSize, cfg.Kubernetes.RequestTimeoutSeconds, + cfg.NamespaceSelectors.Exclude, cfg.NamespaceSelectors.Include) + if err != nil { + return []inventory.Namespace{}, err + } + + log.Infof("Found %d namespaces", len(namespaces)) + + return namespaces, nil +} + +func GetAccountRoutedNamespaces(defaultAccount string, namespaces []inventory.Namespace, accountRoutes config.AccountRoutes) map[string][]inventory.Namespace { + accountRoutesForAllNamespaces := make(map[string][]inventory.Namespace) + + accountNamespaces := make(map[string]struct{}) + for routeNS, route := range accountRoutes { + for _, ns := range namespaces { + for _, namespaceRegex := range route.Namespaces { + if regexp.MustCompile(namespaceRegex).MatchString(ns.Name) { + accountNamespaces[ns.Name] = struct{}{} + accountRoutesForAllNamespaces[routeNS] = append(accountRoutesForAllNamespaces[routeNS], ns) + } + } + } + } + // Add namespaces that are not in any account route to the default account + for _, ns := range namespaces { + if _, ok := accountNamespaces[ns.Name]; !ok { + accountRoutesForAllNamespaces[defaultAccount] = append(accountRoutesForAllNamespaces[defaultAccount], ns) + } + } + + return accountRoutesForAllNamespaces +} + +func GetInventoryReports(cfg *config.Application) (AccountRoutedReports, error) { + log.Info("Starting image inventory collection") + + reports := AccountRoutedReports{} + + namespaces, _ := GetAllNamespaces(cfg) + + if len(cfg.AccountRoutes) == 0 { + allNamespacesReport, err := GetInventoryReportForNamespaces(cfg, namespaces) + if err != nil { + return AccountRoutedReports{}, err + } + reports[cfg.AnchoreDetails.Account] = allNamespacesReport + } else { + accountRoutesForAllNamespaces := GetAccountRoutedNamespaces(cfg.AnchoreDetails.Account, namespaces, cfg.AccountRoutes) + + for account, namespaces := range accountRoutesForAllNamespaces { + nsNames := make([]string, 0) + for _, ns := range namespaces { + nsNames = append(nsNames, ns.Name) + } + log.Infof("Namespaces for account %s : %s", account, nsNames) + } + + // Get inventory reports for each account + for account, namespaces := range accountRoutesForAllNamespaces { + accountReport, err := GetInventoryReportForNamespaces(cfg, namespaces) + if err != nil { + return AccountRoutedReports{}, err + } + reports[account] = accountReport + } + } + + return reports, nil +} + func processNamespace( clientset *kubernetes.Clientset, cfg *config.Application, diff --git a/pkg/lib_test.go b/pkg/lib_test.go new file mode 100644 index 0000000..5cfac6b --- /dev/null +++ b/pkg/lib_test.go @@ -0,0 +1,104 @@ +package pkg + +import ( + "testing" + + "github.com/anchore/k8s-inventory/internal/config" + "github.com/anchore/k8s-inventory/pkg/inventory" + + "github.com/stretchr/testify/assert" +) + +var ( + TestNamespace1 = inventory.Namespace{ + Name: "ns1", + } + TestNamespace2 = inventory.Namespace{ + Name: "ns2", + } + TestNamespace3 = inventory.Namespace{ + Name: "ns3", + } + TestNamespace4 = inventory.Namespace{ + Name: "ns4", + } + TestNamespaces = []inventory.Namespace{ + TestNamespace1, + TestNamespace2, + TestNamespace3, + TestNamespace4, + } +) + +func TestGetAccountRoutedNamespaces(t *testing.T) { + type args struct { + defaultAccount string + namespaces []inventory.Namespace + accountRoutes config.AccountRoutes + } + tests := []struct { + name string + args args + want map[string][]inventory.Namespace + }{ + { + name: "no account routes all to default", + args: args{ + defaultAccount: "admin", + namespaces: TestNamespaces, + accountRoutes: config.AccountRoutes{}, + }, + want: map[string][]inventory.Namespace{ + "admin": TestNamespaces, + }, + }, + { + name: "namespaces to individual accounts explicit", + args: args{ + defaultAccount: "admin", + namespaces: TestNamespaces, + accountRoutes: config.AccountRoutes{ + "account1": config.AccountRouteDetails{ + Namespaces: []string{"ns1"}, + }, + "account2": config.AccountRouteDetails{ + Namespaces: []string{"ns2"}, + }, + "account3": config.AccountRouteDetails{ + Namespaces: []string{"ns3"}, + }, + "account4": config.AccountRouteDetails{ + Namespaces: []string{"ns4"}, + }, + }, + }, + want: map[string][]inventory.Namespace{ + "account1": {TestNamespace1}, + "account2": {TestNamespace2}, + "account3": {TestNamespace3}, + "account4": {TestNamespace4}, + }, + }, + { + name: "namespaces to account regex", + args: args{ + defaultAccount: "admin", + namespaces: TestNamespaces, + accountRoutes: config.AccountRoutes{ + "account1": config.AccountRouteDetails{ + Namespaces: []string{"ns.*"}, + }, + }, + }, + want: map[string][]inventory.Namespace{ + "account1": TestNamespaces, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetAccountRoutedNamespaces(tt.args.defaultAccount, tt.args.namespaces, tt.args.accountRoutes) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/test/integration/get_images_test.go b/test/integration/get_images_test.go index bec2992..b1fbbc5 100644 --- a/test/integration/get_images_test.go +++ b/test/integration/get_images_test.go @@ -16,40 +16,42 @@ const ( // Assumes that the hello-world helm chart in ./fixtures was installed (basic nginx container) func TestGetImageResults(t *testing.T) { cmd.InitAppConfig() - report, err := pkg.GetInventoryReport(cmd.GetAppConfig()) + reports, err := pkg.GetInventoryReports(cmd.GetAppConfig()) if err != nil { t.Fatalf("failed to get image results: %v", err) } - if report.ServerVersionMetadata == nil { - t.Errorf("Failed to include Server Version Metadata in report") - } - - if report.Timestamp == "" { - t.Errorf("Failed to include Timestamp in report") - } + for _, report := range reports { + if report.ServerVersionMetadata == nil { + t.Errorf("Failed to include Server Version Metadata in report") + } - foundIntegrationTestNamespace := false - for _, item := range report.Namespaces { - if item.Name != IntegrationTestNamespace { - continue + if report.Timestamp == "" { + t.Errorf("Failed to include Timestamp in report") } - foundIntegrationTestNamespace = true - foundIntegrationTestImage := false - for _, image := range report.Containers { - if !strings.Contains(image.ImageTag, IntegrationTestImageTag) { + + foundIntegrationTestNamespace := false + for _, item := range report.Namespaces { + if item.Name != IntegrationTestNamespace { continue } - foundIntegrationTestImage = true - if image.ImageDigest == "" { - t.Logf("Image Found, but no digest located: %v", image) + foundIntegrationTestNamespace = true + foundIntegrationTestImage := false + for _, image := range report.Containers { + if !strings.Contains(image.ImageTag, IntegrationTestImageTag) { + continue + } + foundIntegrationTestImage = true + if image.ImageDigest == "" { + t.Logf("Image Found, but no digest located: %v", image) + } + } + if !foundIntegrationTestImage { + t.Errorf("failed to locate integration test image") } } - if !foundIntegrationTestImage { - t.Errorf("failed to locate integration test image") + if !foundIntegrationTestNamespace { + t.Errorf("failed to locate integration test namespace") } } - if !foundIntegrationTestNamespace { - t.Errorf("failed to locate integration test namespace") - } }