diff --git a/charts/kubechecks/Chart.yaml b/charts/kubechecks/Chart.yaml index 47c302ec..c0fd3003 100644 --- a/charts/kubechecks/Chart.yaml +++ b/charts/kubechecks/Chart.yaml @@ -1,7 +1,7 @@ apiVersion: v2 name: kubechecks description: A Helm chart for kubechecks -version: 0.5.2 +version: 0.5.3 type: application maintainers: - name: zapier diff --git a/charts/kubechecks/values.yaml b/charts/kubechecks/values.yaml index aadae07a..ca32968b 100644 --- a/charts/kubechecks/values.yaml +++ b/charts/kubechecks/values.yaml @@ -7,6 +7,7 @@ commonLabels: {} configMap: create: false env: {} + # KUBECHECKS_ADDITIONAL_APPS_NAMESPACES: "*" # KUBECHECKS_ARGOCD_API_INSECURE: "false" # KUBECHECKS_ARGOCD_API_PATH_PREFIX: / # KUBECHECKS_ARGOCD_API_NAMESPACE: argocd diff --git a/cmd/controller.go b/cmd/controller.go index 6b2b758c..e049f483 100644 --- a/cmd/controller.go +++ b/cmd/controller.go @@ -49,13 +49,13 @@ var ControllerCmd = &cobra.Command{ // watch app modifications, if necessary if cfg.MonitorAllApplications { - appWatcher, err := app_watcher.NewApplicationWatcher(ctr) + appWatcher, err := app_watcher.NewApplicationWatcher(ctr, ctx) if err != nil { log.Fatal().Err(err).Msg("failed to create watch applications") } go appWatcher.Run(ctx, 1) - appSetWatcher, err := app_watcher.NewApplicationSetWatcher(ctr) + appSetWatcher, err := app_watcher.NewApplicationSetWatcher(ctr, ctx) if err != nil { log.Fatal().Err(err).Msg("failed to create watch application sets") } diff --git a/cmd/root.go b/cmd/root.go index c52668b1..8261026d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -118,6 +118,7 @@ func init() { stringFlag(flags, "replan-comment-msg", "comment message which re-triggers kubechecks on PR.", newStringOpts(). withDefault("kubechecks again")) + stringSliceFlag(flags, "additional-apps-namespaces", "Additional namespaces other than the ArgoCDNamespace to monitor for applications.") panicIfError(viper.BindPFlags(flags)) setupLogOutput() diff --git a/docs/usage.md b/docs/usage.md index 64032f55..ad8dd502 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -36,6 +36,7 @@ The full list of supported environment variables is described below: |Env Var|Description|Default Value| |-----------|-------------|------| +|`KUBECHECKS_ADDITIONAL_APPS_NAMESPACES`|Additional namespaces other than the ArgoCDNamespace to monitor for applications.|`[]`| |`KUBECHECKS_ARGOCD_API_INSECURE`|Enable to use insecure connections over TLS to the ArgoCD API server.|`false`| |`KUBECHECKS_ARGOCD_API_NAMESPACE`|ArgoCD namespace where the application watcher will read Custom Resource Definitions (CRD) for Application and ApplicationSet resources.|`argocd`| |`KUBECHECKS_ARGOCD_API_PLAINTEXT`|Enable to use plaintext connections without TLS.|`false`| diff --git a/localdev/kubechecks/values.yaml b/localdev/kubechecks/values.yaml index 9e5473e3..c91cf650 100644 --- a/localdev/kubechecks/values.yaml +++ b/localdev/kubechecks/values.yaml @@ -1,6 +1,8 @@ configMap: create: true env: + GRPC_ENFORCE_ALPN_ENABLED: false + KUBECHECKS_ADDITIONAL_APPS_NAMESPACES: "*" KUBECHECKS_LOG_LEVEL: debug KUBECHECKS_ENABLE_WEBHOOK_CONTROLLER: "false" KUBECHECKS_ARGOCD_API_INSECURE: "true" diff --git a/pkg/app_watcher/app_watcher.go b/pkg/app_watcher/app_watcher.go index db183dff..eb3b2498 100644 --- a/pkg/app_watcher/app_watcher.go +++ b/pkg/app_watcher/app_watcher.go @@ -9,15 +9,18 @@ import ( appv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" appclientset "github.com/argoproj/argo-cd/v2/pkg/client/clientset/versioned" - informers "github.com/argoproj/argo-cd/v2/pkg/client/informers/externalversions/application/v1alpha1" applisters "github.com/argoproj/argo-cd/v2/pkg/client/listers/application/v1alpha1" + "github.com/argoproj/argo-cd/v2/util/glob" "github.com/rs/zerolog/log" - "github.com/zapier/kubechecks/pkg/appdir" - "github.com/zapier/kubechecks/pkg/container" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apiruntime "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/watch" "k8s.io/client-go/tools/cache" + "github.com/zapier/kubechecks/pkg/appdir" "github.com/zapier/kubechecks/pkg/config" + "github.com/zapier/kubechecks/pkg/container" ) // ApplicationWatcher is the controller that watches ArgoCD Application resources via the Kubernetes API @@ -34,7 +37,7 @@ type ApplicationWatcher struct { // - kubeCfg is the Kubernetes configuration. // - vcsToArgoMap is the mapping between VCS and Argo applications. // - cfg is the server configuration. -func NewApplicationWatcher(ctr container.Container) (*ApplicationWatcher, error) { +func NewApplicationWatcher(ctr container.Container, ctx context.Context) (*ApplicationWatcher, error) { if ctr.KubeClientSet == nil { return nil, fmt.Errorf("kubeCfg cannot be nil") } @@ -43,7 +46,7 @@ func NewApplicationWatcher(ctr container.Container) (*ApplicationWatcher, error) vcsToArgoMap: ctr.VcsToArgoMap, } - appInformer, appLister := ctrl.newApplicationInformerAndLister(time.Second*30, ctr.Config) + appInformer, appLister := ctrl.newApplicationInformerAndLister(time.Second*30, ctr.Config, ctx) ctrl.appInformer = appInformer ctrl.appLister = appLister @@ -116,6 +119,11 @@ func (ctrl *ApplicationWatcher) onApplicationDeleted(obj interface{}) { ctrl.vcsToArgoMap.DeleteApp(app) } +// isAppNamespaceAllowed is used by both the ApplicationWatcher and the ApplicationSetWatcher +func isAppNamespaceAllowed(meta *metav1.ObjectMeta, cfg config.ServerConfig) bool { + return meta.Namespace == cfg.ArgoCDNamespace || glob.MatchStringInList(cfg.AdditionalAppsNamespaces, meta.Namespace, glob.REGEXP) +} + /* newApplicationInformerAndLister, is part of the ApplicationWatcher struct. It sets up a Kubernetes SharedIndexInformer and a Lister for Argo CD Applications. @@ -127,12 +135,41 @@ that need to observe the object. newApplicationInformerAndLister use the data from the informer's cache to provide a read-optimized view of the cache which reduces the load on the API Server and hides some complexity. */ -func (ctrl *ApplicationWatcher) newApplicationInformerAndLister(refreshTimeout time.Duration, cfg config.ServerConfig) (cache.SharedIndexInformer, applisters.ApplicationLister) { - log.Debug().Msgf("Creating Application informer with namespace: %s", cfg.ArgoCDNamespace) - informer := informers.NewApplicationInformer(ctrl.applicationClientset, cfg.ArgoCDNamespace, refreshTimeout, +func (ctrl *ApplicationWatcher) newApplicationInformerAndLister(refreshTimeout time.Duration, cfg config.ServerConfig, ctx context.Context) (cache.SharedIndexInformer, applisters.ApplicationLister) { + + watchNamespace := cfg.ArgoCDNamespace + // If we have at least one additional namespace configured, we need to + // watch on them all. + if len(cfg.AdditionalAppsNamespaces) > 0 { + watchNamespace = "" + } + + informer := cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options metav1.ListOptions) (apiruntime.Object, error) { + // We are only interested in apps that exist in namespaces the + // user wants to be enabled. + appList, err := ctrl.applicationClientset.ArgoprojV1alpha1().Applications(watchNamespace).List(ctx, options) + if err != nil { + return nil, err + } + newItems := []appv1alpha1.Application{} + for _, app := range appList.Items { + if isAppNamespaceAllowed(&app.ObjectMeta, cfg) { + newItems = append(newItems, app) + } + } + appList.Items = newItems + return appList, nil + }, + WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) { + return ctrl.applicationClientset.ArgoprojV1alpha1().Applications(watchNamespace).Watch(ctx, options) + }, + }, + &appv1alpha1.Application{}, + refreshTimeout, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, ) - lister := applisters.NewApplicationLister(informer.GetIndexer()) if _, err := informer.AddEventHandler( cache.ResourceEventHandlerFuncs{ diff --git a/pkg/app_watcher/app_watcher_test.go b/pkg/app_watcher/app_watcher_test.go index 4af7425c..a2097c78 100644 --- a/pkg/app_watcher/app_watcher_test.go +++ b/pkg/app_watcher/app_watcher_test.go @@ -16,7 +16,11 @@ import ( ) func initTestObjects(t *testing.T) *ApplicationWatcher { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cfg, err := config.New() + cfg.AdditionalAppsNamespaces = []string{"*"} // Handle the error appropriately, e.g., log it or fail the test require.NoError(t, err, "failed to create config") @@ -40,7 +44,7 @@ func initTestObjects(t *testing.T) *ApplicationWatcher { vcsToArgoMap: appdir.NewVcsToArgoMap("vcs-username"), } - appInformer, appLister := ctrl.newApplicationInformerAndLister(time.Second*1, cfg) + appInformer, appLister := ctrl.newApplicationInformerAndLister(time.Second*1, cfg, ctx) ctrl.appInformer = appInformer ctrl.appLister = appLister @@ -254,3 +258,49 @@ func TestCanProcessApp(t *testing.T) { }) } } + +func TestIsAppNamespaceAllowed(t *testing.T) { + tests := map[string]struct { + expected bool + cfg config.ServerConfig + meta *metav1.ObjectMeta + }{ + "All namespaces for application are allowed": { + expected: true, + cfg: config.ServerConfig{AdditionalAppsNamespaces: []string{"*"}}, + meta: &metav1.ObjectMeta{Name: "test-app-1", Namespace: "default-ns"}, + }, + "Specific namespaces for application are allowed": { + expected: false, + cfg: config.ServerConfig{AdditionalAppsNamespaces: []string{"default", "kube-system"}}, + meta: &metav1.ObjectMeta{Name: "test-app-1", Namespace: "default-ns"}, + }, + "Wildcard namespace for application is allowed": { + expected: true, + cfg: config.ServerConfig{AdditionalAppsNamespaces: []string{"default-*"}}, + meta: &metav1.ObjectMeta{Name: "test-app-1", Namespace: "default-ns"}, + }, + "Invalid characters in namespace for application are not allowed": { + expected: false, + cfg: config.ServerConfig{AdditionalAppsNamespaces: []string{"", "kube-system"}}, + meta: &metav1.ObjectMeta{Name: "test-appset-1", Namespace: "kube-system"}, + }, + "Regex in namespace for application set is allowed": { + expected: true, + cfg: config.ServerConfig{AdditionalAppsNamespaces: []string{"/^((?!kube-system).)*$/"}}, + meta: &metav1.ObjectMeta{Name: "test-appset-1", Namespace: "kube-namespace"}, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + actual := isAppNamespaceAllowed(test.meta, test.cfg) + assert.Equal(t, test.expected, actual) + }) + } +} diff --git a/pkg/app_watcher/appset_watcher.go b/pkg/app_watcher/appset_watcher.go index c98fedf6..5c203a43 100644 --- a/pkg/app_watcher/appset_watcher.go +++ b/pkg/app_watcher/appset_watcher.go @@ -8,13 +8,15 @@ import ( appv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" appclientset "github.com/argoproj/argo-cd/v2/pkg/client/clientset/versioned" - informers "github.com/argoproj/argo-cd/v2/pkg/client/informers/externalversions/application/v1alpha1" applisters "github.com/argoproj/argo-cd/v2/pkg/client/listers/application/v1alpha1" "github.com/rs/zerolog/log" "github.com/zapier/kubechecks/pkg/appdir" "github.com/zapier/kubechecks/pkg/config" "github.com/zapier/kubechecks/pkg/container" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apiruntime "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/watch" "k8s.io/client-go/tools/cache" ) @@ -28,7 +30,7 @@ type ApplicationSetWatcher struct { } // NewApplicationSetWatcher creates new instance of ApplicationWatcher. -func NewApplicationSetWatcher(ctr container.Container) (*ApplicationSetWatcher, error) { +func NewApplicationSetWatcher(ctr container.Container, ctx context.Context) (*ApplicationSetWatcher, error) { if ctr.KubeClientSet == nil { return nil, fmt.Errorf("kubeCfg cannot be nil") } @@ -37,7 +39,7 @@ func NewApplicationSetWatcher(ctr container.Container) (*ApplicationSetWatcher, vcsToArgoMap: ctr.VcsToArgoMap, } - appInformer, appLister := ctrl.newApplicationSetInformerAndLister(time.Second*30, ctr.Config) + appInformer, appLister := ctrl.newApplicationSetInformerAndLister(time.Second*30, ctr.Config, ctx) ctrl.appInformer = appInformer ctrl.appLister = appLister @@ -61,12 +63,40 @@ func (ctrl *ApplicationSetWatcher) Run(ctx context.Context) { <-ctx.Done() } -func (ctrl *ApplicationSetWatcher) newApplicationSetInformerAndLister(refreshTimeout time.Duration, cfg config.ServerConfig) (cache.SharedIndexInformer, applisters.ApplicationSetLister) { - log.Debug().Msgf("Creating ApplicationSet informer with namespace: %s", cfg.ArgoCDNamespace) - informer := informers.NewApplicationSetInformer(ctrl.applicationClientset, cfg.ArgoCDNamespace, refreshTimeout, +func (ctrl *ApplicationSetWatcher) newApplicationSetInformerAndLister(refreshTimeout time.Duration, cfg config.ServerConfig, ctx context.Context) (cache.SharedIndexInformer, applisters.ApplicationSetLister) { + watchNamespace := cfg.ArgoCDNamespace + // If we have at least one additional namespace configured, we need to + // watch on them all. + if len(cfg.AdditionalAppsNamespaces) > 0 { + watchNamespace = "" + } + + informer := cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options metav1.ListOptions) (apiruntime.Object, error) { + // We are only interested in apps that exist in namespaces the + // user wants to be enabled. + appList, err := ctrl.applicationClientset.ArgoprojV1alpha1().ApplicationSets(watchNamespace).List(ctx, options) + if err != nil { + return nil, err + } + newItems := []appv1alpha1.ApplicationSet{} + for _, appSet := range appList.Items { + if isAppNamespaceAllowed(&appSet.ObjectMeta, cfg) { + newItems = append(newItems, appSet) + } + } + appList.Items = newItems + return appList, nil + }, + WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) { + return ctrl.applicationClientset.ArgoprojV1alpha1().ApplicationSets(watchNamespace).Watch(ctx, options) + }, + }, + &appv1alpha1.ApplicationSet{}, + refreshTimeout, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, ) - AppSetLister := applisters.NewApplicationSetLister(informer.GetIndexer()) if _, err := informer.AddEventHandler( cache.ResourceEventHandlerFuncs{ diff --git a/pkg/app_watcher/appset_watcher_test.go b/pkg/app_watcher/appset_watcher_test.go index 6caa6c4c..b6d65df8 100644 --- a/pkg/app_watcher/appset_watcher_test.go +++ b/pkg/app_watcher/appset_watcher_test.go @@ -15,7 +15,11 @@ import ( ) func initTestObjectsForAppSets(t *testing.T) *ApplicationSetWatcher { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cfg, err := config.New() + cfg.AdditionalAppsNamespaces = []string{"*"} // Handle the error appropriately, e.g., log it or fail the test require.NoError(t, err, "failed to create config") @@ -53,10 +57,9 @@ func initTestObjectsForAppSets(t *testing.T) *ApplicationSetWatcher { vcsToArgoMap: appdir.NewVcsToArgoMap("vcs-username"), } - appInformer, appLister := ctrl.newApplicationSetInformerAndLister(time.Second*1, cfg) + appInformer, appLister := ctrl.newApplicationSetInformerAndLister(time.Second*1, cfg, ctx) ctrl.appInformer = appInformer ctrl.appLister = appLister - return ctrl } diff --git a/pkg/config/config.go b/pkg/config/config.go index 552bdf7d..0163ed4a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -2,6 +2,7 @@ package config import ( "reflect" + "strings" "time" "github.com/mitchellh/mapstructure" @@ -70,6 +71,7 @@ type ServerConfig struct { WorstPreupgradeState pkg.CommitState `mapstructure:"worst-preupgrade-state"` // misc + AdditionalAppsNamespaces []string `mapstructure:"additional-apps-namespaces"` FallbackK8sVersion string `mapstructure:"fallback-k8s-version"` LabelFilter string `mapstructure:"label-filter"` LogLevel zerolog.Level `mapstructure:"log-level"` @@ -107,6 +109,12 @@ func NewWithViper(v *viper.Viper) (ServerConfig, error) { return time.ParseDuration(input) } + if in.String() == "string" && out.String() == "[]string" { + input := value.(string) + ns := strings.Split(input, ",") + return ns, nil + } + return value, nil } }); err != nil { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 2227f484..b32a1674 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -19,6 +19,7 @@ func TestNew(t *testing.T) { v.Set("argocd-api-plaintext", "true") v.Set("worst-conftest-state", "warning") v.Set("repo-refresh-interval", "10m") + v.Set("additional-apps-namespaces", "default,kube-system") cfg, err := NewWithViper(v) require.NoError(t, err) @@ -27,4 +28,5 @@ func TestNew(t *testing.T) { assert.Equal(t, true, cfg.ArgoCDPlainText) assert.Equal(t, pkg.StateWarning, cfg.WorstConfTestState, "worst states can be overridden") assert.Equal(t, time.Minute*10, cfg.RepoRefreshInterval) + assert.Equal(t, []string{"default", "kube-system"}, cfg.AdditionalAppsNamespaces) }