diff --git a/.gitignore b/.gitignore index b3827fffc..407ee6b93 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,8 @@ _gopath/ .vscode vendor dist -Reloader +/reloader +/Reloader !**/chart/reloader !**/internal/reloader *.tgz diff --git a/.goreleaser.yml b/.goreleaser.yml index 08953b788..b49ad2293 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,5 +1,7 @@ builds: -- env: +- main: ./cmd/reloader + binary: reloader + env: - CGO_ENABLED=0 goos: - windows @@ -11,6 +13,11 @@ builds: - arm - arm64 - ppc64le + ldflags: + - -s -w + - -X github.com/stakater/Reloader/internal/pkg/metadata.Version={{.Version}} + - -X github.com/stakater/Reloader/internal/pkg/metadata.Commit={{.Commit}} + - -X github.com/stakater/Reloader/internal/pkg/metadata.BuildDate={{.Date}} archives: - name_template: "{{ .ProjectName }}_v{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" snapshot: diff --git a/Dockerfile b/Dockerfile index e76b396c2..d9ca0edcd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,9 +24,8 @@ COPY go.sum go.sum RUN go mod download # Copy the go source -COPY main.go main.go +COPY cmd/ cmd/ COPY internal/ internal/ -COPY pkg/ pkg/ # Build RUN CGO_ENABLED=0 \ @@ -39,7 +38,7 @@ RUN CGO_ENABLED=0 \ -X github.com/stakater/Reloader/pkg/common.Commit=${COMMIT} \ -X github.com/stakater/Reloader/pkg/common.BuildDate=${BUILD_DATE} \ -X github.com/stakater/Reloader/pkg/common.Edition=${EDITION}" \ - -installsuffix 'static' -mod=mod -a -o manager ./ + -installsuffix 'static' -mod=mod -a -o manager ./cmd/reloader # Use distroless as minimal base image to package the manager binary # Refer to https://github.com/GoogleContainerTools/distroless for more details diff --git a/Dockerfile.ubi b/Dockerfile.ubi index ba22ebba2..76ce5d4a3 100644 --- a/Dockerfile.ubi +++ b/Dockerfile.ubi @@ -1,6 +1,7 @@ ARG BUILDER_IMAGE ARG BASE_IMAGE +# First stage: Build the binary (using the standard Dockerfile as builder) FROM --platform=${BUILDPLATFORM} ${BUILDER_IMAGE} AS SRC FROM ${BASE_IMAGE:-registry.access.redhat.com/ubi9/ubi:9.8-1779374378} AS ubi diff --git a/Makefile b/Makefile index b8c9e11ec..deca3ae42 100644 --- a/Makefile +++ b/Makefile @@ -23,10 +23,17 @@ BUILD= GOCMD = go GOFLAGS ?= $(GOFLAGS:) -LDFLAGS = GOPROXY ?= GOPRIVATE ?= +# Version information for ldflags +GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +BUILD_DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") +LDFLAGS = -s -w \ + -X github.com/stakater/Reloader/internal/pkg/metadata.Version=$(VERSION) \ + -X github.com/stakater/Reloader/internal/pkg/metadata.Commit=$(GIT_COMMIT) \ + -X github.com/stakater/Reloader/internal/pkg/metadata.BuildDate=$(BUILD_DATE) + ## Location to install dependencies to LOCALBIN ?= $(shell pwd)/bin $(LOCALBIN): @@ -94,10 +101,10 @@ install: "$(GOCMD)" mod download run: - go run ./main.go + go run ./cmd/reloader build: - "$(GOCMD)" build ${GOFLAGS} ${LDFLAGS} -o "${BINARY}" + "$(GOCMD)" build ${GOFLAGS} -ldflags '${LDFLAGS}' -o "${BINARY}" ./cmd/reloader lint: ## Run golangci-lint on the codebase go tool golangci-lint run ./... @@ -141,7 +148,7 @@ manifest: docker manifest annotate --arch $(ARCH) $(REPOSITORY_GENERIC) $(REPOSITORY_ARCH) test: - "$(GOCMD)" test -timeout 1800s -v -count=1 ./internal/... ./pkg/... ./test/e2e/utils/... + "$(GOCMD)" test -timeout 1800s -v -count=1 ./internal/... ./test/e2e/utils/... ##@ E2E Tests @@ -199,8 +206,8 @@ apply: deploy: binary-image push apply .PHONY: k8s-manifests -k8s-manifests: $(KUSTOMIZE) ## Generate k8s manifests using Kustomize from 'manifests' folder - $(KUSTOMIZE) build ./deployments/kubernetes/ -o ./deployments/kubernetes/reloader.yaml +k8s-manifests: ## Generate k8s manifests using Kustomize from 'manifests' folder + go tool kustomize build ./deployments/kubernetes/ -o ./deployments/kubernetes/reloader.yaml .PHONY: update-manifests-version update-manifests-version: ## Generate k8s manifests using Kustomize from 'manifests' folder diff --git a/cmd/reloader/main.go b/cmd/reloader/main.go new file mode 100644 index 000000000..83b78c4dc --- /dev/null +++ b/cmd/reloader/main.go @@ -0,0 +1,227 @@ +package main + +import ( + "context" + "fmt" + "net/http" + _ "net/http/pprof" + "os" + "os/signal" + "syscall" + "time" + + "github.com/go-logr/logr" + "github.com/go-logr/zerologr" + "github.com/rs/zerolog" + "github.com/spf13/cobra" + "k8s.io/client-go/discovery" + controllerruntime "sigs.k8s.io/controller-runtime" + + "github.com/stakater/Reloader/internal/pkg/config" + "github.com/stakater/Reloader/internal/pkg/controller" + "github.com/stakater/Reloader/internal/pkg/metadata" + "github.com/stakater/Reloader/internal/pkg/metrics" + "github.com/stakater/Reloader/internal/pkg/openshift" +) + +// Environment variable names for pod identity in HA mode. +const ( + podNameEnv = "POD_NAME" + podNamespaceEnv = "POD_NAMESPACE" +) + +// cfg holds the configuration for this reloader instance. +var cfg *config.Config + +func main() { + if err := newReloaderCommand().Execute(); err != nil { + os.Exit(1) + } +} + +func newReloaderCommand() *cobra.Command { + cfg = config.NewDefault() + + cmd := &cobra.Command{ + Use: "reloader", + Short: "A watcher for your Kubernetes cluster", + RunE: run, + } + + config.BindFlags(cmd.PersistentFlags(), cfg) + return cmd +} + +func run(cmd *cobra.Command, args []string) error { + if err := config.ApplyFlags(cfg); err != nil { + return fmt.Errorf("applying flags: %w", err) + } + + if err := cfg.Validate(); err != nil { + return fmt.Errorf("validating config: %w", err) + } + + if cfg.EnableHA { + if err := validateHAEnvs(); err != nil { + return err + } + cfg.LeaderElection.Identity = os.Getenv(podNameEnv) + if cfg.LeaderElection.Namespace == "" { + cfg.LeaderElection.Namespace = os.Getenv(podNamespaceEnv) + } + } + + log, err := configureLogging(cfg.LogFormat, cfg.LogLevel) + if err != nil { + return fmt.Errorf("configuring logging: %w", err) + } + + controllerruntime.SetLogger(log) + + log.Info("Starting Reloader") + + if cfg.WatchedNamespace != "" { + log.Info("watching single namespace", "namespace", cfg.WatchedNamespace) + } else { + log.Info("watching all namespaces") + } + + if len(cfg.NamespaceSelectors) > 0 { + log.Info("namespace-selector is set", "selectors", cfg.NamespaceSelectorStrings) + } + + if len(cfg.ResourceSelectors) > 0 { + log.Info("resource-label-selector is set", "selectors", cfg.ResourceSelectorStrings) + } + + if cfg.WebhookURL != "" { + log.Info("webhook-url is set, will only send webhook, no resources will be reloaded", "url", cfg.WebhookURL) + } + + if cfg.EnableHA { + log.Info( + "high-availability mode enabled", + "leaderElectionID", cfg.LeaderElection.LockName, + "leaderElectionNamespace", cfg.LeaderElection.Namespace, + ) + } + + collectors := metrics.SetupPrometheusEndpoint() + + if config.ShouldAutoDetectOpenShift() { + restConfig := controllerruntime.GetConfigOrDie() + discoveryClient, err := discovery.NewDiscoveryClientForConfig(restConfig) + if err != nil { + log.V(1).Info("Failed to create discovery client for DeploymentConfig detection", "error", err) + } else if openshift.HasDeploymentConfigSupport(discoveryClient, log) { + cfg.DeploymentConfigEnabled = true + } + } + + controller.AddOptionalSchemes(cfg.ArgoRolloutsEnabled, cfg.DeploymentConfigEnabled) + + mgr, err := controller.NewManager( + controller.ManagerOptions{ + Config: cfg, + Log: log, + Collectors: &collectors, + }, + ) + if err != nil { + return fmt.Errorf("creating manager: %w", err) + } + + if err := controller.SetupReconcilers(mgr, cfg, log, &collectors); err != nil { + return fmt.Errorf("setting up reconcilers: %w", err) + } + + // Skip metadata publisher when ConfigMaps are ignored (no RBAC permissions) + if !cfg.IsResourceIgnored("configmaps") { + if err := mgr.Add(metadata.Runnable(mgr.GetClient(), cfg, log)); err != nil { + log.Error(err, "Failed to add metadata publisher") + // Non-fatal, continue starting + } + } else { + log.Info("skipping metadata publisher (configmaps ignored)") + } + + if cfg.EnablePProf { + go startPProfServer(log) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + sig := <-sigCh + log.Info("Received signal, shutting down", "signal", sig) + cancel() + }() + + log.Info("Starting controller manager") + if err := controller.RunManager(ctx, mgr, log); err != nil { + return fmt.Errorf("manager exited with error: %w", err) + } + + log.Info("Reloader shutdown complete") + return nil +} + +func configureLogging(logFormat, logLevel string) (logr.Logger, error) { + // Parse log level + var level zerolog.Level + switch logLevel { + case "trace": + level = zerolog.TraceLevel + case "debug": + level = zerolog.DebugLevel + case "info", "": + level = zerolog.InfoLevel + case "warn", "warning": + level = zerolog.WarnLevel + case "error": + level = zerolog.ErrorLevel + default: + return logr.Logger{}, fmt.Errorf("unsupported log level: %q", logLevel) + } + + var zl zerolog.Logger + switch logFormat { + case "json": + zl = zerolog.New(os.Stdout).Level(level).With().Timestamp().Logger() + case "": + // Human-readable console output + zl = zerolog.New( + zerolog.ConsoleWriter{ + Out: os.Stdout, + TimeFormat: time.RFC3339, + }, + ).Level(level).With().Timestamp().Logger() + default: + return logr.Logger{}, fmt.Errorf("unsupported log format: %q", logFormat) + } + + return zerologr.New(&zl), nil +} + +func validateHAEnvs() error { + podName := os.Getenv(podNameEnv) + podNamespace := os.Getenv(podNamespaceEnv) + + if podName == "" { + return fmt.Errorf("%s not set, cannot run in HA mode", podNameEnv) + } + if podNamespace == "" { + return fmt.Errorf("%s not set, cannot run in HA mode", podNamespaceEnv) + } + return nil +} + +func startPProfServer(log logr.Logger) { + log.Info("Starting pprof server", "addr", cfg.PProfAddr) + if err := http.ListenAndServe(cfg.PProfAddr, nil); err != nil { + log.Error(err, "Failed to start pprof server") + } +} diff --git a/deployments/kubernetes/chart/reloader/templates/clusterrole.yaml b/deployments/kubernetes/chart/reloader/templates/clusterrole.yaml index bd14dfeb7..11e8a5d43 100644 --- a/deployments/kubernetes/chart/reloader/templates/clusterrole.yaml +++ b/deployments/kubernetes/chart/reloader/templates/clusterrole.yaml @@ -56,12 +56,12 @@ rules: {{- if and (.Capabilities.APIVersions.Has "argoproj.io/v1alpha1") (.Values.reloader.isArgoRollouts) }} - apiGroups: - "argoproj.io" - - "" resources: - rollouts verbs: - list - get + - watch - update - patch {{- end }} @@ -76,6 +76,7 @@ rules: - get - update - patch + - watch {{- if .Values.reloader.ignoreCronJobs }}{{- else }} - apiGroups: - "batch" @@ -84,6 +85,9 @@ rules: verbs: - list - get + - watch + - update + - patch {{- end }} {{- if .Values.reloader.ignoreJobs }}{{- else }} - apiGroups: @@ -95,6 +99,7 @@ rules: - delete - list - get + - watch {{- end}} {{- if .Values.reloader.enableHA }} - apiGroups: @@ -105,17 +110,6 @@ rules: - create - get - update -{{- end}} -{{- if .Values.reloader.enableCSIIntegration }} - - apiGroups: - - "secrets-store.csi.x-k8s.io" - resources: - - secretproviderclasspodstatuses - - secretproviderclasses - verbs: - - list - - get - - watch {{- end}} - apiGroups: - "" diff --git a/deployments/kubernetes/chart/reloader/templates/deployment.yaml b/deployments/kubernetes/chart/reloader/templates/deployment.yaml index 31ab80895..c1b145020 100644 --- a/deployments/kubernetes/chart/reloader/templates/deployment.yaml +++ b/deployments/kubernetes/chart/reloader/templates/deployment.yaml @@ -176,10 +176,12 @@ spec: ports: - name: http containerPort: 9090 + - name: health + containerPort: 8080 livenessProbe: httpGet: - path: /live - port: http + path: /healthz + port: health timeoutSeconds: {{ .Values.reloader.deployment.livenessProbe.timeoutSeconds | default "5" }} failureThreshold: {{ .Values.reloader.deployment.livenessProbe.failureThreshold | default "5" }} periodSeconds: {{ .Values.reloader.deployment.livenessProbe.periodSeconds | default "10" }} @@ -187,8 +189,8 @@ spec: initialDelaySeconds: {{ .Values.reloader.deployment.livenessProbe.initialDelaySeconds | default "10" }} readinessProbe: httpGet: - path: /metrics - port: http + path: /readyz + port: health timeoutSeconds: {{ .Values.reloader.deployment.readinessProbe.timeoutSeconds | default "5" }} failureThreshold: {{ .Values.reloader.deployment.readinessProbe.failureThreshold | default "5" }} periodSeconds: {{ .Values.reloader.deployment.readinessProbe.periodSeconds | default "10" }} @@ -213,7 +215,7 @@ spec: {{- . | toYaml | nindent 10 }} {{- end }} {{- end }} - {{- if or (.Values.reloader.logFormat) (.Values.reloader.logLevel) (.Values.reloader.ignoreSecrets) (.Values.reloader.ignoreNamespaces) (include "reloader-namespaceSelector" .) (.Values.reloader.resourceLabelSelector) (.Values.reloader.ignoreConfigMaps) (.Values.reloader.custom_annotations) (eq .Values.reloader.isArgoRollouts true) (eq .Values.reloader.reloadOnCreate true) (eq .Values.reloader.reloadOnDelete true) (ne .Values.reloader.reloadStrategy "default") (.Values.reloader.enableHA) (.Values.reloader.autoReloadAll) (.Values.reloader.ignoreJobs) (.Values.reloader.ignoreCronJobs) (.Values.reloader.enableCSIIntegration)}} + {{- if or (.Values.reloader.logFormat) (.Values.reloader.logLevel) (.Values.reloader.ignoreSecrets) (.Values.reloader.ignoreNamespaces) (include "reloader-namespaceSelector" .) (.Values.reloader.resourceLabelSelector) (.Values.reloader.ignoreConfigMaps) (.Values.reloader.custom_annotations) (eq .Values.reloader.isArgoRollouts true) (eq .Values.reloader.reloadOnCreate true) (eq .Values.reloader.reloadOnDelete true) (ne .Values.reloader.reloadStrategy "default") (.Values.reloader.enableHA) (.Values.reloader.autoReloadAll) (.Values.reloader.ignoreJobs) (.Values.reloader.ignoreCronJobs)}} args: {{- if .Values.reloader.logFormat }} - "--log-format={{ .Values.reloader.logFormat }}" @@ -238,7 +240,7 @@ spec: - "--namespaces-to-ignore={{ .Values.reloader.ignoreNamespaces }}" {{- end }} {{- if (include "reloader-namespaceSelector" .) }} - - "--namespace-selector=\"{{ include "reloader-namespaceSelector" . }}\"" + - "--namespace-selector={{ include "reloader-namespaceSelector" . }}" {{- end }} {{- if .Values.reloader.resourceLabelSelector }} - "--resource-label-selector={{ .Values.reloader.resourceLabelSelector }}" @@ -249,9 +251,6 @@ spec: - "--pprof-addr={{ .Values.reloader.pprofAddr }}" {{- end }} {{- end }} - {{- if .Values.reloader.enableCSIIntegration }} - - "--enable-csi-integration=true" - {{- end }} {{- if .Values.reloader.custom_annotations }} {{- if .Values.reloader.custom_annotations.configmap }} - "--configmap-annotation" diff --git a/deployments/kubernetes/chart/reloader/templates/role.yaml b/deployments/kubernetes/chart/reloader/templates/role.yaml index 7355d873b..c6cfed646 100644 --- a/deployments/kubernetes/chart/reloader/templates/role.yaml +++ b/deployments/kubernetes/chart/reloader/templates/role.yaml @@ -47,12 +47,12 @@ rules: {{- if and (.Capabilities.APIVersions.Has "argoproj.io/v1alpha1") (.Values.reloader.isArgoRollouts) }} - apiGroups: - "argoproj.io" - - "" resources: - rollouts verbs: - list - get + - watch - update - patch {{- end }} @@ -67,6 +67,7 @@ rules: - get - update - patch + - watch - apiGroups: - "batch" resources: @@ -74,6 +75,9 @@ rules: verbs: - list - get + - watch + - update + - patch - apiGroups: - "batch" resources: @@ -83,6 +87,7 @@ rules: - delete - list - get + - watch {{- if .Values.reloader.enableHA }} - apiGroups: - "coordination.k8s.io" @@ -92,17 +97,6 @@ rules: - create - get - update -{{- end}} -{{- if .Values.reloader.enableCSIIntegration }} - - apiGroups: - - "secrets-store.csi.x-k8s.io" - resources: - - secretproviderclasspodstatuses - - secretproviderclasses - verbs: - - list - - get - - watch {{- end}} - apiGroups: - "" diff --git a/deployments/kubernetes/chart/reloader/values.yaml b/deployments/kubernetes/chart/reloader/values.yaml index 66d00f21e..1e57c5d67 100644 --- a/deployments/kubernetes/chart/reloader/values.yaml +++ b/deployments/kubernetes/chart/reloader/values.yaml @@ -49,7 +49,6 @@ reloader: enableHA: false # Set to true to enable pprof for profiling enablePProf: false - enableCSIIntegration: false # Address to start pprof server on. Default is ":6060" pprofAddr: ":6060" # Set to true if you have a pod security policy that enforces readOnlyRootFilesystem diff --git a/go.mod b/go.mod index 49dad9f84..e8703a8dd 100644 --- a/go.mod +++ b/go.mod @@ -4,21 +4,23 @@ go 1.26.3 require ( github.com/argoproj/argo-rollouts v1.9.0 + github.com/go-logr/logr v1.4.3 + github.com/go-logr/zerologr v1.2.3 github.com/onsi/ginkgo/v2 v2.27.4 github.com/onsi/gomega v1.39.0 github.com/openshift/api v0.0.0-20260402111718-ad9eb11110b6 github.com/openshift/client-go v0.0.0-20260330134249-7e1499aaacd7 - github.com/parnurzeal/gorequest v0.3.0 github.com/prometheus/client_golang v1.23.2 - github.com/sirupsen/logrus v1.9.4 + github.com/prometheus/client_model v0.6.2 + github.com/rs/zerolog v1.35.1 github.com/spf13/cobra v1.10.2 - github.com/stretchr/testify v1.11.1 - k8s.io/api v0.35.3 - k8s.io/apimachinery v0.35.3 - k8s.io/client-go v0.35.3 - k8s.io/kubectl v0.35.3 + github.com/spf13/pflag v1.0.10 + github.com/spf13/viper v1.12.0 + k8s.io/api v0.36.0 + k8s.io/apimachinery v0.36.0 + k8s.io/client-go v0.36.0 k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 - sigs.k8s.io/secrets-store-csi-driver v1.5.5 + sigs.k8s.io/controller-runtime v0.24.1 ) require ( @@ -52,6 +54,7 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bkielbasa/cyclop v1.2.3 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect github.com/blizzy78/varnamelen v0.8.0 // indirect github.com/bombsimon/wsl/v4 v4.7.0 // indirect github.com/bombsimon/wsl/v5 v5.3.0 // indirect @@ -75,18 +78,19 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/denis-tingaikin/go-header v0.5.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect - github.com/elazarl/goproxy v0.0.0-20240726154733-8b0c20506380 // indirect - github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/ettle/strcase v0.2.0 // indirect + github.com/evanphx/json-patch v4.12.0+incompatible // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fatih/color v1.18.0 // indirect github.com/fatih/structtag v1.2.0 // indirect github.com/firefart/nonamedreturns v1.0.6 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.1 // indirect github.com/fzipp/gocyclo v0.6.0 // indirect github.com/ghostiam/protogetter v0.3.18 // indirect github.com/go-critic/go-critic v0.14.3 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/go-errors/errors v1.4.2 // indirect github.com/go-openapi/jsonpointer v0.22.5 // indirect github.com/go-openapi/jsonreference v0.21.5 // indirect github.com/go-openapi/swag v0.25.5 // indirect @@ -131,7 +135,6 @@ require ( github.com/google/pprof v0.0.0-20260106004452-d7df1bf2cac7 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gordonklaus/ineffassign v0.2.0 // indirect - github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/gostaticanalysis/analysisutil v0.7.1 // indirect github.com/gostaticanalysis/comment v1.5.0 // indirect github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect @@ -174,23 +177,19 @@ require ( github.com/mgechev/revive v1.13.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/moby/spdystream v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/moricho/tparallel v0.3.2 // indirect - github.com/moul/http2curl v1.0.0 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/nakabonne/nestif v0.3.1 // indirect github.com/nishanths/exhaustive v0.12.0 // indirect github.com/nishanths/predeclared v0.2.2 // indirect github.com/nunnatsa/ginkgolinter v0.21.2 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.20.1 // indirect github.com/quasilyte/go-ruleguard v0.4.5 // indirect @@ -208,18 +207,18 @@ require ( github.com/sashamelentyev/interfacebloat v1.1.0 // indirect github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect github.com/securego/gosec/v2 v2.22.11 // indirect + github.com/sergi/go-diff v1.2.0 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/sivchari/containedctx v1.0.3 // indirect - github.com/smartystreets/goconvey v1.7.2 // indirect github.com/sonatard/noctx v0.4.0 // indirect github.com/sourcegraph/go-diff v0.7.0 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect - github.com/spf13/pflag v1.0.10 // indirect - github.com/spf13/viper v1.12.0 // indirect github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect github.com/subosito/gotenv v1.4.1 // indirect github.com/tetafro/godot v1.5.4 // indirect github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect @@ -232,6 +231,7 @@ require ( github.com/uudashr/iface v1.4.1 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xen0n/gosmopolitan v1.3.0 // indirect + github.com/xlab/treeprint v1.2.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yagipy/maintidx v1.0.0 // indirect github.com/yeya24/promlinter v0.3.0 // indirect @@ -243,7 +243,7 @@ require ( go.augendre.info/fatcontext v0.9.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect + go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 // indirect @@ -257,18 +257,24 @@ require ( golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.44.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect honnef.co/go/tools v0.6.1 // indirect - k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/apiextensions-apiserver v0.36.0 // indirect + k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 // indirect mvdan.cc/gofumpt v0.9.2 // indirect mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/kustomize/api v0.20.1 // indirect + sigs.k8s.io/kustomize/cmd/config v0.20.1 // indirect + sigs.k8s.io/kustomize/kustomize/v5 v5.7.1 // indirect + sigs.k8s.io/kustomize/kyaml v0.20.1 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect sigs.k8s.io/yaml v1.6.0 // indirect @@ -302,3 +308,5 @@ replace ( k8s.io/sample-cli-plugin v0.0.0 => k8s.io/sample-cli-plugin v0.24.2 k8s.io/sample-controller v0.0.0 => k8s.io/sample-controller v0.24.2 ) + +tool sigs.k8s.io/kustomize/kustomize/v5 diff --git a/go.sum b/go.sum index f8522cda9..458c1664a 100644 --- a/go.sum +++ b/go.sum @@ -54,8 +54,6 @@ github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEW github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg= github.com/argoproj/argo-rollouts v1.9.0 h1:bXgBpwCByXyAUcgBnyP0fxkSW2CEot78InTFjFlag5g= github.com/argoproj/argo-rollouts v1.9.0/go.mod h1:jOalqf2kDSmCp7eQpFF4i3kHnlEqNE/Yjwz1q7CpPIU= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/ashanbrown/forbidigo/v2 v2.3.0 h1:OZZDOchCgsX5gvToVtEBoV2UWbFfI6RKQTir2UZzSxo= github.com/ashanbrown/forbidigo/v2 v2.3.0/go.mod h1:5p6VmsG5/1xx3E785W9fouMxIOkvY2rRV9nMdWadd6c= github.com/ashanbrown/makezero/v2 v2.1.0 h1:snuKYMbqosNokUKm+R6/+vOPs8yVAi46La7Ck6QYSaE= @@ -66,6 +64,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bkielbasa/cyclop v1.2.3 h1:faIVMIGDIANuGPWH031CZJTi2ymOQBULs9H21HSMa5w= github.com/bkielbasa/cyclop v1.2.3/go.mod h1:kHTwA9Q0uZqOADdupvcFJQtp/ksSnytRMe8ztxG8Fuo= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M= github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k= github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ= @@ -117,12 +117,14 @@ github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42 github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/elazarl/goproxy v0.0.0-20240726154733-8b0c20506380 h1:1NyRx2f4W4WBRyg0Kys0ZbaNmDDzZ2R/C7DTi+bbsJ0= -github.com/elazarl/goproxy v0.0.0-20240726154733-8b0c20506380/go.mod h1:thX175TtLTzLj3p7N/Q9IiKZ7NF+p72cvL91emV0hzo= -github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= -github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= +github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84= +github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= @@ -131,8 +133,8 @@ github.com/firefart/nonamedreturns v1.0.6 h1:vmiBcKV/3EqKY3ZiPxCINmpS431OcE1S47A github.com/firefart/nonamedreturns v1.0.6/go.mod h1:R8NisJnSIpvPWheCq0mNRXJok6D8h7fagJTF8EMEwCo= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= @@ -147,8 +149,14 @@ github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01 github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog= github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-logr/zerologr v1.2.3 h1:up5N9vcH9Xck3jJkXzgyOxozT14R47IyDODz8LM1KSs= +github.com/go-logr/zerologr v1.2.3/go.mod h1:BxwGo7y5zgSHYR1BjbnHPyF/5ZjVKfKxAZANVu6E8Ho= github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= @@ -250,16 +258,14 @@ github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20260106004452-d7df1bf2cac7 h1:kmPAX+IJBcUAFTddx2+xC0H7sk2U9ijIIxZLLrPLNng= github.com/google/pprof v0.0.0-20260106004452-d7df1bf2cac7/go.mod h1:67FPmZWbr+KDT/VlpWtw6sO9XSjpJmLuHpoLmWiTGgY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gordonklaus/ineffassign v0.2.0 h1:Uths4KnmwxNJNzq87fwQQDDnbNb7De00VOk9Nu0TySs= github.com/gordonklaus/ineffassign v0.2.0/go.mod h1:TIpymnagPSexySzs7F9FnO1XFTy8IT3a59vmZp5Y9Lw= -github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= -github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk= github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc= github.com/gostaticanalysis/comment v1.4.2/go.mod h1:KLUTGDv6HOCotCH8h2erHKmpci2ZoR8VPu34YA2uzdM= @@ -297,8 +303,6 @@ github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ= github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY= github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0= @@ -311,8 +315,11 @@ github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/tt github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kulti/thelper v0.7.1 h1:fI8QITAoFVLx+y+vSyuLBP+rcVIB8jKooNSCT2EiI98= @@ -371,24 +378,20 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= -github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI= github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U= -github.com/moul/http2curl v1.0.0 h1:dRMWoAtb+ePxMlLkrCbAqh4TlPHXvoGUSQ323/9Zahs= -github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= -github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U= github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE= github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg= @@ -412,8 +415,6 @@ github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJ github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs= github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= -github.com/parnurzeal/gorequest v0.3.0 h1:SoFyqCDC9COr1xuS6VA8fC8RU7XyrJZN2ona1kEX7FI= -github.com/parnurzeal/gorequest v0.3.0/go.mod h1:3Kh2QUMJoqw3icWAecsyzkpY7UzRfDhbRdTjtNwNiUE= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= @@ -450,6 +451,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryancurrah/gomodguard v1.4.1 h1:eWC8eUMNZ/wM/PWuZBv7JxxqT5fiIKSIyTvjb7Elr+g= github.com/ryancurrah/gomodguard v1.4.1/go.mod h1:qnMJwV1hX9m+YJseXEBhd2s90+1Xn6x9dLz11ualI1I= @@ -473,10 +476,6 @@ github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE= github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4= -github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= -github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= -github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg= -github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= github.com/sonatard/noctx v0.4.0 h1:7MC/5Gg4SQ4lhLYR6mvOP6mQVSxCrdyiExo7atBs27o= github.com/sonatard/noctx v0.4.0/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas= github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0= @@ -505,6 +504,7 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= @@ -543,6 +543,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM= github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4= +github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= +github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM= @@ -576,8 +578,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= @@ -588,8 +590,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= -golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 h1:HDjDiATsGqvuqvkDvgJjD1IgPrVekcSXVVE21JwvzGE= @@ -605,7 +607,6 @@ golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -678,7 +679,6 @@ golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200329025819-fd4102a86c65/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= @@ -702,9 +702,12 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= @@ -714,36 +717,46 @@ gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI= honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4= -k8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ= -k8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4= -k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8= -k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= -k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg= -k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c= -k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= -k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/api v0.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80= +k8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34= +k8s.io/apiextensions-apiserver v0.36.0 h1:Wt7E8J+VBCbj4FjiBfDTK/neXDDjyJVJc7xfuOHImZ0= +k8s.io/apiextensions-apiserver v0.36.0/go.mod h1:kGDjH0msuiIB3tgsYRV0kS9GqpMYMUsQ3GHv7TApyug= +k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ= +k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc= +k8s.io/client-go v0.36.0 h1:pOYi7C4RHChYjMiHpZSpSbIM6ZxVbRXBy7CuiIwqA3c= +k8s.io/client-go v0.36.0/go.mod h1:ZKKcpwF0aLYfkHFCjillCKaTK/yBkEDHTDXCFY6AS9Y= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 h1:V+sn9a/1fEYDGwnllCmqXBk8x7obZ+hl869Q3Abumkg= k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= -k8s.io/kubectl v0.35.3 h1:1KqSYXk/sodU7VeDvK6atX2kAGUZd2QTeR5K7Hb9r9w= -k8s.io/kubectl v0.35.3/go.mod h1:GPHxZqRe+u/i3gTBoVQHeIyq2NilfNPj9hDWeuN3x5s= k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= mvdan.cc/gofumpt v0.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4= mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s= mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 h1:ssMzja7PDPJV8FStj7hq9IKiuiKhgz9ErWw+m68e7DI= mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15/go.mod h1:4M5MMXl2kW6fivUT6yRGpLLPNfuGtU2Z0cPvFquGDYU= +sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= +sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/kustomize/api v0.20.1 h1:iWP1Ydh3/lmldBnH/S5RXgT98vWYMaTUL1ADcr+Sv7I= +sigs.k8s.io/kustomize/api v0.20.1/go.mod h1:t6hUFxO+Ph0VxIk1sKp1WS0dOjbPCtLJ4p8aADLwqjM= +sigs.k8s.io/kustomize/cmd/config v0.20.1 h1:4APUORmZe2BYrsqgGfEKdd/r7gM6i43egLrUzilpiFo= +sigs.k8s.io/kustomize/cmd/config v0.20.1/go.mod h1:R7rQ8kxknVlXWVUIbxWtMgu8DCCNVtl8V0KrmeVd/KE= +sigs.k8s.io/kustomize/kustomize/v5 v5.7.1 h1:sYJsarwy/SDJfjjLMUqwFDGPwzUtMOQ1i1Ed49+XSbw= +sigs.k8s.io/kustomize/kustomize/v5 v5.7.1/go.mod h1:+5/SrBcJ4agx1SJknGuR/c9thwRSKLxnKoI5BzXFaLU= +sigs.k8s.io/kustomize/kyaml v0.20.1 h1:PCMnA2mrVbRP3NIB6v9kYCAc38uvFLVs8j/CD567A78= +sigs.k8s.io/kustomize/kyaml v0.20.1/go.mod h1:0EmkQHRUsJxY8Ug9Niig1pUMSCGHxQ5RklbpV/Ri6po= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/secrets-store-csi-driver v1.5.5 h1:LJDpDL5TILhlP68nGvtGSlJFxSDgAD2m148NT0Ts7os= -sigs.k8s.io/secrets-store-csi-driver v1.5.5/go.mod h1:i2WqLicYH00hrTG3JAzICPMF4HL4KMEORlDt9UQoZLk= sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= diff --git a/internal/pkg/alerting/alerter.go b/internal/pkg/alerting/alerter.go new file mode 100644 index 000000000..edbc22812 --- /dev/null +++ b/internal/pkg/alerting/alerter.go @@ -0,0 +1,51 @@ +package alerting + +import ( + "context" + "time" + + "github.com/stakater/Reloader/internal/pkg/config" +) + +// AlertMessage contains the details of a reload event to be sent as an alert. +type AlertMessage struct { + WorkloadKind string + WorkloadName string + WorkloadNamespace string + ResourceKind string + ResourceName string + ResourceNamespace string + Timestamp time.Time +} + +// Alerter is the interface for sending reload notifications. +type Alerter interface { + Send(ctx context.Context, message AlertMessage) error +} + +// NewAlerter creates an Alerter based on the configuration. +// Returns a NoOpAlerter if alerting is disabled. +func NewAlerter(cfg *config.Config) Alerter { + alertCfg := cfg.Alerting + if !alertCfg.Enabled || alertCfg.WebhookURL == "" { + return &NoOpAlerter{} + } + + switch alertCfg.Sink { + case "slack": + return NewSlackAlerter(alertCfg.WebhookURL, alertCfg.Proxy, alertCfg.Additional) + case "teams": + return NewTeamsAlerter(alertCfg.WebhookURL, alertCfg.Proxy, alertCfg.Additional) + case "gchat": + return NewGChatAlerter(alertCfg.WebhookURL, alertCfg.Proxy, alertCfg.Additional) + default: + return NewRawAlerter(alertCfg.WebhookURL, alertCfg.Proxy, alertCfg.Additional, alertCfg.Structured) + } +} + +// NoOpAlerter is an Alerter that does nothing. +type NoOpAlerter struct{} + +func (a *NoOpAlerter) Send(ctx context.Context, message AlertMessage) error { + return nil +} diff --git a/internal/pkg/alerting/alerter_test.go b/internal/pkg/alerting/alerter_test.go new file mode 100644 index 000000000..d6ae4ad40 --- /dev/null +++ b/internal/pkg/alerting/alerter_test.go @@ -0,0 +1,273 @@ +package alerting + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stakater/Reloader/internal/pkg/config" +) + +// testServer creates a test HTTP server that captures the request body. +// Returns the server and a function to retrieve the captured body. +func testServer(t *testing.T, expectedContentType string) (*httptest.Server, func() []byte) { + t.Helper() + var body []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("Expected POST request, got %s", r.Method) + } + if r.Header.Get("Content-Type") != expectedContentType { + t.Errorf("Expected Content-Type %s, got %s", expectedContentType, r.Header.Get("Content-Type")) + } + body, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + })) + return server, func() []byte { return body } +} + +// testAlertMessage returns a standard AlertMessage for testing. +func testAlertMessage() AlertMessage { + return AlertMessage{ + WorkloadKind: "Deployment", + WorkloadName: "nginx", + WorkloadNamespace: "default", + ResourceKind: "ConfigMap", + ResourceName: "nginx-config", + ResourceNamespace: "default", + Timestamp: time.Now(), + } +} + +func TestNewAlerter(t *testing.T) { + tests := []struct { + name string + setup func(*config.Config) + wantType string + }{ + { + name: "disabled", + setup: func(cfg *config.Config) { + cfg.Alerting.Enabled = false + }, + wantType: "*alerting.NoOpAlerter", + }, + { + name: "no webhook URL", + setup: func(cfg *config.Config) { + cfg.Alerting.Enabled = true + cfg.Alerting.WebhookURL = "" + }, + wantType: "*alerting.NoOpAlerter", + }, + { + name: "slack", + setup: func(cfg *config.Config) { + cfg.Alerting.Enabled = true + cfg.Alerting.WebhookURL = "http://example.com/webhook" + cfg.Alerting.Sink = "slack" + }, + wantType: "*alerting.SlackAlerter", + }, + { + name: "teams", + setup: func(cfg *config.Config) { + cfg.Alerting.Enabled = true + cfg.Alerting.WebhookURL = "http://example.com/webhook" + cfg.Alerting.Sink = "teams" + }, + wantType: "*alerting.TeamsAlerter", + }, + { + name: "gchat", + setup: func(cfg *config.Config) { + cfg.Alerting.Enabled = true + cfg.Alerting.WebhookURL = "http://example.com/webhook" + cfg.Alerting.Sink = "gchat" + }, + wantType: "*alerting.GChatAlerter", + }, + { + name: "raw", + setup: func(cfg *config.Config) { + cfg.Alerting.Enabled = true + cfg.Alerting.WebhookURL = "http://example.com/webhook" + cfg.Alerting.Sink = "raw" + }, + wantType: "*alerting.RawAlerter", + }, + { + name: "empty sink defaults to raw", + setup: func(cfg *config.Config) { + cfg.Alerting.Enabled = true + cfg.Alerting.WebhookURL = "http://example.com/webhook" + cfg.Alerting.Sink = "" + }, + wantType: "*alerting.RawAlerter", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := config.NewDefault() + tt.setup(cfg) + alerter := NewAlerter(cfg) + gotType := getTypeName(alerter) + if gotType != tt.wantType { + t.Errorf("NewAlerter() type = %s, want %s", gotType, tt.wantType) + } + }) + } +} + +func getTypeName(a Alerter) string { + switch a.(type) { + case *NoOpAlerter: + return "*alerting.NoOpAlerter" + case *SlackAlerter: + return "*alerting.SlackAlerter" + case *TeamsAlerter: + return "*alerting.TeamsAlerter" + case *GChatAlerter: + return "*alerting.GChatAlerter" + case *RawAlerter: + return "*alerting.RawAlerter" + default: + return "unknown" + } +} + +func TestNoOpAlerter_Send(t *testing.T) { + alerter := &NoOpAlerter{} + if err := alerter.Send(context.Background(), AlertMessage{}); err != nil { + t.Errorf("NoOpAlerter.Send() error = %v, want nil", err) + } +} + +func TestAlerter_Send(t *testing.T) { + tests := []struct { + name string + contentType string + newAlert func(url string) Alerter + validate func(t *testing.T, body []byte) + }{ + { + name: "slack", + contentType: "application/json", + newAlert: func(url string) Alerter { return NewSlackAlerter(url, "", "Test Cluster") }, + validate: func(t *testing.T, body []byte) { + var msg slackMessage + if err := json.Unmarshal(body, &msg); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if len(msg.Attachments) != 1 { + t.Fatalf("Expected 1 attachment, got %d", len(msg.Attachments)) + } + if msg.Attachments[0].Text == "" { + t.Error("Expected non-empty attachment text") + } + if msg.Attachments[0].Color != "good" { + t.Errorf("Expected color 'good', got %s", msg.Attachments[0].Color) + } + if msg.Attachments[0].AuthorName != "Reloader" { + t.Errorf("Expected author_name 'Reloader', got %s", msg.Attachments[0].AuthorName) + } + }, + }, + { + name: "teams", + contentType: "application/json", + newAlert: func(url string) Alerter { return NewTeamsAlerter(url, "", "") }, + validate: func(t *testing.T, body []byte) { + var msg teamsMessage + if err := json.Unmarshal(body, &msg); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if msg.Type != "MessageCard" { + t.Errorf("@type = %s, want MessageCard", msg.Type) + } + }, + }, + { + name: "gchat", + contentType: "application/json", + newAlert: func(url string) Alerter { return NewGChatAlerter(url, "", "") }, + validate: func(t *testing.T, body []byte) { + var msg gchatMessage + if err := json.Unmarshal(body, &msg); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if len(msg.Cards) != 1 { + t.Errorf("cards = %d, want 1", len(msg.Cards)) + } + }, + }, + { + name: "raw plain text (default)", + contentType: "text/plain", + newAlert: func(url string) Alerter { return NewRawAlerter(url, "", "custom-info", false) }, + validate: func(t *testing.T, body []byte) { + text := string(body) + if text == "" { + t.Error("Expected non-empty text") + } + if !strings.Contains(text, "custom-info") { + t.Error("Expected text to contain 'custom-info'") + } + if !strings.Contains(text, "nginx") { + t.Error("Expected text to contain workload name 'nginx'") + } + }, + }, + { + name: "raw structured JSON", + contentType: "application/json", + newAlert: func(url string) Alerter { return NewRawAlerter(url, "", "custom-info", true) }, + validate: func(t *testing.T, body []byte) { + var msg rawMessage + if err := json.Unmarshal(body, &msg); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if msg.Event != "reload" { + t.Errorf("event = %s, want reload", msg.Event) + } + if msg.WorkloadName != "nginx" { + t.Errorf("workloadName = %s, want nginx", msg.WorkloadName) + } + if msg.Additional != "custom-info" { + t.Errorf("additional = %s, want custom-info", msg.Additional) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server, getBody := testServer(t, tt.contentType) + defer server.Close() + + alerter := tt.newAlert(server.URL) + if err := alerter.Send(context.Background(), testAlertMessage()); err != nil { + t.Fatalf("Send() error = %v", err) + } + tt.validate(t, getBody()) + }) + } +} + +func TestAlerter_WebhookError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + alerter := NewRawAlerter(server.URL, "", "", false) + if err := alerter.Send(context.Background(), AlertMessage{}); err == nil { + t.Error("Expected error for non-2xx response") + } +} diff --git a/internal/pkg/alerting/gchat.go b/internal/pkg/alerting/gchat.go new file mode 100644 index 000000000..8ad0c2f7f --- /dev/null +++ b/internal/pkg/alerting/gchat.go @@ -0,0 +1,90 @@ +package alerting + +import ( + "context" + "encoding/json" + "fmt" +) + +// GChatAlerter sends alerts to Google Chat webhooks. +type GChatAlerter struct { + webhookURL string + additional string + client *httpClient +} + +// NewGChatAlerter creates a new GChatAlerter. +func NewGChatAlerter(webhookURL, proxyURL, additional string) *GChatAlerter { + return &GChatAlerter{ + webhookURL: webhookURL, + additional: additional, + client: newHTTPClient(proxyURL), + } +} + +// gchatMessage represents a Google Chat message. +type gchatMessage struct { + Text string `json:"text,omitempty"` + Cards []gchatCard `json:"cards,omitempty"` +} + +type gchatCard struct { + Header gchatHeader `json:"header"` + Sections []gchatSection `json:"sections"` +} + +type gchatHeader struct { + Title string `json:"title"` + Subtitle string `json:"subtitle,omitempty"` +} + +type gchatSection struct { + Widgets []gchatWidget `json:"widgets"` +} + +type gchatWidget struct { + KeyValue *gchatKeyValue `json:"keyValue,omitempty"` +} + +type gchatKeyValue struct { + TopLabel string `json:"topLabel"` + Content string `json:"content"` +} + +func (a *GChatAlerter) Send(ctx context.Context, message AlertMessage) error { + msg := a.buildMessage(message) + + body, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("marshaling gchat message: %w", err) + } + + return a.client.post(ctx, a.webhookURL, body) +} + +func (a *GChatAlerter) buildMessage(msg AlertMessage) gchatMessage { + widgets := []gchatWidget{ + {KeyValue: &gchatKeyValue{TopLabel: "Workload", Content: fmt.Sprintf("%s/%s (%s)", msg.WorkloadNamespace, msg.WorkloadName, msg.WorkloadKind)}}, + {KeyValue: &gchatKeyValue{TopLabel: "Resource", Content: fmt.Sprintf("%s/%s (%s)", msg.ResourceNamespace, msg.ResourceName, msg.ResourceKind)}}, + {KeyValue: &gchatKeyValue{TopLabel: "Time", Content: msg.Timestamp.Format("2006-01-02 15:04:05 UTC")}}, + } + + subtitle := "" + if a.additional != "" { + subtitle = a.additional + } + + return gchatMessage{ + Cards: []gchatCard{ + { + Header: gchatHeader{ + Title: "Reloader triggered reload", + Subtitle: subtitle, + }, + Sections: []gchatSection{ + {Widgets: widgets}, + }, + }, + }, + } +} diff --git a/internal/pkg/alerting/http.go b/internal/pkg/alerting/http.go new file mode 100644 index 000000000..2501e695f --- /dev/null +++ b/internal/pkg/alerting/http.go @@ -0,0 +1,59 @@ +package alerting + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + + httputil "github.com/stakater/Reloader/internal/pkg/http" +) + +// httpClient wraps http.Client with common configuration. +type httpClient struct { + client *http.Client +} + +// newHTTPClient creates a new httpClient with optional proxy support. +func newHTTPClient(proxyURL string) *httpClient { + cfg := httputil.DefaultConfig() + cfg.Timeout = httputil.AlertingTimeout + cfg.ProxyURL = proxyURL + + return &httpClient{ + client: httputil.NewClient(cfg), + } +} + +// post sends a POST request with JSON body. +func (c *httpClient) post(ctx context.Context, url string, body []byte) error { + return c.doPost(ctx, url, body, "application/json") +} + +// postText sends a POST request with plain text body. +func (c *httpClient) postText(ctx context.Context, url string, text string) error { + return c.doPost(ctx, url, []byte(text), "text/plain") +} + +// doPost sends a POST request with the specified content type. +func (c *httpClient) doPost(ctx context.Context, url string, body []byte, contentType string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", contentType) + + resp, err := c.client.Do(req) + if err != nil { + return fmt.Errorf("sending request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) + } + + return nil +} diff --git a/internal/pkg/alerting/raw.go b/internal/pkg/alerting/raw.go new file mode 100644 index 000000000..d8ea3046a --- /dev/null +++ b/internal/pkg/alerting/raw.go @@ -0,0 +1,90 @@ +package alerting + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +// RawAlerter sends alerts to a webhook as plain text (default) or structured JSON. +type RawAlerter struct { + webhookURL string + additional string + structured bool + client *httpClient +} + +// NewRawAlerter creates a new RawAlerter. +// If structured is true, sends JSON; otherwise sends plain text. +func NewRawAlerter(webhookURL, proxyURL, additional string, structured bool) *RawAlerter { + return &RawAlerter{ + webhookURL: webhookURL, + additional: additional, + structured: structured, + client: newHTTPClient(proxyURL), + } +} + +// rawMessage is the JSON payload for structured raw webhook alerts. +type rawMessage struct { + Event string `json:"event"` + WorkloadKind string `json:"workloadKind"` + WorkloadName string `json:"workloadName"` + WorkloadNamespace string `json:"workloadNamespace"` + ResourceKind string `json:"resourceKind"` + ResourceName string `json:"resourceName"` + ResourceNamespace string `json:"resourceNamespace"` + Timestamp string `json:"timestamp"` + Additional string `json:"additional,omitempty"` +} + +func (a *RawAlerter) Send(ctx context.Context, message AlertMessage) error { + if a.structured { + return a.sendStructured(ctx, message) + } + return a.sendPlainText(ctx, message) +} + +func (a *RawAlerter) sendStructured(ctx context.Context, message AlertMessage) error { + msg := rawMessage{ + Event: "reload", + WorkloadKind: message.WorkloadKind, + WorkloadName: message.WorkloadName, + WorkloadNamespace: message.WorkloadNamespace, + ResourceKind: message.ResourceKind, + ResourceName: message.ResourceName, + ResourceNamespace: message.ResourceNamespace, + Timestamp: message.Timestamp.Format("2006-01-02T15:04:05Z07:00"), + Additional: a.additional, + } + + body, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("marshaling raw message: %w", err) + } + + return a.client.post(ctx, a.webhookURL, body) +} + +func (a *RawAlerter) sendPlainText(ctx context.Context, message AlertMessage) error { + text := a.formatMessage(message) + // Strip markdown formatting for plain text + text = strings.ReplaceAll(text, "*", "") + return a.client.postText(ctx, a.webhookURL, text) +} + +func (a *RawAlerter) formatMessage(msg AlertMessage) string { + text := fmt.Sprintf( + "Reloader triggered reload - Workload: %s/%s (%s), Resource: %s/%s (%s), Time: %s", + msg.WorkloadNamespace, msg.WorkloadName, msg.WorkloadKind, + msg.ResourceNamespace, msg.ResourceName, msg.ResourceKind, + msg.Timestamp.Format("2006-01-02 15:04:05 UTC"), + ) + + if a.additional != "" { + text = a.additional + " : " + text + } + + return text +} diff --git a/internal/pkg/alerting/slack.go b/internal/pkg/alerting/slack.go new file mode 100644 index 000000000..68df2ac00 --- /dev/null +++ b/internal/pkg/alerting/slack.go @@ -0,0 +1,128 @@ +package alerting + +import ( + "context" + "encoding/json" + "fmt" +) + +// SlackAlerter sends alerts to Slack webhooks. +type SlackAlerter struct { + webhookURL string + additional string + client *httpClient +} + +// NewSlackAlerter creates a new SlackAlerter. +func NewSlackAlerter(webhookURL, proxyURL, additional string) *SlackAlerter { + return &SlackAlerter{ + webhookURL: webhookURL, + additional: additional, + client: newHTTPClient(proxyURL), + } +} + +// slackMessage represents a Slack webhook message. +type slackMessage struct { + Username string `json:"username,omitempty"` + IconEmoji string `json:"icon_emoji,omitempty"` + IconURL string `json:"icon_url,omitempty"` + Channel string `json:"channel,omitempty"` + ThreadTimestamp string `json:"thread_ts,omitempty"` + Text string `json:"text,omitempty"` + Attachments []slackAttachment `json:"attachments,omitempty"` + Parse string `json:"parse,omitempty"` + ResponseType string `json:"response_type,omitempty"` + ReplaceOriginal bool `json:"replace_original,omitempty"` + DeleteOriginal bool `json:"delete_original,omitempty"` + ReplyBroadcast bool `json:"reply_broadcast,omitempty"` +} + +// slackAttachment represents a Slack message attachment. +type slackAttachment struct { + Color string `json:"color,omitempty"` + Fallback string `json:"fallback,omitempty"` + + CallbackID string `json:"callback_id,omitempty"` + ID int `json:"id,omitempty"` + + AuthorID string `json:"author_id,omitempty"` + AuthorName string `json:"author_name,omitempty"` + AuthorSubname string `json:"author_subname,omitempty"` + AuthorLink string `json:"author_link,omitempty"` + AuthorIcon string `json:"author_icon,omitempty"` + + Title string `json:"title,omitempty"` + TitleLink string `json:"title_link,omitempty"` + Pretext string `json:"pretext,omitempty"` + Text string `json:"text,omitempty"` + + ImageURL string `json:"image_url,omitempty"` + ThumbURL string `json:"thumb_url,omitempty"` + + ServiceName string `json:"service_name,omitempty"` + ServiceIcon string `json:"service_icon,omitempty"` + FromURL string `json:"from_url,omitempty"` + OriginalURL string `json:"original_url,omitempty"` + + Fields []slackField `json:"fields,omitempty"` + MarkdownIn []string `json:"mrkdwn_in,omitempty"` + + Footer string `json:"footer,omitempty"` + FooterIcon string `json:"footer_icon,omitempty"` + + Actions []slackAction `json:"actions,omitempty"` +} + +// slackField represents a field in a Slack attachment. +type slackField struct { + Title string `json:"title"` + Value string `json:"value"` + Short bool `json:"short"` +} + +// slackAction represents an action button in a Slack attachment. +type slackAction struct { + Type string `json:"type"` + Text string `json:"text"` + URL string `json:"url"` + Style string `json:"style"` +} + +func (a *SlackAlerter) Send(ctx context.Context, message AlertMessage) error { + text := a.formatMessage(message) + msg := slackMessage{ + Attachments: []slackAttachment{ + { + Text: text, + Color: "good", + AuthorName: "Reloader", + }, + }, + } + + body, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("marshaling slack message: %w", err) + } + + return a.client.post(ctx, a.webhookURL, body) +} + +func (a *SlackAlerter) formatMessage(msg AlertMessage) string { + text := fmt.Sprintf( + "Reloader triggered reload\n"+ + "*Workload:* %s/%s (%s)\n"+ + "*Resource:* %s/%s (%s)\n"+ + "*Time:* %s", + msg.WorkloadNamespace, msg.WorkloadName, msg.WorkloadKind, + msg.ResourceNamespace, msg.ResourceName, msg.ResourceKind, + msg.Timestamp.Format("2006-01-02 15:04:05 UTC"), + ) + + if a.additional != "" { + text = a.additional + "\n" + text + } + + return text +} diff --git a/internal/pkg/alerting/teams.go b/internal/pkg/alerting/teams.go new file mode 100644 index 000000000..99b08d5c8 --- /dev/null +++ b/internal/pkg/alerting/teams.go @@ -0,0 +1,81 @@ +package alerting + +import ( + "context" + "encoding/json" + "fmt" +) + +// TeamsAlerter sends alerts to Microsoft Teams webhooks. +type TeamsAlerter struct { + webhookURL string + additional string + client *httpClient +} + +// NewTeamsAlerter creates a new TeamsAlerter. +func NewTeamsAlerter(webhookURL, proxyURL, additional string) *TeamsAlerter { + return &TeamsAlerter{ + webhookURL: webhookURL, + additional: additional, + client: newHTTPClient(proxyURL), + } +} + +// teamsMessage represents a Microsoft Teams message card. +type teamsMessage struct { + Type string `json:"@type"` + Context string `json:"@context"` + ThemeColor string `json:"themeColor"` + Summary string `json:"summary"` + Sections []teamsSection `json:"sections"` +} + +type teamsSection struct { + ActivityTitle string `json:"activityTitle"` + ActivitySubtitle string `json:"activitySubtitle,omitempty"` + Facts []teamsFact `json:"facts"` +} + +type teamsFact struct { + Name string `json:"name"` + Value string `json:"value"` +} + +func (a *TeamsAlerter) Send(ctx context.Context, message AlertMessage) error { + msg := a.buildMessage(message) + + body, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("marshaling teams message: %w", err) + } + + return a.client.post(ctx, a.webhookURL, body) +} + +func (a *TeamsAlerter) buildMessage(msg AlertMessage) teamsMessage { + facts := []teamsFact{ + {Name: "Workload", Value: fmt.Sprintf("%s/%s (%s)", msg.WorkloadNamespace, msg.WorkloadName, msg.WorkloadKind)}, + {Name: "Resource", Value: fmt.Sprintf("%s/%s (%s)", msg.ResourceNamespace, msg.ResourceName, msg.ResourceKind)}, + {Name: "Time", Value: msg.Timestamp.Format("2006-01-02 15:04:05 UTC")}, + } + + subtitle := "" + if a.additional != "" { + subtitle = a.additional + } + + return teamsMessage{ + Type: "MessageCard", + Context: "http://schema.org/extensions", + ThemeColor: "0076D7", + Summary: "Reloader triggered reload", + Sections: []teamsSection{ + { + ActivityTitle: "Reloader triggered reload", + ActivitySubtitle: subtitle, + Facts: facts, + }, + }, + } +} diff --git a/internal/pkg/alerts/alert.go b/internal/pkg/alerts/alert.go deleted file mode 100644 index 6b9568ff0..000000000 --- a/internal/pkg/alerts/alert.go +++ /dev/null @@ -1,154 +0,0 @@ -package alert - -import ( - "fmt" - "os" - "strings" - - "github.com/parnurzeal/gorequest" - "github.com/sirupsen/logrus" -) - -type AlertSink string - -const ( - AlertSinkSlack AlertSink = "slack" - AlertSinkTeams AlertSink = "teams" - AlertSinkGoogleChat AlertSink = "gchat" - AlertSinkRaw AlertSink = "raw" -) - -// function to send alert msg to webhook service -func SendWebhookAlert(msg string) { - webhook_url, ok := os.LookupEnv("ALERT_WEBHOOK_URL") - if !ok { - logrus.Error("ALERT_WEBHOOK_URL env variable not provided") - return - } - webhook_url = strings.TrimSpace(webhook_url) - alert_sink := os.Getenv("ALERT_SINK") - alert_sink = strings.ToLower(strings.TrimSpace(alert_sink)) - - // Provision to add Proxy to reach webhook server if required - webhook_proxy := os.Getenv("ALERT_WEBHOOK_PROXY") - webhook_proxy = strings.TrimSpace(webhook_proxy) - - // Provision to add Additional information in the alert. e.g ClusterName - alert_additional_info, ok := os.LookupEnv("ALERT_ADDITIONAL_INFO") - if ok { - alert_additional_info = strings.TrimSpace(alert_additional_info) - msg = fmt.Sprintf("%s : %s", alert_additional_info, msg) - } - - switch AlertSink(alert_sink) { - case AlertSinkSlack: - sendSlackAlert(webhook_url, webhook_proxy, msg) - case AlertSinkTeams: - sendTeamsAlert(webhook_url, webhook_proxy, msg) - case AlertSinkGoogleChat: - sendGoogleChatAlert(webhook_url, webhook_proxy, msg) - default: - msg = strings.ReplaceAll(msg, "*", "") - sendRawWebhookAlert(webhook_url, webhook_proxy, msg) - } -} - -// function to handle server redirection -func redirectPolicy(req gorequest.Request, via []gorequest.Request) error { - return fmt.Errorf("incorrect token (redirection)") -} - -// function to send alert to slack -func sendSlackAlert(webhookUrl string, proxy string, msg string) []error { - attachment := Attachment{ - Text: msg, - Color: "good", - AuthorName: "Reloader", - } - - payload := WebhookMessage{ - Attachments: []Attachment{attachment}, - } - - request := gorequest.New().Proxy(proxy) - resp, _, err := request. - Post(webhookUrl). - RedirectPolicy(redirectPolicy). - Send(payload). - End() - - if err != nil { - return err - } - if resp.StatusCode >= 400 { - return []error{fmt.Errorf("error sending msg. status: %v", resp.Status)} - } - - return nil -} - -// function to send alert to Microsoft Teams webhook -func sendTeamsAlert(webhookUrl string, proxy string, msg string) []error { - attachment := Attachment{ - Text: msg, - } - - request := gorequest.New().Proxy(proxy) - resp, _, err := request. - Post(webhookUrl). - RedirectPolicy(redirectPolicy). - Send(attachment). - End() - - if err != nil { - return err - } - if resp.StatusCode != 200 { - return []error{fmt.Errorf("error sending msg. status: %v", resp.Status)} - } - - return nil -} - -// function to send alert to Google Chat webhook -func sendGoogleChatAlert(webhookUrl string, proxy string, msg string) []error { - payload := map[string]interface{}{ - "text": msg, - } - - request := gorequest.New().Proxy(proxy) - resp, _, err := request. - Post(webhookUrl). - RedirectPolicy(redirectPolicy). - Send(payload). - End() - - if err != nil { - return err - } - if resp.StatusCode != 200 { - return []error{fmt.Errorf("error sending msg. status: %v", resp.Status)} - } - - return nil -} - -// function to send alert to webhook service as text -func sendRawWebhookAlert(webhookUrl string, proxy string, msg string) []error { - request := gorequest.New().Proxy(proxy) - resp, _, err := request. - Post(webhookUrl). - Type("text"). - RedirectPolicy(redirectPolicy). - Send(msg). - End() - - if err != nil { - return err - } - if resp.StatusCode >= 400 { - return []error{fmt.Errorf("error sending msg. status: %v", resp.Status)} - } - - return nil -} diff --git a/internal/pkg/alerts/slack_alert.go b/internal/pkg/alerts/slack_alert.go deleted file mode 100644 index a21727a25..000000000 --- a/internal/pkg/alerts/slack_alert.go +++ /dev/null @@ -1,61 +0,0 @@ -package alert - -type WebhookMessage struct { - Username string `json:"username,omitempty"` - IconEmoji string `json:"icon_emoji,omitempty"` - IconURL string `json:"icon_url,omitempty"` - Channel string `json:"channel,omitempty"` - ThreadTimestamp string `json:"thread_ts,omitempty"` - Text string `json:"text,omitempty"` - Attachments []Attachment `json:"attachments,omitempty"` - Parse string `json:"parse,omitempty"` - ResponseType string `json:"response_type,omitempty"` - ReplaceOriginal bool `json:"replace_original,omitempty"` - DeleteOriginal bool `json:"delete_original,omitempty"` - ReplyBroadcast bool `json:"reply_broadcast,omitempty"` -} - -type Attachment struct { - Color string `json:"color,omitempty"` - Fallback string `json:"fallback,omitempty"` - - CallbackID string `json:"callback_id,omitempty"` - ID int `json:"id,omitempty"` - - AuthorID string `json:"author_id,omitempty"` - AuthorName string `json:"author_name,omitempty"` - AuthorSubname string `json:"author_subname,omitempty"` - AuthorLink string `json:"author_link,omitempty"` - AuthorIcon string `json:"author_icon,omitempty"` - - Title string `json:"title,omitempty"` - TitleLink string `json:"title_link,omitempty"` - Pretext string `json:"pretext,omitempty"` - Text string `json:"text,omitempty"` - - ImageURL string `json:"image_url,omitempty"` - ThumbURL string `json:"thumb_url,omitempty"` - - ServiceName string `json:"service_name,omitempty"` - ServiceIcon string `json:"service_icon,omitempty"` - FromURL string `json:"from_url,omitempty"` - OriginalURL string `json:"original_url,omitempty"` - - MarkdownIn []string `json:"mrkdwn_in,omitempty"` - - Footer string `json:"footer,omitempty"` - FooterIcon string `json:"footer_icon,omitempty"` -} - -type Field struct { - Title string `json:"title"` - Value string `json:"value"` - Short bool `json:"short"` -} - -type Action struct { - Type string `json:"type"` - Text string `json:"text"` - Url string `json:"url"` - Style string `json:"style"` -} diff --git a/internal/pkg/app/app.go b/internal/pkg/app/app.go deleted file mode 100644 index 734fd2a9c..000000000 --- a/internal/pkg/app/app.go +++ /dev/null @@ -1,9 +0,0 @@ -package app - -import "github.com/stakater/Reloader/internal/pkg/cmd" - -// Run runs the command -func Run() error { - rootCmd := cmd.NewReloaderCommand() - return rootCmd.Execute() -} diff --git a/internal/pkg/callbacks/rolling_upgrade.go b/internal/pkg/callbacks/rolling_upgrade.go deleted file mode 100644 index 3a0551405..000000000 --- a/internal/pkg/callbacks/rolling_upgrade.go +++ /dev/null @@ -1,727 +0,0 @@ -package callbacks - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/sirupsen/logrus" - appsv1 "k8s.io/api/apps/v1" - batchv1 "k8s.io/api/batch/v1" - v1 "k8s.io/api/core/v1" - meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - patchtypes "k8s.io/apimachinery/pkg/types" - - "github.com/stakater/Reloader/internal/pkg/options" - "github.com/stakater/Reloader/pkg/kube" - - "maps" - - argorolloutv1alpha1 "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1" -) - -// ItemFunc is a generic function to return a specific resource in given namespace -type ItemFunc func(kube.Clients, string, string) (runtime.Object, error) - -// ItemsFunc is a generic function to return a specific resource array in given namespace -type ItemsFunc func(kube.Clients, string) []runtime.Object - -// ContainersFunc is a generic func to return containers -type ContainersFunc func(runtime.Object) []v1.Container - -// InitContainersFunc is a generic func to return containers -type InitContainersFunc func(runtime.Object) []v1.Container - -// VolumesFunc is a generic func to return volumes -type VolumesFunc func(runtime.Object) []v1.Volume - -// UpdateFunc performs the resource update -type UpdateFunc func(kube.Clients, string, runtime.Object) error - -// PatchFunc performs the resource patch -type PatchFunc func(kube.Clients, string, runtime.Object, patchtypes.PatchType, []byte) error - -// PatchTemplateFunc is a generic func to return strategic merge JSON patch template -type PatchTemplatesFunc func() PatchTemplates - -// AnnotationsFunc is a generic func to return annotations -type AnnotationsFunc func(runtime.Object) map[string]string - -// PodAnnotationsFunc is a generic func to return annotations -type PodAnnotationsFunc func(runtime.Object) map[string]string - -// RollingUpgradeFuncs contains generic functions to perform rolling upgrade -type RollingUpgradeFuncs struct { - ItemFunc ItemFunc - ItemsFunc ItemsFunc - AnnotationsFunc AnnotationsFunc - PodAnnotationsFunc PodAnnotationsFunc - ContainersFunc ContainersFunc - ContainerPatchPathFunc ContainersFunc - InitContainersFunc InitContainersFunc - UpdateFunc UpdateFunc - PatchFunc PatchFunc - PatchTemplatesFunc PatchTemplatesFunc - VolumesFunc VolumesFunc - ResourceType string - SupportsPatch bool -} - -// PatchTemplates contains merge JSON patch templates -type PatchTemplates struct { - AnnotationTemplate string - EnvVarTemplate string - DeleteEnvVarTemplate string -} - -// GetDeploymentItem returns the deployment in given namespace -func GetDeploymentItem(clients kube.Clients, name string, namespace string) (runtime.Object, error) { - deployment, err := clients.KubernetesClient.AppsV1().Deployments(namespace).Get(context.TODO(), name, meta_v1.GetOptions{}) - if err != nil { - logrus.Errorf("Failed to get deployment %v", err) - return nil, err - } - - if deployment.Spec.Template.Annotations == nil { - annotations := make(map[string]string) - deployment.Spec.Template.Annotations = annotations - } - - return deployment, nil -} - -// GetDeploymentItems returns the deployments in given namespace -func GetDeploymentItems(clients kube.Clients, namespace string) []runtime.Object { - deployments, err := clients.KubernetesClient.AppsV1().Deployments(namespace).List(context.TODO(), meta_v1.ListOptions{}) - if err != nil { - logrus.Errorf("Failed to list deployments %v", err) - } - - items := make([]runtime.Object, len(deployments.Items)) - // Ensure we always have pod annotations to add to - for i, v := range deployments.Items { - if v.Spec.Template.Annotations == nil { - annotations := make(map[string]string) - deployments.Items[i].Spec.Template.Annotations = annotations - } - items[i] = &deployments.Items[i] - } - - return items -} - -// GetCronJobItem returns the job in given namespace -func GetCronJobItem(clients kube.Clients, name string, namespace string) (runtime.Object, error) { - cronjob, err := clients.KubernetesClient.BatchV1().CronJobs(namespace).Get(context.TODO(), name, meta_v1.GetOptions{}) - if err != nil { - logrus.Errorf("Failed to get cronjob %v", err) - return nil, err - } - - return cronjob, nil -} - -// GetCronJobItems returns the jobs in given namespace -func GetCronJobItems(clients kube.Clients, namespace string) []runtime.Object { - cronjobs, err := clients.KubernetesClient.BatchV1().CronJobs(namespace).List(context.TODO(), meta_v1.ListOptions{}) - if err != nil { - logrus.Errorf("Failed to list cronjobs %v", err) - } - - items := make([]runtime.Object, len(cronjobs.Items)) - // Ensure we always have pod annotations to add to - for i, v := range cronjobs.Items { - if v.Spec.JobTemplate.Spec.Template.Annotations == nil { - annotations := make(map[string]string) - cronjobs.Items[i].Spec.JobTemplate.Spec.Template.Annotations = annotations - } - items[i] = &cronjobs.Items[i] - } - - return items -} - -// GetJobItem returns the job in given namespace -func GetJobItem(clients kube.Clients, name string, namespace string) (runtime.Object, error) { - job, err := clients.KubernetesClient.BatchV1().Jobs(namespace).Get(context.TODO(), name, meta_v1.GetOptions{}) - if err != nil { - logrus.Errorf("Failed to get job %v", err) - return nil, err - } - - return job, nil -} - -// GetJobItems returns the jobs in given namespace -func GetJobItems(clients kube.Clients, namespace string) []runtime.Object { - jobs, err := clients.KubernetesClient.BatchV1().Jobs(namespace).List(context.TODO(), meta_v1.ListOptions{}) - if err != nil { - logrus.Errorf("Failed to list jobs %v", err) - } - - items := make([]runtime.Object, len(jobs.Items)) - // Ensure we always have pod annotations to add to - for i, v := range jobs.Items { - if v.Spec.Template.Annotations == nil { - annotations := make(map[string]string) - jobs.Items[i].Spec.Template.Annotations = annotations - } - items[i] = &jobs.Items[i] - } - - return items -} - -// GetDaemonSetItem returns the daemonSet in given namespace -func GetDaemonSetItem(clients kube.Clients, name string, namespace string) (runtime.Object, error) { - daemonSet, err := clients.KubernetesClient.AppsV1().DaemonSets(namespace).Get(context.TODO(), name, meta_v1.GetOptions{}) - if err != nil { - logrus.Errorf("Failed to get daemonSet %v", err) - return nil, err - } - - return daemonSet, nil -} - -// GetDaemonSetItems returns the daemonSets in given namespace -func GetDaemonSetItems(clients kube.Clients, namespace string) []runtime.Object { - daemonSets, err := clients.KubernetesClient.AppsV1().DaemonSets(namespace).List(context.TODO(), meta_v1.ListOptions{}) - if err != nil { - logrus.Errorf("Failed to list daemonSets %v", err) - } - - items := make([]runtime.Object, len(daemonSets.Items)) - // Ensure we always have pod annotations to add to - for i, v := range daemonSets.Items { - if v.Spec.Template.Annotations == nil { - daemonSets.Items[i].Spec.Template.Annotations = make(map[string]string) - } - items[i] = &daemonSets.Items[i] - } - - return items -} - -// GetStatefulSetItem returns the statefulSet in given namespace -func GetStatefulSetItem(clients kube.Clients, name string, namespace string) (runtime.Object, error) { - statefulSet, err := clients.KubernetesClient.AppsV1().StatefulSets(namespace).Get(context.TODO(), name, meta_v1.GetOptions{}) - if err != nil { - logrus.Errorf("Failed to get statefulSet %v", err) - return nil, err - } - - return statefulSet, nil -} - -// GetStatefulSetItems returns the statefulSets in given namespace -func GetStatefulSetItems(clients kube.Clients, namespace string) []runtime.Object { - statefulSets, err := clients.KubernetesClient.AppsV1().StatefulSets(namespace).List(context.TODO(), meta_v1.ListOptions{}) - if err != nil { - logrus.Errorf("Failed to list statefulSets %v", err) - } - - items := make([]runtime.Object, len(statefulSets.Items)) - // Ensure we always have pod annotations to add to - for i, v := range statefulSets.Items { - if v.Spec.Template.Annotations == nil { - statefulSets.Items[i].Spec.Template.Annotations = make(map[string]string) - } - items[i] = &statefulSets.Items[i] - } - - return items -} - -// GetRolloutItem returns the rollout in given namespace -func GetRolloutItem(clients kube.Clients, name string, namespace string) (runtime.Object, error) { - rollout, err := clients.ArgoRolloutClient.ArgoprojV1alpha1().Rollouts(namespace).Get(context.TODO(), name, meta_v1.GetOptions{}) - if err != nil { - logrus.Errorf("Failed to get Rollout %v", err) - return nil, err - } - - return rollout, nil -} - -// GetRolloutItems returns the rollouts in given namespace -func GetRolloutItems(clients kube.Clients, namespace string) []runtime.Object { - rollouts, err := clients.ArgoRolloutClient.ArgoprojV1alpha1().Rollouts(namespace).List(context.TODO(), meta_v1.ListOptions{}) - if err != nil { - logrus.Errorf("Failed to list Rollouts %v", err) - } - - items := make([]runtime.Object, len(rollouts.Items)) - // Ensure we always have pod annotations to add to - for i, v := range rollouts.Items { - if v.Spec.Template.Annotations == nil { - rollouts.Items[i].Spec.Template.Annotations = make(map[string]string) - } - items[i] = &rollouts.Items[i] - } - - return items -} - -// GetDeploymentAnnotations returns the annotations of given deployment -func GetDeploymentAnnotations(item runtime.Object) map[string]string { - deployment, ok := item.(*appsv1.Deployment) - if !ok { - return nil - } - if deployment.Annotations == nil { - deployment.Annotations = make(map[string]string) - } - return deployment.Annotations -} - -// GetCronJobAnnotations returns the annotations of given cronjob -func GetCronJobAnnotations(item runtime.Object) map[string]string { - cronJob, ok := item.(*batchv1.CronJob) - if !ok { - return nil - } - if cronJob.Annotations == nil { - cronJob.Annotations = make(map[string]string) - } - return cronJob.Annotations -} - -// GetJobAnnotations returns the annotations of given job -func GetJobAnnotations(item runtime.Object) map[string]string { - job, ok := item.(*batchv1.Job) - if !ok { - return nil - } - if job.Annotations == nil { - job.Annotations = make(map[string]string) - } - return job.Annotations -} - -// GetDaemonSetAnnotations returns the annotations of given daemonSet -func GetDaemonSetAnnotations(item runtime.Object) map[string]string { - daemonSet, ok := item.(*appsv1.DaemonSet) - if !ok { - return nil - } - if daemonSet.Annotations == nil { - daemonSet.Annotations = make(map[string]string) - } - return daemonSet.Annotations -} - -// GetStatefulSetAnnotations returns the annotations of given statefulSet -func GetStatefulSetAnnotations(item runtime.Object) map[string]string { - statefulSet, ok := item.(*appsv1.StatefulSet) - if !ok { - return nil - } - if statefulSet.Annotations == nil { - statefulSet.Annotations = make(map[string]string) - } - return statefulSet.Annotations -} - -// GetRolloutAnnotations returns the annotations of given rollout -func GetRolloutAnnotations(item runtime.Object) map[string]string { - rollout, ok := item.(*argorolloutv1alpha1.Rollout) - if !ok { - return nil - } - if rollout.Annotations == nil { - rollout.Annotations = make(map[string]string) - } - return rollout.Annotations -} - -// GetDeploymentPodAnnotations returns the pod's annotations of given deployment -func GetDeploymentPodAnnotations(item runtime.Object) map[string]string { - deployment, ok := item.(*appsv1.Deployment) - if !ok { - return nil - } - if deployment.Spec.Template.Annotations == nil { - deployment.Spec.Template.Annotations = make(map[string]string) - } - return deployment.Spec.Template.Annotations -} - -// GetCronJobPodAnnotations returns the pod's annotations of given cronjob -func GetCronJobPodAnnotations(item runtime.Object) map[string]string { - cronJob, ok := item.(*batchv1.CronJob) - if !ok { - return nil - } - if cronJob.Spec.JobTemplate.Spec.Template.Annotations == nil { - cronJob.Spec.JobTemplate.Spec.Template.Annotations = make(map[string]string) - } - return cronJob.Spec.JobTemplate.Spec.Template.Annotations -} - -// GetJobPodAnnotations returns the pod's annotations of given job -func GetJobPodAnnotations(item runtime.Object) map[string]string { - job, ok := item.(*batchv1.Job) - if !ok { - return nil - } - if job.Spec.Template.Annotations == nil { - job.Spec.Template.Annotations = make(map[string]string) - } - return job.Spec.Template.Annotations -} - -// GetDaemonSetPodAnnotations returns the pod's annotations of given daemonSet -func GetDaemonSetPodAnnotations(item runtime.Object) map[string]string { - daemonSet, ok := item.(*appsv1.DaemonSet) - if !ok { - return nil - } - if daemonSet.Spec.Template.Annotations == nil { - daemonSet.Spec.Template.Annotations = make(map[string]string) - } - return daemonSet.Spec.Template.Annotations -} - -// GetStatefulSetPodAnnotations returns the pod's annotations of given statefulSet -func GetStatefulSetPodAnnotations(item runtime.Object) map[string]string { - statefulSet, ok := item.(*appsv1.StatefulSet) - if !ok { - return nil - } - if statefulSet.Spec.Template.Annotations == nil { - statefulSet.Spec.Template.Annotations = make(map[string]string) - } - return statefulSet.Spec.Template.Annotations -} - -// GetRolloutPodAnnotations returns the pod's annotations of given rollout -func GetRolloutPodAnnotations(item runtime.Object) map[string]string { - rollout, ok := item.(*argorolloutv1alpha1.Rollout) - if !ok { - return nil - } - if rollout.Spec.Template.Annotations == nil { - rollout.Spec.Template.Annotations = make(map[string]string) - } - return rollout.Spec.Template.Annotations -} - -// GetDeploymentContainers returns the containers of given deployment -func GetDeploymentContainers(item runtime.Object) []v1.Container { - deployment, ok := item.(*appsv1.Deployment) - if !ok { - return []v1.Container{} - } - return deployment.Spec.Template.Spec.Containers -} - -// GetCronJobContainers returns the containers of given cronjob -func GetCronJobContainers(item runtime.Object) []v1.Container { - cronJob, ok := item.(*batchv1.CronJob) - if !ok { - return []v1.Container{} - } - return cronJob.Spec.JobTemplate.Spec.Template.Spec.Containers -} - -// GetJobContainers returns the containers of given job -func GetJobContainers(item runtime.Object) []v1.Container { - job, ok := item.(*batchv1.Job) - if !ok { - return []v1.Container{} - } - return job.Spec.Template.Spec.Containers -} - -// GetDaemonSetContainers returns the containers of given daemonSet -func GetDaemonSetContainers(item runtime.Object) []v1.Container { - daemonSet, ok := item.(*appsv1.DaemonSet) - if !ok { - return []v1.Container{} - } - return daemonSet.Spec.Template.Spec.Containers -} - -// GetStatefulSetContainers returns the containers of given statefulSet -func GetStatefulSetContainers(item runtime.Object) []v1.Container { - statefulSet, ok := item.(*appsv1.StatefulSet) - if !ok { - return []v1.Container{} - } - return statefulSet.Spec.Template.Spec.Containers -} - -// GetRolloutContainers returns the containers of given rollout -func GetRolloutContainers(item runtime.Object) []v1.Container { - rollout, ok := item.(*argorolloutv1alpha1.Rollout) - if !ok { - return []v1.Container{} - } - return rollout.Spec.Template.Spec.Containers -} - -// GetDeploymentInitContainers returns the containers of given deployment -func GetDeploymentInitContainers(item runtime.Object) []v1.Container { - deployment, ok := item.(*appsv1.Deployment) - if !ok { - return []v1.Container{} - } - return deployment.Spec.Template.Spec.InitContainers -} - -// GetCronJobInitContainers returns the containers of given cronjob -func GetCronJobInitContainers(item runtime.Object) []v1.Container { - cronJob, ok := item.(*batchv1.CronJob) - if !ok { - return []v1.Container{} - } - return cronJob.Spec.JobTemplate.Spec.Template.Spec.InitContainers -} - -// GetJobInitContainers returns the containers of given job -func GetJobInitContainers(item runtime.Object) []v1.Container { - job, ok := item.(*batchv1.Job) - if !ok { - return []v1.Container{} - } - return job.Spec.Template.Spec.InitContainers -} - -// GetDaemonSetInitContainers returns the containers of given daemonSet -func GetDaemonSetInitContainers(item runtime.Object) []v1.Container { - daemonSet, ok := item.(*appsv1.DaemonSet) - if !ok { - return []v1.Container{} - } - return daemonSet.Spec.Template.Spec.InitContainers -} - -// GetStatefulSetInitContainers returns the containers of given statefulSet -func GetStatefulSetInitContainers(item runtime.Object) []v1.Container { - statefulSet, ok := item.(*appsv1.StatefulSet) - if !ok { - return []v1.Container{} - } - return statefulSet.Spec.Template.Spec.InitContainers -} - -// GetRolloutInitContainers returns the containers of given rollout -func GetRolloutInitContainers(item runtime.Object) []v1.Container { - rollout, ok := item.(*argorolloutv1alpha1.Rollout) - if !ok { - return []v1.Container{} - } - return rollout.Spec.Template.Spec.InitContainers -} - -// GetPatchTemplates returns patch templates -func GetPatchTemplates() PatchTemplates { - return PatchTemplates{ - AnnotationTemplate: `{"spec":{"template":{"metadata":{"annotations":{"%s":"%s"}}}}}`, // strategic merge patch - EnvVarTemplate: `{"spec":{"template":{"spec":{"containers":[{"name":"%s","env":[{"name":"%s","value":"%s"}]}]}}}}`, // strategic merge patch - DeleteEnvVarTemplate: `[{"op":"remove","path":"/spec/template/spec/containers/%d/env/%d"}]`, // JSON patch - } -} - -// UpdateDeployment performs rolling upgrade on deployment -func UpdateDeployment(clients kube.Clients, namespace string, resource runtime.Object) error { - deployment, ok := resource.(*appsv1.Deployment) - if !ok { - return errors.New("resource is not a Deployment") - } - _, err := clients.KubernetesClient.AppsV1().Deployments(namespace).Update(context.TODO(), deployment, meta_v1.UpdateOptions{FieldManager: "Reloader"}) - return err -} - -// PatchDeployment performs rolling upgrade on deployment -func PatchDeployment(clients kube.Clients, namespace string, resource runtime.Object, patchType patchtypes.PatchType, bytes []byte) error { - deployment, ok := resource.(*appsv1.Deployment) - if !ok { - return errors.New("resource is not a Deployment") - } - _, err := clients.KubernetesClient.AppsV1().Deployments(namespace).Patch(context.TODO(), deployment.Name, patchType, bytes, meta_v1.PatchOptions{FieldManager: "Reloader"}) - return err -} - -// CreateJobFromCronjob performs rolling upgrade on cronjob -func CreateJobFromCronjob(clients kube.Clients, namespace string, resource runtime.Object) error { - cronJob, ok := resource.(*batchv1.CronJob) - if !ok { - return errors.New("resource is not a CronJob") - } - - annotations := make(map[string]string) - annotations["cronjob.kubernetes.io/instantiate"] = "manual" - maps.Copy(annotations, cronJob.Spec.JobTemplate.Annotations) - - job := &batchv1.Job{ - ObjectMeta: meta_v1.ObjectMeta{ - GenerateName: cronJob.Name + "-", - Namespace: cronJob.Namespace, - Annotations: annotations, - Labels: cronJob.Spec.JobTemplate.Labels, - OwnerReferences: []meta_v1.OwnerReference{*meta_v1.NewControllerRef(cronJob, batchv1.SchemeGroupVersion.WithKind("CronJob"))}, - }, - Spec: cronJob.Spec.JobTemplate.Spec, - } - _, err := clients.KubernetesClient.BatchV1().Jobs(namespace).Create(context.TODO(), job, meta_v1.CreateOptions{FieldManager: "Reloader"}) - return err -} - -func PatchCronJob(clients kube.Clients, namespace string, resource runtime.Object, patchType patchtypes.PatchType, bytes []byte) error { - return errors.New("not supported patching: CronJob") -} - -// ReCreateJobFromjob performs rolling upgrade on job -func ReCreateJobFromjob(clients kube.Clients, namespace string, resource runtime.Object) error { - oldJob, ok := resource.(*batchv1.Job) - if !ok { - return errors.New("resource is not a Job") - } - job := oldJob.DeepCopy() - - // Delete the old job - policy := meta_v1.DeletePropagationBackground - err := clients.KubernetesClient.BatchV1().Jobs(namespace).Delete(context.TODO(), job.Name, meta_v1.DeleteOptions{PropagationPolicy: &policy}) - if err != nil { - return err - } - - // Remove fields that should not be specified when creating a new Job - job.ResourceVersion = "" - job.UID = "" - job.CreationTimestamp = meta_v1.Time{} - job.Status = batchv1.JobStatus{} - - // Remove problematic labels - delete(job.Spec.Template.Labels, "controller-uid") - delete(job.Spec.Template.Labels, batchv1.ControllerUidLabel) - delete(job.Spec.Template.Labels, batchv1.JobNameLabel) - delete(job.Spec.Template.Labels, "job-name") - - // Remove the selector to allow it to be auto-generated - job.Spec.Selector = nil - - // Create the new job with same spec - _, err = clients.KubernetesClient.BatchV1().Jobs(namespace).Create(context.TODO(), job, meta_v1.CreateOptions{FieldManager: "Reloader"}) - return err -} - -func PatchJob(clients kube.Clients, namespace string, resource runtime.Object, patchType patchtypes.PatchType, bytes []byte) error { - return errors.New("not supported patching: Job") -} - -// UpdateDaemonSet performs rolling upgrade on daemonSet -func UpdateDaemonSet(clients kube.Clients, namespace string, resource runtime.Object) error { - daemonSet, ok := resource.(*appsv1.DaemonSet) - if !ok { - return errors.New("resource is not a DaemonSet") - } - _, err := clients.KubernetesClient.AppsV1().DaemonSets(namespace).Update(context.TODO(), daemonSet, meta_v1.UpdateOptions{FieldManager: "Reloader"}) - return err -} - -func PatchDaemonSet(clients kube.Clients, namespace string, resource runtime.Object, patchType patchtypes.PatchType, bytes []byte) error { - daemonSet, ok := resource.(*appsv1.DaemonSet) - if !ok { - return errors.New("resource is not a DaemonSet") - } - _, err := clients.KubernetesClient.AppsV1().DaemonSets(namespace).Patch(context.TODO(), daemonSet.Name, patchType, bytes, meta_v1.PatchOptions{FieldManager: "Reloader"}) - return err -} - -// UpdateStatefulSet performs rolling upgrade on statefulSet -func UpdateStatefulSet(clients kube.Clients, namespace string, resource runtime.Object) error { - statefulSet, ok := resource.(*appsv1.StatefulSet) - if !ok { - return errors.New("resource is not a StatefulSet") - } - _, err := clients.KubernetesClient.AppsV1().StatefulSets(namespace).Update(context.TODO(), statefulSet, meta_v1.UpdateOptions{FieldManager: "Reloader"}) - return err -} - -func PatchStatefulSet(clients kube.Clients, namespace string, resource runtime.Object, patchType patchtypes.PatchType, bytes []byte) error { - statefulSet, ok := resource.(*appsv1.StatefulSet) - if !ok { - return errors.New("resource is not a StatefulSet") - } - _, err := clients.KubernetesClient.AppsV1().StatefulSets(namespace).Patch(context.TODO(), statefulSet.Name, patchType, bytes, meta_v1.PatchOptions{FieldManager: "Reloader"}) - return err -} - -// UpdateRollout performs rolling upgrade on rollout -func UpdateRollout(clients kube.Clients, namespace string, resource runtime.Object) error { - rollout, ok := resource.(*argorolloutv1alpha1.Rollout) - if !ok { - return errors.New("resource is not a Rollout") - } - strategy := rollout.GetAnnotations()[options.RolloutStrategyAnnotation] - var err error - switch options.ToArgoRolloutStrategy(strategy) { - case options.RestartStrategy: - _, err = clients.ArgoRolloutClient.ArgoprojV1alpha1().Rollouts(namespace).Patch(context.TODO(), rollout.Name, patchtypes.MergePatchType, []byte(fmt.Sprintf(`{"spec": {"restartAt": "%s"}}`, time.Now().Format(time.RFC3339))), meta_v1.PatchOptions{FieldManager: "Reloader"}) - case options.RolloutStrategy: - _, err = clients.ArgoRolloutClient.ArgoprojV1alpha1().Rollouts(namespace).Update(context.TODO(), rollout, meta_v1.UpdateOptions{FieldManager: "Reloader"}) - } - return err -} - -func PatchRollout(clients kube.Clients, namespace string, resource runtime.Object, patchType patchtypes.PatchType, bytes []byte) error { - return errors.New("not supported patching: Rollout") -} - -// GetDeploymentVolumes returns the Volumes of given deployment -func GetDeploymentVolumes(item runtime.Object) []v1.Volume { - deployment, ok := item.(*appsv1.Deployment) - if !ok { - return []v1.Volume{} - } - return deployment.Spec.Template.Spec.Volumes -} - -// GetCronJobVolumes returns the Volumes of given cronjob -func GetCronJobVolumes(item runtime.Object) []v1.Volume { - cronJob, ok := item.(*batchv1.CronJob) - if !ok { - return []v1.Volume{} - } - return cronJob.Spec.JobTemplate.Spec.Template.Spec.Volumes -} - -// GetJobVolumes returns the Volumes of given job -func GetJobVolumes(item runtime.Object) []v1.Volume { - job, ok := item.(*batchv1.Job) - if !ok { - return []v1.Volume{} - } - return job.Spec.Template.Spec.Volumes -} - -// GetDaemonSetVolumes returns the Volumes of given daemonSet -func GetDaemonSetVolumes(item runtime.Object) []v1.Volume { - daemonSet, ok := item.(*appsv1.DaemonSet) - if !ok { - return []v1.Volume{} - } - return daemonSet.Spec.Template.Spec.Volumes -} - -// GetStatefulSetVolumes returns the Volumes of given statefulSet -func GetStatefulSetVolumes(item runtime.Object) []v1.Volume { - statefulSet, ok := item.(*appsv1.StatefulSet) - if !ok { - return []v1.Volume{} - } - return statefulSet.Spec.Template.Spec.Volumes -} - -// GetRolloutVolumes returns the Volumes of given rollout -func GetRolloutVolumes(item runtime.Object) []v1.Volume { - rollout, ok := item.(*argorolloutv1alpha1.Rollout) - if !ok { - return []v1.Volume{} - } - return rollout.Spec.Template.Spec.Volumes -} diff --git a/internal/pkg/callbacks/rolling_upgrade_test.go b/internal/pkg/callbacks/rolling_upgrade_test.go deleted file mode 100644 index 75583de45..000000000 --- a/internal/pkg/callbacks/rolling_upgrade_test.go +++ /dev/null @@ -1,773 +0,0 @@ -package callbacks_test - -import ( - "context" - "fmt" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - appsv1 "k8s.io/api/apps/v1" - batchv1 "k8s.io/api/batch/v1" - v1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - watch "k8s.io/apimachinery/pkg/watch" - "k8s.io/client-go/kubernetes/fake" - - argorolloutv1alpha1 "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1" - fakeargoclientset "github.com/argoproj/argo-rollouts/pkg/client/clientset/versioned/fake" - patchtypes "k8s.io/apimachinery/pkg/types" - - "github.com/stakater/Reloader/internal/pkg/callbacks" - "github.com/stakater/Reloader/internal/pkg/options" - "github.com/stakater/Reloader/internal/pkg/testutil" - "github.com/stakater/Reloader/pkg/kube" -) - -var ( - clients = setupTestClients() -) - -type testFixtures struct { - defaultContainers []v1.Container - defaultInitContainers []v1.Container - defaultVolumes []v1.Volume - namespace string -} - -func newTestFixtures() testFixtures { - return testFixtures{ - defaultContainers: []v1.Container{{Name: "container1"}, {Name: "container2"}}, - defaultInitContainers: []v1.Container{{Name: "init-container1"}, {Name: "init-container2"}}, - defaultVolumes: []v1.Volume{{Name: "volume1"}, {Name: "volume2"}}, - namespace: "default", - } -} - -func setupTestClients() kube.Clients { - return kube.Clients{ - KubernetesClient: fake.NewClientset(), - ArgoRolloutClient: fakeargoclientset.NewSimpleClientset(), - } -} - -// TestUpdateRollout test update rollout strategy annotation -func TestUpdateRollout(t *testing.T) { - namespace := "test-ns" - - cases := map[string]struct { - name string - strategy string - isRestart bool - }{ - "test-without-strategy": { - name: "defaults to rollout strategy", - strategy: "", - isRestart: false, - }, - "test-with-restart-strategy": { - name: "triggers a restart strategy", - strategy: "restart", - isRestart: true, - }, - "test-with-rollout-strategy": { - name: "triggers a rollout strategy", - strategy: "rollout", - isRestart: false, - }, - } - for name, tc := range cases { - t.Run(name, func(t *testing.T) { - rollout, err := testutil.CreateRollout( - clients.ArgoRolloutClient, name, namespace, - map[string]string{options.RolloutStrategyAnnotation: tc.strategy}, - ) - if err != nil { - t.Errorf("creating rollout: %v", err) - } - modifiedChan := watchRollout(rollout.Name, namespace) - - err = callbacks.UpdateRollout(clients, namespace, rollout) - if err != nil { - t.Errorf("updating rollout: %v", err) - } - rollout, err = clients.ArgoRolloutClient.ArgoprojV1alpha1().Rollouts( - namespace).Get(context.TODO(), rollout.Name, metav1.GetOptions{}) - - if err != nil { - t.Errorf("getting rollout: %v", err) - } - if isRestartStrategy(rollout) == tc.isRestart { - t.Errorf("Should not be a restart strategy") - } - select { - case <-modifiedChan: - // object has been modified - case <-time.After(1 * time.Second): - t.Errorf("Rollout has not been updated") - } - }) - } -} - -func TestPatchRollout(t *testing.T) { - namespace := "test-ns" - rollout := testutil.GetRollout(namespace, "test", map[string]string{options.RolloutStrategyAnnotation: ""}) - err := callbacks.PatchRollout(clients, namespace, rollout, patchtypes.StrategicMergePatchType, []byte(`{"spec": {}}`)) - assert.EqualError(t, err, "not supported patching: Rollout") -} - -func TestResourceItem(t *testing.T) { - fixtures := newTestFixtures() - - tests := []struct { - name string - createFunc func(kube.Clients, string, string) (runtime.Object, error) - getItemFunc func(kube.Clients, string, string) (runtime.Object, error) - deleteFunc func(kube.Clients, string, string) error - }{ - { - name: "Deployment", - createFunc: createTestDeploymentWithAnnotations, - getItemFunc: callbacks.GetDeploymentItem, - deleteFunc: deleteTestDeployment, - }, - { - name: "CronJob", - createFunc: createTestCronJobWithAnnotations, - getItemFunc: callbacks.GetCronJobItem, - deleteFunc: deleteTestCronJob, - }, - { - name: "Job", - createFunc: createTestJobWithAnnotations, - getItemFunc: callbacks.GetJobItem, - deleteFunc: deleteTestJob, - }, - { - name: "DaemonSet", - createFunc: createTestDaemonSetWithAnnotations, - getItemFunc: callbacks.GetDaemonSetItem, - deleteFunc: deleteTestDaemonSet, - }, - { - name: "StatefulSet", - createFunc: createTestStatefulSetWithAnnotations, - getItemFunc: callbacks.GetStatefulSetItem, - deleteFunc: deleteTestStatefulSet, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resource, err := tt.createFunc(clients, fixtures.namespace, "1") - assert.NoError(t, err) - - accessor, err := meta.Accessor(resource) - assert.NoError(t, err) - - _, err = tt.getItemFunc(clients, accessor.GetName(), fixtures.namespace) - assert.NoError(t, err) - - err = tt.deleteFunc(clients, fixtures.namespace, accessor.GetName()) - assert.NoError(t, err) - }) - } -} - -func TestResourceItems(t *testing.T) { - fixtures := newTestFixtures() - - tests := []struct { - name string - createFunc func(kube.Clients, string) error - getItemsFunc func(kube.Clients, string) []runtime.Object - deleteFunc func(kube.Clients, string) error - expectedCount int - }{ - { - name: "Deployments", - createFunc: createTestDeployments, - getItemsFunc: callbacks.GetDeploymentItems, - deleteFunc: deleteTestDeployments, - expectedCount: 2, - }, - { - name: "CronJobs", - createFunc: createTestCronJobs, - getItemsFunc: callbacks.GetCronJobItems, - deleteFunc: deleteTestCronJobs, - expectedCount: 2, - }, - { - name: "Jobs", - createFunc: createTestJobs, - getItemsFunc: callbacks.GetJobItems, - deleteFunc: deleteTestJobs, - expectedCount: 2, - }, - { - name: "DaemonSets", - createFunc: createTestDaemonSets, - getItemsFunc: callbacks.GetDaemonSetItems, - deleteFunc: deleteTestDaemonSets, - expectedCount: 2, - }, - { - name: "StatefulSets", - createFunc: createTestStatefulSets, - getItemsFunc: callbacks.GetStatefulSetItems, - deleteFunc: deleteTestStatefulSets, - expectedCount: 2, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := tt.createFunc(clients, fixtures.namespace) - assert.NoError(t, err) - - items := tt.getItemsFunc(clients, fixtures.namespace) - assert.Equal(t, tt.expectedCount, len(items)) - }) - } -} - -func TestGetAnnotations(t *testing.T) { - testAnnotations := map[string]string{"version": "1"} - - tests := []struct { - name string - resource runtime.Object - getFunc func(runtime.Object) map[string]string - }{ - {"Deployment", &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Annotations: testAnnotations}}, callbacks.GetDeploymentAnnotations}, - {"CronJob", &batchv1.CronJob{ObjectMeta: metav1.ObjectMeta{Annotations: testAnnotations}}, callbacks.GetCronJobAnnotations}, - {"Job", &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Annotations: testAnnotations}}, callbacks.GetJobAnnotations}, - {"DaemonSet", &appsv1.DaemonSet{ObjectMeta: metav1.ObjectMeta{Annotations: testAnnotations}}, callbacks.GetDaemonSetAnnotations}, - {"StatefulSet", &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{Annotations: testAnnotations}}, callbacks.GetStatefulSetAnnotations}, - {"Rollout", &argorolloutv1alpha1.Rollout{ObjectMeta: metav1.ObjectMeta{Annotations: testAnnotations}}, callbacks.GetRolloutAnnotations}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, testAnnotations, tt.getFunc(tt.resource)) - }) - } -} - -func TestGetPodAnnotations(t *testing.T) { - testAnnotations := map[string]string{"version": "1"} - - tests := []struct { - name string - resource runtime.Object - getFunc func(runtime.Object) map[string]string - }{ - {"Deployment", createResourceWithPodAnnotations(&appsv1.Deployment{}, testAnnotations), callbacks.GetDeploymentPodAnnotations}, - {"CronJob", createResourceWithPodAnnotations(&batchv1.CronJob{}, testAnnotations), callbacks.GetCronJobPodAnnotations}, - {"Job", createResourceWithPodAnnotations(&batchv1.Job{}, testAnnotations), callbacks.GetJobPodAnnotations}, - {"DaemonSet", createResourceWithPodAnnotations(&appsv1.DaemonSet{}, testAnnotations), callbacks.GetDaemonSetPodAnnotations}, - {"StatefulSet", createResourceWithPodAnnotations(&appsv1.StatefulSet{}, testAnnotations), callbacks.GetStatefulSetPodAnnotations}, - {"Rollout", createResourceWithPodAnnotations(&argorolloutv1alpha1.Rollout{}, testAnnotations), callbacks.GetRolloutPodAnnotations}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, testAnnotations, tt.getFunc(tt.resource)) - }) - } -} - -func TestGetContainers(t *testing.T) { - fixtures := newTestFixtures() - - tests := []struct { - name string - resource runtime.Object - getFunc func(runtime.Object) []v1.Container - }{ - {"Deployment", createResourceWithContainers(&appsv1.Deployment{}, fixtures.defaultContainers), callbacks.GetDeploymentContainers}, - {"DaemonSet", createResourceWithContainers(&appsv1.DaemonSet{}, fixtures.defaultContainers), callbacks.GetDaemonSetContainers}, - {"StatefulSet", createResourceWithContainers(&appsv1.StatefulSet{}, fixtures.defaultContainers), callbacks.GetStatefulSetContainers}, - {"CronJob", createResourceWithContainers(&batchv1.CronJob{}, fixtures.defaultContainers), callbacks.GetCronJobContainers}, - {"Job", createResourceWithContainers(&batchv1.Job{}, fixtures.defaultContainers), callbacks.GetJobContainers}, - {"Rollout", createResourceWithContainers(&argorolloutv1alpha1.Rollout{}, fixtures.defaultContainers), callbacks.GetRolloutContainers}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, fixtures.defaultContainers, tt.getFunc(tt.resource)) - }) - } -} - -func TestGetInitContainers(t *testing.T) { - fixtures := newTestFixtures() - - tests := []struct { - name string - resource runtime.Object - getFunc func(runtime.Object) []v1.Container - }{ - {"Deployment", createResourceWithInitContainers(&appsv1.Deployment{}, fixtures.defaultInitContainers), callbacks.GetDeploymentInitContainers}, - {"DaemonSet", createResourceWithInitContainers(&appsv1.DaemonSet{}, fixtures.defaultInitContainers), callbacks.GetDaemonSetInitContainers}, - {"StatefulSet", createResourceWithInitContainers(&appsv1.StatefulSet{}, fixtures.defaultInitContainers), callbacks.GetStatefulSetInitContainers}, - {"CronJob", createResourceWithInitContainers(&batchv1.CronJob{}, fixtures.defaultInitContainers), callbacks.GetCronJobInitContainers}, - {"Job", createResourceWithInitContainers(&batchv1.Job{}, fixtures.defaultInitContainers), callbacks.GetJobInitContainers}, - {"Rollout", createResourceWithInitContainers(&argorolloutv1alpha1.Rollout{}, fixtures.defaultInitContainers), callbacks.GetRolloutInitContainers}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, fixtures.defaultInitContainers, tt.getFunc(tt.resource)) - }) - } -} - -func TestUpdateResources(t *testing.T) { - fixtures := newTestFixtures() - - tests := []struct { - name string - createFunc func(kube.Clients, string, string) (runtime.Object, error) - updateFunc func(kube.Clients, string, runtime.Object) error - deleteFunc func(kube.Clients, string, string) error - }{ - {"Deployment", createTestDeploymentWithAnnotations, callbacks.UpdateDeployment, deleteTestDeployment}, - {"DaemonSet", createTestDaemonSetWithAnnotations, callbacks.UpdateDaemonSet, deleteTestDaemonSet}, - {"StatefulSet", createTestStatefulSetWithAnnotations, callbacks.UpdateStatefulSet, deleteTestStatefulSet}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resource, err := tt.createFunc(clients, fixtures.namespace, "1") - assert.NoError(t, err) - - err = tt.updateFunc(clients, fixtures.namespace, resource) - assert.NoError(t, err) - - accessor, err := meta.Accessor(resource) - assert.NoError(t, err) - - err = tt.deleteFunc(clients, fixtures.namespace, accessor.GetName()) - assert.NoError(t, err) - }) - } -} - -func TestPatchResources(t *testing.T) { - fixtures := newTestFixtures() - - tests := []struct { - name string - createFunc func(kube.Clients, string, string) (runtime.Object, error) - patchFunc func(kube.Clients, string, runtime.Object, patchtypes.PatchType, []byte) error - deleteFunc func(kube.Clients, string, string) error - assertFunc func(err error) - }{ - {"Deployment", createTestDeploymentWithAnnotations, callbacks.PatchDeployment, deleteTestDeployment, func(err error) { - assert.NoError(t, err) - patchedResource, err := callbacks.GetDeploymentItem(clients, "test-deployment", fixtures.namespace) - assert.NoError(t, err) - assert.Equal(t, "test", patchedResource.(*appsv1.Deployment).Annotations["test"]) - }}, - {"DaemonSet", createTestDaemonSetWithAnnotations, callbacks.PatchDaemonSet, deleteTestDaemonSet, func(err error) { - assert.NoError(t, err) - patchedResource, err := callbacks.GetDaemonSetItem(clients, "test-daemonset", fixtures.namespace) - assert.NoError(t, err) - assert.Equal(t, "test", patchedResource.(*appsv1.DaemonSet).Annotations["test"]) - }}, - {"StatefulSet", createTestStatefulSetWithAnnotations, callbacks.PatchStatefulSet, deleteTestStatefulSet, func(err error) { - assert.NoError(t, err) - patchedResource, err := callbacks.GetStatefulSetItem(clients, "test-statefulset", fixtures.namespace) - assert.NoError(t, err) - assert.Equal(t, "test", patchedResource.(*appsv1.StatefulSet).Annotations["test"]) - }}, - {"CronJob", createTestCronJobWithAnnotations, callbacks.PatchCronJob, deleteTestCronJob, func(err error) { - assert.EqualError(t, err, "not supported patching: CronJob") - }}, - {"Job", createTestJobWithAnnotations, callbacks.PatchJob, deleteTestJob, func(err error) { - assert.EqualError(t, err, "not supported patching: Job") - }}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resource, err := tt.createFunc(clients, fixtures.namespace, "1") - assert.NoError(t, err) - - err = tt.patchFunc(clients, fixtures.namespace, resource, patchtypes.StrategicMergePatchType, []byte(`{"metadata":{"annotations":{"test":"test"}}}`)) - tt.assertFunc(err) - - accessor, err := meta.Accessor(resource) - assert.NoError(t, err) - - err = tt.deleteFunc(clients, fixtures.namespace, accessor.GetName()) - assert.NoError(t, err) - }) - } -} - -func TestCreateJobFromCronjob(t *testing.T) { - fixtures := newTestFixtures() - - runtimeObj, err := createTestCronJobWithAnnotations(clients, fixtures.namespace, "1") - assert.NoError(t, err) - - cronJob := runtimeObj.(*batchv1.CronJob) - err = callbacks.CreateJobFromCronjob(clients, fixtures.namespace, cronJob) - assert.NoError(t, err) - - jobList, err := clients.KubernetesClient.BatchV1().Jobs(fixtures.namespace).List(context.TODO(), metav1.ListOptions{}) - assert.NoError(t, err) - - ownerFound := false - for _, job := range jobList.Items { - if isControllerOwner("CronJob", cronJob.Name, job.OwnerReferences) { - ownerFound = true - break - } - } - assert.Truef(t, ownerFound, "Missing CronJob owner reference") - - err = deleteTestCronJob(clients, fixtures.namespace, cronJob.Name) - assert.NoError(t, err) -} - -func TestReCreateJobFromJob(t *testing.T) { - fixtures := newTestFixtures() - - job, err := createTestJobWithAnnotations(clients, fixtures.namespace, "1") - assert.NoError(t, err) - - err = callbacks.ReCreateJobFromjob(clients, fixtures.namespace, job.(*batchv1.Job)) - assert.NoError(t, err) - - err = deleteTestJob(clients, fixtures.namespace, "test-job") - assert.NoError(t, err) -} - -func TestGetVolumes(t *testing.T) { - fixtures := newTestFixtures() - - tests := []struct { - name string - resource runtime.Object - getFunc func(runtime.Object) []v1.Volume - }{ - {"Deployment", createResourceWithVolumes(&appsv1.Deployment{}, fixtures.defaultVolumes), callbacks.GetDeploymentVolumes}, - {"CronJob", createResourceWithVolumes(&batchv1.CronJob{}, fixtures.defaultVolumes), callbacks.GetCronJobVolumes}, - {"Job", createResourceWithVolumes(&batchv1.Job{}, fixtures.defaultVolumes), callbacks.GetJobVolumes}, - {"DaemonSet", createResourceWithVolumes(&appsv1.DaemonSet{}, fixtures.defaultVolumes), callbacks.GetDaemonSetVolumes}, - {"StatefulSet", createResourceWithVolumes(&appsv1.StatefulSet{}, fixtures.defaultVolumes), callbacks.GetStatefulSetVolumes}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, fixtures.defaultVolumes, tt.getFunc(tt.resource)) - }) - } -} - -func TesGetPatchTemplateAnnotation(t *testing.T) { - templates := callbacks.GetPatchTemplates() - assert.NotEmpty(t, templates.AnnotationTemplate) - assert.Equal(t, 2, strings.Count(templates.AnnotationTemplate, "%s")) -} - -func TestGetPatchTemplateEnvVar(t *testing.T) { - templates := callbacks.GetPatchTemplates() - assert.NotEmpty(t, templates.EnvVarTemplate) - assert.Equal(t, 3, strings.Count(templates.EnvVarTemplate, "%s")) -} - -func TestGetPatchDeleteTemplateEnvVar(t *testing.T) { - templates := callbacks.GetPatchTemplates() - assert.NotEmpty(t, templates.DeleteEnvVarTemplate) - assert.Equal(t, 2, strings.Count(templates.DeleteEnvVarTemplate, "%d")) -} - -// Helper functions - -func isRestartStrategy(rollout *argorolloutv1alpha1.Rollout) bool { - return rollout.Spec.RestartAt == nil -} - -func watchRollout(name, namespace string) chan interface{} { - timeOut := int64(1) - modifiedChan := make(chan interface{}) - watcher, _ := clients.ArgoRolloutClient.ArgoprojV1alpha1().Rollouts(namespace).Watch(context.Background(), metav1.ListOptions{TimeoutSeconds: &timeOut}) - go watchModified(watcher, name, modifiedChan) - return modifiedChan -} - -func watchModified(watcher watch.Interface, name string, modifiedChan chan interface{}) { - for event := range watcher.ResultChan() { - item := event.Object.(*argorolloutv1alpha1.Rollout) - if item.Name == name { - switch event.Type { - case watch.Modified: - modifiedChan <- nil - } - return - } - } -} - -func createTestDeployments(clients kube.Clients, namespace string) error { - for i := 1; i <= 2; i++ { - _, err := testutil.CreateDeployment(clients.KubernetesClient, fmt.Sprintf("test-deployment-%d", i), namespace, false) - if err != nil { - return err - } - } - return nil -} - -func deleteTestDeployments(clients kube.Clients, namespace string) error { - for i := 1; i <= 2; i++ { - err := testutil.DeleteDeployment(clients.KubernetesClient, namespace, fmt.Sprintf("test-deployment-%d", i)) - if err != nil { - return err - } - } - return nil -} - -func createTestCronJobs(clients kube.Clients, namespace string) error { - for i := 1; i <= 2; i++ { - _, err := testutil.CreateCronJob(clients.KubernetesClient, fmt.Sprintf("test-cron-%d", i), namespace, false) - if err != nil { - return err - } - } - return nil -} - -func deleteTestCronJobs(clients kube.Clients, namespace string) error { - for i := 1; i <= 2; i++ { - err := testutil.DeleteCronJob(clients.KubernetesClient, namespace, fmt.Sprintf("test-cron-%d", i)) - if err != nil { - return err - } - } - return nil -} - -func createTestJobs(clients kube.Clients, namespace string) error { - for i := 1; i <= 2; i++ { - _, err := testutil.CreateJob(clients.KubernetesClient, fmt.Sprintf("test-job-%d", i), namespace, false) - if err != nil { - return err - } - } - return nil -} - -func deleteTestJobs(clients kube.Clients, namespace string) error { - for i := 1; i <= 2; i++ { - err := testutil.DeleteJob(clients.KubernetesClient, namespace, fmt.Sprintf("test-job-%d", i)) - if err != nil { - return err - } - } - return nil -} - -func createTestDaemonSets(clients kube.Clients, namespace string) error { - for i := 1; i <= 2; i++ { - _, err := testutil.CreateDaemonSet(clients.KubernetesClient, fmt.Sprintf("test-daemonset-%d", i), namespace, false) - if err != nil { - return err - } - } - return nil -} - -func deleteTestDaemonSets(clients kube.Clients, namespace string) error { - for i := 1; i <= 2; i++ { - err := testutil.DeleteDaemonSet(clients.KubernetesClient, namespace, fmt.Sprintf("test-daemonset-%d", i)) - if err != nil { - return err - } - } - return nil -} - -func createTestStatefulSets(clients kube.Clients, namespace string) error { - for i := 1; i <= 2; i++ { - _, err := testutil.CreateStatefulSet(clients.KubernetesClient, fmt.Sprintf("test-statefulset-%d", i), namespace, false) - if err != nil { - return err - } - } - return nil -} - -func deleteTestStatefulSets(clients kube.Clients, namespace string) error { - for i := 1; i <= 2; i++ { - err := testutil.DeleteStatefulSet(clients.KubernetesClient, namespace, fmt.Sprintf("test-statefulset-%d", i)) - if err != nil { - return err - } - } - return nil -} - -func createResourceWithPodAnnotations(obj runtime.Object, annotations map[string]string) runtime.Object { - switch v := obj.(type) { - case *appsv1.Deployment: - v.Spec.Template.Annotations = annotations - case *appsv1.DaemonSet: - v.Spec.Template.Annotations = annotations - case *appsv1.StatefulSet: - v.Spec.Template.Annotations = annotations - case *batchv1.CronJob: - v.Spec.JobTemplate.Spec.Template.Annotations = annotations - case *batchv1.Job: - v.Spec.Template.Annotations = annotations - case *argorolloutv1alpha1.Rollout: - v.Spec.Template.Annotations = annotations - } - return obj -} - -func createResourceWithContainers(obj runtime.Object, containers []v1.Container) runtime.Object { - switch v := obj.(type) { - case *appsv1.Deployment: - v.Spec.Template.Spec.Containers = containers - case *appsv1.DaemonSet: - v.Spec.Template.Spec.Containers = containers - case *appsv1.StatefulSet: - v.Spec.Template.Spec.Containers = containers - case *batchv1.CronJob: - v.Spec.JobTemplate.Spec.Template.Spec.Containers = containers - case *batchv1.Job: - v.Spec.Template.Spec.Containers = containers - case *argorolloutv1alpha1.Rollout: - v.Spec.Template.Spec.Containers = containers - } - return obj -} - -func createResourceWithInitContainers(obj runtime.Object, initContainers []v1.Container) runtime.Object { - switch v := obj.(type) { - case *appsv1.Deployment: - v.Spec.Template.Spec.InitContainers = initContainers - case *appsv1.DaemonSet: - v.Spec.Template.Spec.InitContainers = initContainers - case *appsv1.StatefulSet: - v.Spec.Template.Spec.InitContainers = initContainers - case *batchv1.CronJob: - v.Spec.JobTemplate.Spec.Template.Spec.InitContainers = initContainers - case *batchv1.Job: - v.Spec.Template.Spec.InitContainers = initContainers - case *argorolloutv1alpha1.Rollout: - v.Spec.Template.Spec.InitContainers = initContainers - } - return obj -} - -func createResourceWithVolumes(obj runtime.Object, volumes []v1.Volume) runtime.Object { - switch v := obj.(type) { - case *appsv1.Deployment: - v.Spec.Template.Spec.Volumes = volumes - case *batchv1.CronJob: - v.Spec.JobTemplate.Spec.Template.Spec.Volumes = volumes - case *batchv1.Job: - v.Spec.Template.Spec.Volumes = volumes - case *appsv1.DaemonSet: - v.Spec.Template.Spec.Volumes = volumes - case *appsv1.StatefulSet: - v.Spec.Template.Spec.Volumes = volumes - } - return obj -} - -func createTestDeploymentWithAnnotations(clients kube.Clients, namespace, version string) (runtime.Object, error) { - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment", - Namespace: namespace, - Annotations: map[string]string{"version": version}, - }, - } - return clients.KubernetesClient.AppsV1().Deployments(namespace).Create(context.TODO(), deployment, metav1.CreateOptions{}) -} - -func deleteTestDeployment(clients kube.Clients, namespace, name string) error { - return clients.KubernetesClient.AppsV1().Deployments(namespace).Delete(context.TODO(), name, metav1.DeleteOptions{}) -} - -func createTestDaemonSetWithAnnotations(clients kube.Clients, namespace, version string) (runtime.Object, error) { - daemonSet := &appsv1.DaemonSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-daemonset", - Namespace: namespace, - Annotations: map[string]string{"version": version}, - }, - } - return clients.KubernetesClient.AppsV1().DaemonSets(namespace).Create(context.TODO(), daemonSet, metav1.CreateOptions{}) -} - -func deleteTestDaemonSet(clients kube.Clients, namespace, name string) error { - return clients.KubernetesClient.AppsV1().DaemonSets(namespace).Delete(context.TODO(), name, metav1.DeleteOptions{}) -} - -func createTestStatefulSetWithAnnotations(clients kube.Clients, namespace, version string) (runtime.Object, error) { - statefulSet := &appsv1.StatefulSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-statefulset", - Namespace: namespace, - Annotations: map[string]string{"version": version}, - }, - } - return clients.KubernetesClient.AppsV1().StatefulSets(namespace).Create(context.TODO(), statefulSet, metav1.CreateOptions{}) -} - -func deleteTestStatefulSet(clients kube.Clients, namespace, name string) error { - return clients.KubernetesClient.AppsV1().StatefulSets(namespace).Delete(context.TODO(), name, metav1.DeleteOptions{}) -} - -func createTestCronJobWithAnnotations(clients kube.Clients, namespace, version string) (runtime.Object, error) { - cronJob := &batchv1.CronJob{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cronjob", - Namespace: namespace, - Annotations: map[string]string{"version": version}, - }, - } - return clients.KubernetesClient.BatchV1().CronJobs(namespace).Create(context.TODO(), cronJob, metav1.CreateOptions{}) -} - -func deleteTestCronJob(clients kube.Clients, namespace, name string) error { - return clients.KubernetesClient.BatchV1().CronJobs(namespace).Delete(context.TODO(), name, metav1.DeleteOptions{}) -} - -func createTestJobWithAnnotations(clients kube.Clients, namespace, version string) (runtime.Object, error) { - job := &batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-job", - Namespace: namespace, - Annotations: map[string]string{"version": version}, - }, - } - return clients.KubernetesClient.BatchV1().Jobs(namespace).Create(context.TODO(), job, metav1.CreateOptions{}) -} - -func deleteTestJob(clients kube.Clients, namespace, name string) error { - return clients.KubernetesClient.BatchV1().Jobs(namespace).Delete(context.TODO(), name, metav1.DeleteOptions{}) -} - -func isControllerOwner(kind, name string, ownerRefs []metav1.OwnerReference) bool { - for _, ownerRef := range ownerRefs { - if *ownerRef.Controller && ownerRef.Kind == kind && ownerRef.Name == name { - return true - } - } - return false -} diff --git a/internal/pkg/cmd/reloader.go b/internal/pkg/cmd/reloader.go deleted file mode 100644 index 00463fa77..000000000 --- a/internal/pkg/cmd/reloader.go +++ /dev/null @@ -1,225 +0,0 @@ -package cmd - -import ( - "context" - "errors" - "fmt" - "net/http" - _ "net/http/pprof" - "os" - "strings" - - "github.com/stakater/Reloader/internal/pkg/constants" - "github.com/stakater/Reloader/internal/pkg/leadership" - - "github.com/sirupsen/logrus" - "github.com/spf13/cobra" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - "github.com/stakater/Reloader/internal/pkg/controller" - "github.com/stakater/Reloader/internal/pkg/metrics" - "github.com/stakater/Reloader/internal/pkg/options" - "github.com/stakater/Reloader/internal/pkg/util" - "github.com/stakater/Reloader/pkg/common" - "github.com/stakater/Reloader/pkg/kube" -) - -// NewReloaderCommand starts the reloader controller -func NewReloaderCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "reloader", - Short: "A watcher for your Kubernetes cluster", - PreRunE: validateFlags, - Run: startReloader, - } - - // options - util.ConfigureReloaderFlags(cmd) - - return cmd -} - -func validateFlags(*cobra.Command, []string) error { - // Ensure the reload strategy is one of the following... - var validReloadStrategy bool - valid := []string{constants.EnvVarsReloadStrategy, constants.AnnotationsReloadStrategy} - for _, s := range valid { - if s == options.ReloadStrategy { - validReloadStrategy = true - } - } - - if !validReloadStrategy { - err := fmt.Sprintf("%s must be one of: %s", constants.ReloadStrategyFlag, strings.Join(valid, ", ")) - return errors.New(err) - } - - // Validate that HA options are correct - if options.EnableHA { - if err := validateHAEnvs(); err != nil { - return err - } - } - - return nil -} - -func configureLogging(logFormat, logLevel string) error { - switch logFormat { - case "json": - logrus.SetFormatter(&logrus.JSONFormatter{}) - default: - // just let the library use default on empty string. - if logFormat != "" { - return fmt.Errorf("unsupported logging formatter: %q", logFormat) - } - } - // set log level - level, err := logrus.ParseLevel(logLevel) - if err != nil { - return err - } - logrus.SetLevel(level) - return nil -} - -func validateHAEnvs() error { - podName, podNamespace := getHAEnvs() - - if podName == "" { - return fmt.Errorf("%s not set, cannot run in HA mode without %s set", constants.PodNameEnv, constants.PodNameEnv) - } - if podNamespace == "" { - return fmt.Errorf("%s not set, cannot run in HA mode without %s set", constants.PodNamespaceEnv, constants.PodNamespaceEnv) - } - return nil -} - -func getHAEnvs() (string, string) { - podName := os.Getenv(constants.PodNameEnv) - podNamespace := os.Getenv(constants.PodNamespaceEnv) - - return podName, podNamespace -} - -func startReloader(cmd *cobra.Command, args []string) { - common.GetCommandLineOptions() - err := configureLogging(options.LogFormat, options.LogLevel) - if err != nil { - logrus.Warn(err) - } - - logrus.Info("Starting Reloader") - isGlobal := false - currentNamespace := os.Getenv("KUBERNETES_NAMESPACE") - if len(currentNamespace) == 0 { - currentNamespace = v1.NamespaceAll - isGlobal = true - logrus.Warnf("KUBERNETES_NAMESPACE is unset, will detect changes in all namespaces.") - } - - // create the clientset - clientset, err := kube.GetKubernetesClient() - if err != nil { - logrus.Fatal(err) - } - - ignoredResourcesList, err := util.GetIgnoredResourcesList() - if err != nil { - logrus.Fatal(err) - } - - ignoredNamespacesList := options.NamespacesToIgnore - namespaceLabelSelector := "" - - if isGlobal { - namespaceLabelSelector, err = common.GetNamespaceLabelSelector(options.NamespaceSelectors) - if err != nil { - logrus.Fatal(err) - } - } - - resourceLabelSelector, err := common.GetResourceLabelSelector(options.ResourceSelectors) - if err != nil { - logrus.Fatal(err) - } - - if len(namespaceLabelSelector) > 0 { - logrus.Warnf("namespace-selector is set, will only detect changes in namespaces with these labels: %s.", namespaceLabelSelector) - } - - if len(resourceLabelSelector) > 0 { - logrus.Warnf("resource-label-selector is set, will only detect changes on resources with these labels: %s.", resourceLabelSelector) - } - - if options.WebhookUrl != "" { - logrus.Warnf("webhook-url is set, will only send webhook, no resources will be reloaded") - } - - collectors := metrics.SetupPrometheusEndpoint() - - var controllers []*controller.Controller - for k := range kube.ResourceMap { - if k == constants.SecretProviderClassController && !shouldRunCSIController() { - continue - } - - if ignoredResourcesList.Contains(k) || (len(namespaceLabelSelector) == 0 && k == "namespaces") { - continue - } - - c, err := controller.NewController(clientset, k, currentNamespace, ignoredNamespacesList, namespaceLabelSelector, resourceLabelSelector, collectors) - if err != nil { - logrus.Fatalf("%s", err) - } - - controllers = append(controllers, c) - - // If HA is enabled we only run the controller when - if options.EnableHA { - continue - } - // Now let's start the controller - stop := make(chan struct{}) - defer close(stop) - logrus.Infof("Starting Controller to watch resource type: %s", k) - go c.Run(1, stop) - } - - // Run leadership election - if options.EnableHA { - podName, podNamespace := getHAEnvs() - lock := leadership.GetNewLock(clientset.CoordinationV1(), constants.LockName, podName, podNamespace) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - leadership.RunLeaderElection(lock, ctx, cancel, podName, controllers) - } - - common.PublishMetaInfoConfigmap(clientset) - - if options.EnablePProf { - go startPProfServer() - } - - leadership.SetupLivenessEndpoint() - logrus.Fatal(http.ListenAndServe(constants.DefaultHttpListenAddr, nil)) -} - -func startPProfServer() { - logrus.Infof("Starting pprof server on %s", options.PProfAddr) - if err := http.ListenAndServe(options.PProfAddr, nil); err != nil { - logrus.Errorf("Failed to start pprof server: %v", err) - } -} - -func shouldRunCSIController() bool { - if !options.EnableCSIIntegration { - logrus.Info("Skipping secretproviderclasspodstatuses controller: EnableCSIIntegration is disabled") - return false - } - if !kube.IsCSIInstalled { - logrus.Info("Skipping secretproviderclasspodstatuses controller: CSI CRDs not installed") - return false - } - return true -} diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go new file mode 100644 index 000000000..c33b78adc --- /dev/null +++ b/internal/pkg/config/config.go @@ -0,0 +1,189 @@ +// Package config provides configuration management for Reloader. +package config + +import ( + "strings" + "time" + + "k8s.io/apimachinery/pkg/labels" +) + +// ReloadStrategy defines how Reloader triggers workload restarts. +type ReloadStrategy string + +const ( + ReloadStrategyEnvVars ReloadStrategy = "env-vars" + ReloadStrategyAnnotations ReloadStrategy = "annotations" +) + +// ArgoRolloutStrategy defines the strategy for Argo Rollout updates. +type ArgoRolloutStrategy string + +const ( + ArgoRolloutStrategyRestart ArgoRolloutStrategy = "restart" + ArgoRolloutStrategyRollout ArgoRolloutStrategy = "rollout" +) + +// Config holds all configuration for Reloader. +type Config struct { + Annotations AnnotationConfig `json:"annotations"` + AutoReloadAll bool `json:"autoReloadAll"` + ReloadStrategy ReloadStrategy `json:"reloadStrategy"` + ArgoRolloutsEnabled bool `json:"argoRolloutsEnabled"` + ArgoRolloutStrategy ArgoRolloutStrategy `json:"argoRolloutStrategy"` + DeploymentConfigEnabled bool `json:"deploymentConfigEnabled"` + ReloadOnCreate bool `json:"reloadOnCreate"` + ReloadOnDelete bool `json:"reloadOnDelete"` + SyncAfterRestart bool `json:"syncAfterRestart"` + EnableHA bool `json:"enableHA"` + WebhookURL string `json:"webhookUrl,omitempty"` + + IgnoredResources []string `json:"ignoredResources,omitempty"` + IgnoredWorkloads []string `json:"ignoredWorkloads,omitempty"` + IgnoredNamespaces []string `json:"ignoredNamespaces,omitempty"` + NamespaceSelectors []labels.Selector `json:"-"` + ResourceSelectors []labels.Selector `json:"-"` + NamespaceSelectorStrings []string `json:"namespaceSelectors,omitempty"` + ResourceSelectorStrings []string `json:"resourceSelectors,omitempty"` + + LogFormat string `json:"logFormat,omitempty"` + LogLevel string `json:"logLevel"` + MetricsAddr string `json:"metricsAddr"` + HealthAddr string `json:"healthAddr"` + EnablePProf bool `json:"enablePProf"` + PProfAddr string `json:"pprofAddr,omitempty"` + + Alerting AlertingConfig `json:"alerting"` + LeaderElection LeaderElectionConfig `json:"leaderElection"` + WatchedNamespace string `json:"watchedNamespace,omitempty"` + SyncPeriod time.Duration `json:"syncPeriod"` +} + +// AnnotationConfig holds customizable annotation keys. +type AnnotationConfig struct { + Prefix string `json:"prefix"` + Auto string `json:"auto"` + ConfigmapAuto string `json:"configmapAuto"` + SecretAuto string `json:"secretAuto"` + ConfigmapReload string `json:"configmapReload"` + SecretReload string `json:"secretReload"` + ConfigmapExclude string `json:"configmapExclude"` + SecretExclude string `json:"secretExclude"` + Ignore string `json:"ignore"` + Search string `json:"search"` + Match string `json:"match"` + RolloutStrategy string `json:"rolloutStrategy"` + PausePeriod string `json:"pausePeriod"` + PausedAt string `json:"pausedAt"` + LastReloadedFrom string `json:"lastReloadedFrom"` +} + +// AlertingConfig holds configuration for alerting integrations. +type AlertingConfig struct { + Enabled bool `json:"enabled"` + WebhookURL string `json:"webhookUrl,omitempty"` + Sink string `json:"sink,omitempty"` + Proxy string `json:"proxy,omitempty"` + Additional string `json:"additional,omitempty"` + Structured bool `json:"structured,omitempty"` // For raw sink: send structured JSON instead of plain text +} + +// LeaderElectionConfig holds configuration for leader election. +type LeaderElectionConfig struct { + LockName string `json:"lockName"` + Namespace string `json:"namespace,omitempty"` + Identity string `json:"identity,omitempty"` + LeaseDuration time.Duration `json:"leaseDuration"` + RenewDeadline time.Duration `json:"renewDeadline"` + RetryPeriod time.Duration `json:"retryPeriod"` + ReleaseOnCancel bool `json:"releaseOnCancel"` +} + +// NewDefault creates a Config with default values. +func NewDefault() *Config { + return &Config{ + Annotations: DefaultAnnotations(), + AutoReloadAll: false, + ReloadStrategy: ReloadStrategyEnvVars, + ArgoRolloutsEnabled: false, + ArgoRolloutStrategy: ArgoRolloutStrategyRollout, + DeploymentConfigEnabled: false, + ReloadOnCreate: false, + ReloadOnDelete: false, + SyncAfterRestart: false, + EnableHA: false, + WebhookURL: "", + IgnoredResources: []string{}, + IgnoredWorkloads: []string{}, + IgnoredNamespaces: []string{}, + NamespaceSelectors: []labels.Selector{}, + ResourceSelectors: []labels.Selector{}, + LogFormat: "", + LogLevel: "info", + MetricsAddr: ":9090", + HealthAddr: ":8080", + EnablePProf: false, + PProfAddr: ":6060", + Alerting: AlertingConfig{}, + LeaderElection: LeaderElectionConfig{ + LockName: "reloader-leader-election", + LeaseDuration: 15 * time.Second, + RenewDeadline: 10 * time.Second, + RetryPeriod: 2 * time.Second, + ReleaseOnCancel: true, + }, + WatchedNamespace: "", + SyncPeriod: 0, + } +} + +// DefaultAnnotations returns the default annotation configuration. +func DefaultAnnotations() AnnotationConfig { + return AnnotationConfig{ + Prefix: "reloader.stakater.com", + Auto: "reloader.stakater.com/auto", + ConfigmapAuto: "configmap.reloader.stakater.com/auto", + SecretAuto: "secret.reloader.stakater.com/auto", + ConfigmapReload: "configmap.reloader.stakater.com/reload", + SecretReload: "secret.reloader.stakater.com/reload", + ConfigmapExclude: "configmaps.exclude.reloader.stakater.com/reload", + SecretExclude: "secrets.exclude.reloader.stakater.com/reload", + Ignore: "reloader.stakater.com/ignore", + Search: "reloader.stakater.com/search", + Match: "reloader.stakater.com/match", + RolloutStrategy: "reloader.stakater.com/rollout-strategy", + PausePeriod: "deployment.reloader.stakater.com/pause-period", + PausedAt: "deployment.reloader.stakater.com/paused-at", + LastReloadedFrom: "reloader.stakater.com/last-reloaded-from", + } +} + +// IsResourceIgnored checks if a resource name should be ignored (case-insensitive). +func (c *Config) IsResourceIgnored(name string) bool { + for _, ignored := range c.IgnoredResources { + if strings.EqualFold(ignored, name) { + return true + } + } + return false +} + +// IsWorkloadIgnored checks if a workload type should be ignored (case-insensitive). +func (c *Config) IsWorkloadIgnored(workloadType string) bool { + for _, ignored := range c.IgnoredWorkloads { + if strings.EqualFold(ignored, workloadType) { + return true + } + } + return false +} + +// IsNamespaceIgnored checks if a namespace should be ignored. +func (c *Config) IsNamespaceIgnored(namespace string) bool { + for _, ignored := range c.IgnoredNamespaces { + if ignored == namespace { + return true + } + } + return false +} diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go new file mode 100644 index 000000000..f117ad609 --- /dev/null +++ b/internal/pkg/config/config_test.go @@ -0,0 +1,201 @@ +package config + +import ( + "testing" + "time" +) + +func TestNewDefault(t *testing.T) { + cfg := NewDefault() + + if cfg == nil { + t.Fatal("NewDefault() returned nil") + } + + if cfg.ReloadStrategy != ReloadStrategyEnvVars { + t.Errorf("ReloadStrategy = %v, want %v", cfg.ReloadStrategy, ReloadStrategyEnvVars) + } + + if cfg.ArgoRolloutStrategy != ArgoRolloutStrategyRollout { + t.Errorf("ArgoRolloutStrategy = %v, want %v", cfg.ArgoRolloutStrategy, ArgoRolloutStrategyRollout) + } + + if cfg.AutoReloadAll { + t.Error("AutoReloadAll should be false by default") + } + + if cfg.ArgoRolloutsEnabled { + t.Error("ArgoRolloutsEnabled should be false by default") + } + + if cfg.ReloadOnCreate { + t.Error("ReloadOnCreate should be false by default") + } + + if cfg.ReloadOnDelete { + t.Error("ReloadOnDelete should be false by default") + } + + if cfg.EnableHA { + t.Error("EnableHA should be false by default") + } + + if cfg.LogLevel != "info" { + t.Errorf("LogLevel = %q, want %q", cfg.LogLevel, "info") + } + + if cfg.MetricsAddr != ":9090" { + t.Errorf("MetricsAddr = %q, want %q", cfg.MetricsAddr, ":9090") + } + + if cfg.HealthAddr != ":8080" { + t.Errorf("HealthAddr = %q, want %q", cfg.HealthAddr, ":8080") + } + + if cfg.PProfAddr != ":6060" { + t.Errorf("PProfAddr = %q, want %q", cfg.PProfAddr, ":6060") + } +} + +func TestDefaultAnnotations(t *testing.T) { + ann := DefaultAnnotations() + + tests := []struct { + name string + got string + want string + }{ + {"Prefix", ann.Prefix, "reloader.stakater.com"}, + {"Auto", ann.Auto, "reloader.stakater.com/auto"}, + {"ConfigmapAuto", ann.ConfigmapAuto, "configmap.reloader.stakater.com/auto"}, + {"SecretAuto", ann.SecretAuto, "secret.reloader.stakater.com/auto"}, + {"ConfigmapReload", ann.ConfigmapReload, "configmap.reloader.stakater.com/reload"}, + {"SecretReload", ann.SecretReload, "secret.reloader.stakater.com/reload"}, + {"ConfigmapExclude", ann.ConfigmapExclude, "configmaps.exclude.reloader.stakater.com/reload"}, + {"SecretExclude", ann.SecretExclude, "secrets.exclude.reloader.stakater.com/reload"}, + {"Ignore", ann.Ignore, "reloader.stakater.com/ignore"}, + {"Search", ann.Search, "reloader.stakater.com/search"}, + {"Match", ann.Match, "reloader.stakater.com/match"}, + {"RolloutStrategy", ann.RolloutStrategy, "reloader.stakater.com/rollout-strategy"}, + {"PausePeriod", ann.PausePeriod, "deployment.reloader.stakater.com/pause-period"}, + {"PausedAt", ann.PausedAt, "deployment.reloader.stakater.com/paused-at"}, + {"LastReloadedFrom", ann.LastReloadedFrom, "reloader.stakater.com/last-reloaded-from"}, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + if tt.got != tt.want { + t.Errorf("%s = %q, want %q", tt.name, tt.got, tt.want) + } + }, + ) + } +} + +func TestDefaultLeaderElection(t *testing.T) { + cfg := NewDefault() + + if cfg.LeaderElection.LockName != "reloader-leader-election" { + t.Errorf("LockName = %q, want %q", cfg.LeaderElection.LockName, "reloader-leader-election") + } + + if cfg.LeaderElection.LeaseDuration != 15*time.Second { + t.Errorf("LeaseDuration = %v, want %v", cfg.LeaderElection.LeaseDuration, 15*time.Second) + } + + if cfg.LeaderElection.RenewDeadline != 10*time.Second { + t.Errorf("RenewDeadline = %v, want %v", cfg.LeaderElection.RenewDeadline, 10*time.Second) + } + + if cfg.LeaderElection.RetryPeriod != 2*time.Second { + t.Errorf("RetryPeriod = %v, want %v", cfg.LeaderElection.RetryPeriod, 2*time.Second) + } + + if !cfg.LeaderElection.ReleaseOnCancel { + t.Error("ReleaseOnCancel should be true by default") + } +} + +func TestConfig_IsResourceIgnored(t *testing.T) { + cfg := NewDefault() + cfg.IgnoredResources = []string{"configmaps", "secrets"} + + tests := []struct { + name string + resource string + want bool + }{ + {"exact match lowercase", "configmaps", true}, + {"exact match uppercase", "CONFIGMAPS", true}, + {"exact match mixed case", "ConfigMaps", true}, + {"not ignored", "deployments", false}, + {"partial match (not ignored)", "config", false}, + {"empty string", "", false}, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + got := cfg.IsResourceIgnored(tt.resource) + if got != tt.want { + t.Errorf("IsResourceIgnored(%q) = %v, want %v", tt.resource, got, tt.want) + } + }, + ) + } +} + +func TestConfig_IsWorkloadIgnored(t *testing.T) { + cfg := NewDefault() + cfg.IgnoredWorkloads = []string{"jobs", "cronjobs"} + + tests := []struct { + name string + workload string + want bool + }{ + {"exact match", "jobs", true}, + {"case insensitive", "JOBS", true}, + {"not ignored", "deployments", false}, + {"empty string", "", false}, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + got := cfg.IsWorkloadIgnored(tt.workload) + if got != tt.want { + t.Errorf("IsWorkloadIgnored(%q) = %v, want %v", tt.workload, got, tt.want) + } + }, + ) + } +} + +func TestConfig_IsNamespaceIgnored(t *testing.T) { + cfg := NewDefault() + cfg.IgnoredNamespaces = []string{"kube-system", "kube-public"} + + tests := []struct { + name string + namespace string + want bool + }{ + {"exact match", "kube-system", true}, + {"case sensitive no match", "Kube-System", false}, + {"not ignored", "default", false}, + {"empty string", "", false}, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + got := cfg.IsNamespaceIgnored(tt.namespace) + if got != tt.want { + t.Errorf("IsNamespaceIgnored(%q) = %v, want %v", tt.namespace, got, tt.want) + } + }, + ) + } +} diff --git a/internal/pkg/config/flags.go b/internal/pkg/config/flags.go new file mode 100644 index 000000000..195b84efa --- /dev/null +++ b/internal/pkg/config/flags.go @@ -0,0 +1,377 @@ +package config + +import ( + "fmt" + "strings" + "time" + + "github.com/spf13/pflag" + "github.com/spf13/viper" + "k8s.io/apimachinery/pkg/labels" +) + +// v is the viper instance for configuration. +var v *viper.Viper + +func init() { + v = viper.New() + // Convert flag names like "alert-webhook-url" to env vars like "ALERT_WEBHOOK_URL" + v.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + v.AutomaticEnv() +} + +// BindFlags binds configuration flags to the provided flag set. +// Call this before parsing flags, then call ApplyFlags after parsing. +func BindFlags(fs *pflag.FlagSet, cfg *Config) { + // Auto reload + fs.Bool( + "auto-reload-all", cfg.AutoReloadAll, + "Automatically reload all resources when their configmaps/secrets are updated, without requiring annotations", + ) + + // Reload strategy + fs.String( + "reload-strategy", string(cfg.ReloadStrategy), + "Strategy for triggering workload restart: 'env-vars' (default, GitOps friendly) or 'annotations'", + ) + + // Argo Rollouts + fs.String( + "is-Argo-Rollouts", "false", + "Enable Argo Rollouts support (true/false)", + ) + + // OpenShift DeploymentConfig + fs.String( + "is-openshift", "", + "Enable OpenShift DeploymentConfig support (true/false/auto). Empty or 'auto' enables auto-detection", + ) + + // Event watching + fs.String( + "reload-on-create", "false", + "Reload when configmaps/secrets are created (true/false)", + ) + fs.String( + "reload-on-delete", "false", + "Reload when configmaps/secrets are deleted (true/false)", + ) + + // Sync after restart + fs.Bool( + "sync-after-restart", cfg.SyncAfterRestart, + "Trigger sync operation after restart", + ) + + // High availability / Leader election + fs.Bool( + "enable-ha", cfg.EnableHA, + "Enable high-availability mode with leader election", + ) + fs.String( + "leader-election-id", cfg.LeaderElection.LockName, + "Name of the lease resource for leader election", + ) + fs.String( + "leader-election-namespace", cfg.LeaderElection.Namespace, + "Namespace for the leader election lease (defaults to pod namespace)", + ) + fs.Duration( + "leader-election-lease-duration", cfg.LeaderElection.LeaseDuration, + "Duration that non-leader candidates will wait before attempting to acquire leadership", + ) + fs.Duration( + "leader-election-renew-deadline", cfg.LeaderElection.RenewDeadline, + "Duration that the acting leader will retry refreshing leadership before giving up", + ) + fs.Duration( + "leader-election-retry-period", cfg.LeaderElection.RetryPeriod, + "Duration between leader election retries", + ) + fs.Bool( + "leader-election-release-on-cancel", cfg.LeaderElection.ReleaseOnCancel, + "Release the leader lock when the manager is stopped", + ) + + // Webhook + fs.String( + "webhook-url", cfg.WebhookURL, + "URL to send notification instead of triggering reload", + ) + + // Filtering - resources + fs.String( + "resources-to-ignore", "", + "Comma-separated list of resources to ignore (valid options: 'configMaps' or 'secrets')", + ) + fs.String( + "ignored-workload-types", "", + "Comma-separated list of workload types to ignore (valid options: 'jobs', 'cronjobs', or both)", + ) + fs.String( + "namespaces-to-ignore", "", + "Comma-separated list of namespaces to ignore", + ) + + // Filtering - selectors + fs.StringSlice( + "namespace-selector", nil, + "Namespace label selectors (can be specified multiple times)", + ) + fs.StringSlice( + "resource-label-selector", nil, + "Resource label selectors (can be specified multiple times)", + ) + + // Logging + fs.String( + "log-format", cfg.LogFormat, + "Log format: 'json' or empty for default", + ) + fs.String( + "log-level", cfg.LogLevel, + "Log level: trace, debug, info, warning, error, fatal, panic", + ) + + // Metrics + fs.String( + "metrics-addr", cfg.MetricsAddr, + "Address to serve metrics on", + ) + + // Health probes + fs.String( + "health-addr", cfg.HealthAddr, + "Address to serve health probes on", + ) + + // Profiling + fs.Bool( + "enable-pprof", cfg.EnablePProf, + "Enable pprof profiling server", + ) + fs.String( + "pprof-addr", cfg.PProfAddr, + "Address for pprof server", + ) + + // Annotation customization (flag names match v1 for backward compatibility) + fs.String( + "auto-annotation", cfg.Annotations.Auto, + "Annotation to detect changes in secrets/configmaps", + ) + fs.String( + "configmap-auto-annotation", cfg.Annotations.ConfigmapAuto, + "Annotation to detect changes in configmaps", + ) + fs.String( + "secret-auto-annotation", cfg.Annotations.SecretAuto, + "Annotation to detect changes in secrets", + ) + fs.String( + "configmap-annotation", cfg.Annotations.ConfigmapReload, + "Annotation to detect changes in configmaps, specified by name", + ) + fs.String( + "secret-annotation", cfg.Annotations.SecretReload, + "Annotation to detect changes in secrets, specified by name", + ) + fs.String( + "auto-search-annotation", cfg.Annotations.Search, + "Annotation to detect changes in configmaps or secrets tagged with special match annotation", + ) + fs.String( + "search-match-annotation", cfg.Annotations.Match, + "Annotation to mark secrets or configmaps to match the search", + ) + fs.String( + "pause-deployment-annotation", cfg.Annotations.PausePeriod, + "Annotation to define the time period to pause a deployment after a configmap/secret change", + ) + fs.String( + "pause-deployment-time-annotation", cfg.Annotations.PausedAt, + "Annotation to indicate when a deployment was paused by Reloader", + ) + + // Watched namespace (for single-namespace mode) + fs.String( + "watch-namespace", cfg.WatchedNamespace, + "Namespace to watch (empty for all namespaces)", + ) + + // Alerting + fs.Bool( + "alert-on-reload", cfg.Alerting.Enabled, + "Enable sending alerts when resources are reloaded", + ) + fs.String( + "alert-webhook-url", cfg.Alerting.WebhookURL, + "Webhook URL to send alerts to", + ) + fs.String( + "alert-sink", cfg.Alerting.Sink, + "Alert sink type: 'slack', 'teams', 'gchat', or 'raw' (default)", + ) + fs.String( + "alert-proxy", cfg.Alerting.Proxy, + "Proxy URL for alert webhook requests", + ) + fs.String( + "alert-additional-info", cfg.Alerting.Additional, + "Additional info to include in alerts (e.g., cluster name)", + ) + fs.Bool( + "alert-structured", cfg.Alerting.Structured, + "For raw sink: send structured JSON instead of plain text", + ) + + // Bind pflags to viper + _ = v.BindPFlags(fs) + + // Bind legacy env var names that don't match the automatic conversion + // (flag "alert-proxy" -> env "ALERT_PROXY", but legacy is "ALERT_WEBHOOK_PROXY") + _ = v.BindEnv("alert-proxy", "ALERT_PROXY", "ALERT_WEBHOOK_PROXY") +} + +// ApplyFlags applies flag values from viper to the config struct. +// Call this after parsing flags. +func ApplyFlags(cfg *Config) error { + // Boolean flags + cfg.AutoReloadAll = v.GetBool("auto-reload-all") + cfg.SyncAfterRestart = v.GetBool("sync-after-restart") + cfg.EnableHA = v.GetBool("enable-ha") + cfg.EnablePProf = v.GetBool("enable-pprof") + + // Boolean string flags (legacy format: "true"/"false" strings) + cfg.ArgoRolloutsEnabled = parseBoolString(v.GetString("is-Argo-Rollouts")) + cfg.ReloadOnCreate = parseBoolString(v.GetString("reload-on-create")) + cfg.ReloadOnDelete = parseBoolString(v.GetString("reload-on-delete")) + + switch strings.ToLower(strings.TrimSpace(v.GetString("is-openshift"))) { + case "true": + cfg.DeploymentConfigEnabled = true + case "false": + cfg.DeploymentConfigEnabled = false + default: + } + + // String flags + cfg.ReloadStrategy = ReloadStrategy(v.GetString("reload-strategy")) + cfg.WebhookURL = v.GetString("webhook-url") + cfg.LogFormat = v.GetString("log-format") + cfg.LogLevel = v.GetString("log-level") + cfg.MetricsAddr = v.GetString("metrics-addr") + cfg.HealthAddr = v.GetString("health-addr") + cfg.PProfAddr = v.GetString("pprof-addr") + cfg.WatchedNamespace = v.GetString("watch-namespace") + if cfg.WatchedNamespace == "" { + cfg.WatchedNamespace = v.GetString("KUBERNETES_NAMESPACE") + } + + // Leader election + cfg.LeaderElection.LockName = v.GetString("leader-election-id") + cfg.LeaderElection.Namespace = v.GetString("leader-election-namespace") + cfg.LeaderElection.LeaseDuration = v.GetDuration("leader-election-lease-duration") + cfg.LeaderElection.RenewDeadline = v.GetDuration("leader-election-renew-deadline") + cfg.LeaderElection.RetryPeriod = v.GetDuration("leader-election-retry-period") + cfg.LeaderElection.ReleaseOnCancel = v.GetBool("leader-election-release-on-cancel") + + // Annotations + cfg.Annotations.Auto = v.GetString("auto-annotation") + cfg.Annotations.ConfigmapAuto = v.GetString("configmap-auto-annotation") + cfg.Annotations.SecretAuto = v.GetString("secret-auto-annotation") + cfg.Annotations.ConfigmapReload = v.GetString("configmap-annotation") + cfg.Annotations.SecretReload = v.GetString("secret-annotation") + cfg.Annotations.Search = v.GetString("auto-search-annotation") + cfg.Annotations.Match = v.GetString("search-match-annotation") + cfg.Annotations.PausePeriod = v.GetString("pause-deployment-annotation") + cfg.Annotations.PausedAt = v.GetString("pause-deployment-time-annotation") + + // Alerting + cfg.Alerting.Enabled = v.GetBool("alert-on-reload") + cfg.Alerting.WebhookURL = v.GetString("alert-webhook-url") + cfg.Alerting.Sink = strings.ToLower(v.GetString("alert-sink")) + cfg.Alerting.Proxy = v.GetString("alert-proxy") + cfg.Alerting.Additional = v.GetString("alert-additional-info") + cfg.Alerting.Structured = v.GetBool("alert-structured") + + // Special case: if webhook URL is set, auto-enable alerting + if cfg.Alerting.WebhookURL != "" { + cfg.Alerting.Enabled = true + } + + // Parse comma-separated lists + cfg.IgnoredResources = splitAndTrim(v.GetString("resources-to-ignore")) + cfg.IgnoredWorkloads = splitAndTrim(v.GetString("ignored-workload-types")) + cfg.IgnoredNamespaces = splitAndTrim(v.GetString("namespaces-to-ignore")) + + // Get selector slices and join with comma + nsSelectors := v.GetStringSlice("namespace-selector") + resSelectors := v.GetStringSlice("resource-label-selector") + + if len(nsSelectors) > 0 { + cfg.NamespaceSelectorStrings = nsSelectors + } + if len(resSelectors) > 0 { + cfg.ResourceSelectorStrings = resSelectors + } + + if len(nsSelectors) > 0 { + joinedNS := strings.Join(nsSelectors, ",") + selector, err := labels.Parse(joinedNS) + if err != nil { + return fmt.Errorf("invalid selector %q: %w", joinedNS, err) + } + cfg.NamespaceSelectors = []labels.Selector{selector} + } + if len(resSelectors) > 0 { + joinedRes := strings.Join(resSelectors, ",") + selector, err := labels.Parse(joinedRes) + if err != nil { + return fmt.Errorf("invalid selector %q: %w", joinedRes, err) + } + cfg.ResourceSelectors = []labels.Selector{selector} + } + + // Ensure duration defaults are preserved if not set + if cfg.LeaderElection.LeaseDuration == 0 { + cfg.LeaderElection.LeaseDuration = 15 * time.Second + } + if cfg.LeaderElection.RenewDeadline == 0 { + cfg.LeaderElection.RenewDeadline = 10 * time.Second + } + if cfg.LeaderElection.RetryPeriod == 0 { + cfg.LeaderElection.RetryPeriod = 2 * time.Second + } + + return nil +} + +// parseBoolString parses a string as a boolean, defaulting to false. +func parseBoolString(s string) bool { + s = strings.ToLower(strings.TrimSpace(s)) + return s == "true" || s == "1" || s == "yes" +} + +// ShouldAutoDetectOpenShift returns true if OpenShift DeploymentConfig support +// should be auto-detected (i.e., the --is-openshift flag was not explicitly set). +func ShouldAutoDetectOpenShift() bool { + val := strings.ToLower(strings.TrimSpace(v.GetString("is-openshift"))) + return val == "" || val == "auto" +} + +// splitAndTrim splits a comma-separated string and trims whitespace. +func splitAndTrim(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + result = append(result, p) + } + } + return result +} diff --git a/internal/pkg/config/flags_test.go b/internal/pkg/config/flags_test.go new file mode 100644 index 000000000..6a5b3fb26 --- /dev/null +++ b/internal/pkg/config/flags_test.go @@ -0,0 +1,435 @@ +package config + +import ( + "strings" + "testing" + + "github.com/spf13/pflag" + "github.com/spf13/viper" +) + +// resetViper resets the viper instance for testing. +func resetViper() { + v = viper.New() + v.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + v.AutomaticEnv() +} + +func TestBindFlags(t *testing.T) { + resetViper() + cfg := NewDefault() + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + + BindFlags(fs, cfg) + + expectedFlags := []string{ + "auto-reload-all", + "reload-strategy", + "is-Argo-Rollouts", + "reload-on-create", + "reload-on-delete", + "sync-after-restart", + "enable-ha", + "leader-election-id", + "leader-election-namespace", + "leader-election-lease-duration", + "leader-election-renew-deadline", + "leader-election-retry-period", + "leader-election-release-on-cancel", + "webhook-url", + "resources-to-ignore", + "ignored-workload-types", + "namespaces-to-ignore", + "namespace-selector", + "resource-label-selector", + "log-format", + "log-level", + "metrics-addr", + "health-addr", + "enable-pprof", + "pprof-addr", + "auto-annotation", + "configmap-auto-annotation", + "secret-auto-annotation", + "configmap-annotation", + "secret-annotation", + "auto-search-annotation", + "search-match-annotation", + "pause-deployment-annotation", + "pause-deployment-time-annotation", + "watch-namespace", + "alert-on-reload", + "alert-webhook-url", + "alert-sink", + "alert-proxy", + "alert-additional-info", + "alert-structured", + } + + for _, flagName := range expectedFlags { + if fs.Lookup(flagName) == nil { + t.Errorf("Expected flag %q to be registered", flagName) + } + } +} + +func TestBindFlags_DefaultValues(t *testing.T) { + resetViper() + cfg := NewDefault() + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + + BindFlags(fs, cfg) + + if err := fs.Parse([]string{}); err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if err := ApplyFlags(cfg); err != nil { + t.Fatalf("ApplyFlags() error = %v", err) + } + + if cfg.ReloadStrategy != ReloadStrategyEnvVars { + t.Errorf("ReloadStrategy = %v, want %v", cfg.ReloadStrategy, ReloadStrategyEnvVars) + } + + if cfg.LogLevel != "info" { + t.Errorf("LogLevel = %q, want %q", cfg.LogLevel, "info") + } +} + +func TestBindFlags_CustomValues(t *testing.T) { + resetViper() + cfg := NewDefault() + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + + BindFlags(fs, cfg) + + args := []string{ + "--auto-reload-all=true", + "--reload-strategy=annotations", + "--log-level=debug", + "--log-format=json", + "--webhook-url=https://example.com/hook", + "--enable-ha=true", + "--enable-pprof=true", + } + + if err := fs.Parse(args); err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if err := ApplyFlags(cfg); err != nil { + t.Fatalf("ApplyFlags() error = %v", err) + } + + if !cfg.AutoReloadAll { + t.Error("AutoReloadAll should be true") + } + + if cfg.ReloadStrategy != ReloadStrategyAnnotations { + t.Errorf("ReloadStrategy = %v, want %v", cfg.ReloadStrategy, ReloadStrategyAnnotations) + } + + if cfg.LogLevel != "debug" { + t.Errorf("LogLevel = %q, want %q", cfg.LogLevel, "debug") + } + + if cfg.LogFormat != "json" { + t.Errorf("LogFormat = %q, want %q", cfg.LogFormat, "json") + } + + if cfg.WebhookURL != "https://example.com/hook" { + t.Errorf("WebhookURL = %q, want %q", cfg.WebhookURL, "https://example.com/hook") + } + + if !cfg.EnableHA { + t.Error("EnableHA should be true") + } + + if !cfg.EnablePProf { + t.Error("EnablePProf should be true") + } +} + +func TestApplyFlags_BooleanStrings(t *testing.T) { + tests := []struct { + name string + args []string + want bool + wantErr bool + }{ + {"true lowercase", []string{"--is-Argo-Rollouts=true"}, true, false}, + {"TRUE uppercase", []string{"--is-Argo-Rollouts=TRUE"}, true, false}, + {"1", []string{"--is-Argo-Rollouts=1"}, true, false}, + {"yes", []string{"--is-Argo-Rollouts=yes"}, true, false}, + {"false", []string{"--is-Argo-Rollouts=false"}, false, false}, + {"no", []string{"--is-Argo-Rollouts=no"}, false, false}, + {"0", []string{"--is-Argo-Rollouts=0"}, false, false}, + {"empty", []string{}, false, false}, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + resetViper() + cfg := NewDefault() + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + BindFlags(fs, cfg) + + if err := fs.Parse(tt.args); err != nil { + t.Fatalf("Parse() error = %v", err) + } + + err := ApplyFlags(cfg) + if (err != nil) != tt.wantErr { + t.Errorf("ApplyFlags() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if cfg.ArgoRolloutsEnabled != tt.want { + t.Errorf("ArgoRolloutsEnabled = %v, want %v", cfg.ArgoRolloutsEnabled, tt.want) + } + }, + ) + } +} + +func TestApplyFlags_CommaSeparatedLists(t *testing.T) { + resetViper() + cfg := NewDefault() + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + BindFlags(fs, cfg) + + args := []string{ + "--resources-to-ignore=configMaps,secrets", + "--ignored-workload-types=jobs,cronjobs", + "--namespaces-to-ignore=kube-system,kube-public", + } + + if err := fs.Parse(args); err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if err := ApplyFlags(cfg); err != nil { + t.Fatalf("ApplyFlags() error = %v", err) + } + + if len(cfg.IgnoredResources) != 2 { + t.Errorf("IgnoredResources length = %d, want 2", len(cfg.IgnoredResources)) + } + if cfg.IgnoredResources[0] != "configMaps" || cfg.IgnoredResources[1] != "secrets" { + t.Errorf("IgnoredResources = %v", cfg.IgnoredResources) + } + + if len(cfg.IgnoredWorkloads) != 2 { + t.Errorf("IgnoredWorkloads length = %d, want 2", len(cfg.IgnoredWorkloads)) + } + + if len(cfg.IgnoredNamespaces) != 2 { + t.Errorf("IgnoredNamespaces length = %d, want 2", len(cfg.IgnoredNamespaces)) + } +} + +func TestApplyFlags_Selectors(t *testing.T) { + resetViper() + cfg := NewDefault() + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + BindFlags(fs, cfg) + + args := []string{ + "--namespace-selector=env=production,team=platform", + "--resource-label-selector=app=myapp", + } + + if err := fs.Parse(args); err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if err := ApplyFlags(cfg); err != nil { + t.Fatalf("ApplyFlags() error = %v", err) + } + + if len(cfg.NamespaceSelectors) != 1 { + t.Errorf("NamespaceSelectors length = %d, want 1", len(cfg.NamespaceSelectors)) + } + + if len(cfg.ResourceSelectors) != 1 { + t.Errorf("ResourceSelectors length = %d, want 1", len(cfg.ResourceSelectors)) + } + + if len(cfg.NamespaceSelectorStrings) != 2 { + t.Errorf("NamespaceSelectorStrings length = %d, want 2", len(cfg.NamespaceSelectorStrings)) + } +} + +func TestApplyFlags_InvalidSelector(t *testing.T) { + resetViper() + cfg := NewDefault() + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + BindFlags(fs, cfg) + + args := []string{ + "--namespace-selector=env in (prod,staging", // missing closing paren + } + + if err := fs.Parse(args); err != nil { + t.Fatalf("Parse() error = %v", err) + } + + err := ApplyFlags(cfg) + if err == nil { + t.Error("ApplyFlags() should return error for invalid selector") + } +} + +func TestApplyFlags_AlertingEnvVars(t *testing.T) { + tests := []struct { + name string + envVars map[string]string + wantURL string + wantSink string + wantEnable bool + }{ + { + name: "ALERT_WEBHOOK_URL enables alerting", + envVars: map[string]string{ + "ALERT_WEBHOOK_URL": "https://hooks.example.com", + }, + wantURL: "https://hooks.example.com", + wantEnable: true, + }, + { + name: "all alert env vars", + envVars: map[string]string{ + "ALERT_WEBHOOK_URL": "https://hooks.example.com", + "ALERT_SINK": "slack", + "ALERT_WEBHOOK_PROXY": "http://proxy:8080", + }, + wantURL: "https://hooks.example.com", + wantSink: "slack", + wantEnable: true, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + resetViper() + + for k, val := range tt.envVars { + t.Setenv(k, val) + } + + cfg := NewDefault() + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + BindFlags(fs, cfg) + + if err := fs.Parse([]string{}); err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if err := ApplyFlags(cfg); err != nil { + t.Fatalf("ApplyFlags() error = %v", err) + } + + if cfg.Alerting.WebhookURL != tt.wantURL { + t.Errorf("Alerting.WebhookURL = %q, want %q", cfg.Alerting.WebhookURL, tt.wantURL) + } + + if tt.wantSink != "" && cfg.Alerting.Sink != tt.wantSink { + t.Errorf("Alerting.Sink = %q, want %q", cfg.Alerting.Sink, tt.wantSink) + } + + if cfg.Alerting.Enabled != tt.wantEnable { + t.Errorf("Alerting.Enabled = %v, want %v", cfg.Alerting.Enabled, tt.wantEnable) + } + }, + ) + } +} + +func TestApplyFlags_LegacyProxyEnvVar(t *testing.T) { + resetViper() + + t.Setenv("ALERT_WEBHOOK_PROXY", "http://legacy-proxy:8080") + + cfg := NewDefault() + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + BindFlags(fs, cfg) + + if err := fs.Parse([]string{}); err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if err := ApplyFlags(cfg); err != nil { + t.Fatalf("ApplyFlags() error = %v", err) + } + + if cfg.Alerting.Proxy != "http://legacy-proxy:8080" { + t.Errorf("Alerting.Proxy = %q, want %q", cfg.Alerting.Proxy, "http://legacy-proxy:8080") + } +} + +func TestParseBoolString(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"true", true}, + {"TRUE", true}, + {"True", true}, + {" true ", true}, + {"1", true}, + {"yes", true}, + {"YES", true}, + {"false", false}, + {"FALSE", false}, + {"0", false}, + {"no", false}, + {"", false}, + {"invalid", false}, + } + + for _, tt := range tests { + t.Run( + tt.input, func(t *testing.T) { + got := parseBoolString(tt.input) + if got != tt.want { + t.Errorf("parseBoolString(%q) = %v, want %v", tt.input, got, tt.want) + } + }, + ) + } +} + +func TestSplitAndTrim(t *testing.T) { + tests := []struct { + name string + input string + want []string + }{ + {"empty string", "", nil}, + {"single value", "abc", []string{"abc"}}, + {"multiple values", "a,b,c", []string{"a", "b", "c"}}, + {"with spaces", " a , b , c ", []string{"a", "b", "c"}}, + {"empty elements", "a,,b", []string{"a", "b"}}, + {"only commas", ",,,", []string{}}, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + got := splitAndTrim(tt.input) + if len(got) != len(tt.want) { + t.Errorf("splitAndTrim(%q) length = %d, want %d", tt.input, len(got), len(tt.want)) + return + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("splitAndTrim(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i]) + } + } + }, + ) + } +} diff --git a/internal/pkg/config/validation.go b/internal/pkg/config/validation.go new file mode 100644 index 000000000..b3d2695b7 --- /dev/null +++ b/internal/pkg/config/validation.go @@ -0,0 +1,160 @@ +package config + +import ( + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/labels" + + "github.com/stakater/Reloader/internal/pkg/workload" +) + +// ValidationError represents a configuration validation error. +type ValidationError struct { + Field string + Message string +} + +func (e ValidationError) Error() string { + return fmt.Sprintf("config.%s: %s", e.Field, e.Message) +} + +// ValidationErrors is a collection of validation errors. +type ValidationErrors []ValidationError + +func (e ValidationErrors) Error() string { + if len(e) == 0 { + return "" + } + if len(e) == 1 { + return e[0].Error() + } + var b strings.Builder + b.WriteString("multiple configuration errors:\n") + for _, err := range e { + b.WriteString(" - ") + b.WriteString(err.Error()) + b.WriteString("\n") + } + return b.String() +} + +// Validate checks the configuration for errors and normalizes values. +func (c *Config) Validate() error { + var errs ValidationErrors + + switch c.ReloadStrategy { + case ReloadStrategyEnvVars, ReloadStrategyAnnotations: + // valid + case "": + c.ReloadStrategy = ReloadStrategyEnvVars + default: + errs = append( + errs, ValidationError{ + Field: "ReloadStrategy", + Message: fmt.Sprintf("invalid value %q, must be %q or %q", c.ReloadStrategy, ReloadStrategyEnvVars, ReloadStrategyAnnotations), + }, + ) + } + + switch c.ArgoRolloutStrategy { + case ArgoRolloutStrategyRestart, ArgoRolloutStrategyRollout: + // valid + case "": + c.ArgoRolloutStrategy = ArgoRolloutStrategyRollout + default: + errs = append( + errs, ValidationError{ + Field: "ArgoRolloutStrategy", + Message: fmt.Sprintf( + "invalid value %q, must be %q or %q", c.ArgoRolloutStrategy, ArgoRolloutStrategyRestart, ArgoRolloutStrategyRollout, + ), + }, + ) + } + + switch strings.ToLower(c.LogLevel) { + case "trace", "debug", "info", "warn", "warning", "error", "fatal", "panic", "": + // valid + default: + errs = append( + errs, ValidationError{ + Field: "LogLevel", + Message: fmt.Sprintf("invalid log level %q", c.LogLevel), + }, + ) + } + + switch strings.ToLower(c.LogFormat) { + case "json", "": + // valid + default: + errs = append( + errs, ValidationError{ + Field: "LogFormat", + Message: fmt.Sprintf("invalid log format %q, must be \"json\" or empty", c.LogFormat), + }, + ) + } + + c.IgnoredResources = normalizeToLower(c.IgnoredResources) + + // Normalize ignored workloads to canonical Kind values (e.g., "cronjobs" -> "CronJob") + c.IgnoredWorkloads = normalizeToLower(c.IgnoredWorkloads) + normalizedWorkloads := make([]string, 0, len(c.IgnoredWorkloads)) + for _, w := range c.IgnoredWorkloads { + kind, err := workload.KindFromString(w) + if err != nil { + errs = append( + errs, ValidationError{ + Field: "IgnoredWorkloads", + Message: fmt.Sprintf("unknown workload type %q", w), + }, + ) + } else { + normalizedWorkloads = append(normalizedWorkloads, string(kind)) + } + } + c.IgnoredWorkloads = normalizedWorkloads + + if len(errs) > 0 { + return errs + } + return nil +} + +// normalizeToLower converts all strings in the slice to lowercase and removes empty strings. +func normalizeToLower(items []string) []string { + if len(items) == 0 { + return items + } + result := make([]string, 0, len(items)) + for _, item := range items { + item = strings.TrimSpace(strings.ToLower(item)) + if item != "" { + result = append(result, item) + } + } + return result +} + +// ParseSelectors parses a slice of selector strings into label selectors. +func ParseSelectors(selectorStrings []string) ([]labels.Selector, error) { + if len(selectorStrings) == 0 { + return nil, nil + } + + selectors := make([]labels.Selector, 0, len(selectorStrings)) + for _, s := range selectorStrings { + s = strings.TrimSpace(s) + if s == "" { + continue + } + selector, err := labels.Parse(s) + if err != nil { + return nil, fmt.Errorf("invalid selector %q: %w", s, err) + } + selectors = append(selectors, selector) + } + return selectors, nil +} diff --git a/internal/pkg/config/validation_test.go b/internal/pkg/config/validation_test.go new file mode 100644 index 000000000..52dc6f000 --- /dev/null +++ b/internal/pkg/config/validation_test.go @@ -0,0 +1,339 @@ +package config + +import ( + "errors" + "strings" + "testing" +) + +func TestConfig_Validate_ReloadStrategy(t *testing.T) { + tests := []struct { + name string + strategy ReloadStrategy + wantErr bool + wantVal ReloadStrategy + }{ + {"valid env-vars", ReloadStrategyEnvVars, false, ReloadStrategyEnvVars}, + {"valid annotations", ReloadStrategyAnnotations, false, ReloadStrategyAnnotations}, + {"empty defaults to env-vars", "", false, ReloadStrategyEnvVars}, + {"invalid strategy", "invalid", true, ""}, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + cfg := NewDefault() + cfg.ReloadStrategy = tt.strategy + + err := cfg.Validate() + + if tt.wantErr { + if err == nil { + t.Error("Validate() should return error for invalid strategy") + } + return + } + + if err != nil { + t.Errorf("Validate() error = %v", err) + return + } + + if cfg.ReloadStrategy != tt.wantVal { + t.Errorf("ReloadStrategy = %v, want %v", cfg.ReloadStrategy, tt.wantVal) + } + }, + ) + } +} + +func TestConfig_Validate_ArgoRolloutStrategy(t *testing.T) { + tests := []struct { + name string + strategy ArgoRolloutStrategy + wantErr bool + wantVal ArgoRolloutStrategy + }{ + {"valid restart", ArgoRolloutStrategyRestart, false, ArgoRolloutStrategyRestart}, + {"valid rollout", ArgoRolloutStrategyRollout, false, ArgoRolloutStrategyRollout}, + {"empty defaults to rollout", "", false, ArgoRolloutStrategyRollout}, + {"invalid strategy", "invalid", true, ""}, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + cfg := NewDefault() + cfg.ArgoRolloutStrategy = tt.strategy + + err := cfg.Validate() + + if tt.wantErr { + if err == nil { + t.Error("Validate() should return error for invalid strategy") + } + return + } + + if err != nil { + t.Errorf("Validate() error = %v", err) + return + } + + if cfg.ArgoRolloutStrategy != tt.wantVal { + t.Errorf("ArgoRolloutStrategy = %v, want %v", cfg.ArgoRolloutStrategy, tt.wantVal) + } + }, + ) + } +} + +func TestConfig_Validate_LogLevel(t *testing.T) { + validLevels := []string{"trace", "debug", "info", "warn", "warning", "error", "fatal", "panic", ""} + for _, level := range validLevels { + t.Run( + "valid_"+level, func(t *testing.T) { + cfg := NewDefault() + cfg.LogLevel = level + if err := cfg.Validate(); err != nil { + t.Errorf("Validate() error for level %q: %v", level, err) + } + }, + ) + } + + t.Run( + "invalid level", func(t *testing.T) { + cfg := NewDefault() + cfg.LogLevel = "invalid" + err := cfg.Validate() + if err == nil { + t.Error("Validate() should return error for invalid log level") + } + }, + ) +} + +func TestConfig_Validate_LogFormat(t *testing.T) { + tests := []struct { + name string + format string + wantErr bool + }{ + {"json format", "json", false}, + {"empty format", "", false}, + {"invalid format", "xml", true}, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + cfg := NewDefault() + cfg.LogFormat = tt.format + err := cfg.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }, + ) + } +} + +func TestConfig_Validate_NormalizesIgnoredResources(t *testing.T) { + cfg := NewDefault() + cfg.IgnoredResources = []string{"ConfigMaps", "SECRETS", " spaces "} + + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } + + expected := []string{"configmaps", "secrets", "spaces"} + if len(cfg.IgnoredResources) != len(expected) { + t.Fatalf("IgnoredResources length = %d, want %d", len(cfg.IgnoredResources), len(expected)) + } + + for i, got := range cfg.IgnoredResources { + if got != expected[i] { + t.Errorf("IgnoredResources[%d] = %q, want %q", i, got, expected[i]) + } + } +} + +func TestConfig_Validate_NormalizesIgnoredWorkloads(t *testing.T) { + cfg := NewDefault() + cfg.IgnoredWorkloads = []string{"Jobs", "CRONJOBS", ""} + + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } + + // Should be normalized to canonical Kind values (e.g., "CronJob" not "cronjobs") + expected := []string{"Job", "CronJob"} + if len(cfg.IgnoredWorkloads) != len(expected) { + t.Fatalf("IgnoredWorkloads length = %d, want %d", len(cfg.IgnoredWorkloads), len(expected)) + } + + for i, got := range cfg.IgnoredWorkloads { + if got != expected[i] { + t.Errorf("IgnoredWorkloads[%d] = %q, want %q", i, got, expected[i]) + } + } +} + +func TestConfig_Validate_InvalidIgnoredWorkload(t *testing.T) { + cfg := NewDefault() + cfg.IgnoredWorkloads = []string{"deployment", "invalidtype"} + + err := cfg.Validate() + if err == nil { + t.Fatal("Validate() should return error for invalid workload type") + } + + if !strings.Contains(err.Error(), "invalidtype") { + t.Errorf("Error should mention invalid workload type, got: %v", err) + } +} + +func TestConfig_Validate_MultipleErrors(t *testing.T) { + cfg := NewDefault() + cfg.ReloadStrategy = "invalid" + cfg.ArgoRolloutStrategy = "invalid" + cfg.LogLevel = "invalid" + cfg.LogFormat = "invalid" + + err := cfg.Validate() + if err == nil { + t.Fatal("Validate() should return error for multiple invalid values") + } + + var errs ValidationErrors + ok := errors.As(err, &errs) + if !ok { + t.Fatalf("Expected ValidationErrors, got %T", err) + } + + if len(errs) != 4 { + t.Errorf("Expected 4 errors, got %d: %v", len(errs), errs) + } +} + +func TestValidationError_Error(t *testing.T) { + err := ValidationError{ + Field: "TestField", + Message: "test message", + } + + expected := "config.TestField: test message" + if err.Error() != expected { + t.Errorf("Error() = %q, want %q", err.Error(), expected) + } +} + +func TestValidationErrors_Error(t *testing.T) { + t.Run( + "empty", func(t *testing.T) { + var errs ValidationErrors + if errs.Error() != "" { + t.Errorf("Empty errors should return empty string, got %q", errs.Error()) + } + }, + ) + + t.Run( + "single error", func(t *testing.T) { + errs := ValidationErrors{ + {Field: "Field1", Message: "error1"}, + } + if !strings.Contains(errs.Error(), "Field1") { + t.Errorf("Error() should contain field name, got %q", errs.Error()) + } + }, + ) + + t.Run( + "multiple errors", func(t *testing.T) { + errs := ValidationErrors{ + {Field: "Field1", Message: "error1"}, + {Field: "Field2", Message: "error2"}, + } + errStr := errs.Error() + if !strings.Contains(errStr, "multiple configuration errors") { + t.Errorf("Error() should mention multiple errors, got %q", errStr) + } + if !strings.Contains(errStr, "Field1") || !strings.Contains(errStr, "Field2") { + t.Errorf("Error() should contain all field names, got %q", errStr) + } + }, + ) +} + +func TestParseSelectors(t *testing.T) { + tests := []struct { + name string + selectors []string + wantLen int + wantErr bool + }{ + {"nil input", nil, 0, false}, + {"empty input", []string{}, 0, false}, + {"single valid selector", []string{"env=production"}, 1, false}, + {"multiple valid selectors", []string{"env=production", "team=platform"}, 2, false}, + {"selector with whitespace", []string{" env=production "}, 1, false}, + {"empty string in list", []string{"env=production", "", "team=platform"}, 2, false}, + {"invalid selector syntax", []string{"env in (prod,staging"}, 0, true}, // missing closing paren + {"set-based selector", []string{"env in (prod,staging)"}, 1, false}, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + selectors, err := ParseSelectors(tt.selectors) + if (err != nil) != tt.wantErr { + t.Errorf("ParseSelectors() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && len(selectors) != tt.wantLen { + t.Errorf("ParseSelectors() returned %d selectors, want %d", len(selectors), tt.wantLen) + } + }, + ) + } +} + +func TestNormalizeToLower(t *testing.T) { + tests := []struct { + name string + input []string + want []string + }{ + {"nil input", nil, nil}, + {"empty input", []string{}, []string{}}, + {"lowercase", []string{"abc"}, []string{"abc"}}, + {"uppercase", []string{"ABC"}, []string{"abc"}}, + {"mixed case", []string{"AbC"}, []string{"abc"}}, + {"with whitespace", []string{" abc "}, []string{"abc"}}, + {"removes empty", []string{"abc", "", "def"}, []string{"abc", "def"}}, + {"only whitespace", []string{" "}, []string{}}, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + got := normalizeToLower(tt.input) + if tt.want == nil && got != nil { + t.Errorf("normalizeToLower() = %v, want nil", got) + return + } + if len(got) != len(tt.want) { + t.Errorf("normalizeToLower() length = %d, want %d", len(got), len(tt.want)) + return + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("normalizeToLower()[%d] = %q, want %q", i, got[i], tt.want[i]) + } + } + }, + ) + } +} diff --git a/internal/pkg/constants/constants.go b/internal/pkg/constants/constants.go deleted file mode 100644 index 8025a29e5..000000000 --- a/internal/pkg/constants/constants.go +++ /dev/null @@ -1,36 +0,0 @@ -package constants - -const ( - // DefaultHttpListenAddr is the default listening address for global http server - DefaultHttpListenAddr = ":9090" - - // ConfigmapEnvVarPostfix is a postfix for configmap envVar - ConfigmapEnvVarPostfix = "CONFIGMAP" - // SecretEnvVarPostfix is a postfix for secret envVar - SecretEnvVarPostfix = "SECRET" - // SecretProviderClassEnvVarPostfix is a postfix for secretproviderclasspodstatus envVar - SecretProviderClassEnvVarPostfix = "SECRETPROVIDERCLASS" - // EnvVarPrefix is a Prefix for environment variable - EnvVarPrefix = "STAKATER_" - - // ReloaderAnnotationPrefix is a Prefix for all reloader annotations - ReloaderAnnotationPrefix = "reloader.stakater.com" - // LastReloadedFromAnnotation is an annotation used to describe the last resource that triggered a reload - LastReloadedFromAnnotation = "last-reloaded-from" - - // ReloadStrategyFlag The reload strategy flag name - ReloadStrategyFlag = "reload-strategy" - // EnvVarsReloadStrategy instructs Reloader to add container environment variables to facilitate a restart - EnvVarsReloadStrategy = "env-vars" - // AnnotationsReloadStrategy instructs Reloader to add pod template annotations to facilitate a restart - AnnotationsReloadStrategy = "annotations" - // SecretProviderClassController enables support for SecretProviderClassPodStatus resources - SecretProviderClassController = "secretproviderclasspodstatuses" -) - -// Leadership election related consts -const ( - LockName string = "stakater-reloader-lock" - PodNameEnv string = "POD_NAME" - PodNamespaceEnv string = "POD_NAMESPACE" -) diff --git a/internal/pkg/constants/enums.go b/internal/pkg/constants/enums.go deleted file mode 100644 index 43fc60352..000000000 --- a/internal/pkg/constants/enums.go +++ /dev/null @@ -1,15 +0,0 @@ -package constants - -// Result is a status for deployment update -type Result int - -const ( - // Updated is returned when environment variable is created/updated - Updated Result = 1 + iota - // NotUpdated is returned when environment variable is found but had value equals to the new value - NotUpdated - // NoEnvVarFound is returned when no environment variable is found - NoEnvVarFound - // NoContainerFound is returned when no environment variable is found - NoContainerFound -) diff --git a/internal/pkg/controller/configmap_reconciler.go b/internal/pkg/controller/configmap_reconciler.go new file mode 100644 index 000000000..04bd3bb3f --- /dev/null +++ b/internal/pkg/controller/configmap_reconciler.go @@ -0,0 +1,69 @@ +package controller + +import ( + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/stakater/Reloader/internal/pkg/alerting" + "github.com/stakater/Reloader/internal/pkg/config" + "github.com/stakater/Reloader/internal/pkg/events" + "github.com/stakater/Reloader/internal/pkg/metrics" + "github.com/stakater/Reloader/internal/pkg/reload" + "github.com/stakater/Reloader/internal/pkg/webhook" + "github.com/stakater/Reloader/internal/pkg/workload" +) + +// ConfigMapReconciler watches ConfigMaps and triggers workload reloads. +type ConfigMapReconciler = ResourceReconciler[*corev1.ConfigMap] + +// NewConfigMapReconciler creates a new ConfigMapReconciler with the given dependencies. +func NewConfigMapReconciler( + c client.Client, + log logr.Logger, + cfg *config.Config, + reloadService *reload.Service, + registry *workload.Registry, + collectors *metrics.Collectors, + eventRecorder *events.Recorder, + webhookClient *webhook.Client, + alerter alerting.Alerter, + pauseHandler *reload.PauseHandler, + nsCache *NamespaceCache, +) *ConfigMapReconciler { + return NewResourceReconciler( + ResourceReconcilerDeps{ + Client: c, + Log: log, + Config: cfg, + ReloadService: reloadService, + Registry: registry, + Collectors: collectors, + EventRecorder: eventRecorder, + WebhookClient: webhookClient, + Alerter: alerter, + PauseHandler: pauseHandler, + NamespaceCache: nsCache, + }, + ResourceConfig[*corev1.ConfigMap]{ + ResourceType: reload.ResourceTypeConfigMap, + NewResource: func() *corev1.ConfigMap { return &corev1.ConfigMap{} }, + CreateChange: func(cm *corev1.ConfigMap, eventType reload.EventType) reload.ResourceChange { + return reload.ConfigMapChange{ConfigMap: cm, EventType: eventType} + }, + CreatePredicates: func(cfg *config.Config, hasher *reload.Hasher) predicate.Predicate { + return reload.ConfigMapPredicates(cfg, hasher) + }, + }, + ) +} + +// SetupConfigMapReconciler sets up a ConfigMap reconciler with the manager. +func SetupConfigMapReconciler(mgr ctrl.Manager, r *ConfigMapReconciler) error { + return r.SetupWithManager(mgr, &corev1.ConfigMap{}) +} + +var _ reconcile.Reconciler = &ConfigMapReconciler{} diff --git a/internal/pkg/controller/configmap_reconciler_test.go b/internal/pkg/controller/configmap_reconciler_test.go new file mode 100644 index 000000000..1b1140577 --- /dev/null +++ b/internal/pkg/controller/configmap_reconciler_test.go @@ -0,0 +1,160 @@ +package controller_test + +import ( + "testing" + + "github.com/stakater/Reloader/internal/pkg/config" + "github.com/stakater/Reloader/internal/pkg/testutil" +) + +func TestConfigMapReconciler_NotFound(t *testing.T) { + cfg := config.NewDefault() + reconciler := newConfigMapReconciler(t, cfg) + assertReconcileSuccess(t, reconciler, reconcileRequest("nonexistent-cm", "default")) +} + +func TestConfigMapReconciler_NotFound_ReloadOnDelete(t *testing.T) { + cfg := config.NewDefault() + cfg.ReloadOnDelete = true + + deployment := testutil.NewDeployment("test-deployment", "default", map[string]string{ + cfg.Annotations.ConfigmapReload: "deleted-cm", + }) + reconciler := newConfigMapReconciler(t, cfg, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("deleted-cm", "default")) +} + +func TestConfigMapReconciler_IgnoredNamespace(t *testing.T) { + cfg := config.NewDefault() + cfg.IgnoredNamespaces = []string{"kube-system"} + + cm := testutil.NewConfigMap("test-cm", "kube-system") + reconciler := newConfigMapReconciler(t, cfg, cm) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-cm", "kube-system")) +} + +func TestConfigMapReconciler_NoMatchingWorkloads(t *testing.T) { + cfg := config.NewDefault() + + cm := testutil.NewConfigMap("test-cm", "default") + deployment := testutil.NewDeployment("test-deployment", "default", nil) + reconciler := newConfigMapReconciler(t, cfg, cm, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-cm", "default")) +} + +func TestConfigMapReconciler_MatchingDeployment_AutoAnnotation(t *testing.T) { + cfg := config.NewDefault() + cfg.AutoReloadAll = true + + cm := testutil.NewConfigMap("test-cm", "default") + deployment := testutil.NewDeploymentWithEnvFrom("test-deployment", "default", "test-cm", "") + reconciler := newConfigMapReconciler(t, cfg, cm, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-cm", "default")) +} + +func TestConfigMapReconciler_MatchingDeployment_ExplicitAnnotation(t *testing.T) { + cfg := config.NewDefault() + + cm := testutil.NewConfigMap("test-cm", "default") + deployment := testutil.NewDeployment("test-deployment", "default", map[string]string{ + cfg.Annotations.ConfigmapReload: "test-cm", + }) + reconciler := newConfigMapReconciler(t, cfg, cm, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-cm", "default")) +} + +func TestConfigMapReconciler_WorkloadInDifferentNamespace(t *testing.T) { + cfg := config.NewDefault() + + cm := testutil.NewConfigMap("test-cm", "namespace-a") + deployment := testutil.NewDeployment("test-deployment", "namespace-b", map[string]string{ + cfg.Annotations.ConfigmapReload: "test-cm", + }) + reconciler := newConfigMapReconciler(t, cfg, cm, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-cm", "namespace-a")) +} + +func TestConfigMapReconciler_IgnoredWorkloadType(t *testing.T) { + cfg := config.NewDefault() + cfg.IgnoredWorkloads = []string{"deployment"} + + cm := testutil.NewConfigMap("test-cm", "default") + deployment := testutil.NewDeployment("test-deployment", "default", map[string]string{ + cfg.Annotations.ConfigmapReload: "test-cm", + }) + reconciler := newConfigMapReconciler(t, cfg, cm, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-cm", "default")) +} + +func TestConfigMapReconciler_DaemonSet(t *testing.T) { + cfg := config.NewDefault() + + cm := testutil.NewConfigMap("test-cm", "default") + daemonset := testutil.NewDaemonSet("test-daemonset", "default", map[string]string{ + cfg.Annotations.ConfigmapReload: "test-cm", + }) + reconciler := newConfigMapReconciler(t, cfg, cm, daemonset) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-cm", "default")) +} + +func TestConfigMapReconciler_StatefulSet(t *testing.T) { + cfg := config.NewDefault() + + cm := testutil.NewConfigMap("test-cm", "default") + statefulset := testutil.NewStatefulSet("test-statefulset", "default", map[string]string{ + cfg.Annotations.ConfigmapReload: "test-cm", + }) + reconciler := newConfigMapReconciler(t, cfg, cm, statefulset) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-cm", "default")) +} + +func TestConfigMapReconciler_MultipleWorkloads(t *testing.T) { + cfg := config.NewDefault() + + cm := testutil.NewConfigMap("shared-cm", "default") + deployment1 := testutil.NewDeployment("deployment-1", "default", map[string]string{ + cfg.Annotations.ConfigmapReload: "shared-cm", + }) + deployment2 := testutil.NewDeployment("deployment-2", "default", map[string]string{ + cfg.Annotations.ConfigmapReload: "shared-cm", + }) + daemonset := testutil.NewDaemonSet("daemonset-1", "default", map[string]string{ + cfg.Annotations.ConfigmapReload: "shared-cm", + }) + + reconciler := newConfigMapReconciler(t, cfg, cm, deployment1, deployment2, daemonset) + assertReconcileSuccess(t, reconciler, reconcileRequest("shared-cm", "default")) +} + +func TestConfigMapReconciler_VolumeMount(t *testing.T) { + cfg := config.NewDefault() + cfg.AutoReloadAll = true + + cm := testutil.NewConfigMap("volume-cm", "default") + deployment := testutil.NewDeploymentWithVolume("test-deployment", "default", "volume-cm", "") + reconciler := newConfigMapReconciler(t, cfg, cm, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("volume-cm", "default")) +} + +func TestConfigMapReconciler_ProjectedVolume(t *testing.T) { + cfg := config.NewDefault() + cfg.AutoReloadAll = true + + cm := testutil.NewConfigMap("projected-cm", "default") + deployment := testutil.NewDeploymentWithProjectedVolume("test-deployment", "default", "projected-cm", "") + reconciler := newConfigMapReconciler(t, cfg, cm, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("projected-cm", "default")) +} + +func TestConfigMapReconciler_SearchAnnotation(t *testing.T) { + cfg := config.NewDefault() + + cm := testutil.NewConfigMapWithAnnotations("test-cm", "default", map[string]string{ + cfg.Annotations.Match: "true", + }) + deployment := testutil.NewDeployment("test-deployment", "default", map[string]string{ + cfg.Annotations.Search: "true", + }) + reconciler := newConfigMapReconciler(t, cfg, cm, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-cm", "default")) +} diff --git a/internal/pkg/controller/controller.go b/internal/pkg/controller/controller.go deleted file mode 100644 index 287be4dee..000000000 --- a/internal/pkg/controller/controller.go +++ /dev/null @@ -1,435 +0,0 @@ -package controller - -import ( - "fmt" - "sync" - "sync/atomic" - "time" - - "github.com/sirupsen/logrus" - v1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/fields" - "k8s.io/apimachinery/pkg/util/runtime" - "k8s.io/apimachinery/pkg/util/wait" - "k8s.io/client-go/kubernetes" - typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1" - "k8s.io/client-go/tools/cache" - "k8s.io/client-go/tools/record" - "k8s.io/client-go/util/workqueue" - "k8s.io/kubectl/pkg/scheme" - csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1" - - "github.com/stakater/Reloader/internal/pkg/constants" - "github.com/stakater/Reloader/internal/pkg/handler" - "github.com/stakater/Reloader/internal/pkg/metrics" - "github.com/stakater/Reloader/internal/pkg/options" - "github.com/stakater/Reloader/internal/pkg/util" - "github.com/stakater/Reloader/pkg/kube" -) - -// Controller for checking events -type Controller struct { - client kubernetes.Interface - queue workqueue.TypedRateLimitingInterface[any] - informer cache.Controller - namespace string - resource string - ignoredNamespaces util.List - collectors metrics.Collectors - recorder record.EventRecorder - namespaceSelector string - resourceSelector string -} - -// controllerInitialized flags guard against processing Add/Delete events before -// the worker goroutines have started. Written by runWorker (in a goroutine) and -// read by the informer event handlers, so they must be atomic. -var secretControllerInitialized atomic.Bool -var configmapControllerInitialized atomic.Bool - -// selectedNamespacesCache holds an immutable snapshot of the set of namespace -// names that match the namespace label selector. Written exclusively by the -// namespace controller's informer goroutine; read concurrently by configmap/ -// secret controller informer goroutines. Using atomic.Value with an immutable -// map[string]struct{} snapshot avoids mutexes and prevents data races. -var selectedNamespacesCache atomic.Value // always stores map[string]struct{} - -// loadSelectedNamespaces returns the current namespace snapshot (never nil). -func loadSelectedNamespaces() map[string]struct{} { - if v := selectedNamespacesCache.Load(); v != nil { - if m, ok := v.(map[string]struct{}); ok { - return m - } - } - return map[string]struct{}{} -} - -// storeSelectedNamespaces replaces the current snapshot with one built from ns. -// It is the only mutator of selectedNamespacesCache and is called only from -// the namespace controller's informer goroutine (or from tests for setup). -func storeSelectedNamespaces(ns []string) { - m := make(map[string]struct{}, len(ns)) - for _, n := range ns { - m[n] = struct{}{} - } - selectedNamespacesCache.Store(m) -} - -// loadSelectedNamespacesList returns the current namespace names as a slice. -// Intended for use in tests where slice-based assertions are more convenient. -func loadSelectedNamespacesList() []string { - m := loadSelectedNamespaces() - result := make([]string, 0, len(m)) - for k := range m { - result = append(result, k) - } - return result -} - -// NewController for initializing a Controller -func NewController(client kubernetes.Interface, resource string, namespace string, ignoredNamespaces []string, namespaceLabelSelector string, resourceLabelSelector string, collectors metrics.Collectors) (*Controller, error) { - if options.SyncAfterRestart { - secretControllerInitialized.Store(true) - configmapControllerInitialized.Store(true) - } - - c := Controller{ - client: client, - namespace: namespace, - ignoredNamespaces: ignoredNamespaces, - namespaceSelector: namespaceLabelSelector, - resourceSelector: resourceLabelSelector, - resource: resource, - } - eventBroadcaster := record.NewBroadcaster() - eventBroadcaster.StartRecordingToSink(&typedcorev1.EventSinkImpl{ - Interface: client.CoreV1().Events(""), - }) - recorder := eventBroadcaster.NewRecorder(scheme.Scheme, - v1.EventSource{Component: fmt.Sprintf("reloader-%s", resource)}) - - queue := workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[any]()) - - optionsModifier := func(opts *metav1.ListOptions) { - if resource == "namespaces" { - opts.LabelSelector = c.namespaceSelector - } else if len(c.resourceSelector) > 0 { - opts.LabelSelector = c.resourceSelector - } else { - opts.FieldSelector = fields.Everything().String() - } - } - - getterRESTClient, err := getClientForResource(resource, client) - if err != nil { - return nil, fmt.Errorf("failed to initialize REST client for %s: %w", resource, err) - } - - listWatcher := cache.NewFilteredListWatchFromClient(getterRESTClient, resource, namespace, optionsModifier) - - _, informer := cache.NewInformerWithOptions(cache.InformerOptions{ - ListerWatcher: listWatcher, - ObjectType: kube.ResourceMap[resource], - ResyncPeriod: 0, - Handler: cache.ResourceEventHandlerFuncs{ - AddFunc: c.Add, - UpdateFunc: c.Update, - DeleteFunc: c.Delete, - }, - Indexers: cache.Indexers{}, - }) - c.informer = informer - c.queue = queue - c.collectors = collectors - c.recorder = recorder - - logrus.Infof("created controller for: %s", resource) - return &c, nil -} - -// Add function to add a new object to the queue in case of creating a resource -func (c *Controller) Add(obj interface{}) { - c.collectors.RecordEventReceived("add", c.resource) - - switch object := obj.(type) { - case *v1.Namespace: - c.addSelectedNamespaceToCache(*object) - return - case *csiv1.SecretProviderClassPodStatus: - return - } - - if options.ReloadOnCreate == "true" { - if !c.resourceInIgnoredNamespace(obj) && c.resourceInSelectedNamespaces(obj) && secretControllerInitialized.Load() && configmapControllerInitialized.Load() { - c.enqueue(handler.ResourceCreatedHandler{ - Resource: obj, - Collectors: c.collectors, - Recorder: c.recorder, - EnqueueTime: time.Now(), - }) - } else { - c.collectors.RecordSkipped("ignored_or_not_selected") - } - } -} - -func (c *Controller) resourceInIgnoredNamespace(raw interface{}) bool { - switch obj := raw.(type) { - case *v1.ConfigMap: - return c.ignoredNamespaces.Contains(obj.Namespace) - case *v1.Secret: - return c.ignoredNamespaces.Contains(obj.Namespace) - case *csiv1.SecretProviderClassPodStatus: - return c.ignoredNamespaces.Contains(obj.Namespace) - } - return false -} - -func (c *Controller) resourceInSelectedNamespaces(raw interface{}) bool { - if len(c.namespaceSelector) == 0 { - return true - } - - namespaces := loadSelectedNamespaces() - var ns string - switch object := raw.(type) { - case *v1.ConfigMap: - ns = object.GetNamespace() - case *v1.Secret: - ns = object.GetNamespace() - case *csiv1.SecretProviderClassPodStatus: - ns = object.GetNamespace() - default: - return false - } - _, ok := namespaces[ns] - return ok -} - -func (c *Controller) addSelectedNamespaceToCache(namespace v1.Namespace) { - old := loadSelectedNamespaces() - next := make(map[string]struct{}, len(old)+1) - for k := range old { - next[k] = struct{}{} - } - next[namespace.GetName()] = struct{}{} - selectedNamespacesCache.Store(next) - logrus.Infof("added namespace to be watched: %s", namespace.GetName()) -} - -func (c *Controller) removeSelectedNamespaceFromCache(namespace v1.Namespace) { - old := loadSelectedNamespaces() - if _, ok := old[namespace.GetName()]; !ok { - return - } - next := make(map[string]struct{}, len(old)) - for k := range old { - next[k] = struct{}{} - } - delete(next, namespace.GetName()) - selectedNamespacesCache.Store(next) - logrus.Infof("removed namespace from watch: %s", namespace.GetName()) -} - -// Update function to add an old object and a new object to the queue in case of updating a resource -func (c *Controller) Update(old interface{}, new interface{}) { - c.collectors.RecordEventReceived("update", c.resource) - - switch new.(type) { - case *v1.Namespace: - return - } - - if !c.resourceInIgnoredNamespace(new) && c.resourceInSelectedNamespaces(new) { - c.enqueue(handler.ResourceUpdatedHandler{ - Resource: new, - OldResource: old, - Collectors: c.collectors, - Recorder: c.recorder, - EnqueueTime: time.Now(), - }) - } else { - c.collectors.RecordSkipped("ignored_or_not_selected") - } -} - -// Delete function to add an object to the queue in case of deleting a resource -func (c *Controller) Delete(old interface{}) { - c.collectors.RecordEventReceived("delete", c.resource) - - if _, ok := old.(*csiv1.SecretProviderClassPodStatus); ok { - return - } - - if options.ReloadOnDelete == "true" { - if !c.resourceInIgnoredNamespace(old) && c.resourceInSelectedNamespaces(old) && secretControllerInitialized.Load() && configmapControllerInitialized.Load() { - c.enqueue(handler.ResourceDeleteHandler{ - Resource: old, - Collectors: c.collectors, - Recorder: c.recorder, - EnqueueTime: time.Now(), - }) - } else { - c.collectors.RecordSkipped("ignored_or_not_selected") - } - } - - switch object := old.(type) { - case *v1.Namespace: - c.removeSelectedNamespaceFromCache(*object) - return - } -} - -// enqueue adds an item to the queue and records metrics -func (c *Controller) enqueue(item interface{}) { - c.queue.Add(item) - c.collectors.RecordQueueAdd() - c.collectors.SetQueueDepth(c.queue.Len()) -} - -// Run function for controller which handles the queue -func (c *Controller) Run(threadiness int, stopCh chan struct{}) { - defer runtime.HandleCrash() - - var wg sync.WaitGroup - - wg.Add(1) - go func() { - defer wg.Done() - c.informer.Run(stopCh) - }() - - // Wait for all involved caches to be synced, before processing items from the queue is started - if !cache.WaitForCacheSync(stopCh, c.informer.HasSynced) { - runtime.HandleError(fmt.Errorf("timed out waiting for caches to sync")) - c.queue.ShutDown() - wg.Wait() - return - } - - for i := 0; i < threadiness; i++ { - wg.Add(1) - go func() { - defer wg.Done() - wait.Until(c.runWorker, time.Second, stopCh) - }() - } - - <-stopCh - logrus.Infof("Stopping Controller for %s", c.resource) - c.queue.ShutDown() // unblock workers so they drain and exit - logrus.Infof("Queue shut down for %s, waiting for goroutines", c.resource) - wg.Wait() // block until informer and all workers have exited - logrus.Infof("All goroutines exited for %s", c.resource) -} - -func (c *Controller) runWorker() { - // At this point the controller is fully initialized and we can start processing the resources - if c.resource == string(v1.ResourceSecrets) { - secretControllerInitialized.Store(true) - } else if c.resource == string(v1.ResourceConfigMaps) { - configmapControllerInitialized.Store(true) - } - - for c.processNextItem() { - } -} - -func (c *Controller) processNextItem() bool { - // Wait until there is a new item in the working queue - resourceHandler, quit := c.queue.Get() - if quit { - return false - } - - c.collectors.SetQueueDepth(c.queue.Len()) - - // Tell the queue that we are done with processing this key. This unblocks the key for other workers - // This allows safe parallel processing because two events with the same key are never processed in - // parallel. - defer c.queue.Done(resourceHandler) - - // Record queue latency if the handler supports it - if h, ok := resourceHandler.(handler.TimedHandler); ok { - queueLatency := time.Since(h.GetEnqueueTime()) - c.collectors.RecordQueueLatency(queueLatency) - } - - // Track reconcile/handler duration - startTime := time.Now() - - // Invoke the method containing the business logic - rh, ok := resourceHandler.(handler.ResourceHandler) - if !ok { - logrus.Errorf("Invalid resource handler type: %T", resourceHandler) - // Clear rate-limiter state so the item doesn't leak memory in the queue. - c.queue.Forget(resourceHandler) - c.collectors.RecordError("invalid_handler_type") - return true - } - err := rh.Handle() - - duration := time.Since(startTime) - - if err != nil { - c.collectors.RecordReconcile("error", duration) - } else { - c.collectors.RecordReconcile("success", duration) - } - - // Handle the error if something went wrong during the execution of the business logic - c.handleErr(err, resourceHandler) - return true -} - -// handleErr checks if an error happened and makes sure we will retry later. -func (c *Controller) handleErr(err error, key interface{}) { - if err == nil { - // Forget about the #AddRateLimited history of the key on every successful synchronization. - // This ensures that future processing of updates for this key is not delayed because of - // an outdated error history. - c.queue.Forget(key) - - // Record successful event processing - c.collectors.RecordEventProcessed("unknown", c.resource, "success") - return - } - - // Record error - c.collectors.RecordError("handler_error") - - // This controller retries 5 times if something goes wrong. After that, it stops trying. - if c.queue.NumRequeues(key) < 5 { - logrus.Errorf("Error syncing events: %v", err) - - // Record retry - c.collectors.RecordRetry() - - // Re-enqueue the key rate limited. Based on the rate limiter on the - // queue and the re-enqueue history, the key will be processed later again. - c.queue.AddRateLimited(key) - c.collectors.SetQueueDepth(c.queue.Len()) - return - } - - c.queue.Forget(key) - // Report to an external entity that, even after several retries, we could not successfully process this key - runtime.HandleError(err) - logrus.Errorf("Dropping key out of the queue: %v", err) - logrus.Debugf("Dropping the key %q out of the queue: %v", key, err) - - c.collectors.RecordEventProcessed("unknown", c.resource, "dropped") -} - -func getClientForResource(resource string, coreClient kubernetes.Interface) (cache.Getter, error) { - if resource == constants.SecretProviderClassController { - csiClient, err := kube.GetCSIClient() - if err != nil { - return nil, fmt.Errorf("failed to get CSI client: %w", err) - } - return csiClient.SecretsstoreV1().RESTClient(), nil - } - return coreClient.CoreV1().RESTClient(), nil -} diff --git a/internal/pkg/controller/controller_test.go b/internal/pkg/controller/controller_test.go deleted file mode 100644 index b2ae32eca..000000000 --- a/internal/pkg/controller/controller_test.go +++ /dev/null @@ -1,755 +0,0 @@ -package controller - -import ( - "errors" - "testing" - "time" - - "github.com/stretchr/testify/assert" - v1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/util/workqueue" - - "github.com/stakater/Reloader/internal/pkg/handler" - "github.com/stakater/Reloader/internal/pkg/metrics" - "github.com/stakater/Reloader/internal/pkg/options" - "github.com/stakater/Reloader/pkg/common" -) - -// mockResourceHandler implements handler.ResourceHandler and handler.TimedHandler for testing. -type mockResourceHandler struct { - handleErr error - handleCalls int - enqueueTime time.Time -} - -func (m *mockResourceHandler) Handle() error { - m.handleCalls++ - return m.handleErr -} - -func (m *mockResourceHandler) GetConfig() (common.Config, string) { - return common.Config{ - ResourceName: "test-resource", - Namespace: "test-ns", - Type: "configmap", - SHAValue: "sha256:test", - }, "test-resource" -} - -func (m *mockResourceHandler) GetEnqueueTime() time.Time { - return m.enqueueTime -} - -// resetGlobalState resets global variables between tests -func resetGlobalState() { - secretControllerInitialized.Store(false) - configmapControllerInitialized.Store(false) - storeSelectedNamespaces([]string{}) -} - -// newTestController creates a controller for testing without starting informers -func newTestController(ignoredNamespaces []string, namespaceSelector string) *Controller { - queue := workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[any]()) - collectors := metrics.NewCollectors() - - return &Controller{ - queue: queue, - ignoredNamespaces: ignoredNamespaces, - namespaceSelector: namespaceSelector, - collectors: collectors, - resource: "configmaps", - } -} - -func TestResourceInIgnoredNamespace(t *testing.T) { - tests := []struct { - name string - ignoredNamespaces []string - resource interface{} - expected bool - }{ - { - name: "ConfigMap in ignored namespace", - ignoredNamespaces: []string{"kube-system", "default"}, - resource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "kube-system", - }, - }, - expected: true, - }, - { - name: "ConfigMap not in ignored namespace", - ignoredNamespaces: []string{"kube-system", "default"}, - resource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "my-namespace", - }, - }, - expected: false, - }, - { - name: "Secret in ignored namespace", - ignoredNamespaces: []string{"kube-system"}, - resource: &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "kube-system", - }, - }, - expected: true, - }, - { - name: "Secret not in ignored namespace", - ignoredNamespaces: []string{"kube-system"}, - resource: &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "my-namespace", - }, - }, - expected: false, - }, - { - name: "Empty ignored namespaces list", - ignoredNamespaces: []string{}, - resource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "any-namespace", - }, - }, - expected: false, - }, - { - name: "Unknown resource type", - ignoredNamespaces: []string{"kube-system"}, - resource: &v1.Pod{}, // Not a ConfigMap or Secret - expected: false, - }, - } - - for _, tt := range tests { - t.Run( - tt.name, func(t *testing.T) { - c := newTestController(tt.ignoredNamespaces, "") - result := c.resourceInIgnoredNamespace(tt.resource) - assert.Equal(t, tt.expected, result) - }, - ) - } -} - -func TestResourceInSelectedNamespaces(t *testing.T) { - tests := []struct { - name string - namespaceSelector string - cachedNamespaces []string - resource interface{} - expected bool - }{ - { - name: "No namespace selector - all namespaces allowed", - namespaceSelector: "", - cachedNamespaces: []string{}, - resource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "any-namespace", - }, - }, - expected: true, - }, - { - name: "ConfigMap in selected namespace", - namespaceSelector: "env=prod", - cachedNamespaces: []string{"prod-ns", "staging-ns"}, - resource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "prod-ns", - }, - }, - expected: true, - }, - { - name: "ConfigMap not in selected namespace", - namespaceSelector: "env=prod", - cachedNamespaces: []string{"prod-ns"}, - resource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "dev-ns", - }, - }, - expected: false, - }, - { - name: "Secret in selected namespace", - namespaceSelector: "env=prod", - cachedNamespaces: []string{"prod-ns"}, - resource: &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "prod-ns", - }, - }, - expected: true, - }, - { - name: "Secret not in selected namespace", - namespaceSelector: "env=prod", - cachedNamespaces: []string{"prod-ns"}, - resource: &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "dev-ns", - }, - }, - expected: false, - }, - { - name: "Unknown resource type with selector", - namespaceSelector: "env=prod", - cachedNamespaces: []string{"prod-ns"}, - resource: &v1.Pod{}, - expected: false, - }, - } - - for _, tt := range tests { - t.Run( - tt.name, func(t *testing.T) { - resetGlobalState() - storeSelectedNamespaces(tt.cachedNamespaces) - - c := newTestController([]string{}, tt.namespaceSelector) - result := c.resourceInSelectedNamespaces(tt.resource) - assert.Equal(t, tt.expected, result) - }, - ) - } -} - -func TestAddSelectedNamespaceToCache(t *testing.T) { - resetGlobalState() - - c := newTestController([]string{}, "env=prod") - - // Add first namespace - ns1 := v1.Namespace{ - ObjectMeta: metav1.ObjectMeta{Name: "namespace-1"}, - } - c.addSelectedNamespaceToCache(ns1) - assert.Contains(t, loadSelectedNamespaces(), "namespace-1") - assert.Len(t, loadSelectedNamespaces(), 1) - - // Add second namespace - ns2 := v1.Namespace{ - ObjectMeta: metav1.ObjectMeta{Name: "namespace-2"}, - } - c.addSelectedNamespaceToCache(ns2) - assert.Contains(t, loadSelectedNamespaces(), "namespace-1") - assert.Contains(t, loadSelectedNamespaces(), "namespace-2") - assert.Len(t, loadSelectedNamespaces(), 2) -} - -func TestRemoveSelectedNamespaceFromCache(t *testing.T) { - tests := []struct { - name string - initialCache []string - namespaceToRemove string - expectedCache []string - }{ - { - name: "Remove existing namespace", - initialCache: []string{"ns-1", "ns-2", "ns-3"}, - namespaceToRemove: "ns-2", - expectedCache: []string{"ns-1", "ns-3"}, - }, - { - name: "Remove non-existing namespace", - initialCache: []string{"ns-1", "ns-2"}, - namespaceToRemove: "ns-3", - expectedCache: []string{"ns-1", "ns-2"}, - }, - { - name: "Remove from empty cache", - initialCache: []string{}, - namespaceToRemove: "ns-1", - expectedCache: []string{}, - }, - { - name: "Remove only namespace", - initialCache: []string{"ns-1"}, - namespaceToRemove: "ns-1", - expectedCache: []string{}, - }, - } - - for _, tt := range tests { - t.Run( - tt.name, func(t *testing.T) { - resetGlobalState() - storeSelectedNamespaces(tt.initialCache) - - c := newTestController([]string{}, "env=prod") - ns := v1.Namespace{ - ObjectMeta: metav1.ObjectMeta{Name: tt.namespaceToRemove}, - } - c.removeSelectedNamespaceFromCache(ns) - - assert.ElementsMatch(t, tt.expectedCache, loadSelectedNamespacesList()) - }, - ) - } -} - -func TestAddHandler(t *testing.T) { - tests := []struct { - name string - reloadOnCreate string - ignoredNamespaces []string - resource interface{} - controllersInit bool - expectQueueItem bool - }{ - { - name: "Namespace resource - should not queue", - reloadOnCreate: "true", - ignoredNamespaces: []string{}, - resource: &v1.Namespace{ - ObjectMeta: metav1.ObjectMeta{Name: "test-ns"}, - }, - controllersInit: true, - expectQueueItem: false, - }, - { - name: "ReloadOnCreate disabled", - reloadOnCreate: "false", - ignoredNamespaces: []string{}, - resource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "default", - }, - }, - controllersInit: true, - expectQueueItem: false, - }, - { - name: "ConfigMap in ignored namespace", - reloadOnCreate: "true", - ignoredNamespaces: []string{"kube-system"}, - resource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "kube-system", - }, - }, - controllersInit: true, - expectQueueItem: false, - }, - { - name: "Controllers not initialized", - reloadOnCreate: "true", - ignoredNamespaces: []string{}, - resource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "default", - }, - }, - controllersInit: false, - expectQueueItem: false, - }, - { - name: "Valid ConfigMap - should queue", - reloadOnCreate: "true", - ignoredNamespaces: []string{}, - resource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "default", - }, - }, - controllersInit: true, - expectQueueItem: true, - }, - } - - for _, tt := range tests { - t.Run( - tt.name, func(t *testing.T) { - resetGlobalState() - options.ReloadOnCreate = tt.reloadOnCreate - secretControllerInitialized.Store(tt.controllersInit) - configmapControllerInitialized.Store(tt.controllersInit) - - c := newTestController(tt.ignoredNamespaces, "") - c.Add(tt.resource) - - if tt.expectQueueItem { - assert.Equal(t, 1, c.queue.Len(), "Expected queue to have 1 item") - } else { - assert.Equal(t, 0, c.queue.Len(), "Expected queue to be empty") - } - }, - ) - } -} - -func TestUpdateHandler(t *testing.T) { - tests := []struct { - name string - ignoredNamespaces []string - namespaceSelector string - cachedNamespaces []string - oldResource interface{} - newResource interface{} - expectQueueItem bool - }{ - { - name: "Namespace resource - should not queue", - ignoredNamespaces: []string{}, - oldResource: &v1.Namespace{ - ObjectMeta: metav1.ObjectMeta{Name: "test-ns"}, - }, - newResource: &v1.Namespace{ - ObjectMeta: metav1.ObjectMeta{Name: "test-ns"}, - }, - expectQueueItem: false, - }, - { - name: "ConfigMap in ignored namespace", - ignoredNamespaces: []string{"kube-system"}, - oldResource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "kube-system", - }, - }, - newResource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "kube-system", - }, - }, - expectQueueItem: false, - }, - { - name: "ConfigMap not in selected namespace", - ignoredNamespaces: []string{}, - namespaceSelector: "env=prod", - cachedNamespaces: []string{"prod-ns"}, - oldResource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "dev-ns", - }, - }, - newResource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "dev-ns", - }, - }, - expectQueueItem: false, - }, - { - name: "Valid ConfigMap update - should queue", - ignoredNamespaces: []string{}, - oldResource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "default", - }, - Data: map[string]string{"key": "old-value"}, - }, - newResource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "default", - }, - Data: map[string]string{"key": "new-value"}, - }, - expectQueueItem: true, - }, - { - name: "Valid Secret update - should queue", - ignoredNamespaces: []string{}, - oldResource: &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "default", - }, - }, - newResource: &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "default", - }, - }, - expectQueueItem: true, - }, - } - - for _, tt := range tests { - t.Run( - tt.name, func(t *testing.T) { - resetGlobalState() - if tt.cachedNamespaces != nil { - storeSelectedNamespaces(tt.cachedNamespaces) - } - - c := newTestController(tt.ignoredNamespaces, tt.namespaceSelector) - c.Update(tt.oldResource, tt.newResource) - - if tt.expectQueueItem { - assert.Equal(t, 1, c.queue.Len(), "Expected queue to have 1 item") - // Verify the queued item is the correct type - item, _ := c.queue.Get() - _, ok := item.(handler.ResourceUpdatedHandler) - assert.True(t, ok, "Expected ResourceUpdatedHandler in queue") - c.queue.Done(item) - } else { - assert.Equal(t, 0, c.queue.Len(), "Expected queue to be empty") - } - }, - ) - } -} - -func TestDeleteHandler(t *testing.T) { - tests := []struct { - name string - reloadOnDelete string - ignoredNamespaces []string - resource interface{} - controllersInit bool - expectQueueItem bool - }{ - { - name: "ReloadOnDelete disabled", - reloadOnDelete: "false", - ignoredNamespaces: []string{}, - resource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "default", - }, - }, - controllersInit: true, - expectQueueItem: false, - }, - { - name: "ConfigMap in ignored namespace", - reloadOnDelete: "true", - ignoredNamespaces: []string{"kube-system"}, - resource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "kube-system", - }, - }, - controllersInit: true, - expectQueueItem: false, - }, - { - name: "Controllers not initialized", - reloadOnDelete: "true", - ignoredNamespaces: []string{}, - resource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "default", - }, - }, - controllersInit: false, - expectQueueItem: false, - }, - { - name: "Valid ConfigMap delete - should queue", - reloadOnDelete: "true", - ignoredNamespaces: []string{}, - resource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "default", - }, - }, - controllersInit: true, - expectQueueItem: true, - }, - { - name: "Namespace delete - updates cache", - reloadOnDelete: "false", // Disable to test cache update only - ignoredNamespaces: []string{}, - resource: &v1.Namespace{ - ObjectMeta: metav1.ObjectMeta{Name: "test-ns"}, - }, - controllersInit: true, - expectQueueItem: false, - }, - } - - for _, tt := range tests { - t.Run( - tt.name, func(t *testing.T) { - resetGlobalState() - options.ReloadOnDelete = tt.reloadOnDelete - secretControllerInitialized.Store(tt.controllersInit) - configmapControllerInitialized.Store(tt.controllersInit) - - c := newTestController(tt.ignoredNamespaces, "") - c.Delete(tt.resource) - - if tt.expectQueueItem { - assert.Equal(t, 1, c.queue.Len(), "Expected queue to have 1 item") - // Verify the queued item is the correct type - item, _ := c.queue.Get() - _, ok := item.(handler.ResourceDeleteHandler) - assert.True(t, ok, "Expected ResourceDeleteHandler in queue") - c.queue.Done(item) - } else { - assert.Equal(t, 0, c.queue.Len(), "Expected queue to be empty") - } - }, - ) - } -} - -func TestHandleErr(t *testing.T) { - t.Run( - "No error - should forget key", func(t *testing.T) { - resetGlobalState() - c := newTestController([]string{}, "") - - key := "test-key" - // Add key to queue first - c.queue.Add(key) - item, _ := c.queue.Get() - - // Handle with no error - c.handleErr(nil, item) - c.queue.Done(item) - - // Key should be forgotten (NumRequeues should be 0) - assert.Equal(t, 0, c.queue.NumRequeues(key)) - }, - ) - - t.Run( - "Error at max retries - should drop key", func(t *testing.T) { - resetGlobalState() - c := newTestController([]string{}, "") - - key := "test-key-max" - - // Simulate 5 previous failures (max retries) - for range 5 { - c.queue.AddRateLimited(key) - } - - // After max retries, handleErr should forget the key - c.handleErr(assert.AnError, key) - - // Key should be forgotten - assert.Equal(t, 0, c.queue.NumRequeues(key)) - }, - ) -} - -func TestAddHandlerWithNamespaceEvent(t *testing.T) { - resetGlobalState() - - c := newTestController([]string{}, "env=prod") - - // When a namespace is added, it should be cached - ns := &v1.Namespace{ - ObjectMeta: metav1.ObjectMeta{Name: "new-namespace"}, - } - - c.Add(ns) - - assert.Contains(t, loadSelectedNamespaces(), "new-namespace") - assert.Equal(t, 0, c.queue.Len(), "Namespace add should not queue anything") -} - -func TestDeleteHandlerWithNamespaceEvent(t *testing.T) { - resetGlobalState() - storeSelectedNamespaces([]string{"ns-1", "ns-to-delete", "ns-2"}) - - c := newTestController([]string{}, "env=prod") - options.ReloadOnDelete = "true" - secretControllerInitialized.Store(true) - configmapControllerInitialized.Store(true) - - ns := &v1.Namespace{ - ObjectMeta: metav1.ObjectMeta{Name: "ns-to-delete"}, - } - - c.Delete(ns) - - assert.NotContains(t, loadSelectedNamespaces(), "ns-to-delete") - assert.Contains(t, loadSelectedNamespaces(), "ns-1") - assert.Contains(t, loadSelectedNamespaces(), "ns-2") - assert.Equal(t, 0, c.queue.Len(), "Namespace delete should not queue anything") -} - -func TestProcessNextItem(t *testing.T) { - tests := []struct { - name string - handler *mockResourceHandler - expectContinue bool - expectCalls int - }{ - { - name: "Successful handler execution", - handler: &mockResourceHandler{ - handleErr: nil, - enqueueTime: time.Now().Add(-10 * time.Millisecond), - }, - expectContinue: true, - expectCalls: 1, - }, - { - name: "Handler returns error", - handler: &mockResourceHandler{ - handleErr: errors.New("test error"), - enqueueTime: time.Now().Add(-10 * time.Millisecond), - }, - expectContinue: true, - expectCalls: 1, - }, - } - - for _, tt := range tests { - t.Run( - tt.name, func(t *testing.T) { - resetGlobalState() - c := newTestController([]string{}, "") - - c.queue.Add(tt.handler) - - result := c.processNextItem() - - assert.Equal(t, tt.expectContinue, result) - assert.Equal(t, tt.expectCalls, tt.handler.handleCalls) - }, - ) - } -} - -func TestProcessNextItemQueueShutdown(t *testing.T) { - resetGlobalState() - c := newTestController([]string{}, "") - - c.queue.ShutDown() - - result := c.processNextItem() - assert.False(t, result, "Should return false when queue is shutdown") -} diff --git a/internal/pkg/controller/deployment_reconciler.go b/internal/pkg/controller/deployment_reconciler.go new file mode 100644 index 000000000..ebc1b759a --- /dev/null +++ b/internal/pkg/controller/deployment_reconciler.go @@ -0,0 +1,100 @@ +package controller + +import ( + "context" + + "github.com/go-logr/logr" + appsv1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/api/errors" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + "github.com/stakater/Reloader/internal/pkg/config" + "github.com/stakater/Reloader/internal/pkg/reload" +) + +// DeploymentReconciler reconciles Deployment objects to handle pause expiration. +// This reconciler watches for deployments that were paused by Reloader and +// unpauses them when the pause period expires. +type DeploymentReconciler struct { + client.Client + Log logr.Logger + Config *config.Config + PauseHandler *reload.PauseHandler +} + +// Reconcile handles Deployment pause expiration. +func (r *DeploymentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := r.Log.WithValues("deployment", req.NamespacedName) + log.V(1).Info("reconciling deployment", "namespace", req.Namespace, "name", req.Name) + + var deploy appsv1.Deployment + if err := r.Get(ctx, req.NamespacedName, &deploy); err != nil { + if errors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + // Check if this deployment was paused by Reloader + if !r.PauseHandler.IsPausedByReloader(&deploy) { + return ctrl.Result{}, nil + } + + // Check if pause period has expired + expired, remainingTime, err := r.PauseHandler.CheckPauseExpired(&deploy) + if err != nil { + log.Error(err, "Failed to check pause expiration") + return ctrl.Result{}, err + } + + if !expired { + // Still within pause period - requeue to check again + log.V(1).Info("Deployment pause not yet expired", "remaining", remainingTime) + return ctrl.Result{RequeueAfter: remainingTime}, nil + } + + log.Info("Unpausing deployment after pause period expired") + err = UpdateObjectWithRetry( + ctx, r.Client, &deploy, func() (bool, error) { + if !r.PauseHandler.IsPausedByReloader(&deploy) { + return false, nil + } + r.PauseHandler.ClearPause(&deploy) + return true, nil + }, + ) + + if err != nil { + log.Error(err, "Failed to unpause deployment") + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil +} + +// SetupWithManager sets up the DeploymentReconciler with the manager. +func (r *DeploymentReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&appsv1.Deployment{}). + WithEventFilter(r.pausedByReloaderPredicate()). + Complete(r) +} + +// pausedByReloaderPredicate returns a predicate that only selects deployments +// that have been paused by Reloader (have the paused-at annotation). +func (r *DeploymentReconciler) pausedByReloaderPredicate() predicate.Predicate { + return predicate.NewPredicateFuncs( + func(obj client.Object) bool { + annotations := obj.GetAnnotations() + if annotations == nil { + return false + } + + // Only process if deployment has our paused-at annotation + _, hasPausedAt := annotations[r.Config.Annotations.PausedAt] + return hasPausedAt + }, + ) +} diff --git a/internal/pkg/controller/filter.go b/internal/pkg/controller/filter.go new file mode 100644 index 000000000..7503bc40b --- /dev/null +++ b/internal/pkg/controller/filter.go @@ -0,0 +1,56 @@ +package controller + +import ( + "time" + + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + "github.com/stakater/Reloader/internal/pkg/config" + "github.com/stakater/Reloader/internal/pkg/reload" +) + +// BuildEventFilter combines a resource-specific predicate with common filters. +// +// startTime is the moment the controller began watching; it is used to tell +// genuine post-startup creates apart from the initial-sync replay of +// pre-existing resources (which the informer delivers as create events). +func BuildEventFilter(resourcePredicate predicate.Predicate, cfg *config.Config, startTime time.Time) predicate.Predicate { + return predicate.And( + resourcePredicate, + reload.NamespaceFilterPredicate(cfg), + reload.LabelSelectorPredicate(cfg), + reload.IgnoreAnnotationPredicate(cfg), + createEventPredicate(cfg, startTime), + ) +} + +func createEventPredicate(cfg *config.Config, startTime time.Time) predicate.Predicate { + return predicate.Funcs{ + CreateFunc: func(e event.CreateEvent) bool { + if !cfg.ReloadOnCreate { + return false + } + // SyncAfterRestart processes every create, including the + // initial-sync replay of resources that already existed. + if cfg.SyncAfterRestart { + return true + } + // Otherwise only honor resources created after the controller + // started. Resources replayed during the initial cache sync carry + // an older creation timestamp and must not trigger reloads on + // startup, but a genuine create that arrives afterwards must be + // honored even if it is the very first event this controller sees. + return e.Object.GetCreationTimestamp().After(startTime) + }, + UpdateFunc: func(e event.UpdateEvent) bool { + return true + }, + DeleteFunc: func(e event.DeleteEvent) bool { + return cfg.ReloadOnDelete + }, + GenericFunc: func(e event.GenericEvent) bool { + return false + }, + } +} diff --git a/internal/pkg/controller/filter_test.go b/internal/pkg/controller/filter_test.go new file mode 100644 index 000000000..16b2ae8ea --- /dev/null +++ b/internal/pkg/controller/filter_test.go @@ -0,0 +1,213 @@ +package controller + +import ( + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/event" + + "github.com/stakater/Reloader/internal/pkg/config" +) + +func TestCreateEventPredicate_CreateEvent(t *testing.T) { + startTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + reloadOnCreate bool + syncAfterRestart bool + // createdAfterStart controls the resource's creation timestamp relative + // to the controller start time: true => created after start (a genuine + // post-startup create), false => created before start (initial-sync replay). + createdAfterStart bool + expectedResult bool + }{ + { + // Regression: a genuine create after startup must be honored even + // when it is the very first event the controller sees (no prior + // reconcile). This is the reloadOnCreate e2e scenario. + name: "reload on create enabled, created after start", + reloadOnCreate: true, + syncAfterRestart: false, + createdAfterStart: true, + expectedResult: true, + }, + { + // Pre-existing resources replayed during initial sync must not + // trigger reloads on startup. + name: "reload on create enabled, created before start (initial sync replay)", + reloadOnCreate: true, + syncAfterRestart: false, + createdAfterStart: false, + expectedResult: false, + }, + { + name: "reload on create disabled", + reloadOnCreate: false, + syncAfterRestart: false, + createdAfterStart: true, + expectedResult: false, + }, + { + // SyncAfterRestart processes every create, including initial-sync + // replays of pre-existing resources. + name: "sync after restart honors pre-existing create", + reloadOnCreate: true, + syncAfterRestart: true, + createdAfterStart: false, + expectedResult: true, + }, + { + name: "sync after restart but reload on create disabled", + reloadOnCreate: false, + syncAfterRestart: true, + createdAfterStart: true, + expectedResult: false, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + cfg := &config.Config{ + ReloadOnCreate: tt.reloadOnCreate, + SyncAfterRestart: tt.syncAfterRestart, + } + + pred := createEventPredicate(cfg, startTime) + + creationTime := startTime.Add(-time.Hour) + if tt.createdAfterStart { + creationTime = startTime.Add(time.Hour) + } + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + CreationTimestamp: metav1.NewTime(creationTime), + }, + } + + e := event.CreateEvent{Object: cm} + result := pred.Create(e) + + if result != tt.expectedResult { + t.Errorf("CreateFunc() = %v, want %v", result, tt.expectedResult) + } + }, + ) + } +} + +func TestCreateEventPredicate_UpdateEvent(t *testing.T) { + cfg := &config.Config{} + + pred := createEventPredicate(cfg, time.Now()) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + e := event.UpdateEvent{ObjectOld: cm, ObjectNew: cm} + result := pred.Update(e) + + if !result { + t.Error("UpdateFunc() should always return true") + } +} + +func TestCreateEventPredicate_DeleteEvent(t *testing.T) { + tests := []struct { + name string + reloadOnDelete bool + expectedResult bool + }{ + { + name: "reload on delete enabled", + reloadOnDelete: true, + expectedResult: true, + }, + { + name: "reload on delete disabled", + reloadOnDelete: false, + expectedResult: false, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + cfg := &config.Config{ + ReloadOnDelete: tt.reloadOnDelete, + } + + pred := createEventPredicate(cfg, time.Now()) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + e := event.DeleteEvent{Object: cm} + result := pred.Delete(e) + + if result != tt.expectedResult { + t.Errorf("DeleteFunc() = %v, want %v", result, tt.expectedResult) + } + }, + ) + } +} + +func TestCreateEventPredicate_GenericEvent(t *testing.T) { + cfg := &config.Config{} + + pred := createEventPredicate(cfg, time.Now()) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + e := event.GenericEvent{Object: cm} + result := pred.Generic(e) + + if result { + t.Error("GenericFunc() should always return false") + } +} + +func TestBuildEventFilter(t *testing.T) { + cfg := &config.Config{ + ReloadOnCreate: true, + ReloadOnDelete: true, + } + + resourcePred := &alwaysTruePredicate{} + + filter := BuildEventFilter(resourcePred, cfg, time.Now()) + + if filter == nil { + t.Fatal("BuildEventFilter() should return a non-nil predicate") + } + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + e := event.UpdateEvent{ObjectOld: cm, ObjectNew: cm} + result := filter.Update(e) + + if !result { + t.Error("UpdateFunc() should return true when all predicates pass") + } +} + +// alwaysTruePredicate is a helper predicate for testing +type alwaysTruePredicate struct{} + +func (p *alwaysTruePredicate) Create(_ event.CreateEvent) bool { return true } +func (p *alwaysTruePredicate) Delete(_ event.DeleteEvent) bool { return true } +func (p *alwaysTruePredicate) Update(_ event.UpdateEvent) bool { return true } +func (p *alwaysTruePredicate) Generic(_ event.GenericEvent) bool { return true } diff --git a/internal/pkg/controller/handler.go b/internal/pkg/controller/handler.go new file mode 100644 index 000000000..062001846 --- /dev/null +++ b/internal/pkg/controller/handler.go @@ -0,0 +1,197 @@ +package controller + +import ( + "context" + "time" + + "github.com/go-logr/logr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/stakater/Reloader/internal/pkg/alerting" + "github.com/stakater/Reloader/internal/pkg/events" + "github.com/stakater/Reloader/internal/pkg/metrics" + "github.com/stakater/Reloader/internal/pkg/reload" + "github.com/stakater/Reloader/internal/pkg/webhook" + "github.com/stakater/Reloader/internal/pkg/workload" +) + +// ReloadHandler handles the common reload workflow. +type ReloadHandler struct { + Client client.Client + Lister *workload.Lister + ReloadService *reload.Service + WebhookClient *webhook.Client + Collectors *metrics.Collectors + EventRecorder *events.Recorder + Alerter alerting.Alerter + PauseHandler *reload.PauseHandler +} + +// Process handles the reload workflow: list workloads, get decisions, webhook or apply. +func (h *ReloadHandler) Process( + ctx context.Context, + namespace, resourceName string, + resourceType reload.ResourceType, + getDecisions func([]workload.Workload) []reload.ReloadDecision, + log logr.Logger, +) (ctrl.Result, error) { + workloads, err := h.Lister.List(ctx, namespace) + if err != nil { + log.Error(err, "failed to list workloads") + h.Collectors.RecordError("list_workloads") + return ctrl.Result{}, err + } + + workloadsByKind := make(map[string]int) + for _, w := range workloads { + workloadsByKind[string(w.Kind())]++ + } + for kind, count := range workloadsByKind { + h.Collectors.RecordWorkloadsScanned(kind, count) + } + + decisions := reload.FilterDecisions(getDecisions(workloads)) + + matchedByKind := make(map[string]int) + for _, d := range decisions { + matchedByKind[string(d.Workload.Kind())]++ + } + for kind, count := range matchedByKind { + h.Collectors.RecordWorkloadsMatched(kind, count) + } + + if len(decisions) == 0 { + h.Collectors.RecordSkipped("no_match") + } + + if h.WebhookClient.IsConfigured() && len(decisions) > 0 { + return h.sendWebhook(ctx, resourceName, namespace, resourceType, decisions, log) + } + + h.applyReloads(ctx, resourceName, namespace, resourceType, decisions, log) + return ctrl.Result{}, nil +} + +func (h *ReloadHandler) sendWebhook( + ctx context.Context, + resourceName, namespace string, + resourceType reload.ResourceType, + decisions []reload.ReloadDecision, + log logr.Logger, +) (ctrl.Result, error) { + var workloads []webhook.WorkloadInfo + var hash string + for _, d := range decisions { + workloads = append( + workloads, webhook.WorkloadInfo{ + Kind: string(d.Workload.Kind()), + Name: d.Workload.GetName(), + Namespace: d.Workload.GetNamespace(), + }, + ) + if hash == "" { + hash = d.Hash + } + } + + payload := webhook.Payload{ + Kind: string(resourceType), + Namespace: namespace, + ResourceName: resourceName, + ResourceType: string(resourceType), + Hash: hash, + Timestamp: time.Now().UTC(), + Workloads: workloads, + } + + actionStartTime := time.Now() + if err := h.WebhookClient.Send(ctx, payload); err != nil { + log.Error(err, "failed to send webhook notification") + h.Collectors.RecordReload(false, namespace) + h.Collectors.RecordAction("webhook", "error", time.Since(actionStartTime)) + h.Collectors.RecordError("webhook_send") + return ctrl.Result{}, err + } + + log.Info( + "webhook notification sent", + "resource", resourceName, + "workloadCount", len(workloads), + ) + h.Collectors.RecordReload(true, namespace) + h.Collectors.RecordAction("webhook", "success", time.Since(actionStartTime)) + return ctrl.Result{}, nil +} + +func (h *ReloadHandler) applyReloads( + ctx context.Context, + resourceName, resourceNamespace string, + resourceType reload.ResourceType, + decisions []reload.ReloadDecision, + log logr.Logger, +) { + for _, decision := range decisions { + log.Info( + "reloading workload", + "workload", decision.Workload.GetName(), + "kind", decision.Workload.Kind(), + "reason", decision.Reason, + ) + + actionStartTime := time.Now() + updated, err := UpdateWorkloadWithRetry( + ctx, + h.Client, + h.ReloadService, + h.PauseHandler, + decision.Workload, + resourceName, + resourceType, + resourceNamespace, + decision.Hash, + decision.AutoReload, + ) + actionLatency := time.Since(actionStartTime) + + if err != nil { + log.Error( + err, "failed to update workload", + "workload", decision.Workload.GetName(), + "kind", decision.Workload.Kind(), + ) + h.EventRecorder.ReloadFailed(decision.Workload.GetObject(), resourceType.Kind(), resourceName, err) + h.Collectors.RecordReload(false, resourceNamespace) + h.Collectors.RecordAction(string(decision.Workload.Kind()), "error", actionLatency) + h.Collectors.RecordError("update_workload") + continue + } + + if updated { + h.EventRecorder.ReloadSuccess(decision.Workload.GetObject(), resourceType.Kind(), resourceName) + h.Collectors.RecordReload(true, resourceNamespace) + h.Collectors.RecordAction(string(decision.Workload.Kind()), "success", actionLatency) + log.Info( + "workload reloaded successfully", + "workload", decision.Workload.GetName(), + "kind", decision.Workload.Kind(), + ) + + if err := h.Alerter.Send( + ctx, alerting.AlertMessage{ + WorkloadKind: string(decision.Workload.Kind()), + WorkloadName: decision.Workload.GetName(), + WorkloadNamespace: decision.Workload.GetNamespace(), + ResourceKind: resourceType.Kind(), + ResourceName: resourceName, + ResourceNamespace: resourceNamespace, + Timestamp: time.Now(), + }, + ); err != nil { + log.Error(err, "failed to send alert") + } + } else { + h.Collectors.RecordAction(string(decision.Workload.Kind()), "no_change", actionLatency) + } + } +} diff --git a/internal/pkg/controller/manager.go b/internal/pkg/controller/manager.go new file mode 100644 index 000000000..2190b8b36 --- /dev/null +++ b/internal/pkg/controller/manager.go @@ -0,0 +1,244 @@ +package controller + +import ( + "context" + "fmt" + + argorolloutsv1alpha1 "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1" + "github.com/go-logr/logr" + openshiftv1 "github.com/openshift/api/apps/v1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/healthz" + ctrlmetrics "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + "github.com/stakater/Reloader/internal/pkg/alerting" + "github.com/stakater/Reloader/internal/pkg/config" + "github.com/stakater/Reloader/internal/pkg/events" + "github.com/stakater/Reloader/internal/pkg/metrics" + "github.com/stakater/Reloader/internal/pkg/reload" + "github.com/stakater/Reloader/internal/pkg/webhook" + "github.com/stakater/Reloader/internal/pkg/workload" +) + +var runtimeScheme = runtime.NewScheme() + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(runtimeScheme)) +} + +// AddOptionalSchemes adds optional workload type schemes if enabled. +func AddOptionalSchemes(argoRolloutsEnabled, deploymentConfigEnabled bool) { + if argoRolloutsEnabled { + utilruntime.Must(argorolloutsv1alpha1.AddToScheme(runtimeScheme)) + } + if deploymentConfigEnabled { + utilruntime.Must(openshiftv1.AddToScheme(runtimeScheme)) + } +} + +// ManagerOptions contains options for creating a new Manager. +type ManagerOptions struct { + Config *config.Config + Log logr.Logger + Collectors *metrics.Collectors +} + +// NewManager creates a new controller-runtime manager with the given options. +// This follows controller-runtime and operator-sdk conventions for leader election. +func NewManager(opts ManagerOptions) (ctrl.Manager, error) { + cfg := opts.Config + le := cfg.LeaderElection + + mgrOpts := ctrl.Options{ + Scheme: runtimeScheme, + Metrics: ctrlmetrics.Options{ + BindAddress: cfg.MetricsAddr, + }, + HealthProbeBindAddress: cfg.HealthAddr, + + // Leader election configuration following operator-sdk best practices: + // - LeaderElection enables/disables leader election + // - LeaderElectionID is the name of the lease resource + // - LeaderElectionNamespace where the lease is created (defaults to pod namespace) + // - LeaderElectionReleaseOnCancel allows faster failover by releasing the lock on shutdown + LeaderElection: cfg.EnableHA, + LeaderElectionID: le.LockName, + LeaderElectionNamespace: le.Namespace, + LeaderElectionReleaseOnCancel: le.ReleaseOnCancel, + LeaseDuration: &le.LeaseDuration, + RenewDeadline: &le.RenewDeadline, + RetryPeriod: &le.RetryPeriod, + } + + if cfg.WatchedNamespace != "" { + mgrOpts.Cache = cache.Options{ + DefaultNamespaces: map[string]cache.Config{ + cfg.WatchedNamespace: {}, + }, + } + opts.Log.Info("namespace filtering enabled", "namespace", cfg.WatchedNamespace) + } + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), mgrOpts) + if err != nil { + return nil, fmt.Errorf("creating manager: %w", err) + } + + // Add health and readiness probes. + // The healthz probe reports whether the manager is running. + // The readyz probe reports whether the manager is ready to serve requests. + // When leader election is enabled, readyz will fail until this instance becomes leader. + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + return nil, fmt.Errorf("setting up health check: %w", err) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + return nil, fmt.Errorf("setting up ready check: %w", err) + } + + return mgr, nil +} + +// NewManagerWithRestConfig creates a new controller-runtime manager with the given rest.Config. +// This is useful for testing where you have a pre-existing cluster configuration. +func NewManagerWithRestConfig(opts ManagerOptions, restConfig *rest.Config) (ctrl.Manager, error) { + cfg := opts.Config + le := cfg.LeaderElection + + mgrOpts := ctrl.Options{ + Scheme: runtimeScheme, + Metrics: ctrlmetrics.Options{ + BindAddress: "0", // Disable metrics server in tests + }, + HealthProbeBindAddress: "0", // Disable health probes in tests + + // Leader election configuration + LeaderElection: cfg.EnableHA, + LeaderElectionID: le.LockName, + LeaderElectionNamespace: le.Namespace, + LeaderElectionReleaseOnCancel: le.ReleaseOnCancel, + LeaseDuration: &le.LeaseDuration, + RenewDeadline: &le.RenewDeadline, + RetryPeriod: &le.RetryPeriod, + } + + if cfg.WatchedNamespace != "" { + mgrOpts.Cache = cache.Options{ + DefaultNamespaces: map[string]cache.Config{ + cfg.WatchedNamespace: {}, + }, + } + } + + mgr, err := ctrl.NewManager(restConfig, mgrOpts) + if err != nil { + return nil, fmt.Errorf("creating manager: %w", err) + } + + return mgr, nil +} + +// SetupReconcilers sets up all reconcilers with the manager. +func SetupReconcilers(mgr ctrl.Manager, cfg *config.Config, log logr.Logger, collectors *metrics.Collectors) error { + registry := workload.NewRegistry( + workload.RegistryOptions{ + ArgoRolloutsEnabled: cfg.ArgoRolloutsEnabled, + DeploymentConfigEnabled: cfg.DeploymentConfigEnabled, + RolloutStrategyAnnotation: cfg.Annotations.RolloutStrategy, + }, + ) + reloadService := reload.NewService(cfg, log.WithName("reload")) + eventRecorder := events.NewRecorder(mgr.GetEventRecorder("reloader")) + pauseHandler := reload.NewPauseHandler(cfg) + + // Create alerter based on configuration + alerter := alerting.NewAlerter(cfg) + if cfg.Alerting.Enabled { + log.Info("alerting enabled", "sink", cfg.Alerting.Sink) + } + + // Create webhook client if URL is configured + var webhookClient *webhook.Client + if cfg.WebhookURL != "" { + webhookClient = webhook.NewClient(cfg.WebhookURL, log.WithName("webhook")) + log.Info("webhook mode enabled", "url", cfg.WebhookURL) + } + + // Create namespace cache if namespace selectors are configured. + // This cache is shared between the namespace reconciler and resource reconcilers. + var nsCache *NamespaceCache + if len(cfg.NamespaceSelectors) > 0 { + nsCache = NewNamespaceCache(true) + if err := (&NamespaceReconciler{ + Client: mgr.GetClient(), + Log: log.WithName("namespace-reconciler"), + Config: cfg, + Cache: nsCache, + }).SetupWithManager(mgr); err != nil { + return fmt.Errorf("setting up namespace reconciler: %w", err) + } + log.Info("namespace reconciler enabled for label selector filtering") + } + + // Setup ConfigMap reconciler + if !cfg.IsResourceIgnored("configmaps") { + cmReconciler := NewConfigMapReconciler( + mgr.GetClient(), + log.WithName("configmap-reconciler"), + cfg, + reloadService, + registry, + collectors, + eventRecorder, + webhookClient, + alerter, + pauseHandler, + nsCache, + ) + if err := SetupConfigMapReconciler(mgr, cmReconciler); err != nil { + return fmt.Errorf("setting up configmap reconciler: %w", err) + } + } + + // Setup Secret reconciler + if !cfg.IsResourceIgnored("secrets") { + secretReconciler := NewSecretReconciler( + mgr.GetClient(), + log.WithName("secret-reconciler"), + cfg, + reloadService, + registry, + collectors, + eventRecorder, + webhookClient, + alerter, + pauseHandler, + nsCache, + ) + if err := SetupSecretReconciler(mgr, secretReconciler); err != nil { + return fmt.Errorf("setting up secret reconciler: %w", err) + } + } + + // Setup Deployment reconciler for pause handling + if err := (&DeploymentReconciler{ + Client: mgr.GetClient(), + Log: log.WithName("deployment-reconciler"), + Config: cfg, + PauseHandler: pauseHandler, + }).SetupWithManager(mgr); err != nil { + return fmt.Errorf("setting up deployment reconciler: %w", err) + } + + return nil +} + +// RunManager starts the manager and blocks until it stops. +func RunManager(ctx context.Context, mgr ctrl.Manager, log logr.Logger) error { + log.Info("starting manager") + return mgr.Start(ctx) +} diff --git a/internal/pkg/controller/namespace_reconciler.go b/internal/pkg/controller/namespace_reconciler.go new file mode 100644 index 000000000..4e220fd5e --- /dev/null +++ b/internal/pkg/controller/namespace_reconciler.go @@ -0,0 +1,145 @@ +package controller + +import ( + "context" + "sync" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/stakater/Reloader/internal/pkg/config" + "github.com/stakater/Reloader/internal/pkg/reload" +) + +// NamespaceCache provides thread-safe access to the set of namespaces +// that match the configured namespace label selector. +type NamespaceCache struct { + mu sync.RWMutex + namespaces map[string]struct{} + enabled bool +} + +// NewNamespaceCache creates a new NamespaceCache. +// If enabled is false, all namespace checks return true (allow all). +func NewNamespaceCache(enabled bool) *NamespaceCache { + return &NamespaceCache{ + namespaces: make(map[string]struct{}), + enabled: enabled, + } +} + +// Add adds a namespace to the cache. +func (c *NamespaceCache) Add(name string) { + c.mu.Lock() + defer c.mu.Unlock() + c.namespaces[name] = struct{}{} +} + +// Remove removes a namespace from the cache. +func (c *NamespaceCache) Remove(name string) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.namespaces, name) +} + +// Contains checks if a namespace is in the cache. +// If namespace selectors are not enabled, always returns true. +func (c *NamespaceCache) Contains(name string) bool { + if !c.enabled { + return true + } + c.mu.RLock() + defer c.mu.RUnlock() + _, ok := c.namespaces[name] + return ok +} + +// List returns a copy of all namespace names in the cache. +func (c *NamespaceCache) List() []string { + c.mu.RLock() + defer c.mu.RUnlock() + result := make([]string, 0, len(c.namespaces)) + for name := range c.namespaces { + result = append(result, name) + } + return result +} + +// IsEnabled returns whether namespace selector filtering is enabled. +func (c *NamespaceCache) IsEnabled() bool { + return c.enabled +} + +// NamespaceReconciler watches Namespace objects and maintains a cache +// of namespaces that match the configured label selector. +type NamespaceReconciler struct { + client.Client + Log logr.Logger + Config *config.Config + Cache *NamespaceCache +} + +// Reconcile handles Namespace events and updates the namespace cache. +func (r *NamespaceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := r.Log.WithValues("namespace", req.Name) + + var ns corev1.Namespace + if err := r.Get(ctx, req.NamespacedName, &ns); err != nil { + if errors.IsNotFound(err) { + // Namespace was deleted - remove from cache + r.Cache.Remove(req.Name) + log.V(1).Info("removed namespace from cache (deleted)") + return ctrl.Result{}, nil + } + log.Error(err, "failed to get Namespace") + return ctrl.Result{}, err + } + + // Check if namespace matches any of the configured selectors + if r.matchesSelectors(&ns) { + r.Cache.Add(ns.Name) + log.V(1).Info("added namespace to cache") + } else { + // Labels might have changed, remove from cache if no longer matches + r.Cache.Remove(ns.Name) + log.V(1).Info("removed namespace from cache (labels no longer match)") + } + + return ctrl.Result{}, nil +} + +// matchesSelectors checks if the namespace matches any configured label selector. +func (r *NamespaceReconciler) matchesSelectors(ns *corev1.Namespace) bool { + if len(r.Config.NamespaceSelectors) == 0 { + // No selectors configured - should not happen since reconciler is only + // set up when selectors are configured, but handle gracefully + return true + } + + nsLabels := ns.GetLabels() + if nsLabels == nil { + nsLabels = make(map[string]string) + } + + for _, selector := range r.Config.NamespaceSelectors { + if selector.Matches(reload.LabelsSet(nsLabels)) { + return true + } + } + + return false +} + +// SetupWithManager sets up the controller with the Manager. +func (r *NamespaceReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&corev1.Namespace{}). + Complete(r) +} + +// Ensure NamespaceReconciler implements reconcile.Reconciler +var _ reconcile.Reconciler = &NamespaceReconciler{} diff --git a/internal/pkg/controller/namespace_reconciler_test.go b/internal/pkg/controller/namespace_reconciler_test.go new file mode 100644 index 000000000..604dca92a --- /dev/null +++ b/internal/pkg/controller/namespace_reconciler_test.go @@ -0,0 +1,151 @@ +package controller_test + +import ( + "testing" + + "k8s.io/apimachinery/pkg/labels" + + "github.com/stakater/Reloader/internal/pkg/config" + "github.com/stakater/Reloader/internal/pkg/controller" + "github.com/stakater/Reloader/internal/pkg/testutil" +) + +func TestNamespaceCache_Basic(t *testing.T) { + cache := controller.NewNamespaceCache(true) + + cache.Add("namespace-1") + if !cache.Contains("namespace-1") { + t.Error("Cache should contain namespace-1") + } + if cache.Contains("namespace-2") { + t.Error("Cache should not contain namespace-2") + } + + cache.Remove("namespace-1") + if cache.Contains("namespace-1") { + t.Error("Cache should not contain namespace-1 after removal") + } +} + +func TestNamespaceCache_Disabled(t *testing.T) { + cache := controller.NewNamespaceCache(false) + + if !cache.Contains("any-namespace") { + t.Error("Disabled cache should return true for any namespace") + } +} + +func TestNamespaceCache_List(t *testing.T) { + cache := controller.NewNamespaceCache(true) + cache.Add("ns-1") + cache.Add("ns-2") + cache.Add("ns-3") + + list := cache.List() + if len(list) != 3 { + t.Errorf("Expected 3 namespaces, got %d", len(list)) + } + + found := make(map[string]bool) + for _, ns := range list { + found[ns] = true + } + for _, expected := range []string{"ns-1", "ns-2", "ns-3"} { + if !found[expected] { + t.Errorf("Expected %s in list", expected) + } + } +} + +func TestNamespaceCache_IsEnabled(t *testing.T) { + if !controller.NewNamespaceCache(true).IsEnabled() { + t.Error("EnabledCache.IsEnabled() should return true") + } + if controller.NewNamespaceCache(false).IsEnabled() { + t.Error("DisabledCache.IsEnabled() should return false") + } +} + +func TestNamespaceReconciler_Add(t *testing.T) { + cfg := config.NewDefault() + selector, _ := labels.Parse("env=production") + cfg.NamespaceSelectors = []labels.Selector{selector} + + cache := controller.NewNamespaceCache(true) + ns := testutil.NewNamespace("test-ns", map[string]string{"env": "production"}) + reconciler := newNamespaceReconciler(t, cfg, cache, ns) + + assertReconcileSuccess(t, reconciler, namespaceRequest("test-ns")) + + if !cache.Contains("test-ns") { + t.Error("Cache should contain test-ns after reconcile") + } +} + +func TestNamespaceReconciler_Remove_LabelChange(t *testing.T) { + cfg := config.NewDefault() + selector, _ := labels.Parse("env=production") + cfg.NamespaceSelectors = []labels.Selector{selector} + + cache := controller.NewNamespaceCache(true) + cache.Add("test-ns") // Pre-populate + + ns := testutil.NewNamespace("test-ns", map[string]string{"env": "staging"}) // Non-matching + reconciler := newNamespaceReconciler(t, cfg, cache, ns) + + assertReconcileSuccess(t, reconciler, namespaceRequest("test-ns")) + + if cache.Contains("test-ns") { + t.Error("Cache should not contain test-ns after reconcile (labels no longer match)") + } +} + +func TestNamespaceReconciler_Remove_Delete(t *testing.T) { + cfg := config.NewDefault() + selector, _ := labels.Parse("env=production") + cfg.NamespaceSelectors = []labels.Selector{selector} + + cache := controller.NewNamespaceCache(true) + cache.Add("deleted-ns") // Pre-populate + + reconciler := newNamespaceReconciler(t, cfg, cache) // No namespace in cluster + + assertReconcileSuccess(t, reconciler, namespaceRequest("deleted-ns")) + + if cache.Contains("deleted-ns") { + t.Error("Cache should not contain deleted-ns after reconcile") + } +} + +func TestNamespaceReconciler_MultipleSelectors(t *testing.T) { + cfg := config.NewDefault() + selector1, _ := labels.Parse("env=production") + selector2, _ := labels.Parse("team=platform") + cfg.NamespaceSelectors = []labels.Selector{selector1, selector2} + + cache := controller.NewNamespaceCache(true) + ns := testutil.NewNamespace("test-ns", map[string]string{"team": "platform"}) + reconciler := newNamespaceReconciler(t, cfg, cache, ns) + + assertReconcileSuccess(t, reconciler, namespaceRequest("test-ns")) + + if !cache.Contains("test-ns") { + t.Error("Cache should contain test-ns (matches second selector)") + } +} + +func TestNamespaceReconciler_NoLabels(t *testing.T) { + cfg := config.NewDefault() + selector, _ := labels.Parse("env=production") + cfg.NamespaceSelectors = []labels.Selector{selector} + + cache := controller.NewNamespaceCache(true) + ns := testutil.NewNamespace("test-ns", nil) // No labels + reconciler := newNamespaceReconciler(t, cfg, cache, ns) + + assertReconcileSuccess(t, reconciler, namespaceRequest("test-ns")) + + if cache.Contains("test-ns") { + t.Error("Cache should not contain test-ns (no labels)") + } +} diff --git a/internal/pkg/controller/resource_reconciler.go b/internal/pkg/controller/resource_reconciler.go new file mode 100644 index 000000000..0e511d74c --- /dev/null +++ b/internal/pkg/controller/resource_reconciler.go @@ -0,0 +1,194 @@ +package controller + +import ( + "context" + "time" + + "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/api/errors" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + "github.com/stakater/Reloader/internal/pkg/alerting" + "github.com/stakater/Reloader/internal/pkg/config" + "github.com/stakater/Reloader/internal/pkg/events" + "github.com/stakater/Reloader/internal/pkg/metrics" + "github.com/stakater/Reloader/internal/pkg/reload" + "github.com/stakater/Reloader/internal/pkg/webhook" + "github.com/stakater/Reloader/internal/pkg/workload" +) + +// ResourceReconcilerDeps holds shared dependencies for resource reconcilers. +type ResourceReconcilerDeps struct { + Client client.Client + Log logr.Logger + Config *config.Config + ReloadService *reload.Service + Registry *workload.Registry + Collectors *metrics.Collectors + EventRecorder *events.Recorder + WebhookClient *webhook.Client + Alerter alerting.Alerter + PauseHandler *reload.PauseHandler + NamespaceCache *NamespaceCache +} + +// ResourceConfig provides type-specific configuration for a resource reconciler. +type ResourceConfig[T client.Object] struct { + // ResourceType identifies the type of resource (configmap or secret). + ResourceType reload.ResourceType + + // NewResource creates a new instance of the resource type. + NewResource func() T + + // CreateChange creates a change event for the resource. + CreateChange func(resource T, eventType reload.EventType) reload.ResourceChange + + // CreatePredicates creates the predicates for this resource type. + CreatePredicates func(cfg *config.Config, hasher *reload.Hasher) predicate.Predicate +} + +// ResourceReconciler is a generic reconciler for ConfigMaps and Secrets. +type ResourceReconciler[T client.Object] struct { + ResourceReconcilerDeps + ResourceConfig[T] + + handler *ReloadHandler +} + +// NewResourceReconciler creates a new generic resource reconciler. +func NewResourceReconciler[T client.Object]( + deps ResourceReconcilerDeps, + cfg ResourceConfig[T], +) *ResourceReconciler[T] { + return &ResourceReconciler[T]{ + ResourceReconcilerDeps: deps, + ResourceConfig: cfg, + } +} + +// Reconcile handles resource events and triggers workload reloads as needed. +func (r *ResourceReconciler[T]) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + startTime := time.Now() + resourceType := string(r.ResourceType) + log := r.Log.WithValues(resourceType, req.NamespacedName) + + r.Collectors.RecordEventReceived("reconcile", resourceType) + + resource := r.NewResource() + if err := r.Client.Get(ctx, req.NamespacedName, resource); err != nil { + if errors.IsNotFound(err) { + return r.handleNotFound(ctx, req, log, startTime) + } + log.Error(err, "failed to get "+resourceType) + r.Collectors.RecordError("get_" + resourceType) + r.Collectors.RecordReconcile("error", time.Since(startTime)) + return ctrl.Result{}, err + } + + namespace := resource.GetNamespace() + if r.Config.IsNamespaceIgnored(namespace) { + log.V(1).Info("skipping " + resourceType + " in ignored namespace") + r.Collectors.RecordSkipped("ignored_namespace") + r.Collectors.RecordReconcile("success", time.Since(startTime)) + return ctrl.Result{}, nil + } + + if r.NamespaceCache != nil && r.NamespaceCache.IsEnabled() && !r.NamespaceCache.Contains(namespace) { + log.V(1).Info("skipping "+resourceType+" in namespace not matching selector", "namespace", namespace) + r.Collectors.RecordSkipped("namespace_selector") + r.Collectors.RecordReconcile("success", time.Since(startTime)) + return ctrl.Result{}, nil + } + + result, err := r.reloadHandler().Process( + ctx, req.Namespace, req.Name, r.ResourceType, + func(workloads []workload.Workload) []reload.ReloadDecision { + return r.ReloadService.Process(r.CreateChange(resource, reload.EventTypeUpdate), workloads) + }, log, + ) + + r.recordReconcile(startTime, err) + return result, err +} + +func (r *ResourceReconciler[T]) handleNotFound( + ctx context.Context, + req ctrl.Request, + log logr.Logger, + startTime time.Time, +) (ctrl.Result, error) { + if r.Config.ReloadOnDelete { + r.Collectors.RecordEventReceived("delete", string(r.ResourceType)) + result, err := r.handleDelete(ctx, req, log) + r.recordReconcile(startTime, err) + return result, err + } + r.Collectors.RecordSkipped("not_found") + r.Collectors.RecordReconcile("success", time.Since(startTime)) + return ctrl.Result{}, nil +} + +func (r *ResourceReconciler[T]) handleDelete( + ctx context.Context, + req ctrl.Request, + log logr.Logger, +) (ctrl.Result, error) { + log.Info("handling " + string(r.ResourceType) + " deletion") + + // Create a minimal resource with just name/namespace for the delete event + resource := r.NewResource() + resource.SetName(req.Name) + resource.SetNamespace(req.Namespace) + + return r.reloadHandler().Process( + ctx, req.Namespace, req.Name, r.ResourceType, + func(workloads []workload.Workload) []reload.ReloadDecision { + return r.ReloadService.Process(r.CreateChange(resource, reload.EventTypeDelete), workloads) + }, log, + ) +} + +func (r *ResourceReconciler[T]) recordReconcile(startTime time.Time, err error) { + if err != nil { + r.Collectors.RecordReconcile("error", time.Since(startTime)) + } else { + r.Collectors.RecordReconcile("success", time.Since(startTime)) + } +} + +func (r *ResourceReconciler[T]) reloadHandler() *ReloadHandler { + if r.handler == nil { + r.handler = &ReloadHandler{ + Client: r.Client, + Lister: workload.NewLister(r.Client, r.Registry, r.Config), + ReloadService: r.ReloadService, + WebhookClient: r.WebhookClient, + Collectors: r.Collectors, + EventRecorder: r.EventRecorder, + Alerter: r.Alerter, + PauseHandler: r.PauseHandler, + } + } + return r.handler +} + +// SetupWithManager sets up the controller with the Manager. +func (r *ResourceReconciler[T]) SetupWithManager(mgr ctrl.Manager, forObject T) error { + // Capture the moment the controller is wired up (before the manager starts + // watching). Resources that already exist are replayed during the initial + // cache sync with an older creation timestamp; the create predicate uses + // this to ignore those replays while still honoring genuine creates that + // arrive afterwards. + startTime := time.Now() + return ctrl.NewControllerManagedBy(mgr). + For(forObject). + WithEventFilter( + BuildEventFilter( + r.CreatePredicates(r.Config, r.ReloadService.Hasher()), + r.Config, startTime, + ), + ). + Complete(r) +} diff --git a/internal/pkg/controller/retry.go b/internal/pkg/controller/retry.go new file mode 100644 index 000000000..ffb30615e --- /dev/null +++ b/internal/pkg/controller/retry.go @@ -0,0 +1,206 @@ +package controller + +import ( + "context" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/stakater/Reloader/internal/pkg/reload" + "github.com/stakater/Reloader/internal/pkg/workload" +) + +// UpdateObjectWithRetry updates a Kubernetes object with retry on conflict. +// It re-fetches the object on each retry attempt and calls modifyFn to apply changes. +// The modifyFn receives the latest version of the object and should modify it in place. +// If modifyFn returns false, the update is skipped (e.g., if the condition no longer applies). +func UpdateObjectWithRetry( + ctx context.Context, + c client.Client, + obj client.Object, + modifyFn func() (shouldUpdate bool, err error), +) error { + return retry.RetryOnConflict( + retry.DefaultBackoff, func() error { + if err := c.Get(ctx, client.ObjectKeyFromObject(obj), obj); err != nil { + if errors.IsNotFound(err) { + return nil + } + return err + } + + shouldUpdate, err := modifyFn() + if err != nil { + return err + } + + if !shouldUpdate { + return nil + } + + return c.Update(ctx, obj, client.FieldOwner(workload.FieldManager)) + }, + ) +} + +// UpdateWorkloadWithRetry updates a workload with exponential backoff on conflict. +// On conflict, it re-fetches the object, re-applies the reload changes, and retries. +// Workloads use their UpdateStrategy to determine how they're updated: +// - UpdateStrategyPatch: uses strategic merge patch with retry (most workloads) +// - UpdateStrategyRecreate: deletes and recreates (Jobs) +// - UpdateStrategyCreateNew: creates a new resource from template (CronJobs) +// Deployments have additional pause handling for paused rollouts. +func UpdateWorkloadWithRetry( + ctx context.Context, + c client.Client, + reloadService *reload.Service, + pauseHandler *reload.PauseHandler, + wl workload.Workload, + resourceName string, + resourceType reload.ResourceType, + namespace string, + hash string, + autoReload bool, +) (bool, error) { + switch wl.UpdateStrategy() { + case workload.UpdateStrategyRecreate, workload.UpdateStrategyCreateNew: + return updateWithSpecialStrategy(ctx, c, reloadService, wl, resourceName, resourceType, namespace, hash, autoReload) + default: + // UpdateStrategyPatch: use standard retry logic with special handling for Deployments + if wl.Kind() == workload.KindDeployment { + return updateDeploymentWithPause(ctx, c, reloadService, pauseHandler, wl, resourceName, resourceType, namespace, hash, autoReload) + } + return updateStandardWorkload(ctx, c, reloadService, wl, resourceName, resourceType, namespace, hash, autoReload) + } +} + +// retryWithReload wraps the common retry logic for workload updates. +// It handles re-fetching on conflict, applying reload changes, and calling the update function. +func retryWithReload( + ctx context.Context, + c client.Client, + reloadService *reload.Service, + wl workload.Workload, + resourceName string, + resourceType reload.ResourceType, + namespace string, + hash string, + autoReload bool, + updateFn func() error, +) (bool, error) { + var updated bool + isFirstAttempt := true + + err := retry.RetryOnConflict( + retry.DefaultBackoff, func() error { + if !isFirstAttempt { + obj := wl.GetObject() + key := client.ObjectKeyFromObject(obj) + if err := c.Get(ctx, key, obj); err != nil { + if errors.IsNotFound(err) { + return nil + } + return err + } + wl.ResetOriginal() + } + isFirstAttempt = false + + var applyErr error + updated, applyErr = reloadService.ApplyReload(ctx, wl, resourceName, resourceType, namespace, hash, autoReload) + if applyErr != nil { + return applyErr + } + + if !updated { + return nil + } + + return updateFn() + }, + ) + + return updated, err +} + +// updateStandardWorkload updates DaemonSets, StatefulSets, etc. +func updateStandardWorkload( + ctx context.Context, + c client.Client, + reloadService *reload.Service, + wl workload.Workload, + resourceName string, + resourceType reload.ResourceType, + namespace string, + hash string, + autoReload bool, +) (bool, error) { + return retryWithReload( + ctx, c, reloadService, wl, resourceName, resourceType, namespace, hash, autoReload, + func() error { + return wl.Update(ctx, c) + }, + ) +} + +// updateDeploymentWithPause updates a Deployment and applies pause if configured. +func updateDeploymentWithPause( + ctx context.Context, + c client.Client, + reloadService *reload.Service, + pauseHandler *reload.PauseHandler, + wl workload.Workload, + resourceName string, + resourceType reload.ResourceType, + namespace string, + hash string, + autoReload bool, +) (bool, error) { + shouldPause := pauseHandler != nil && pauseHandler.ShouldPause(wl) + + return retryWithReload( + ctx, c, reloadService, wl, resourceName, resourceType, namespace, hash, autoReload, + func() error { + if shouldPause { + if err := pauseHandler.ApplyPause(wl); err != nil { + return err + } + } + return wl.Update(ctx, c) + }, + ) +} + +// updateWithSpecialStrategy handles workloads that don't use standard patch. +// It applies reload changes, then delegates to the workload's PerformSpecialUpdate. +func updateWithSpecialStrategy( + ctx context.Context, + c client.Client, + reloadService *reload.Service, + wl workload.Workload, + resourceName string, + resourceType reload.ResourceType, + namespace string, + hash string, + autoReload bool, +) (bool, error) { + updated, err := reloadService.ApplyReload( + ctx, + wl, + resourceName, + resourceType, + namespace, + hash, + autoReload, + ) + if err != nil { + return false, err + } + + if !updated { + return false, nil + } + + return wl.PerformSpecialUpdate(ctx, c) +} diff --git a/internal/pkg/controller/retry_test.go b/internal/pkg/controller/retry_test.go new file mode 100644 index 000000000..ff33c0b55 --- /dev/null +++ b/internal/pkg/controller/retry_test.go @@ -0,0 +1,587 @@ +package controller_test + +import ( + "context" + "testing" + + "github.com/go-logr/logr/testr" + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/stakater/Reloader/internal/pkg/config" + "github.com/stakater/Reloader/internal/pkg/controller" + "github.com/stakater/Reloader/internal/pkg/reload" + "github.com/stakater/Reloader/internal/pkg/testutil" + "github.com/stakater/Reloader/internal/pkg/workload" +) + +func TestUpdateWorkloadWithRetry_WorkloadTypes(t *testing.T) { + tests := []struct { + name string + object runtime.Object + workload func(runtime.Object) workload.Workload + resourceType reload.ResourceType + verify func(t *testing.T, c client.Client) + }{ + { + name: "Deployment", + object: testutil.NewDeployment("test-deployment", "default", nil), + workload: func(o runtime.Object) workload.Workload { + return workload.NewDeploymentWorkload(o.(*appsv1.Deployment)) + }, + resourceType: reload.ResourceTypeConfigMap, + verify: func(t *testing.T, c client.Client) { + var result appsv1.Deployment + if err := c.Get(context.Background(), types.NamespacedName{Name: "test-deployment", Namespace: "default"}, &result); err != nil { + t.Fatalf("Failed to get deployment: %v", err) + } + if result.Spec.Template.Annotations == nil { + t.Fatal("Expected pod template annotations to be set") + } + }, + }, + { + name: "DaemonSet", + object: testutil.NewDaemonSet("test-daemonset", "default", nil), + workload: func(o runtime.Object) workload.Workload { + return workload.NewDaemonSetWorkload(o.(*appsv1.DaemonSet)) + }, + resourceType: reload.ResourceTypeSecret, + verify: func(t *testing.T, c client.Client) { + var result appsv1.DaemonSet + if err := c.Get(context.Background(), types.NamespacedName{Name: "test-daemonset", Namespace: "default"}, &result); err != nil { + t.Fatalf("Failed to get daemonset: %v", err) + } + if result.Spec.Template.Annotations == nil { + t.Fatal("Expected pod template annotations to be set") + } + }, + }, + { + name: "StatefulSet", + object: testutil.NewStatefulSet("test-statefulset", "default", nil), + workload: func(o runtime.Object) workload.Workload { + return workload.NewStatefulSetWorkload(o.(*appsv1.StatefulSet)) + }, + resourceType: reload.ResourceTypeConfigMap, + verify: func(t *testing.T, c client.Client) { + var result appsv1.StatefulSet + if err := c.Get(context.Background(), types.NamespacedName{Name: "test-statefulset", Namespace: "default"}, &result); err != nil { + t.Fatalf("Failed to get statefulset: %v", err) + } + if result.Spec.Template.Annotations == nil { + t.Fatal("Expected pod template annotations to be set") + } + }, + }, + { + name: "Job", + object: testutil.NewJob("test-job", "default"), + workload: func(o runtime.Object) workload.Workload { + return workload.NewJobWorkload(o.(*batchv1.Job)) + }, + resourceType: reload.ResourceTypeConfigMap, + verify: func(t *testing.T, c client.Client) { + var jobs batchv1.JobList + if err := c.List(context.Background(), &jobs, client.InNamespace("default")); err != nil { + t.Fatalf("Failed to list jobs: %v", err) + } + if len(jobs.Items) != 1 { + t.Errorf("Expected 1 job (recreated), got %d", len(jobs.Items)) + } + }, + }, + { + name: "CronJob", + object: testutil.NewCronJob("test-cronjob", "default"), + workload: func(o runtime.Object) workload.Workload { + return workload.NewCronJobWorkload(o.(*batchv1.CronJob)) + }, + resourceType: reload.ResourceTypeSecret, + verify: func(t *testing.T, c client.Client) { + var jobs batchv1.JobList + if err := c.List(context.Background(), &jobs, client.InNamespace("default")); err != nil { + t.Fatalf("Failed to list jobs: %v", err) + } + if len(jobs.Items) != 1 { + t.Errorf("Expected 1 job from cronjob, got %d", len(jobs.Items)) + } + if len(jobs.Items) > 0 && jobs.Items[0].Annotations["cronjob.kubernetes.io/instantiate"] != "manual" { + t.Error("Expected job to have manual instantiate annotation") + } + }, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + cfg := config.NewDefault() + reloadService := reload.NewService(cfg, testr.New(t)) + + fakeClient := fake.NewClientBuilder(). + WithScheme(testutil.NewScheme()). + WithRuntimeObjects(tt.object). + Build() + + wl := tt.workload(tt.object) + + updated, err := controller.UpdateWorkloadWithRetry( + context.Background(), + fakeClient, + reloadService, + nil, // no pause handler + wl, + "test-resource", + tt.resourceType, + "default", + "abc123", + false, + ) + + if err != nil { + t.Fatalf("UpdateWorkloadWithRetry failed: %v", err) + } + if !updated { + t.Error("Expected workload to be updated") + } + + tt.verify(t, fakeClient) + }, + ) + } +} + +func TestUpdateWorkloadWithRetry_Strategies(t *testing.T) { + tests := []struct { + name string + strategy config.ReloadStrategy + verify func(t *testing.T, cfg *config.Config, result *appsv1.Deployment) + }{ + { + name: "EnvVarStrategy", + strategy: config.ReloadStrategyEnvVars, + verify: func(t *testing.T, cfg *config.Config, result *appsv1.Deployment) { + found := false + for _, env := range result.Spec.Template.Spec.Containers[0].Env { + if env.Name == "STAKATER_TEST_CM_CONFIGMAP" && env.Value == "abc123" { + found = true + break + } + } + if !found { + t.Error("Expected STAKATER_TEST_CM_CONFIGMAP env var to be set") + } + }, + }, + { + name: "AnnotationStrategy", + strategy: config.ReloadStrategyAnnotations, + verify: func(t *testing.T, cfg *config.Config, result *appsv1.Deployment) { + if result.Spec.Template.Annotations == nil { + t.Fatal("Expected pod template annotations to be set") + } + if _, ok := result.Spec.Template.Annotations[cfg.Annotations.LastReloadedFrom]; !ok { + t.Errorf("Expected %s annotation to be set", cfg.Annotations.LastReloadedFrom) + } + for _, env := range result.Spec.Template.Spec.Containers[0].Env { + if env.Name == "STAKATER_TEST_CM_CONFIGMAP" { + t.Error("Annotation strategy should not add env vars") + } + } + }, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + cfg := config.NewDefault() + cfg.ReloadStrategy = tt.strategy + reloadService := reload.NewService(cfg, testr.New(t)) + + deployment := testutil.NewDeployment("test-deployment", "default", nil) + fakeClient := fake.NewClientBuilder(). + WithScheme(testutil.NewScheme()). + WithObjects(deployment). + Build() + + wl := workload.NewDeploymentWorkload(deployment) + + updated, err := controller.UpdateWorkloadWithRetry( + context.Background(), + fakeClient, + reloadService, + nil, // no pause handler for this test + wl, + "test-cm", + reload.ResourceTypeConfigMap, + "default", + "abc123", + false, + ) + + if err != nil { + t.Fatalf("UpdateWorkloadWithRetry failed: %v", err) + } + if !updated { + t.Error("Expected workload to be updated") + } + + var result appsv1.Deployment + if err := fakeClient.Get( + context.Background(), types.NamespacedName{Name: "test-deployment", Namespace: "default"}, &result, + ); err != nil { + t.Fatalf("Failed to get deployment: %v", err) + } + + tt.verify(t, cfg, &result) + }, + ) + } +} + +func TestUpdateWorkloadWithRetry_NoUpdate(t *testing.T) { + cfg := config.NewDefault() + reloadService := reload.NewService(cfg, testr.New(t)) + + deployment := testutil.NewDeployment("test-deployment", "default", nil) + deployment.Spec.Template.Spec.Containers[0].Env = []corev1.EnvVar{ + { + Name: "STAKATER_TEST_CM_CONFIGMAP", + Value: "abc123", + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(testutil.NewScheme()). + WithObjects(deployment). + Build() + + wl := workload.NewDeploymentWorkload(deployment) + + updated, err := controller.UpdateWorkloadWithRetry( + context.Background(), + fakeClient, + reloadService, + nil, // no pause handler + wl, + "test-cm", + reload.ResourceTypeConfigMap, + "default", + "abc123", // Same hash as already set + false, + ) + + if err != nil { + t.Fatalf("UpdateWorkloadWithRetry failed: %v", err) + } + if updated { + t.Error("Expected workload NOT to be updated (same hash)") + } +} + +func TestResourceTypeKind(t *testing.T) { + tests := []struct { + resourceType reload.ResourceType + expectedKind string + }{ + {reload.ResourceTypeConfigMap, "ConfigMap"}, + {reload.ResourceTypeSecret, "Secret"}, + } + + for _, tt := range tests { + t.Run( + string(tt.resourceType), func(t *testing.T) { + if got := tt.resourceType.Kind(); got != tt.expectedKind { + t.Errorf("ResourceType.Kind() = %v, want %v", got, tt.expectedKind) + } + }, + ) + } +} + +func TestUpdateWorkloadWithRetry_PauseDeployment(t *testing.T) { + cfg := config.NewDefault() + reloadService := reload.NewService(cfg, testr.New(t)) + pauseHandler := reload.NewPauseHandler(cfg) + + deployment := testutil.NewDeployment( + "test-deployment", "default", map[string]string{ + "reloader.stakater.com/auto": "true", + "deployment.reloader.stakater.com/pause-period": "5m", + }, + ) + + fakeClient := fake.NewClientBuilder(). + WithScheme(testutil.NewScheme()). + WithObjects(deployment). + Build() + + wl := workload.NewDeploymentWorkload(deployment) + + updated, err := controller.UpdateWorkloadWithRetry( + context.Background(), + fakeClient, + reloadService, + pauseHandler, + wl, + "test-cm", + reload.ResourceTypeConfigMap, + "default", + "abc123", + true, + ) + + if err != nil { + t.Fatalf("UpdateWorkloadWithRetry failed: %v", err) + } + if !updated { + t.Error("Expected workload to be updated") + } + + var result appsv1.Deployment + if err := fakeClient.Get( + context.Background(), types.NamespacedName{Name: "test-deployment", Namespace: "default"}, &result, + ); err != nil { + t.Fatalf("Failed to get deployment: %v", err) + } + + if result.Spec.Template.Annotations == nil { + t.Fatal("Expected pod template annotations to be set") + } + + if !result.Spec.Paused { + t.Error("Expected deployment to be paused (spec.Paused=true)") + } + + pausedAt := result.Annotations[cfg.Annotations.PausedAt] + if pausedAt == "" { + t.Error("Expected paused-at annotation to be set") + } +} + +// TestUpdateWorkloadWithRetry_PauseWithExplicitAnnotation tests pause with explicit configmap annotation (no auto). +func TestUpdateWorkloadWithRetry_PauseWithExplicitAnnotation(t *testing.T) { + cfg := config.NewDefault() + reloadService := reload.NewService(cfg, testr.New(t)) + pauseHandler := reload.NewPauseHandler(cfg) + + deployment := testutil.NewDeployment( + "test-deployment", "default", map[string]string{ + cfg.Annotations.ConfigmapReload: "test-cm", // explicit, not auto + cfg.Annotations.PausePeriod: "5m", + }, + ) + + fakeClient := fake.NewClientBuilder(). + WithScheme(testutil.NewScheme()). + WithObjects(deployment). + Build() + + wl := workload.NewDeploymentWorkload(deployment) + + updated, err := controller.UpdateWorkloadWithRetry( + context.Background(), + fakeClient, + reloadService, + pauseHandler, + wl, + "test-cm", + reload.ResourceTypeConfigMap, + "default", + "abc123", + false, // NOT auto reload + ) + + if err != nil { + t.Fatalf("UpdateWorkloadWithRetry failed: %v", err) + } + if !updated { + t.Error("Expected workload to be updated") + } + + var result appsv1.Deployment + if err := fakeClient.Get( + context.Background(), types.NamespacedName{Name: "test-deployment", Namespace: "default"}, &result, + ); err != nil { + t.Fatalf("Failed to get deployment: %v", err) + } + + if result.Spec.Template.Annotations == nil { + t.Fatal("Expected pod template annotations to be set") + } + + if !result.Spec.Paused { + t.Error("Expected deployment to be paused (spec.Paused=true)") + } + + pausedAt := result.Annotations[cfg.Annotations.PausedAt] + if pausedAt == "" { + t.Error("Expected paused-at annotation to be set") + } +} + +// TestUpdateWorkloadWithRetry_PauseWithSecretReload tests pause with Secret-triggered reload. +func TestUpdateWorkloadWithRetry_PauseWithSecretReload(t *testing.T) { + cfg := config.NewDefault() + reloadService := reload.NewService(cfg, testr.New(t)) + pauseHandler := reload.NewPauseHandler(cfg) + + deployment := testutil.NewDeployment( + "test-deployment", "default", map[string]string{ + cfg.Annotations.SecretReload: "test-secret", // explicit secret, not auto + cfg.Annotations.PausePeriod: "5m", + }, + ) + + fakeClient := fake.NewClientBuilder(). + WithScheme(testutil.NewScheme()). + WithObjects(deployment). + Build() + + wl := workload.NewDeploymentWorkload(deployment) + + updated, err := controller.UpdateWorkloadWithRetry( + context.Background(), + fakeClient, + reloadService, + pauseHandler, + wl, + "test-secret", + reload.ResourceTypeSecret, + "default", + "abc123", + false, + ) + + if err != nil { + t.Fatalf("UpdateWorkloadWithRetry failed: %v", err) + } + if !updated { + t.Error("Expected workload to be updated") + } + + var result appsv1.Deployment + if err := fakeClient.Get( + context.Background(), types.NamespacedName{Name: "test-deployment", Namespace: "default"}, &result, + ); err != nil { + t.Fatalf("Failed to get deployment: %v", err) + } + + if !result.Spec.Paused { + t.Error("Expected deployment to be paused (spec.Paused=true)") + } + + pausedAt := result.Annotations[cfg.Annotations.PausedAt] + if pausedAt == "" { + t.Error("Expected paused-at annotation to be set") + } +} + +// TestUpdateWorkloadWithRetry_PauseWithAutoSecret tests pause with auto annotation + Secret change. +func TestUpdateWorkloadWithRetry_PauseWithAutoSecret(t *testing.T) { + cfg := config.NewDefault() + reloadService := reload.NewService(cfg, testr.New(t)) + pauseHandler := reload.NewPauseHandler(cfg) + + deployment := testutil.NewDeployment( + "test-deployment", "default", map[string]string{ + cfg.Annotations.Auto: "true", + cfg.Annotations.PausePeriod: "5m", + }, + ) + + fakeClient := fake.NewClientBuilder(). + WithScheme(testutil.NewScheme()). + WithObjects(deployment). + Build() + + wl := workload.NewDeploymentWorkload(deployment) + + updated, err := controller.UpdateWorkloadWithRetry( + context.Background(), + fakeClient, + reloadService, + pauseHandler, + wl, + "test-secret", + reload.ResourceTypeSecret, + "default", + "abc123", + true, + ) + + if err != nil { + t.Fatalf("UpdateWorkloadWithRetry failed: %v", err) + } + if !updated { + t.Error("Expected workload to be updated") + } + + var result appsv1.Deployment + if err := fakeClient.Get( + context.Background(), types.NamespacedName{Name: "test-deployment", Namespace: "default"}, &result, + ); err != nil { + t.Fatalf("Failed to get deployment: %v", err) + } + + if !result.Spec.Paused { + t.Error("Expected deployment to be paused (spec.Paused=true)") + } +} + +func TestUpdateWorkloadWithRetry_NoPauseWithoutAnnotation(t *testing.T) { + cfg := config.NewDefault() + reloadService := reload.NewService(cfg, testr.New(t)) + pauseHandler := reload.NewPauseHandler(cfg) + + deployment := testutil.NewDeployment( + "test-deployment", "default", map[string]string{ + "reloader.stakater.com/auto": "true", + }, + ) + + fakeClient := fake.NewClientBuilder(). + WithScheme(testutil.NewScheme()). + WithObjects(deployment). + Build() + + wl := workload.NewDeploymentWorkload(deployment) + + updated, err := controller.UpdateWorkloadWithRetry( + context.Background(), + fakeClient, + reloadService, + pauseHandler, + wl, + "test-cm", + reload.ResourceTypeConfigMap, + "default", + "abc123", + true, + ) + + if err != nil { + t.Fatalf("UpdateWorkloadWithRetry failed: %v", err) + } + if !updated { + t.Error("Expected workload to be updated") + } + + var result appsv1.Deployment + if err := fakeClient.Get( + context.Background(), types.NamespacedName{Name: "test-deployment", Namespace: "default"}, &result, + ); err != nil { + t.Fatalf("Failed to get deployment: %v", err) + } + + if result.Spec.Paused { + t.Error("Expected deployment NOT to be paused (no pause-period annotation)") + } +} diff --git a/internal/pkg/controller/secret_reconciler.go b/internal/pkg/controller/secret_reconciler.go new file mode 100644 index 000000000..b50c75476 --- /dev/null +++ b/internal/pkg/controller/secret_reconciler.go @@ -0,0 +1,69 @@ +package controller + +import ( + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/stakater/Reloader/internal/pkg/alerting" + "github.com/stakater/Reloader/internal/pkg/config" + "github.com/stakater/Reloader/internal/pkg/events" + "github.com/stakater/Reloader/internal/pkg/metrics" + "github.com/stakater/Reloader/internal/pkg/reload" + "github.com/stakater/Reloader/internal/pkg/webhook" + "github.com/stakater/Reloader/internal/pkg/workload" +) + +// SecretReconciler watches Secrets and triggers workload reloads. +type SecretReconciler = ResourceReconciler[*corev1.Secret] + +// NewSecretReconciler creates a new SecretReconciler with the given dependencies. +func NewSecretReconciler( + c client.Client, + log logr.Logger, + cfg *config.Config, + reloadService *reload.Service, + registry *workload.Registry, + collectors *metrics.Collectors, + eventRecorder *events.Recorder, + webhookClient *webhook.Client, + alerter alerting.Alerter, + pauseHandler *reload.PauseHandler, + nsCache *NamespaceCache, +) *SecretReconciler { + return NewResourceReconciler( + ResourceReconcilerDeps{ + Client: c, + Log: log, + Config: cfg, + ReloadService: reloadService, + Registry: registry, + Collectors: collectors, + EventRecorder: eventRecorder, + WebhookClient: webhookClient, + Alerter: alerter, + PauseHandler: pauseHandler, + NamespaceCache: nsCache, + }, + ResourceConfig[*corev1.Secret]{ + ResourceType: reload.ResourceTypeSecret, + NewResource: func() *corev1.Secret { return &corev1.Secret{} }, + CreateChange: func(s *corev1.Secret, eventType reload.EventType) reload.ResourceChange { + return reload.SecretChange{Secret: s, EventType: eventType} + }, + CreatePredicates: func(cfg *config.Config, hasher *reload.Hasher) predicate.Predicate { + return reload.SecretPredicates(cfg, hasher) + }, + }, + ) +} + +// SetupSecretReconciler sets up a Secret reconciler with the manager. +func SetupSecretReconciler(mgr ctrl.Manager, r *SecretReconciler) error { + return r.SetupWithManager(mgr, &corev1.Secret{}) +} + +var _ reconcile.Reconciler = &SecretReconciler{} diff --git a/internal/pkg/controller/secret_reconciler_test.go b/internal/pkg/controller/secret_reconciler_test.go new file mode 100644 index 000000000..f55e84a80 --- /dev/null +++ b/internal/pkg/controller/secret_reconciler_test.go @@ -0,0 +1,173 @@ +package controller_test + +import ( + "testing" + + "github.com/stakater/Reloader/internal/pkg/config" + "github.com/stakater/Reloader/internal/pkg/testutil" +) + +func TestSecretReconciler_NotFound(t *testing.T) { + cfg := config.NewDefault() + reconciler := newSecretReconciler(t, cfg) + assertReconcileSuccess(t, reconciler, reconcileRequest("nonexistent-secret", "default")) +} + +func TestSecretReconciler_NotFound_ReloadOnDelete(t *testing.T) { + cfg := config.NewDefault() + cfg.ReloadOnDelete = true + + deployment := testutil.NewDeployment("test-deployment", "default", map[string]string{ + cfg.Annotations.SecretReload: "deleted-secret", + }) + reconciler := newSecretReconciler(t, cfg, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("deleted-secret", "default")) +} + +func TestSecretReconciler_IgnoredNamespace(t *testing.T) { + cfg := config.NewDefault() + cfg.IgnoredNamespaces = []string{"kube-system"} + + secret := testutil.NewSecret("test-secret", "kube-system") + reconciler := newSecretReconciler(t, cfg, secret) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-secret", "kube-system")) +} + +func TestSecretReconciler_NoMatchingWorkloads(t *testing.T) { + cfg := config.NewDefault() + + secret := testutil.NewSecret("test-secret", "default") + deployment := testutil.NewDeployment("test-deployment", "default", nil) + reconciler := newSecretReconciler(t, cfg, secret, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-secret", "default")) +} + +func TestSecretReconciler_MatchingDeployment_AutoAnnotation(t *testing.T) { + cfg := config.NewDefault() + cfg.AutoReloadAll = true + + secret := testutil.NewSecret("test-secret", "default") + deployment := testutil.NewDeploymentWithEnvFrom("test-deployment", "default", "", "test-secret") + reconciler := newSecretReconciler(t, cfg, secret, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-secret", "default")) +} + +func TestSecretReconciler_MatchingDeployment_ExplicitAnnotation(t *testing.T) { + cfg := config.NewDefault() + + secret := testutil.NewSecret("test-secret", "default") + deployment := testutil.NewDeployment("test-deployment", "default", map[string]string{ + cfg.Annotations.SecretReload: "test-secret", + }) + reconciler := newSecretReconciler(t, cfg, secret, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-secret", "default")) +} + +func TestSecretReconciler_WorkloadInDifferentNamespace(t *testing.T) { + cfg := config.NewDefault() + + secret := testutil.NewSecret("test-secret", "namespace-a") + deployment := testutil.NewDeployment("test-deployment", "namespace-b", map[string]string{ + cfg.Annotations.SecretReload: "test-secret", + }) + reconciler := newSecretReconciler(t, cfg, secret, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-secret", "namespace-a")) +} + +func TestSecretReconciler_IgnoredWorkloadType(t *testing.T) { + cfg := config.NewDefault() + cfg.IgnoredWorkloads = []string{"deployment"} + + secret := testutil.NewSecret("test-secret", "default") + deployment := testutil.NewDeployment("test-deployment", "default", map[string]string{ + cfg.Annotations.SecretReload: "test-secret", + }) + reconciler := newSecretReconciler(t, cfg, secret, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-secret", "default")) +} + +func TestSecretReconciler_DaemonSet(t *testing.T) { + cfg := config.NewDefault() + + secret := testutil.NewSecret("test-secret", "default") + daemonset := testutil.NewDaemonSet("test-daemonset", "default", map[string]string{ + cfg.Annotations.SecretReload: "test-secret", + }) + reconciler := newSecretReconciler(t, cfg, secret, daemonset) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-secret", "default")) +} + +func TestSecretReconciler_StatefulSet(t *testing.T) { + cfg := config.NewDefault() + + secret := testutil.NewSecret("test-secret", "default") + statefulset := testutil.NewStatefulSet("test-statefulset", "default", map[string]string{ + cfg.Annotations.SecretReload: "test-secret", + }) + reconciler := newSecretReconciler(t, cfg, secret, statefulset) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-secret", "default")) +} + +func TestSecretReconciler_MultipleWorkloads(t *testing.T) { + cfg := config.NewDefault() + + secret := testutil.NewSecret("shared-secret", "default") + deployment1 := testutil.NewDeployment("deployment-1", "default", map[string]string{ + cfg.Annotations.SecretReload: "shared-secret", + }) + deployment2 := testutil.NewDeployment("deployment-2", "default", map[string]string{ + cfg.Annotations.SecretReload: "shared-secret", + }) + daemonset := testutil.NewDaemonSet("daemonset-1", "default", map[string]string{ + cfg.Annotations.SecretReload: "shared-secret", + }) + + reconciler := newSecretReconciler(t, cfg, secret, deployment1, deployment2, daemonset) + assertReconcileSuccess(t, reconciler, reconcileRequest("shared-secret", "default")) +} + +func TestSecretReconciler_VolumeMount(t *testing.T) { + cfg := config.NewDefault() + cfg.AutoReloadAll = true + + secret := testutil.NewSecret("volume-secret", "default") + deployment := testutil.NewDeploymentWithVolume("test-deployment", "default", "", "volume-secret") + reconciler := newSecretReconciler(t, cfg, secret, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("volume-secret", "default")) +} + +func TestSecretReconciler_ProjectedVolume(t *testing.T) { + cfg := config.NewDefault() + cfg.AutoReloadAll = true + + secret := testutil.NewSecret("projected-secret", "default") + deployment := testutil.NewDeploymentWithProjectedVolume("test-deployment", "default", "", "projected-secret") + reconciler := newSecretReconciler(t, cfg, secret, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("projected-secret", "default")) +} + +func TestSecretReconciler_SearchAnnotation(t *testing.T) { + cfg := config.NewDefault() + + secret := testutil.NewSecretWithAnnotations("test-secret", "default", map[string]string{ + cfg.Annotations.Match: "true", + }) + deployment := testutil.NewDeployment("test-deployment", "default", map[string]string{ + cfg.Annotations.Search: "true", + }) + reconciler := newSecretReconciler(t, cfg, secret, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("test-secret", "default")) +} + +func TestSecretReconciler_ServiceAccountTokenIgnored(t *testing.T) { + cfg := config.NewDefault() + cfg.AutoReloadAll = true + + // Service account tokens should be ignored + secret := testutil.NewSecret("sa-token", "default") + secret.Type = "kubernetes.io/service-account-token" + + deployment := testutil.NewDeploymentWithEnvFrom("test-deployment", "default", "", "sa-token") + reconciler := newSecretReconciler(t, cfg, secret, deployment) + assertReconcileSuccess(t, reconciler, reconcileRequest("sa-token", "default")) +} diff --git a/internal/pkg/controller/test_helpers_test.go b/internal/pkg/controller/test_helpers_test.go new file mode 100644 index 000000000..2b0f9e75b --- /dev/null +++ b/internal/pkg/controller/test_helpers_test.go @@ -0,0 +1,151 @@ +package controller_test + +import ( + "context" + "testing" + + "github.com/go-logr/logr" + "github.com/go-logr/logr/testr" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/stakater/Reloader/internal/pkg/alerting" + "github.com/stakater/Reloader/internal/pkg/config" + "github.com/stakater/Reloader/internal/pkg/controller" + "github.com/stakater/Reloader/internal/pkg/events" + "github.com/stakater/Reloader/internal/pkg/metrics" + "github.com/stakater/Reloader/internal/pkg/reload" + "github.com/stakater/Reloader/internal/pkg/testutil" + "github.com/stakater/Reloader/internal/pkg/webhook" + "github.com/stakater/Reloader/internal/pkg/workload" +) + +// testDeps holds shared test dependencies. +type testDeps struct { + client *fake.ClientBuilder + log logr.Logger + cfg *config.Config + reloadService *reload.Service + registry *workload.Registry + collectors *metrics.Collectors + eventRecorder *events.Recorder + webhookClient *webhook.Client + alerter alerting.Alerter +} + +// newTestDeps creates shared test dependencies for reconciler tests. +func newTestDeps(t *testing.T, cfg *config.Config, objects ...runtime.Object) testDeps { + t.Helper() + log := testr.New(t) + collectors := metrics.NewCollectors() + return testDeps{ + client: fake.NewClientBuilder(). + WithScheme(testutil.NewScheme()). + WithRuntimeObjects(objects...), + log: log, + cfg: cfg, + reloadService: reload.NewService(cfg, log), + registry: workload.NewRegistry( + workload.RegistryOptions{ + ArgoRolloutsEnabled: cfg.ArgoRolloutsEnabled, + DeploymentConfigEnabled: cfg.DeploymentConfigEnabled, + RolloutStrategyAnnotation: cfg.Annotations.RolloutStrategy, + }, + ), + collectors: &collectors, + eventRecorder: events.NewRecorder(nil), + webhookClient: webhook.NewClient("", log), + alerter: &alerting.NoOpAlerter{}, + } +} + +// newConfigMapReconciler creates a ConfigMapReconciler for testing. +func newConfigMapReconciler(t *testing.T, cfg *config.Config, objects ...runtime.Object) *controller.ConfigMapReconciler { + t.Helper() + deps := newTestDeps(t, cfg, objects...) + return controller.NewConfigMapReconciler( + deps.client.Build(), + deps.log, + deps.cfg, + deps.reloadService, + deps.registry, + deps.collectors, + deps.eventRecorder, + deps.webhookClient, + deps.alerter, + nil, + nil, + ) +} + +// newSecretReconciler creates a SecretReconciler for testing. +func newSecretReconciler(t *testing.T, cfg *config.Config, objects ...runtime.Object) *controller.SecretReconciler { + t.Helper() + deps := newTestDeps(t, cfg, objects...) + return controller.NewSecretReconciler( + deps.client.Build(), + deps.log, + deps.cfg, + deps.reloadService, + deps.registry, + deps.collectors, + deps.eventRecorder, + deps.webhookClient, + deps.alerter, + nil, + nil, + ) +} + +// newNamespaceReconciler creates a NamespaceReconciler for testing. +func newNamespaceReconciler(t *testing.T, cfg *config.Config, cache *controller.NamespaceCache, objects ...runtime.Object) *controller.NamespaceReconciler { + t.Helper() + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithRuntimeObjects(objects...). + Build() + + return &controller.NamespaceReconciler{ + Client: fakeClient, + Log: testr.New(t), + Config: cfg, + Cache: cache, + } +} + +// reconcileRequest creates a ctrl.Request for the given name and namespace. +func reconcileRequest(name, namespace string) ctrl.Request { + return ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: name, + Namespace: namespace, + }, + } +} + +// namespaceRequest creates a ctrl.Request for a namespace (no namespace field needed). +func namespaceRequest(name string) ctrl.Request { + return ctrl.Request{ + NamespacedName: types.NamespacedName{Name: name}, + } +} + +// assertReconcileSuccess runs reconcile and asserts no error and no requeue. +func assertReconcileSuccess(t *testing.T, reconciler interface { + Reconcile(context.Context, ctrl.Request) (ctrl.Result, error) +}, req ctrl.Request) { + t.Helper() + result, err := reconciler.Reconcile(context.Background(), req) + if err != nil { + t.Fatalf("Reconcile failed: %v", err) + } + if result.RequeueAfter > 0 { + t.Error("Should not requeue") + } +} diff --git a/internal/pkg/crypto/sha.go b/internal/pkg/crypto/sha.go deleted file mode 100644 index 043fc2273..000000000 --- a/internal/pkg/crypto/sha.go +++ /dev/null @@ -1,20 +0,0 @@ -package crypto - -import ( - "crypto/sha1" - "fmt" - "io" - - "github.com/sirupsen/logrus" -) - -// GenerateSHA generates SHA from string -func GenerateSHA(data string) string { - hasher := sha1.New() - _, err := io.WriteString(hasher, data) - if err != nil { - logrus.Errorf("Unable to write data in hash writer %v", err) - } - sha := hasher.Sum(nil) - return fmt.Sprintf("%x", sha) -} diff --git a/internal/pkg/crypto/sha_test.go b/internal/pkg/crypto/sha_test.go deleted file mode 100644 index 5cb0afc69..000000000 --- a/internal/pkg/crypto/sha_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package crypto - -import ( - "testing" -) - -// TestGenerateSHA generates the sha from given data and verifies whether it is correct or not -func TestGenerateSHA(t *testing.T) { - data := "www.stakater.com" - sha := "abd4ed82fb04548388a6cf3c339fd9dc84d275df" - result := GenerateSHA(data) - if result != sha { - t.Errorf("Failed to generate SHA") - } -} - -// TestGenerateSHAEmptyString verifies that empty string generates a valid hash -// This ensures consistent behavior and avoids issues with string matching operations -func TestGenerateSHAEmptyString(t *testing.T) { - result := GenerateSHA("") - expected := "da39a3ee5e6b4b0d3255bfef95601890afd80709" - if result != expected { - t.Errorf("Failed to generate SHA for empty string. Expected: %s, Got: %s", expected, result) - } - if len(result) != 40 { - t.Errorf("SHA hash should be 40 characters long, got %d", len(result)) - } -} diff --git a/internal/pkg/events/recorder.go b/internal/pkg/events/recorder.go new file mode 100644 index 000000000..14350a038 --- /dev/null +++ b/internal/pkg/events/recorder.go @@ -0,0 +1,65 @@ +package events + +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/events" +) + +const ( + // EventTypeNormal represents a normal event. + EventTypeNormal = corev1.EventTypeNormal + // EventTypeWarning represents a warning event. + EventTypeWarning = corev1.EventTypeWarning + + // ReasonReloaded indicates a workload was successfully reloaded. + ReasonReloaded = "Reloaded" + // ReasonReloadFailed indicates a workload reload failed. + ReasonReloadFailed = "ReloadFailed" + + // actionReloading is the action reported on reload events. + actionReloading = "Reloading" +) + +// Recorder wraps the Kubernetes event recorder. +type Recorder struct { + recorder events.EventRecorder +} + +// NewRecorder creates a new event Recorder. +func NewRecorder(recorder events.EventRecorder) *Recorder { + if recorder == nil { + return nil + } + return &Recorder{recorder: recorder} +} + +// ReloadSuccess records a successful reload event. +func (r *Recorder) ReloadSuccess(object runtime.Object, resourceType, resourceName string) { + if r == nil || r.recorder == nil { + return + } + r.recorder.Eventf( + object, + nil, + EventTypeNormal, + ReasonReloaded, + actionReloading, + "Reloaded due to %s %s change", resourceType, resourceName, + ) +} + +// ReloadFailed records a failed reload event. +func (r *Recorder) ReloadFailed(object runtime.Object, resourceType, resourceName string, err error) { + if r == nil || r.recorder == nil { + return + } + r.recorder.Eventf( + object, + nil, + EventTypeWarning, + ReasonReloadFailed, + actionReloading, + "Failed to reload due to %s %s change: %v", resourceType, resourceName, err, + ) +} diff --git a/internal/pkg/events/recorder_test.go b/internal/pkg/events/recorder_test.go new file mode 100644 index 000000000..f83699145 --- /dev/null +++ b/internal/pkg/events/recorder_test.go @@ -0,0 +1,169 @@ +package events + +import ( + "errors" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + k8sevents "k8s.io/client-go/tools/events" +) + +func TestNewRecorder_NilInput(t *testing.T) { + r := NewRecorder(nil) + if r != nil { + t.Error("NewRecorder(nil) should return nil") + } +} + +func TestNewRecorder_ValidInput(t *testing.T) { + fakeRecorder := k8sevents.NewFakeRecorder(10) + r := NewRecorder(fakeRecorder) + if r == nil { + t.Error("NewRecorder with valid recorder should not return nil") + } +} + +func TestReloadSuccess_RecordsEvent(t *testing.T) { + fakeRecorder := k8sevents.NewFakeRecorder(10) + r := NewRecorder(fakeRecorder) + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + }, + } + + r.ReloadSuccess(pod, "ConfigMap", "my-config") + + select { + case event := <-fakeRecorder.Events: + if event == "" { + t.Error("Expected event to be recorded") + } + // Event format: "Normal Reloaded Reloaded due to ConfigMap my-config change" + expectedContains := []string{"Normal", "Reloaded", "ConfigMap", "my-config"} + for _, expected := range expectedContains { + if !contains(event, expected) { + t.Errorf("Event %q should contain %q", event, expected) + } + } + default: + t.Error("Expected event to be recorded, but none was") + } +} + +func TestReloadFailed_RecordsWarningEvent(t *testing.T) { + fakeRecorder := k8sevents.NewFakeRecorder(10) + r := NewRecorder(fakeRecorder) + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + }, + } + + testErr := errors.New("update conflict") + r.ReloadFailed(pod, "Secret", "my-secret", testErr) + + select { + case event := <-fakeRecorder.Events: + if event == "" { + t.Error("Expected event to be recorded") + } + // Event format: "Warning ReloadFailed Failed to reload due to Secret my-secret change: update conflict" + expectedContains := []string{"Warning", "ReloadFailed", "Secret", "my-secret", "update conflict"} + for _, expected := range expectedContains { + if !contains(event, expected) { + t.Errorf("Event %q should contain %q", event, expected) + } + } + default: + t.Error("Expected event to be recorded, but none was") + } +} + +func TestNilRecorder_NoPanic(t *testing.T) { + var r *Recorder = nil + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + }, + } + + // These should not panic + r.ReloadSuccess(pod, "ConfigMap", "my-config") + r.ReloadFailed(pod, "Secret", "my-secret", errors.New("test error")) +} + +func TestRecorder_NilInternalRecorder(t *testing.T) { + r := &Recorder{recorder: nil} + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + }, + } + + r.ReloadSuccess(pod, "ConfigMap", "my-config") + r.ReloadFailed(pod, "Secret", "my-secret", errors.New("test error")) +} + +func TestReloadSuccess_DifferentObjectTypes(t *testing.T) { + fakeRecorder := k8sevents.NewFakeRecorder(10) + r := NewRecorder(fakeRecorder) + + tests := []struct { + name string + object runtime.Object + }{ + { + name: "Pod", + object: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pod", Namespace: "default"}, + }, + }, + { + name: "ConfigMap", + object: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cm", Namespace: "default"}, + }, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + r.ReloadSuccess(tt.object, "ConfigMap", "my-config") + + select { + case event := <-fakeRecorder.Events: + if event == "" { + t.Error("Expected event to be recorded") + } + default: + t.Error("Expected event to be recorded") + } + }, + ) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstring(s, substr)) +} + +func containsSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/pkg/handler/create.go b/internal/pkg/handler/create.go deleted file mode 100644 index 2ab290031..000000000 --- a/internal/pkg/handler/create.go +++ /dev/null @@ -1,71 +0,0 @@ -package handler - -import ( - "time" - - "github.com/sirupsen/logrus" - v1 "k8s.io/api/core/v1" - "k8s.io/client-go/tools/record" - - "github.com/stakater/Reloader/internal/pkg/metrics" - "github.com/stakater/Reloader/internal/pkg/options" - "github.com/stakater/Reloader/pkg/common" -) - -// ResourceCreatedHandler contains new objects -type ResourceCreatedHandler struct { - Resource interface{} - Collectors metrics.Collectors - Recorder record.EventRecorder - EnqueueTime time.Time // Time when this handler was added to the queue -} - -// GetEnqueueTime returns when this handler was enqueued -func (r ResourceCreatedHandler) GetEnqueueTime() time.Time { - return r.EnqueueTime -} - -// Handle processes the newly created resource -func (r ResourceCreatedHandler) Handle() error { - startTime := time.Now() - result := "error" - - defer func() { - r.Collectors.RecordReconcile(result, time.Since(startTime)) - }() - - if r.Resource == nil { - logrus.Errorf("Resource creation handler received nil resource") - return nil - } - - config, _ := r.GetConfig() - // Send webhook - if options.WebhookUrl != "" { - err := sendUpgradeWebhook(config, options.WebhookUrl) - if err == nil { - result = "success" - } - return err - } - // process resource based on its type - err := doRollingUpgrade(config, r.Collectors, r.Recorder, invokeReloadStrategy) - if err == nil { - result = "success" - } - return err -} - -// GetConfig gets configurations containing SHA, annotations, namespace and resource name -func (r ResourceCreatedHandler) GetConfig() (common.Config, string) { - var oldSHAData string - var config common.Config - if cm, ok := r.Resource.(*v1.ConfigMap); ok { - config = common.GetConfigmapConfig(cm) - } else if secret, ok := r.Resource.(*v1.Secret); ok { - config = common.GetSecretConfig(secret) - } else { - logrus.Warnf("Invalid resource: Resource should be 'Secret' or 'Configmap' but found, %v", r.Resource) - } - return config, oldSHAData -} diff --git a/internal/pkg/handler/create_test.go b/internal/pkg/handler/create_test.go deleted file mode 100644 index ef21f06b5..000000000 --- a/internal/pkg/handler/create_test.go +++ /dev/null @@ -1,353 +0,0 @@ -package handler - -import ( - "testing" - - "github.com/stretchr/testify/assert" - v1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - "github.com/stakater/Reloader/internal/pkg/constants" - "github.com/stakater/Reloader/internal/pkg/metrics" -) - -func TestResourceCreatedHandler_GetConfig(t *testing.T) { - tests := []struct { - name string - resource interface{} - expectedName string - expectedNS string - expectedType string - expectSHANotEmpty bool - expectOldSHAEmpty bool - }{ - { - name: "ConfigMap with data", - resource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "my-configmap", - Namespace: "test-ns", - }, - Data: map[string]string{ - "key1": "value1", - "key2": "value2", - }, - }, - expectedName: "my-configmap", - expectedNS: "test-ns", - expectedType: constants.ConfigmapEnvVarPostfix, - expectSHANotEmpty: true, - expectOldSHAEmpty: true, - }, - { - name: "ConfigMap with empty data", - resource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "empty-configmap", - Namespace: "default", - }, - Data: map[string]string{}, - }, - expectedName: "empty-configmap", - expectedNS: "default", - expectedType: constants.ConfigmapEnvVarPostfix, - expectSHANotEmpty: true, - expectOldSHAEmpty: true, - }, - { - name: "ConfigMap with binary data", - resource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "binary-configmap", - Namespace: "default", - }, - BinaryData: map[string][]byte{ - "binary-key": []byte("binary-value"), - }, - }, - expectedName: "binary-configmap", - expectedNS: "default", - expectedType: constants.ConfigmapEnvVarPostfix, - expectSHANotEmpty: true, - expectOldSHAEmpty: true, - }, - { - name: "ConfigMap with annotations", - resource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "annotated-configmap", - Namespace: "default", - Annotations: map[string]string{ - "reloader.stakater.com/match": "true", - }, - }, - Data: map[string]string{"key": "value"}, - }, - expectedName: "annotated-configmap", - expectedNS: "default", - expectedType: constants.ConfigmapEnvVarPostfix, - expectSHANotEmpty: true, - expectOldSHAEmpty: true, - }, - { - name: "Secret with data", - resource: &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "my-secret", - Namespace: "secret-ns", - }, - Data: map[string][]byte{ - "password": []byte("secret-password"), - }, - }, - expectedName: "my-secret", - expectedNS: "secret-ns", - expectedType: constants.SecretEnvVarPostfix, - expectSHANotEmpty: true, - expectOldSHAEmpty: true, - }, - { - name: "Secret with empty data", - resource: &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "empty-secret", - Namespace: "default", - }, - Data: map[string][]byte{}, - }, - expectedName: "empty-secret", - expectedNS: "default", - expectedType: constants.SecretEnvVarPostfix, - expectSHANotEmpty: true, - expectOldSHAEmpty: true, - }, - { - name: "Secret with StringData", - resource: &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "stringdata-secret", - Namespace: "default", - }, - StringData: map[string]string{ - "username": "admin", - }, - }, - expectedName: "stringdata-secret", - expectedNS: "default", - expectedType: constants.SecretEnvVarPostfix, - expectSHANotEmpty: true, - expectOldSHAEmpty: true, - }, - { - name: "Secret with labels", - resource: &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "labeled-secret", - Namespace: "default", - Labels: map[string]string{ - "app": "test", - }, - }, - Data: map[string][]byte{"key": []byte("value")}, - }, - expectedName: "labeled-secret", - expectedNS: "default", - expectedType: constants.SecretEnvVarPostfix, - expectSHANotEmpty: true, - expectOldSHAEmpty: true, - }, - { - name: "Invalid resource type - string", - resource: "invalid-string", - expectedName: "", - expectedNS: "", - expectedType: "", - expectSHANotEmpty: false, - expectOldSHAEmpty: true, - }, - { - name: "Invalid resource type - int", - resource: 123, - expectedName: "", - expectedNS: "", - expectedType: "", - expectSHANotEmpty: false, - expectOldSHAEmpty: true, - }, - { - name: "Invalid resource type - struct", - resource: struct{ Name string }{Name: "test"}, - expectedName: "", - expectedNS: "", - expectedType: "", - expectSHANotEmpty: false, - expectOldSHAEmpty: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - handler := ResourceCreatedHandler{ - Resource: tt.resource, - Collectors: metrics.NewCollectors(), - } - - config, oldSHA := handler.GetConfig() - - assert.Equal(t, tt.expectedName, config.ResourceName) - assert.Equal(t, tt.expectedNS, config.Namespace) - assert.Equal(t, tt.expectedType, config.Type) - - if tt.expectSHANotEmpty { - assert.NotEmpty(t, config.SHAValue, "SHA should not be empty") - } - - if tt.expectOldSHAEmpty { - assert.Empty(t, oldSHA, "oldSHA should always be empty for create handler") - } - }) - } -} - -func TestResourceCreatedHandler_GetConfig_Annotations(t *testing.T) { - cm := &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "annotated-cm", - Namespace: "default", - Annotations: map[string]string{ - "reloader.stakater.com/match": "true", - "reloader.stakater.com/search": "true", - }, - }, - Data: map[string]string{"key": "value"}, - } - - handler := ResourceCreatedHandler{ - Resource: cm, - Collectors: metrics.NewCollectors(), - } - - config, _ := handler.GetConfig() - - assert.NotNil(t, config.ResourceAnnotations) - assert.Equal(t, "true", config.ResourceAnnotations["reloader.stakater.com/match"]) - assert.Equal(t, "true", config.ResourceAnnotations["reloader.stakater.com/search"]) -} - -func TestResourceCreatedHandler_GetConfig_Labels(t *testing.T) { - secret := &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "labeled-secret", - Namespace: "default", - Labels: map[string]string{ - "app": "myapp", - "version": "v1", - }, - }, - Data: map[string][]byte{"key": []byte("value")}, - } - - handler := ResourceCreatedHandler{ - Resource: secret, - Collectors: metrics.NewCollectors(), - } - - config, _ := handler.GetConfig() - - assert.NotNil(t, config.Labels) - assert.Equal(t, "myapp", config.Labels["app"]) - assert.Equal(t, "v1", config.Labels["version"]) -} - -func TestResourceCreatedHandler_Handle(t *testing.T) { - tests := []struct { - name string - resource interface{} - expectError bool - }{ - { - name: "Nil resource", - resource: nil, - expectError: false, - }, - { - name: "Valid ConfigMap - no workloads to update", - resource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "default", - }, - Data: map[string]string{"key": "value"}, - }, - expectError: false, - }, - { - name: "Valid Secret - no workloads to update", - resource: &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "default", - }, - Data: map[string][]byte{"key": []byte("value")}, - }, - expectError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - handler := ResourceCreatedHandler{ - Resource: tt.resource, - Collectors: metrics.NewCollectors(), - } - - err := handler.Handle() - - if tt.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestResourceCreatedHandler_SHAConsistency(t *testing.T) { - data := map[string]string{"key": "value"} - - cm1 := &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "cm1", Namespace: "default"}, - Data: data, - } - cm2 := &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "cm2", Namespace: "default"}, - Data: data, - } - - handler1 := ResourceCreatedHandler{Resource: cm1, Collectors: metrics.NewCollectors()} - handler2 := ResourceCreatedHandler{Resource: cm2, Collectors: metrics.NewCollectors()} - - config1, _ := handler1.GetConfig() - config2, _ := handler2.GetConfig() - - assert.Equal(t, config1.SHAValue, config2.SHAValue) -} - -func TestResourceCreatedHandler_SHADifference(t *testing.T) { - cm1 := &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "cm", Namespace: "default"}, - Data: map[string]string{"key": "value1"}, - } - cm2 := &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "cm", Namespace: "default"}, - Data: map[string]string{"key": "value2"}, - } - - handler1 := ResourceCreatedHandler{Resource: cm1, Collectors: metrics.NewCollectors()} - handler2 := ResourceCreatedHandler{Resource: cm2, Collectors: metrics.NewCollectors()} - - config1, _ := handler1.GetConfig() - config2, _ := handler2.GetConfig() - - assert.NotEqual(t, config1.SHAValue, config2.SHAValue) -} diff --git a/internal/pkg/handler/delete.go b/internal/pkg/handler/delete.go deleted file mode 100644 index 845bc876e..000000000 --- a/internal/pkg/handler/delete.go +++ /dev/null @@ -1,123 +0,0 @@ -package handler - -import ( - "fmt" - "slices" - "time" - - "github.com/sirupsen/logrus" - - "github.com/stakater/Reloader/internal/pkg/callbacks" - "github.com/stakater/Reloader/internal/pkg/constants" - "github.com/stakater/Reloader/internal/pkg/metrics" - "github.com/stakater/Reloader/internal/pkg/options" - "github.com/stakater/Reloader/internal/pkg/testutil" - "github.com/stakater/Reloader/pkg/common" - - v1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/runtime" - patchtypes "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/tools/record" -) - -// ResourceDeleteHandler contains new objects -type ResourceDeleteHandler struct { - Resource interface{} - Collectors metrics.Collectors - Recorder record.EventRecorder - EnqueueTime time.Time // Time when this handler was added to the queue -} - -// GetEnqueueTime returns when this handler was enqueued -func (r ResourceDeleteHandler) GetEnqueueTime() time.Time { - return r.EnqueueTime -} - -// Handle processes resources being deleted -func (r ResourceDeleteHandler) Handle() error { - startTime := time.Now() - result := "error" - - defer func() { - r.Collectors.RecordReconcile(result, time.Since(startTime)) - }() - - if r.Resource == nil { - logrus.Errorf("Resource delete handler received nil resource") - return nil - } - - config, _ := r.GetConfig() - // Send webhook - if options.WebhookUrl != "" { - err := sendUpgradeWebhook(config, options.WebhookUrl) - if err == nil { - result = "success" - } - return err - } - // process resource based on its type - err := doRollingUpgrade(config, r.Collectors, r.Recorder, invokeDeleteStrategy) - if err == nil { - result = "success" - } - return err -} - -// GetConfig gets configurations containing SHA, annotations, namespace and resource name -func (r ResourceDeleteHandler) GetConfig() (common.Config, string) { - var oldSHAData string - var config common.Config - if cm, ok := r.Resource.(*v1.ConfigMap); ok { - config = common.GetConfigmapConfig(cm) - } else if secret, ok := r.Resource.(*v1.Secret); ok { - config = common.GetSecretConfig(secret) - } else { - logrus.Warnf("Invalid resource: Resource should be 'Secret' or 'Configmap' but found, %v", r.Resource) - } - return config, oldSHAData -} - -func invokeDeleteStrategy(upgradeFuncs callbacks.RollingUpgradeFuncs, item runtime.Object, config common.Config, autoReload bool) InvokeStrategyResult { - if options.ReloadStrategy == constants.AnnotationsReloadStrategy { - return removePodAnnotations(upgradeFuncs, item, config, autoReload) - } - - return removeContainerEnvVars(upgradeFuncs, item, config, autoReload) -} - -func removePodAnnotations(upgradeFuncs callbacks.RollingUpgradeFuncs, item runtime.Object, config common.Config, autoReload bool) InvokeStrategyResult { - config.SHAValue = testutil.GetSHAfromEmptyData() - return updatePodAnnotations(upgradeFuncs, item, config, autoReload) -} - -func removeContainerEnvVars(upgradeFuncs callbacks.RollingUpgradeFuncs, item runtime.Object, config common.Config, autoReload bool) InvokeStrategyResult { - envVar := getEnvVarName(config.ResourceName, config.Type) - container := getContainerUsingResource(upgradeFuncs, item, config, autoReload) - - if container == nil { - return InvokeStrategyResult{constants.NoContainerFound, nil} - } - - // remove if env var exists - if len(container.Env) > 0 { - index := slices.IndexFunc(container.Env, func(envVariable v1.EnvVar) bool { - return envVariable.Name == envVar - }) - if index != -1 { - var patch []byte - if upgradeFuncs.SupportsPatch { - containers := upgradeFuncs.ContainersFunc(item) - containerIndex := slices.IndexFunc(containers, func(c v1.Container) bool { - return c.Name == container.Name - }) - patch = fmt.Appendf(nil, upgradeFuncs.PatchTemplatesFunc().DeleteEnvVarTemplate, containerIndex, index) - } - - container.Env = append(container.Env[:index], container.Env[index+1:]...) - return InvokeStrategyResult{constants.Updated, &Patch{Type: patchtypes.JSONPatchType, Bytes: patch}} - } - } - - return InvokeStrategyResult{constants.NotUpdated, nil} -} diff --git a/internal/pkg/handler/delete_test.go b/internal/pkg/handler/delete_test.go deleted file mode 100644 index 812b0d18a..000000000 --- a/internal/pkg/handler/delete_test.go +++ /dev/null @@ -1,353 +0,0 @@ -package handler - -import ( - "testing" - - "github.com/stretchr/testify/assert" - appsv1 "k8s.io/api/apps/v1" - v1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - - "github.com/stakater/Reloader/internal/pkg/callbacks" - "github.com/stakater/Reloader/internal/pkg/constants" - "github.com/stakater/Reloader/internal/pkg/options" - "github.com/stakater/Reloader/pkg/common" -) - -// mockDeploymentForDelete creates a deployment with containers for testing delete strategies -func mockDeploymentForDelete(name, namespace string, containers []v1.Container, volumes []v1.Volume) *appsv1.Deployment { - return &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: namespace, - }, - Spec: appsv1.DeploymentSpec{ - Template: v1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Annotations: map[string]string{}, - }, - Spec: v1.PodSpec{ - Containers: containers, - Volumes: volumes, - }, - }, - }, - } -} - -// Mock funcs for testing -func mockContainersFunc(item runtime.Object) []v1.Container { - deployment, ok := item.(*appsv1.Deployment) - if !ok { - return nil - } - return deployment.Spec.Template.Spec.Containers -} - -func mockInitContainersFunc(item runtime.Object) []v1.Container { - deployment, ok := item.(*appsv1.Deployment) - if !ok { - return nil - } - return deployment.Spec.Template.Spec.InitContainers -} - -func mockVolumesFunc(item runtime.Object) []v1.Volume { - deployment, ok := item.(*appsv1.Deployment) - if !ok { - return nil - } - return deployment.Spec.Template.Spec.Volumes -} - -func mockPodAnnotationsFunc(item runtime.Object) map[string]string { - deployment, ok := item.(*appsv1.Deployment) - if !ok { - return nil - } - return deployment.Spec.Template.Annotations -} - -func mockPatchTemplatesFunc() callbacks.PatchTemplates { - return callbacks.PatchTemplates{ - AnnotationTemplate: `{"spec":{"template":{"metadata":{"annotations":{"%s":"%s"}}}}}`, - EnvVarTemplate: `{"spec":{"template":{"spec":{"containers":[{"name":"%s","env":[{"name":"%s","value":"%s"}]}]}}}}`, - DeleteEnvVarTemplate: `[{"op":"remove","path":"/spec/template/spec/containers/%d/env/%d"}]`, - } -} - -func TestRemoveContainerEnvVars(t *testing.T) { - tests := []struct { - name string - containers []v1.Container - volumes []v1.Volume - config common.Config - autoReload bool - expected constants.Result - envVarRemoved bool - }{ - { - name: "Remove existing env var - configmap envFrom", - containers: []v1.Container{ - { - Name: "app", - EnvFrom: []v1.EnvFromSource{ - { - ConfigMapRef: &v1.ConfigMapEnvSource{ - LocalObjectReference: v1.LocalObjectReference{ - Name: "my-configmap", - }, - }, - }, - }, - Env: []v1.EnvVar{ - {Name: "STAKATER_MY_CONFIGMAP_CONFIGMAP", Value: "sha-value"}, - }, - }, - }, - volumes: []v1.Volume{}, - config: common.Config{ - ResourceName: "my-configmap", - Type: constants.ConfigmapEnvVarPostfix, - }, - autoReload: true, - expected: constants.Updated, - envVarRemoved: true, - }, - { - name: "No env var to remove", - containers: []v1.Container{ - { - Name: "app", - EnvFrom: []v1.EnvFromSource{ - { - ConfigMapRef: &v1.ConfigMapEnvSource{ - LocalObjectReference: v1.LocalObjectReference{ - Name: "my-configmap", - }, - }, - }, - }, - Env: []v1.EnvVar{}, - }, - }, - volumes: []v1.Volume{}, - config: common.Config{ - ResourceName: "my-configmap", - Type: constants.ConfigmapEnvVarPostfix, - }, - autoReload: true, - expected: constants.NotUpdated, - envVarRemoved: false, - }, - { - name: "Remove existing env var - secret envFrom", - containers: []v1.Container{ - { - Name: "app", - EnvFrom: []v1.EnvFromSource{ - { - SecretRef: &v1.SecretEnvSource{ - LocalObjectReference: v1.LocalObjectReference{ - Name: "my-secret", - }, - }, - }, - }, - Env: []v1.EnvVar{ - {Name: "STAKATER_MY_SECRET_SECRET", Value: "sha-value"}, - }, - }, - }, - volumes: []v1.Volume{}, - config: common.Config{ - ResourceName: "my-secret", - Type: constants.SecretEnvVarPostfix, - }, - autoReload: true, - expected: constants.Updated, - envVarRemoved: true, - }, - { - name: "No container found", - containers: []v1.Container{}, - volumes: []v1.Volume{}, - config: common.Config{ - ResourceName: "my-configmap", - Type: constants.ConfigmapEnvVarPostfix, - }, - autoReload: true, - expected: constants.NoContainerFound, - envVarRemoved: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - deployment := mockDeploymentForDelete("test-deploy", "default", tt.containers, tt.volumes) - - funcs := callbacks.RollingUpgradeFuncs{ - ContainersFunc: mockContainersFunc, - InitContainersFunc: mockInitContainersFunc, - VolumesFunc: mockVolumesFunc, - PodAnnotationsFunc: mockPodAnnotationsFunc, - PatchTemplatesFunc: mockPatchTemplatesFunc, - SupportsPatch: true, - } - - result := removeContainerEnvVars(funcs, deployment, tt.config, tt.autoReload) - - assert.Equal(t, tt.expected, result.Result) - - if tt.envVarRemoved { - containers := deployment.Spec.Template.Spec.Containers - for _, c := range containers { - for _, env := range c.Env { - envVarName := getEnvVarName(tt.config.ResourceName, tt.config.Type) - assert.NotEqual(t, envVarName, env.Name, "Env var should have been removed") - } - } - } - }) - } -} - -func TestInvokeDeleteStrategy(t *testing.T) { - originalStrategy := options.ReloadStrategy - defer func() { - options.ReloadStrategy = originalStrategy - }() - - tests := []struct { - name string - reloadStrategy string - containers []v1.Container - volumes []v1.Volume - config common.Config - }{ - { - name: "Annotations strategy", - reloadStrategy: constants.AnnotationsReloadStrategy, - containers: []v1.Container{ - { - Name: "app", - EnvFrom: []v1.EnvFromSource{ - { - ConfigMapRef: &v1.ConfigMapEnvSource{ - LocalObjectReference: v1.LocalObjectReference{ - Name: "my-configmap", - }, - }, - }, - }, - }, - }, - volumes: []v1.Volume{}, - config: common.Config{ - ResourceName: "my-configmap", - Type: constants.ConfigmapEnvVarPostfix, - SHAValue: "sha-value", - }, - }, - { - name: "EnvVars strategy", - reloadStrategy: constants.EnvVarsReloadStrategy, - containers: []v1.Container{ - { - Name: "app", - EnvFrom: []v1.EnvFromSource{ - { - ConfigMapRef: &v1.ConfigMapEnvSource{ - LocalObjectReference: v1.LocalObjectReference{ - Name: "my-configmap", - }, - }, - }, - }, - Env: []v1.EnvVar{ - {Name: "STAKATER_MY_CONFIGMAP_CONFIGMAP", Value: "sha-value"}, - }, - }, - }, - volumes: []v1.Volume{}, - config: common.Config{ - ResourceName: "my-configmap", - Type: constants.ConfigmapEnvVarPostfix, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - options.ReloadStrategy = tt.reloadStrategy - - deployment := mockDeploymentForDelete("test-deploy", "default", tt.containers, tt.volumes) - - funcs := callbacks.RollingUpgradeFuncs{ - ContainersFunc: mockContainersFunc, - InitContainersFunc: mockInitContainersFunc, - VolumesFunc: mockVolumesFunc, - PodAnnotationsFunc: mockPodAnnotationsFunc, - PatchTemplatesFunc: mockPatchTemplatesFunc, - SupportsPatch: true, - } - - result := invokeDeleteStrategy(funcs, deployment, tt.config, true) - - assert.NotNil(t, result) - }) - } -} - -func TestRemovePodAnnotations(t *testing.T) { - tests := []struct { - name string - containers []v1.Container - volumes []v1.Volume - config common.Config - }{ - { - name: "Remove pod annotations - configmap", - containers: []v1.Container{ - { - Name: "app", - EnvFrom: []v1.EnvFromSource{ - { - ConfigMapRef: &v1.ConfigMapEnvSource{ - LocalObjectReference: v1.LocalObjectReference{ - Name: "my-configmap", - }, - }, - }, - }, - }, - }, - volumes: []v1.Volume{}, - config: common.Config{ - ResourceName: "my-configmap", - Type: constants.ConfigmapEnvVarPostfix, - SHAValue: "sha-value", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - deployment := mockDeploymentForDelete("test-deploy", "default", tt.containers, tt.volumes) - - funcs := callbacks.RollingUpgradeFuncs{ - ContainersFunc: mockContainersFunc, - InitContainersFunc: mockInitContainersFunc, - VolumesFunc: mockVolumesFunc, - PodAnnotationsFunc: mockPodAnnotationsFunc, - PatchTemplatesFunc: mockPatchTemplatesFunc, - SupportsPatch: false, - } - - result := removePodAnnotations(funcs, deployment, tt.config, true) - - assert.Equal(t, constants.Updated, result.Result) - }) - } -} diff --git a/internal/pkg/handler/handler.go b/internal/pkg/handler/handler.go deleted file mode 100644 index 9018f80ae..000000000 --- a/internal/pkg/handler/handler.go +++ /dev/null @@ -1,18 +0,0 @@ -package handler - -import ( - "time" - - "github.com/stakater/Reloader/pkg/common" -) - -// ResourceHandler handles the creation and update of resources -type ResourceHandler interface { - Handle() error - GetConfig() (common.Config, string) -} - -// TimedHandler is a handler that tracks when it was enqueued -type TimedHandler interface { - GetEnqueueTime() time.Time -} diff --git a/internal/pkg/handler/handlers_test.go b/internal/pkg/handler/handlers_test.go deleted file mode 100644 index dedefcc90..000000000 --- a/internal/pkg/handler/handlers_test.go +++ /dev/null @@ -1,281 +0,0 @@ -package handler - -import ( - "testing" - - "github.com/stretchr/testify/assert" - v1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - "github.com/stakater/Reloader/internal/pkg/constants" - "github.com/stakater/Reloader/internal/pkg/metrics" -) - -// Helper function to create a test ConfigMap -func createTestConfigMap(data map[string]string) *v1.ConfigMap { - return &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cm", - Namespace: "default", - }, - Data: data, - } -} - -// Helper function to create a test Secret -func createTestSecret(data map[string][]byte) *v1.Secret { - return &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "default", - }, - Data: data, - } -} - -// Helper function to create test metrics collectors -func createTestCollectors() metrics.Collectors { - return metrics.NewCollectors() -} - -// ============================================================ -// ResourceCreatedHandler Tests -// ============================================================ - -func TestResourceCreatedHandler_GetConfig_ConfigMap(t *testing.T) { - cm := createTestConfigMap(map[string]string{"key": "value"}) - handler := ResourceCreatedHandler{ - Resource: cm, - Collectors: createTestCollectors(), - } - - config, oldSHA := handler.GetConfig() - - assert.Equal(t, "test-cm", config.ResourceName) - assert.Equal(t, "default", config.Namespace) - assert.Equal(t, constants.ConfigmapEnvVarPostfix, config.Type) - assert.NotEmpty(t, config.SHAValue) - assert.Empty(t, oldSHA) -} - -func TestResourceCreatedHandler_GetConfig_Secret(t *testing.T) { - secret := createTestSecret(map[string][]byte{"key": []byte("value")}) - handler := ResourceCreatedHandler{ - Resource: secret, - Collectors: createTestCollectors(), - } - - config, oldSHA := handler.GetConfig() - - assert.Equal(t, "test-secret", config.ResourceName) - assert.Equal(t, "default", config.Namespace) - assert.Equal(t, constants.SecretEnvVarPostfix, config.Type) - assert.NotEmpty(t, config.SHAValue) - assert.Empty(t, oldSHA) -} - -func TestResourceCreatedHandler_GetConfig_InvalidResource(t *testing.T) { - handler := ResourceCreatedHandler{ - Resource: "invalid", - Collectors: createTestCollectors(), - } - - config, _ := handler.GetConfig() - - assert.Empty(t, config.ResourceName) -} - -func TestResourceCreatedHandler_Handle_NilResource(t *testing.T) { - handler := ResourceCreatedHandler{ - Resource: nil, - Collectors: createTestCollectors(), - } - - err := handler.Handle() - - assert.NoError(t, err) -} - -// ============================================================ -// ResourceDeleteHandler Tests -// ============================================================ - -func TestResourceDeleteHandler_GetConfig_ConfigMap(t *testing.T) { - cm := createTestConfigMap(map[string]string{"key": "value"}) - handler := ResourceDeleteHandler{ - Resource: cm, - Collectors: createTestCollectors(), - } - - config, oldSHA := handler.GetConfig() - - assert.Equal(t, "test-cm", config.ResourceName) - assert.Equal(t, "default", config.Namespace) - assert.Equal(t, constants.ConfigmapEnvVarPostfix, config.Type) - assert.NotEmpty(t, config.SHAValue) - assert.Empty(t, oldSHA) -} - -func TestResourceDeleteHandler_GetConfig_Secret(t *testing.T) { - secret := createTestSecret(map[string][]byte{"key": []byte("value")}) - handler := ResourceDeleteHandler{ - Resource: secret, - Collectors: createTestCollectors(), - } - - config, oldSHA := handler.GetConfig() - - assert.Equal(t, "test-secret", config.ResourceName) - assert.Equal(t, "default", config.Namespace) - assert.Equal(t, constants.SecretEnvVarPostfix, config.Type) - assert.NotEmpty(t, config.SHAValue) - assert.Empty(t, oldSHA) -} - -func TestResourceDeleteHandler_GetConfig_InvalidResource(t *testing.T) { - handler := ResourceDeleteHandler{ - Resource: "invalid", - Collectors: createTestCollectors(), - } - - config, _ := handler.GetConfig() - - assert.Empty(t, config.ResourceName) -} - -func TestResourceDeleteHandler_Handle_NilResource(t *testing.T) { - handler := ResourceDeleteHandler{ - Resource: nil, - Collectors: createTestCollectors(), - } - - err := handler.Handle() - - assert.NoError(t, err) -} - -// ============================================================ -// ResourceUpdatedHandler Tests -// ============================================================ - -func TestResourceUpdatedHandler_GetConfig_ConfigMap(t *testing.T) { - oldCM := createTestConfigMap(map[string]string{"key": "old-value"}) - newCM := createTestConfigMap(map[string]string{"key": "new-value"}) - - handler := ResourceUpdatedHandler{ - Resource: newCM, - OldResource: oldCM, - Collectors: createTestCollectors(), - } - - config, oldSHA := handler.GetConfig() - - assert.Equal(t, "test-cm", config.ResourceName) - assert.Equal(t, "default", config.Namespace) - assert.Equal(t, constants.ConfigmapEnvVarPostfix, config.Type) - assert.NotEmpty(t, config.SHAValue) - assert.NotEmpty(t, oldSHA) - assert.NotEqual(t, config.SHAValue, oldSHA) -} - -func TestResourceUpdatedHandler_GetConfig_ConfigMap_SameData(t *testing.T) { - oldCM := createTestConfigMap(map[string]string{"key": "same-value"}) - newCM := createTestConfigMap(map[string]string{"key": "same-value"}) - - handler := ResourceUpdatedHandler{ - Resource: newCM, - OldResource: oldCM, - Collectors: createTestCollectors(), - } - - config, oldSHA := handler.GetConfig() - - assert.Equal(t, "test-cm", config.ResourceName) - assert.Equal(t, config.SHAValue, oldSHA) -} - -func TestResourceUpdatedHandler_GetConfig_Secret(t *testing.T) { - oldSecret := createTestSecret(map[string][]byte{"key": []byte("old-value")}) - newSecret := createTestSecret(map[string][]byte{"key": []byte("new-value")}) - - handler := ResourceUpdatedHandler{ - Resource: newSecret, - OldResource: oldSecret, - Collectors: createTestCollectors(), - } - - config, oldSHA := handler.GetConfig() - - assert.Equal(t, "test-secret", config.ResourceName) - assert.Equal(t, "default", config.Namespace) - assert.Equal(t, constants.SecretEnvVarPostfix, config.Type) - assert.NotEmpty(t, config.SHAValue) - assert.NotEmpty(t, oldSHA) - assert.NotEqual(t, config.SHAValue, oldSHA) -} - -func TestResourceUpdatedHandler_GetConfig_Secret_SameData(t *testing.T) { - oldSecret := createTestSecret(map[string][]byte{"key": []byte("same-value")}) - newSecret := createTestSecret(map[string][]byte{"key": []byte("same-value")}) - - handler := ResourceUpdatedHandler{ - Resource: newSecret, - OldResource: oldSecret, - Collectors: createTestCollectors(), - } - - config, oldSHA := handler.GetConfig() - - assert.Equal(t, "test-secret", config.ResourceName) - assert.Equal(t, config.SHAValue, oldSHA) -} - -func TestResourceUpdatedHandler_GetConfig_InvalidResource(t *testing.T) { - handler := ResourceUpdatedHandler{ - Resource: "invalid", - OldResource: "invalid", - Collectors: createTestCollectors(), - } - - config, _ := handler.GetConfig() - - assert.Empty(t, config.ResourceName) -} - -func TestResourceUpdatedHandler_Handle_NilResource(t *testing.T) { - handler := ResourceUpdatedHandler{ - Resource: nil, - OldResource: nil, - Collectors: createTestCollectors(), - } - - err := handler.Handle() - - assert.NoError(t, err) -} - -func TestResourceUpdatedHandler_Handle_NilOldResource(t *testing.T) { - cm := createTestConfigMap(map[string]string{"key": "value"}) - handler := ResourceUpdatedHandler{ - Resource: cm, - OldResource: nil, - Collectors: createTestCollectors(), - } - - err := handler.Handle() - - assert.NoError(t, err) -} - -func TestResourceUpdatedHandler_Handle_NoChange(t *testing.T) { - cm := createTestConfigMap(map[string]string{"key": "same-value"}) - handler := ResourceUpdatedHandler{ - Resource: cm, - OldResource: cm, - Collectors: createTestCollectors(), - } - - err := handler.Handle() - - assert.NoError(t, err) -} diff --git a/internal/pkg/handler/pause_deployment.go b/internal/pkg/handler/pause_deployment.go deleted file mode 100644 index d255b1cc3..000000000 --- a/internal/pkg/handler/pause_deployment.go +++ /dev/null @@ -1,243 +0,0 @@ -package handler - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "github.com/sirupsen/logrus" - app "k8s.io/api/apps/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - patchtypes "k8s.io/apimachinery/pkg/types" - - "github.com/stakater/Reloader/internal/pkg/options" - "github.com/stakater/Reloader/pkg/kube" -) - -// Keeps track of currently active timers -var activeTimers = make(map[string]*time.Timer) - -// Returns unique key for the activeTimers map -func getTimerKey(namespace, deploymentName string) string { - return fmt.Sprintf("%s/%s", namespace, deploymentName) -} - -// Checks if a deployment is currently paused -func IsPaused(deployment *app.Deployment) bool { - return deployment.Spec.Paused -} - -// Deployment paused by reloader ? -func IsPausedByReloader(deployment *app.Deployment) bool { - if IsPaused(deployment) { - pausedAtAnnotationValue := deployment.Annotations[options.PauseDeploymentTimeAnnotation] - return pausedAtAnnotationValue != "" - } - return false -} - -// Returns the time, the deployment was paused by reloader, nil otherwise -func GetPauseStartTime(deployment *app.Deployment) (*time.Time, error) { - if !IsPausedByReloader(deployment) { - return nil, nil - } - - pausedAtStr := deployment.Annotations[options.PauseDeploymentTimeAnnotation] - parsedTime, err := time.Parse(time.RFC3339, pausedAtStr) - if err != nil { - return nil, err - } - - return &parsedTime, nil -} - -// ParsePauseDuration parses the pause interval value and returns a time.Duration -func ParsePauseDuration(pauseIntervalValue string) (time.Duration, error) { - pauseDuration, err := time.ParseDuration(pauseIntervalValue) - if err != nil { - logrus.Warnf("Failed to parse pause interval value '%s': %v", pauseIntervalValue, err) - return 0, err - } - return pauseDuration, nil -} - -// Pauses a deployment for a specified duration and creates a timer to resume it -// after the specified duration -func PauseDeployment(deployment *app.Deployment, clients kube.Clients, namespace, pauseIntervalValue string) (*app.Deployment, error) { - deploymentName := deployment.Name - pauseDuration, err := ParsePauseDuration(pauseIntervalValue) - - if err != nil { - return nil, err - } - - if !IsPaused(deployment) { - logrus.Infof("Pausing Deployment '%s' in namespace '%s' for %s", deploymentName, namespace, pauseDuration) - - deploymentFuncs := GetDeploymentRollingUpgradeFuncs() - - pausePatch, err := CreatePausePatch() - if err != nil { - logrus.Errorf("Failed to create pause patch for deployment '%s': %v", deploymentName, err) - return deployment, err - } - - err = deploymentFuncs.PatchFunc(clients, namespace, deployment, patchtypes.StrategicMergePatchType, pausePatch) - - if err != nil { - logrus.Errorf("Failed to patch deployment '%s' in namespace '%s': %v", deploymentName, namespace, err) - return deployment, err - } - - updatedDeployment, err := clients.KubernetesClient.AppsV1().Deployments(namespace).Get(context.TODO(), deploymentName, metav1.GetOptions{}) - - CreateResumeTimer(deployment, clients, namespace, pauseDuration) - return updatedDeployment, err - } - - if !IsPausedByReloader(deployment) { - logrus.Infof("Deployment '%s' in namespace '%s' already paused", deploymentName, namespace) - return deployment, nil - } - - // Deployment has already been paused by reloader, check for timer - logrus.Debugf("Deployment '%s' in namespace '%s' is already paused by reloader", deploymentName, namespace) - - timerKey := getTimerKey(namespace, deploymentName) - _, timerExists := activeTimers[timerKey] - - if !timerExists { - logrus.Warnf("Timer does not exist for already paused deployment '%s' in namespace '%s', creating new one", - deploymentName, namespace) - HandleMissingTimer(deployment, pauseDuration, clients, namespace) - } - return deployment, nil -} - -// Handles the case where missing timers for deployments that have been paused by reloader. -// Could occur after new leader election or reloader restart -func HandleMissingTimer(deployment *app.Deployment, pauseDuration time.Duration, clients kube.Clients, namespace string) { - deploymentName := deployment.Name - pauseStartTime, err := GetPauseStartTime(deployment) - if err != nil { - logrus.Errorf("Error parsing pause start time for deployment '%s' in namespace '%s': %v. Resuming deployment immediately", - deploymentName, namespace, err) - ResumeDeployment(deployment, namespace, clients) - return - } - - if pauseStartTime == nil { - return - } - - elapsedPauseTime := time.Since(*pauseStartTime) - remainingPauseTime := pauseDuration - elapsedPauseTime - - if remainingPauseTime <= 0 { - logrus.Infof("Pause period for deployment '%s' in namespace '%s' has expired. Resuming immediately", - deploymentName, namespace) - ResumeDeployment(deployment, namespace, clients) - return - } - - logrus.Infof("Creating missing timer for already paused deployment '%s' in namespace '%s' with remaining time %s", - deploymentName, namespace, remainingPauseTime) - CreateResumeTimer(deployment, clients, namespace, remainingPauseTime) -} - -// CreateResumeTimer creates a timer to resume the deployment after the specified duration -func CreateResumeTimer(deployment *app.Deployment, clients kube.Clients, namespace string, pauseDuration time.Duration) { - deploymentName := deployment.Name - timerKey := getTimerKey(namespace, deployment.Name) - - // Check if there's an existing timer for this deployment - if _, exists := activeTimers[timerKey]; exists { - logrus.Debugf("Timer already exists for deployment '%s' in namespace '%s', Skipping creation", - deploymentName, namespace) - return - } - - // Create and store the new timer - timer := time.AfterFunc(pauseDuration, func() { - ResumeDeployment(deployment, namespace, clients) - }) - - // Add the new timer to the map - activeTimers[timerKey] = timer - - logrus.Debugf("Created pause timer for deployment '%s' in namespace '%s' with duration %s", - deploymentName, namespace, pauseDuration) -} - -// ResumeDeployment resumes a deployment that has been paused by reloader -func ResumeDeployment(deployment *app.Deployment, namespace string, clients kube.Clients) { - deploymentName := deployment.Name - - currentDeployment, err := clients.KubernetesClient.AppsV1().Deployments(namespace).Get(context.TODO(), deploymentName, metav1.GetOptions{}) - - if err != nil { - logrus.Errorf("Failed to get deployment '%s' in namespace '%s': %v", deploymentName, namespace, err) - return - } - - if !IsPausedByReloader(currentDeployment) { - logrus.Infof("Deployment '%s' in namespace '%s' not paused by Reloader. Skipping resume", deploymentName, namespace) - return - } - - deploymentFuncs := GetDeploymentRollingUpgradeFuncs() - - resumePatch, err := CreateResumePatch() - if err != nil { - logrus.Errorf("Failed to create resume patch for deployment '%s': %v", deploymentName, err) - return - } - - // Remove the timer - timerKey := getTimerKey(namespace, deploymentName) - if timer, exists := activeTimers[timerKey]; exists { - timer.Stop() - delete(activeTimers, timerKey) - logrus.Debugf("Removed pause timer for deployment '%s' in namespace '%s'", deploymentName, namespace) - } - - err = deploymentFuncs.PatchFunc(clients, namespace, currentDeployment, patchtypes.StrategicMergePatchType, resumePatch) - - if err != nil { - logrus.Errorf("Failed to resume deployment '%s' in namespace '%s': %v", deploymentName, namespace, err) - return - } - - logrus.Infof("Successfully resumed deployment '%s' in namespace '%s'", deploymentName, namespace) -} - -func CreatePausePatch() ([]byte, error) { - patchData := map[string]interface{}{ - "spec": map[string]interface{}{ - "paused": true, - }, - "metadata": map[string]interface{}{ - "annotations": map[string]string{ - options.PauseDeploymentTimeAnnotation: time.Now().Format(time.RFC3339), - }, - }, - } - - return json.Marshal(patchData) -} - -func CreateResumePatch() ([]byte, error) { - patchData := map[string]interface{}{ - "spec": map[string]interface{}{ - "paused": false, - }, - "metadata": map[string]interface{}{ - "annotations": map[string]interface{}{ - options.PauseDeploymentTimeAnnotation: nil, - }, - }, - } - - return json.Marshal(patchData) -} diff --git a/internal/pkg/handler/pause_deployment_test.go b/internal/pkg/handler/pause_deployment_test.go deleted file mode 100644 index 1f95b11ee..000000000 --- a/internal/pkg/handler/pause_deployment_test.go +++ /dev/null @@ -1,392 +0,0 @@ -package handler - -import ( - "context" - "fmt" - "testing" - "time" - - "github.com/stretchr/testify/assert" - appsv1 "k8s.io/api/apps/v1" - "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - testclient "k8s.io/client-go/kubernetes/fake" - - "github.com/stakater/Reloader/internal/pkg/options" - "github.com/stakater/Reloader/pkg/kube" -) - -func TestIsPaused(t *testing.T) { - tests := []struct { - name string - deployment *appsv1.Deployment - paused bool - }{ - { - name: "paused deployment", - deployment: &appsv1.Deployment{ - Spec: appsv1.DeploymentSpec{ - Paused: true, - }, - }, - paused: true, - }, - { - name: "unpaused deployment", - deployment: &appsv1.Deployment{ - Spec: appsv1.DeploymentSpec{ - Paused: false, - }, - }, - paused: false, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - result := IsPaused(test.deployment) - assert.Equal(t, test.paused, result) - }) - } -} - -func TestIsPausedByReloader(t *testing.T) { - tests := []struct { - name string - deployment *appsv1.Deployment - pausedByReloader bool - }{ - { - name: "paused by reloader", - deployment: &appsv1.Deployment{ - Spec: appsv1.DeploymentSpec{ - Paused: true, - }, - ObjectMeta: metav1.ObjectMeta{ - Annotations: map[string]string{ - options.PauseDeploymentTimeAnnotation: time.Now().Format(time.RFC3339), - }, - }, - }, - pausedByReloader: true, - }, - { - name: "not paused by reloader", - deployment: &appsv1.Deployment{ - Spec: appsv1.DeploymentSpec{ - Paused: true, - }, - ObjectMeta: metav1.ObjectMeta{ - Annotations: map[string]string{}, - }, - }, - pausedByReloader: false, - }, - { - name: "not paused", - deployment: &appsv1.Deployment{ - Spec: appsv1.DeploymentSpec{ - Paused: false, - }, - }, - pausedByReloader: false, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - pausedByReloader := IsPausedByReloader(test.deployment) - assert.Equal(t, test.pausedByReloader, pausedByReloader) - }) - } -} - -func TestGetPauseStartTime(t *testing.T) { - now := time.Now() - nowStr := now.Format(time.RFC3339) - - tests := []struct { - name string - deployment *appsv1.Deployment - pausedByReloader bool - expectedStartTime time.Time - }{ - { - name: "valid pause time", - deployment: &appsv1.Deployment{ - Spec: appsv1.DeploymentSpec{ - Paused: true, - }, - ObjectMeta: metav1.ObjectMeta{ - Annotations: map[string]string{ - options.PauseDeploymentTimeAnnotation: nowStr, - }, - }, - }, - pausedByReloader: true, - expectedStartTime: now, - }, - { - name: "not paused by reloader", - deployment: &appsv1.Deployment{ - Spec: appsv1.DeploymentSpec{ - Paused: false, - }, - }, - pausedByReloader: false, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - actualStartTime, err := GetPauseStartTime(test.deployment) - - assert.NoError(t, err) - - if !test.pausedByReloader { - assert.Nil(t, actualStartTime) - } else { - assert.NotNil(t, actualStartTime) - assert.WithinDuration(t, test.expectedStartTime, *actualStartTime, time.Second) - } - }) - } -} - -func TestParsePauseDuration(t *testing.T) { - tests := []struct { - name string - pauseIntervalValue string - expectedDuration time.Duration - invalidDuration bool - }{ - { - name: "valid duration", - pauseIntervalValue: "10s", - expectedDuration: 10 * time.Second, - invalidDuration: false, - }, - { - name: "valid minute duration", - pauseIntervalValue: "2m", - expectedDuration: 2 * time.Minute, - invalidDuration: false, - }, - { - name: "invalid duration", - pauseIntervalValue: "invalid", - expectedDuration: 0, - invalidDuration: true, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - actualDuration, err := ParsePauseDuration(test.pauseIntervalValue) - - if test.invalidDuration { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, test.expectedDuration, actualDuration) - } - }) - } -} - -func TestHandleMissingTimerSimple(t *testing.T) { - tests := []struct { - name string - deployment *appsv1.Deployment - shouldBePaused bool // Should be unpaused after HandleMissingTimer ? - }{ - { - name: "deployment paused by reloader, pause period has expired and no timer", - deployment: &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment-1", - Annotations: map[string]string{ - options.PauseDeploymentTimeAnnotation: time.Now().Add(-6 * time.Minute).Format(time.RFC3339), - options.PauseDeploymentAnnotation: "5m", - }, - }, - Spec: appsv1.DeploymentSpec{ - Paused: true, - }, - }, - shouldBePaused: false, - }, - { - name: "deployment paused by reloader, pause period expires in the future and no timer", - deployment: &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment-2", - Annotations: map[string]string{ - options.PauseDeploymentTimeAnnotation: time.Now().Add(1 * time.Minute).Format(time.RFC3339), - options.PauseDeploymentAnnotation: "5m", - }, - }, - Spec: appsv1.DeploymentSpec{ - Paused: true, - }, - }, - shouldBePaused: true, - }, - } - - for _, test := range tests { - // Clean up any timers at the end of the test - defer func() { - for key, timer := range activeTimers { - timer.Stop() - delete(activeTimers, key) - } - }() - - t.Run(test.name, func(t *testing.T) { - fakeClient := testclient.NewClientset() - clients := kube.Clients{ - KubernetesClient: fakeClient, - } - - _, err := fakeClient.AppsV1().Deployments("default").Create( - context.TODO(), - test.deployment, - metav1.CreateOptions{}) - assert.NoError(t, err, "Expected no error when creating deployment") - - pauseDuration, _ := ParsePauseDuration(test.deployment.Annotations[options.PauseDeploymentAnnotation]) - HandleMissingTimer(test.deployment, pauseDuration, clients, "default") - - updatedDeployment, _ := fakeClient.AppsV1().Deployments("default").Get(context.TODO(), test.deployment.Name, metav1.GetOptions{}) - - assert.Equal(t, test.shouldBePaused, updatedDeployment.Spec.Paused, - "Deployment should have correct paused state after timer expiration") - - if test.shouldBePaused { - pausedAtAnnotationValue := updatedDeployment.Annotations[options.PauseDeploymentTimeAnnotation] - assert.NotEmpty(t, pausedAtAnnotationValue, - "Pause annotation should be present and contain a value when deployment is paused") - } - }) - } -} - -func TestPauseDeployment(t *testing.T) { - tests := []struct { - name string - deployment *appsv1.Deployment - expectedError bool - expectedPaused bool - expectedAnnotation bool // Should have pause time annotation - pauseInterval string - }{ - { - name: "deployment without pause annotation", - deployment: &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment", - Annotations: map[string]string{}, - }, - Spec: appsv1.DeploymentSpec{ - Paused: false, - }, - }, - expectedError: true, - expectedPaused: false, - expectedAnnotation: false, - pauseInterval: "", - }, - { - name: "deployment already paused but not by reloader", - deployment: &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment", - Annotations: map[string]string{ - options.PauseDeploymentAnnotation: "5m", - }, - }, - Spec: appsv1.DeploymentSpec{ - Paused: true, - }, - }, - expectedError: false, - expectedPaused: true, - expectedAnnotation: false, - pauseInterval: "5m", - }, - { - name: "deployment unpaused that needs to be paused by reloader", - deployment: &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment-3", - Annotations: map[string]string{ - options.PauseDeploymentAnnotation: "5m", - }, - }, - Spec: appsv1.DeploymentSpec{ - Paused: false, - }, - }, - expectedError: false, - expectedPaused: true, - expectedAnnotation: true, - pauseInterval: "5m", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - fakeClient := testclient.NewClientset() - clients := kube.Clients{ - KubernetesClient: fakeClient, - } - - _, err := fakeClient.AppsV1().Deployments("default").Create( - context.TODO(), - test.deployment, - metav1.CreateOptions{}) - assert.NoError(t, err, "Expected no error when creating deployment") - - updatedDeployment, err := PauseDeployment(test.deployment, clients, "default", test.pauseInterval) - if test.expectedError { - assert.Error(t, err, "Expected an error pausing the deployment") - return - } else { - assert.NoError(t, err, "Expected no error pausing the deployment") - } - - assert.Equal(t, test.expectedPaused, updatedDeployment.Spec.Paused, - "Deployment should have correct paused state after pause") - - if test.expectedAnnotation { - pausedAtAnnotationValue := updatedDeployment.Annotations[options.PauseDeploymentTimeAnnotation] - assert.NotEmpty(t, pausedAtAnnotationValue, - "Pause annotation should be present and contain a value when deployment is paused") - } else { - pausedAtAnnotationValue := updatedDeployment.Annotations[options.PauseDeploymentTimeAnnotation] - assert.Empty(t, pausedAtAnnotationValue, - "Pause annotation should not be present when deployment has not been paused by reloader") - } - }) - } -} - -// Simple helper function for test cases -func FindDeploymentByName(deployments []runtime.Object, deploymentName string) (*appsv1.Deployment, error) { - for _, deployment := range deployments { - accessor, err := meta.Accessor(deployment) - if err != nil { - return nil, fmt.Errorf("error getting accessor for item: %w", err) - } - if accessor.GetName() == deploymentName { - deploymentObj, ok := deployment.(*appsv1.Deployment) - if !ok { - return nil, fmt.Errorf("failed to cast to Deployment") - } - return deploymentObj, nil - } - } - return nil, fmt.Errorf("deployment '%s' not found", deploymentName) -} diff --git a/internal/pkg/handler/update.go b/internal/pkg/handler/update.go deleted file mode 100644 index 7a1ad7d99..000000000 --- a/internal/pkg/handler/update.go +++ /dev/null @@ -1,98 +0,0 @@ -package handler - -import ( - "time" - - "github.com/sirupsen/logrus" - v1 "k8s.io/api/core/v1" - "k8s.io/client-go/tools/record" - csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1" - - "github.com/stakater/Reloader/internal/pkg/metrics" - "github.com/stakater/Reloader/internal/pkg/options" - "github.com/stakater/Reloader/internal/pkg/util" - "github.com/stakater/Reloader/pkg/common" -) - -// ResourceUpdatedHandler contains updated objects -type ResourceUpdatedHandler struct { - Resource interface{} - OldResource interface{} - Collectors metrics.Collectors - Recorder record.EventRecorder - EnqueueTime time.Time // Time when this handler was added to the queue -} - -// GetEnqueueTime returns when this handler was enqueued -func (r ResourceUpdatedHandler) GetEnqueueTime() time.Time { - return r.EnqueueTime -} - -// Handle processes the updated resource -func (r ResourceUpdatedHandler) Handle() error { - startTime := time.Now() - result := "error" - - defer func() { - r.Collectors.RecordReconcile(result, time.Since(startTime)) - }() - - if r.Resource == nil || r.OldResource == nil { - logrus.Errorf("Resource update handler received nil resource") - return nil - } - - config, oldSHAData := r.GetConfig() - if config.SHAValue != oldSHAData { - // Send a webhook if update - if options.WebhookUrl != "" { - err := sendUpgradeWebhook(config, options.WebhookUrl) - if err == nil { - result = "success" - } - return err - } - // process resource based on its type - err := doRollingUpgrade(config, r.Collectors, r.Recorder, invokeReloadStrategy) - if err == nil { - result = "success" - } - return err - } - - // No data change - skip - result = "skipped" - r.Collectors.RecordSkipped("no_data_change") - return nil -} - -// GetConfig gets configurations containing SHA, annotations, namespace and resource name -func (r ResourceUpdatedHandler) GetConfig() (common.Config, string) { - var ( - oldSHAData string - config common.Config - ) - - switch res := r.Resource.(type) { - case *v1.ConfigMap: - if old, ok := r.OldResource.(*v1.ConfigMap); ok && old != nil { - oldSHAData = util.GetSHAfromConfigmap(old) - } - config = common.GetConfigmapConfig(res) - - case *v1.Secret: - if old, ok := r.OldResource.(*v1.Secret); ok && old != nil { - oldSHAData = util.GetSHAfromSecret(old.Data) - } - config = common.GetSecretConfig(res) - - case *csiv1.SecretProviderClassPodStatus: - if old, ok := r.OldResource.(*csiv1.SecretProviderClassPodStatus); ok && old != nil && old.Status.Objects != nil { - oldSHAData = util.GetSHAfromSecretProviderClassPodStatus(old.Status) - } - config = common.GetSecretProviderClassPodStatusConfig(res) - default: - logrus.Warnf("Invalid resource: Resource should be 'Secret', 'Configmap' or 'SecretProviderClassPodStatus' but found, %T", r.Resource) - } - return config, oldSHAData -} diff --git a/internal/pkg/handler/update_test.go b/internal/pkg/handler/update_test.go deleted file mode 100644 index 1ae10d413..000000000 --- a/internal/pkg/handler/update_test.go +++ /dev/null @@ -1,520 +0,0 @@ -package handler - -import ( - "testing" - - "github.com/stretchr/testify/assert" - v1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - "github.com/stakater/Reloader/internal/pkg/constants" - "github.com/stakater/Reloader/internal/pkg/metrics" -) - -func TestResourceUpdatedHandler_GetConfig(t *testing.T) { - tests := []struct { - name string - oldResource any - newResource any - expectedName string - expectedNS string - expectedType string - expectSHANotEmpty bool - expectSHAChanged bool - }{ - { - name: "ConfigMap data changed", - oldResource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "my-cm", Namespace: "default"}, - Data: map[string]string{"key": "old-value"}, - }, - newResource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "my-cm", Namespace: "default"}, - Data: map[string]string{"key": "new-value"}, - }, - expectedName: "my-cm", - expectedNS: "default", - expectedType: constants.ConfigmapEnvVarPostfix, - expectSHANotEmpty: true, - expectSHAChanged: true, - }, - { - name: "ConfigMap data unchanged", - oldResource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "my-cm", Namespace: "default"}, - Data: map[string]string{"key": "same-value"}, - }, - newResource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "my-cm", Namespace: "default"}, - Data: map[string]string{"key": "same-value"}, - }, - expectedName: "my-cm", - expectedNS: "default", - expectedType: constants.ConfigmapEnvVarPostfix, - expectSHANotEmpty: true, - expectSHAChanged: false, - }, - { - name: "ConfigMap key added", - oldResource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "my-cm", Namespace: "default"}, - Data: map[string]string{"key1": "value1"}, - }, - newResource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "my-cm", Namespace: "default"}, - Data: map[string]string{"key1": "value1", "key2": "value2"}, - }, - expectedName: "my-cm", - expectedNS: "default", - expectedType: constants.ConfigmapEnvVarPostfix, - expectSHANotEmpty: true, - expectSHAChanged: true, - }, - { - name: "ConfigMap key removed", - oldResource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "my-cm", Namespace: "default"}, - Data: map[string]string{"key1": "value1", "key2": "value2"}, - }, - newResource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "my-cm", Namespace: "default"}, - Data: map[string]string{"key1": "value1"}, - }, - expectedName: "my-cm", - expectedNS: "default", - expectedType: constants.ConfigmapEnvVarPostfix, - expectSHANotEmpty: true, - expectSHAChanged: true, - }, - { - name: "ConfigMap only labels changed - SHA unchanged", - oldResource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "my-cm", - Namespace: "default", - Labels: map[string]string{"version": "v1"}, - }, - Data: map[string]string{"key": "value"}, - }, - newResource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "my-cm", - Namespace: "default", - Labels: map[string]string{"version": "v2"}, - }, - Data: map[string]string{"key": "value"}, - }, - expectedName: "my-cm", - expectedNS: "default", - expectedType: constants.ConfigmapEnvVarPostfix, - expectSHANotEmpty: true, - expectSHAChanged: false, - }, - { - name: "ConfigMap only annotations changed - SHA unchanged", - oldResource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "my-cm", - Namespace: "default", - Annotations: map[string]string{"note": "old"}, - }, - Data: map[string]string{"key": "value"}, - }, - newResource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "my-cm", - Namespace: "default", - Annotations: map[string]string{"note": "new"}, - }, - Data: map[string]string{"key": "value"}, - }, - expectedName: "my-cm", - expectedNS: "default", - expectedType: constants.ConfigmapEnvVarPostfix, - expectSHANotEmpty: true, - expectSHAChanged: false, - }, - { - name: "Secret data changed", - oldResource: &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "my-secret", Namespace: "default"}, - Data: map[string][]byte{"password": []byte("old-pass")}, - }, - newResource: &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "my-secret", Namespace: "default"}, - Data: map[string][]byte{"password": []byte("new-pass")}, - }, - expectedName: "my-secret", - expectedNS: "default", - expectedType: constants.SecretEnvVarPostfix, - expectSHANotEmpty: true, - expectSHAChanged: true, - }, - { - name: "Secret data unchanged", - oldResource: &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "my-secret", Namespace: "default"}, - Data: map[string][]byte{"password": []byte("same-pass")}, - }, - newResource: &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "my-secret", Namespace: "default"}, - Data: map[string][]byte{"password": []byte("same-pass")}, - }, - expectedName: "my-secret", - expectedNS: "default", - expectedType: constants.SecretEnvVarPostfix, - expectSHANotEmpty: true, - expectSHAChanged: false, - }, - { - name: "Secret key added", - oldResource: &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "my-secret", Namespace: "default"}, - Data: map[string][]byte{"key1": []byte("value1")}, - }, - newResource: &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "my-secret", Namespace: "default"}, - Data: map[string][]byte{"key1": []byte("value1"), "key2": []byte("value2")}, - }, - expectedName: "my-secret", - expectedNS: "default", - expectedType: constants.SecretEnvVarPostfix, - expectSHANotEmpty: true, - expectSHAChanged: true, - }, - { - name: "Secret only labels changed - SHA unchanged", - oldResource: &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "my-secret", - Namespace: "default", - Labels: map[string]string{"env": "dev"}, - }, - Data: map[string][]byte{"key": []byte("value")}, - }, - newResource: &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "my-secret", - Namespace: "default", - Labels: map[string]string{"env": "prod"}, - }, - Data: map[string][]byte{"key": []byte("value")}, - }, - expectedName: "my-secret", - expectedNS: "default", - expectedType: constants.SecretEnvVarPostfix, - expectSHANotEmpty: true, - expectSHAChanged: false, - }, - { - name: "Invalid resource type", - oldResource: "invalid", - newResource: "invalid", - expectedName: "", - expectedNS: "", - expectedType: "", - expectSHANotEmpty: false, - expectSHAChanged: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - handler := ResourceUpdatedHandler{ - Resource: tt.newResource, - OldResource: tt.oldResource, - Collectors: metrics.NewCollectors(), - } - - config, oldSHA := handler.GetConfig() - - assert.Equal(t, tt.expectedName, config.ResourceName) - assert.Equal(t, tt.expectedNS, config.Namespace) - assert.Equal(t, tt.expectedType, config.Type) - - if tt.expectSHANotEmpty { - assert.NotEmpty(t, config.SHAValue, "new SHA should not be empty") - assert.NotEmpty(t, oldSHA, "old SHA should not be empty") - } - - if tt.expectSHAChanged { - assert.NotEqual(t, config.SHAValue, oldSHA, "SHA should have changed") - } else if tt.expectSHANotEmpty { - assert.Equal(t, config.SHAValue, oldSHA, "SHA should not have changed") - } - }) - } -} - -func TestResourceUpdatedHandler_Handle(t *testing.T) { - tests := []struct { - name string - oldResource any - newResource any - expectError bool - }{ - { - name: "Both resources nil", - oldResource: nil, - newResource: nil, - expectError: false, - }, - { - name: "Old resource nil", - oldResource: nil, - newResource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "cm", Namespace: "default"}, - Data: map[string]string{"key": "value"}, - }, - expectError: false, - }, - { - name: "New resource nil", - oldResource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "cm", Namespace: "default"}, - Data: map[string]string{"key": "value"}, - }, - newResource: nil, - expectError: false, - }, - { - name: "ConfigMap unchanged - no action", - oldResource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "cm", Namespace: "default"}, - Data: map[string]string{"key": "same"}, - }, - newResource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "cm", Namespace: "default"}, - Data: map[string]string{"key": "same"}, - }, - expectError: false, - }, - { - name: "ConfigMap changed - triggers update", - oldResource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "cm", Namespace: "default"}, - Data: map[string]string{"key": "old"}, - }, - newResource: &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "cm", Namespace: "default"}, - Data: map[string]string{"key": "new"}, - }, - expectError: false, - }, - { - name: "Secret unchanged - no action", - oldResource: &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "secret", Namespace: "default"}, - Data: map[string][]byte{"key": []byte("same")}, - }, - newResource: &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "secret", Namespace: "default"}, - Data: map[string][]byte{"key": []byte("same")}, - }, - expectError: false, - }, - { - name: "Secret changed - triggers update", - oldResource: &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "secret", Namespace: "default"}, - Data: map[string][]byte{"key": []byte("old")}, - }, - newResource: &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "secret", Namespace: "default"}, - Data: map[string][]byte{"key": []byte("new")}, - }, - expectError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - handler := ResourceUpdatedHandler{ - Resource: tt.newResource, - OldResource: tt.oldResource, - Collectors: metrics.NewCollectors(), - } - - err := handler.Handle() - - if tt.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestResourceUpdatedHandler_GetConfig_Annotations(t *testing.T) { - oldCM := &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cm", - Namespace: "default", - Annotations: map[string]string{ - "old-annotation": "old-value", - }, - }, - Data: map[string]string{"key": "value"}, - } - - newCM := &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cm", - Namespace: "default", - Annotations: map[string]string{ - "new-annotation": "new-value", - }, - }, - Data: map[string]string{"key": "value"}, - } - - handler := ResourceUpdatedHandler{ - Resource: newCM, - OldResource: oldCM, - Collectors: metrics.NewCollectors(), - } - - config, _ := handler.GetConfig() - - assert.Equal(t, "new-value", config.ResourceAnnotations["new-annotation"]) - _, hasOld := config.ResourceAnnotations["old-annotation"] - assert.False(t, hasOld) -} - -func TestResourceUpdatedHandler_GetConfig_Labels(t *testing.T) { - oldSecret := &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "secret", - Namespace: "default", - Labels: map[string]string{"version": "v1"}, - }, - Data: map[string][]byte{"key": []byte("value")}, - } - - newSecret := &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "secret", - Namespace: "default", - Labels: map[string]string{"version": "v2"}, - }, - Data: map[string][]byte{"key": []byte("value")}, - } - - handler := ResourceUpdatedHandler{ - Resource: newSecret, - OldResource: oldSecret, - Collectors: metrics.NewCollectors(), - } - - config, _ := handler.GetConfig() - - assert.Equal(t, "v2", config.Labels["version"]) -} - -func TestResourceUpdatedHandler_EmptyToNonEmpty(t *testing.T) { - oldCM := &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "cm", Namespace: "default"}, - Data: map[string]string{}, - } - newCM := &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "cm", Namespace: "default"}, - Data: map[string]string{"key": "value"}, - } - - handler := ResourceUpdatedHandler{ - Resource: newCM, - OldResource: oldCM, - Collectors: metrics.NewCollectors(), - } - - config, oldSHA := handler.GetConfig() - - assert.NotEqual(t, config.SHAValue, oldSHA, "SHA should change when data is added") -} - -func TestResourceUpdatedHandler_NonEmptyToEmpty(t *testing.T) { - oldCM := &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "cm", Namespace: "default"}, - Data: map[string]string{"key": "value"}, - } - newCM := &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "cm", Namespace: "default"}, - Data: map[string]string{}, - } - - handler := ResourceUpdatedHandler{ - Resource: newCM, - OldResource: oldCM, - Collectors: metrics.NewCollectors(), - } - - config, oldSHA := handler.GetConfig() - - assert.NotEqual(t, config.SHAValue, oldSHA, "SHA should change when data is removed") -} - -func TestResourceUpdatedHandler_BinaryDataChange(t *testing.T) { - oldCM := &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "cm", Namespace: "default"}, - BinaryData: map[string][]byte{"binary": []byte("old-binary")}, - } - newCM := &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "cm", Namespace: "default"}, - BinaryData: map[string][]byte{"binary": []byte("new-binary")}, - } - - handler := ResourceUpdatedHandler{ - Resource: newCM, - OldResource: oldCM, - Collectors: metrics.NewCollectors(), - } - - config, oldSHA := handler.GetConfig() - - assert.NotEqual(t, config.SHAValue, oldSHA, "SHA should change when binary data changes") -} - -func TestResourceUpdatedHandler_MixedDataAndBinaryData(t *testing.T) { - oldCM := &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "cm", Namespace: "default"}, - Data: map[string]string{"text": "value"}, - BinaryData: map[string][]byte{"binary": []byte("binary-value")}, - } - newCM := &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "cm", Namespace: "default"}, - Data: map[string]string{"text": "value"}, - BinaryData: map[string][]byte{"binary": []byte("new-binary-value")}, - } - - handler := ResourceUpdatedHandler{ - Resource: newCM, - OldResource: oldCM, - Collectors: metrics.NewCollectors(), - } - - config, oldSHA := handler.GetConfig() - - assert.NotEqual(t, config.SHAValue, oldSHA, "SHA should change when binary data changes") -} - -func TestResourceUpdatedHandler_DifferentNamespaces(t *testing.T) { - oldCM := &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "cm", Namespace: "ns1"}, - Data: map[string]string{"key": "value"}, - } - newCM := &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "cm", Namespace: "ns2"}, - Data: map[string]string{"key": "value"}, - } - - handler := ResourceUpdatedHandler{ - Resource: newCM, - OldResource: oldCM, - Collectors: metrics.NewCollectors(), - } - - config, _ := handler.GetConfig() - - assert.Equal(t, "ns2", config.Namespace) -} diff --git a/internal/pkg/handler/upgrade.go b/internal/pkg/handler/upgrade.go deleted file mode 100644 index a48704030..000000000 --- a/internal/pkg/handler/upgrade.go +++ /dev/null @@ -1,690 +0,0 @@ -package handler - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "strings" - "time" - - "github.com/parnurzeal/gorequest" - "github.com/prometheus/client_golang/prometheus" - "github.com/sirupsen/logrus" - app "k8s.io/api/apps/v1" - v1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - patchtypes "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/wait" - "k8s.io/client-go/tools/record" - "k8s.io/client-go/util/retry" - - alert "github.com/stakater/Reloader/internal/pkg/alerts" - "github.com/stakater/Reloader/internal/pkg/callbacks" - "github.com/stakater/Reloader/internal/pkg/constants" - "github.com/stakater/Reloader/internal/pkg/metrics" - "github.com/stakater/Reloader/internal/pkg/options" - "github.com/stakater/Reloader/internal/pkg/util" - "github.com/stakater/Reloader/pkg/common" - "github.com/stakater/Reloader/pkg/kube" -) - -// GetDeploymentRollingUpgradeFuncs returns all callback funcs for a deployment -func GetDeploymentRollingUpgradeFuncs() callbacks.RollingUpgradeFuncs { - return callbacks.RollingUpgradeFuncs{ - ItemFunc: callbacks.GetDeploymentItem, - ItemsFunc: callbacks.GetDeploymentItems, - AnnotationsFunc: callbacks.GetDeploymentAnnotations, - PodAnnotationsFunc: callbacks.GetDeploymentPodAnnotations, - ContainersFunc: callbacks.GetDeploymentContainers, - InitContainersFunc: callbacks.GetDeploymentInitContainers, - UpdateFunc: callbacks.UpdateDeployment, - PatchFunc: callbacks.PatchDeployment, - PatchTemplatesFunc: callbacks.GetPatchTemplates, - VolumesFunc: callbacks.GetDeploymentVolumes, - ResourceType: "Deployment", - SupportsPatch: true, - } -} - -// GetDeploymentRollingUpgradeFuncs returns all callback funcs for a cronjob -func GetCronJobCreateJobFuncs() callbacks.RollingUpgradeFuncs { - return callbacks.RollingUpgradeFuncs{ - ItemFunc: callbacks.GetCronJobItem, - ItemsFunc: callbacks.GetCronJobItems, - AnnotationsFunc: callbacks.GetCronJobAnnotations, - PodAnnotationsFunc: callbacks.GetCronJobPodAnnotations, - ContainersFunc: callbacks.GetCronJobContainers, - InitContainersFunc: callbacks.GetCronJobInitContainers, - UpdateFunc: callbacks.CreateJobFromCronjob, - PatchFunc: callbacks.PatchCronJob, - PatchTemplatesFunc: func() callbacks.PatchTemplates { return callbacks.PatchTemplates{} }, - VolumesFunc: callbacks.GetCronJobVolumes, - ResourceType: "CronJob", - SupportsPatch: false, - } -} - -// GetDeploymentRollingUpgradeFuncs returns all callback funcs for a cronjob -func GetJobCreateJobFuncs() callbacks.RollingUpgradeFuncs { - return callbacks.RollingUpgradeFuncs{ - ItemFunc: callbacks.GetJobItem, - ItemsFunc: callbacks.GetJobItems, - AnnotationsFunc: callbacks.GetJobAnnotations, - PodAnnotationsFunc: callbacks.GetJobPodAnnotations, - ContainersFunc: callbacks.GetJobContainers, - InitContainersFunc: callbacks.GetJobInitContainers, - UpdateFunc: callbacks.ReCreateJobFromjob, - PatchFunc: callbacks.PatchJob, - PatchTemplatesFunc: func() callbacks.PatchTemplates { return callbacks.PatchTemplates{} }, - VolumesFunc: callbacks.GetJobVolumes, - ResourceType: "Job", - SupportsPatch: false, - } -} - -// GetDaemonSetRollingUpgradeFuncs returns all callback funcs for a daemonset -func GetDaemonSetRollingUpgradeFuncs() callbacks.RollingUpgradeFuncs { - return callbacks.RollingUpgradeFuncs{ - ItemFunc: callbacks.GetDaemonSetItem, - ItemsFunc: callbacks.GetDaemonSetItems, - AnnotationsFunc: callbacks.GetDaemonSetAnnotations, - PodAnnotationsFunc: callbacks.GetDaemonSetPodAnnotations, - ContainersFunc: callbacks.GetDaemonSetContainers, - InitContainersFunc: callbacks.GetDaemonSetInitContainers, - UpdateFunc: callbacks.UpdateDaemonSet, - PatchFunc: callbacks.PatchDaemonSet, - PatchTemplatesFunc: callbacks.GetPatchTemplates, - VolumesFunc: callbacks.GetDaemonSetVolumes, - ResourceType: "DaemonSet", - SupportsPatch: true, - } -} - -// GetStatefulSetRollingUpgradeFuncs returns all callback funcs for a statefulSet -func GetStatefulSetRollingUpgradeFuncs() callbacks.RollingUpgradeFuncs { - return callbacks.RollingUpgradeFuncs{ - ItemFunc: callbacks.GetStatefulSetItem, - ItemsFunc: callbacks.GetStatefulSetItems, - AnnotationsFunc: callbacks.GetStatefulSetAnnotations, - PodAnnotationsFunc: callbacks.GetStatefulSetPodAnnotations, - ContainersFunc: callbacks.GetStatefulSetContainers, - InitContainersFunc: callbacks.GetStatefulSetInitContainers, - UpdateFunc: callbacks.UpdateStatefulSet, - PatchFunc: callbacks.PatchStatefulSet, - PatchTemplatesFunc: callbacks.GetPatchTemplates, - VolumesFunc: callbacks.GetStatefulSetVolumes, - ResourceType: "StatefulSet", - SupportsPatch: true, - } -} - -// GetArgoRolloutRollingUpgradeFuncs returns all callback funcs for a rollout -func GetArgoRolloutRollingUpgradeFuncs() callbacks.RollingUpgradeFuncs { - return callbacks.RollingUpgradeFuncs{ - ItemFunc: callbacks.GetRolloutItem, - ItemsFunc: callbacks.GetRolloutItems, - AnnotationsFunc: callbacks.GetRolloutAnnotations, - PodAnnotationsFunc: callbacks.GetRolloutPodAnnotations, - ContainersFunc: callbacks.GetRolloutContainers, - InitContainersFunc: callbacks.GetRolloutInitContainers, - UpdateFunc: callbacks.UpdateRollout, - PatchFunc: callbacks.PatchRollout, - PatchTemplatesFunc: func() callbacks.PatchTemplates { return callbacks.PatchTemplates{} }, - VolumesFunc: callbacks.GetRolloutVolumes, - ResourceType: "Rollout", - SupportsPatch: false, - } -} - -func sendUpgradeWebhook(config common.Config, webhookUrl string) error { - logrus.Infof("Changes detected in '%s' of type '%s' in namespace '%s', Sending webhook to '%s'", - config.ResourceName, config.Type, config.Namespace, webhookUrl) - - body, errs := sendWebhook(webhookUrl) - if errs != nil { - // return the first error - return errs[0] - } else { - logrus.Info(body) - } - - return nil -} - -func sendWebhook(url string) (string, []error) { - request := gorequest.New() - resp, _, err := request.Post(url).Send(`{"webhook":"update successful"}`).End() - if err != nil { - // the reloader seems to retry automatically so no retry logic added - return "", err - } - defer func() { - closeErr := resp.Body.Close() - if closeErr != nil { - logrus.Error(closeErr) - } - }() - var buffer bytes.Buffer - _, bufferErr := io.Copy(&buffer, resp.Body) - if bufferErr != nil { - logrus.Error(bufferErr) - } - return buffer.String(), nil -} - -func doRollingUpgrade(config common.Config, collectors metrics.Collectors, recorder record.EventRecorder, invoke invokeStrategy) error { - clients := kube.GetClients() - - // Get ignored workload types to avoid listing resources without RBAC permissions - ignoredWorkloadTypes, err := util.GetIgnoredWorkloadTypesList() - if err != nil { - logrus.Errorf("Failed to parse ignored workload types: %v", err) - ignoredWorkloadTypes = util.List{} // Continue with empty list if parsing fails - } - - err = rollingUpgrade(clients, config, GetDeploymentRollingUpgradeFuncs(), collectors, recorder, invoke) - if err != nil { - return err - } - - // Only process CronJobs if they are not ignored - if !ignoredWorkloadTypes.Contains("cronjobs") { - err = rollingUpgrade(clients, config, GetCronJobCreateJobFuncs(), collectors, recorder, invoke) - if err != nil { - return err - } - } - - // Only process Jobs if they are not ignored - if !ignoredWorkloadTypes.Contains("jobs") { - err = rollingUpgrade(clients, config, GetJobCreateJobFuncs(), collectors, recorder, invoke) - if err != nil { - return err - } - } - - err = rollingUpgrade(clients, config, GetDaemonSetRollingUpgradeFuncs(), collectors, recorder, invoke) - if err != nil { - return err - } - err = rollingUpgrade(clients, config, GetStatefulSetRollingUpgradeFuncs(), collectors, recorder, invoke) - if err != nil { - return err - } - - if options.IsArgoRollouts == "true" { - err = rollingUpgrade(clients, config, GetArgoRolloutRollingUpgradeFuncs(), collectors, recorder, invoke) - if err != nil { - return err - } - } - - return nil -} - -func rollingUpgrade(clients kube.Clients, config common.Config, upgradeFuncs callbacks.RollingUpgradeFuncs, collectors metrics.Collectors, recorder record.EventRecorder, strategy invokeStrategy) error { - err := PerformAction(clients, config, upgradeFuncs, collectors, recorder, strategy) - if err != nil { - logrus.Errorf("Rolling upgrade for '%s' failed with error = %v", config.ResourceName, err) - } - return err -} - -// PerformAction invokes the deployment if there is any change in configmap or secret data -func PerformAction(clients kube.Clients, config common.Config, upgradeFuncs callbacks.RollingUpgradeFuncs, collectors metrics.Collectors, recorder record.EventRecorder, strategy invokeStrategy) error { - items := upgradeFuncs.ItemsFunc(clients, config.Namespace) - - // Record workloads scanned - collectors.RecordWorkloadsScanned(upgradeFuncs.ResourceType, len(items)) - - matchedCount := 0 - for _, item := range items { - matched, err := retryOnConflict(retry.DefaultRetry, func(fetchResource bool) (bool, error) { - return upgradeResource(clients, config, upgradeFuncs, collectors, recorder, strategy, item, fetchResource) - }) - if err != nil { - return err - } - if matched { - matchedCount++ - } - } - - // Record workloads matched - collectors.RecordWorkloadsMatched(upgradeFuncs.ResourceType, matchedCount) - - return nil -} - -func retryOnConflict(backoff wait.Backoff, fn func(_ bool) (bool, error)) (bool, error) { - var lastError error - var matched bool - fetchResource := false // do not fetch resource on first attempt, already done by ItemsFunc - err := wait.ExponentialBackoff(backoff, func() (bool, error) { - var err error - matched, err = fn(fetchResource) - fetchResource = true - switch { - case err == nil: - return true, nil - case apierrors.IsConflict(err): - lastError = err - return false, nil - default: - return false, err - } - }) - if wait.Interrupted(err) { - err = lastError - } - return matched, err -} - -func upgradeResource(clients kube.Clients, config common.Config, upgradeFuncs callbacks.RollingUpgradeFuncs, collectors metrics.Collectors, recorder record.EventRecorder, strategy invokeStrategy, resource runtime.Object, fetchResource bool) (bool, error) { - actionStartTime := time.Now() - - accessor, err := meta.Accessor(resource) - if err != nil { - return false, err - } - - resourceName := accessor.GetName() - if fetchResource { - resource, err = upgradeFuncs.ItemFunc(clients, resourceName, config.Namespace) - if err != nil { - return false, err - } - } - if config.Type == constants.SecretProviderClassEnvVarPostfix { - populateAnnotationsFromSecretProviderClass(clients, &config) - } - - annotations := upgradeFuncs.AnnotationsFunc(resource) - podAnnotations := upgradeFuncs.PodAnnotationsFunc(resource) - result := common.ShouldReload(config, upgradeFuncs.ResourceType, annotations, podAnnotations, common.GetCommandLineOptions()) - - if !result.ShouldReload { - logrus.Debugf("No changes detected in '%s' of type '%s' in namespace '%s'", config.ResourceName, config.Type, config.Namespace) - return false, nil - } - - strategyResult := strategy(upgradeFuncs, resource, config, result.AutoReload) - - if strategyResult.Result != constants.Updated { - collectors.RecordSkipped("strategy_not_updated") - return false, nil - } - - // find correct annotation and update the resource - pauseInterval, foundPauseInterval := annotations[options.PauseDeploymentAnnotation] - - if foundPauseInterval { - deployment, ok := resource.(*app.Deployment) - if !ok { - logrus.Warnf("Annotation '%s' only applicable for deployments", options.PauseDeploymentAnnotation) - } else { - _, err = PauseDeployment(deployment, clients, config.Namespace, pauseInterval) - if err != nil { - logrus.Errorf("Failed to pause deployment '%s' in namespace '%s': %v", resourceName, config.Namespace, err) - return true, err - } - } - } - - if upgradeFuncs.SupportsPatch && strategyResult.Patch != nil { - err = upgradeFuncs.PatchFunc(clients, config.Namespace, resource, strategyResult.Patch.Type, strategyResult.Patch.Bytes) - } else { - err = upgradeFuncs.UpdateFunc(clients, config.Namespace, resource) - } - - actionLatency := time.Since(actionStartTime) - - if err != nil { - message := fmt.Sprintf("Update for '%s' of type '%s' in namespace '%s' failed with error %v", resourceName, upgradeFuncs.ResourceType, config.Namespace, err) - logrus.Errorf("Update for '%s' of type '%s' in namespace '%s' failed with error %v", resourceName, upgradeFuncs.ResourceType, config.Namespace, err) - - collectors.Reloaded.With(prometheus.Labels{"success": "false"}).Inc() - collectors.ReloadedByNamespace.With(prometheus.Labels{"success": "false", "namespace": config.Namespace}).Inc() - collectors.RecordAction(upgradeFuncs.ResourceType, "error", actionLatency) - if recorder != nil { - recorder.Event(resource, v1.EventTypeWarning, "ReloadFail", message) - } - return true, err - } else { - message := fmt.Sprintf("Changes detected in '%s' of type '%s' in namespace '%s'", config.ResourceName, config.Type, config.Namespace) - message += fmt.Sprintf(", Updated '%s' of type '%s' in namespace '%s'", resourceName, upgradeFuncs.ResourceType, config.Namespace) - - logrus.Infof("Changes detected in '%s' of type '%s' in namespace '%s'; updated '%s' of type '%s' in namespace '%s'", config.ResourceName, config.Type, config.Namespace, resourceName, upgradeFuncs.ResourceType, config.Namespace) - - collectors.Reloaded.With(prometheus.Labels{"success": "true"}).Inc() - collectors.ReloadedByNamespace.With(prometheus.Labels{"success": "true", "namespace": config.Namespace}).Inc() - collectors.RecordAction(upgradeFuncs.ResourceType, "success", actionLatency) - alert_on_reload, ok := os.LookupEnv("ALERT_ON_RELOAD") - if recorder != nil { - recorder.Event(resource, v1.EventTypeNormal, "Reloaded", message) - } - if ok && alert_on_reload == "true" { - msg := fmt.Sprintf( - "Reloader detected changes in *%s* of type *%s* in namespace *%s*. Hence reloaded *%s* of type *%s* in namespace *%s*", - config.ResourceName, config.Type, config.Namespace, resourceName, upgradeFuncs.ResourceType, config.Namespace) - alert.SendWebhookAlert(msg) - } - } - - return true, nil -} - -func getVolumeMountName(volumes []v1.Volume, mountType string, volumeName string) string { - for i := range volumes { - switch mountType { - case constants.ConfigmapEnvVarPostfix: - if volumes[i].ConfigMap != nil && volumes[i].ConfigMap.Name == volumeName { - return volumes[i].Name - } - - if volumes[i].Projected != nil { - for j := range volumes[i].Projected.Sources { - if volumes[i].Projected.Sources[j].ConfigMap != nil && volumes[i].Projected.Sources[j].ConfigMap.Name == volumeName { - return volumes[i].Name - } - } - } - case constants.SecretEnvVarPostfix: - if volumes[i].Secret != nil && volumes[i].Secret.SecretName == volumeName { - return volumes[i].Name - } - - if volumes[i].Projected != nil { - for j := range volumes[i].Projected.Sources { - if volumes[i].Projected.Sources[j].Secret != nil && volumes[i].Projected.Sources[j].Secret.Name == volumeName { - return volumes[i].Name - } - } - } - case constants.SecretProviderClassEnvVarPostfix: - if volumes[i].CSI != nil && volumes[i].CSI.VolumeAttributes["secretProviderClass"] == volumeName { - return volumes[i].Name - } - } - } - - return "" -} - -func getContainerWithVolumeMount(containers []v1.Container, volumeMountName string) *v1.Container { - for i := range containers { - volumeMounts := containers[i].VolumeMounts - for j := range volumeMounts { - if volumeMounts[j].Name == volumeMountName { - return &containers[i] - } - } - } - - return nil -} - -func getContainerWithEnvReference(containers []v1.Container, resourceName string, resourceType string) *v1.Container { - for i := range containers { - envs := containers[i].Env - for j := range envs { - envVarSource := envs[j].ValueFrom - if envVarSource != nil { - if resourceType == constants.SecretEnvVarPostfix && envVarSource.SecretKeyRef != nil && envVarSource.SecretKeyRef.Name == resourceName { - return &containers[i] - } else if resourceType == constants.ConfigmapEnvVarPostfix && envVarSource.ConfigMapKeyRef != nil && envVarSource.ConfigMapKeyRef.Name == resourceName { - return &containers[i] - } - } - } - - envsFrom := containers[i].EnvFrom - for j := range envsFrom { - if resourceType == constants.SecretEnvVarPostfix && envsFrom[j].SecretRef != nil && envsFrom[j].SecretRef.Name == resourceName { - return &containers[i] - } else if resourceType == constants.ConfigmapEnvVarPostfix && envsFrom[j].ConfigMapRef != nil && envsFrom[j].ConfigMapRef.Name == resourceName { - return &containers[i] - } - } - } - return nil -} - -func getContainerUsingResource(upgradeFuncs callbacks.RollingUpgradeFuncs, item runtime.Object, config common.Config, autoReload bool) *v1.Container { - volumes := upgradeFuncs.VolumesFunc(item) - containers := upgradeFuncs.ContainersFunc(item) - initContainers := upgradeFuncs.InitContainersFunc(item) - var container *v1.Container - // Get the volumeMountName to find volumeMount in container - volumeMountName := getVolumeMountName(volumes, config.Type, config.ResourceName) - // Get the container with mounted configmap/secret - if volumeMountName != "" { - container = getContainerWithVolumeMount(containers, volumeMountName) - if container == nil && len(initContainers) > 0 { - container = getContainerWithVolumeMount(initContainers, volumeMountName) - if container != nil { - // if configmap/secret is being used in init container then return the first Pod container to save reloader env - if len(containers) > 0 { - return &containers[0] - } - // No containers available, return nil to avoid crash - return nil - } - } else if container != nil { - return container - } - } - - // Get the container with referenced secret or configmap as env var - container = getContainerWithEnvReference(containers, config.ResourceName, config.Type) - if container == nil && len(initContainers) > 0 { - container = getContainerWithEnvReference(initContainers, config.ResourceName, config.Type) - if container != nil { - // if configmap/secret is being used in init container then return the first Pod container to save reloader env - if len(containers) > 0 { - return &containers[0] - } - // No containers available, return nil to avoid crash - return nil - } - } - - // Get the first container if the annotation is related to specified configmap or secret i.e. configmap.reloader.stakater.com/reload - if container == nil && !autoReload { - if len(containers) > 0 { - return &containers[0] - } - // No containers available, return nil to avoid crash - return nil - } - - return container -} - -type Patch struct { - Type patchtypes.PatchType - Bytes []byte -} - -type InvokeStrategyResult struct { - Result constants.Result - Patch *Patch -} - -type invokeStrategy func(upgradeFuncs callbacks.RollingUpgradeFuncs, item runtime.Object, config common.Config, autoReload bool) InvokeStrategyResult - -func invokeReloadStrategy(upgradeFuncs callbacks.RollingUpgradeFuncs, item runtime.Object, config common.Config, autoReload bool) InvokeStrategyResult { - if options.ReloadStrategy == constants.AnnotationsReloadStrategy { - return updatePodAnnotations(upgradeFuncs, item, config, autoReload) - } - return updateContainerEnvVars(upgradeFuncs, item, config, autoReload) -} - -func updatePodAnnotations(upgradeFuncs callbacks.RollingUpgradeFuncs, item runtime.Object, config common.Config, autoReload bool) InvokeStrategyResult { - container := getContainerUsingResource(upgradeFuncs, item, config, autoReload) - if container == nil { - return InvokeStrategyResult{constants.NoContainerFound, nil} - } - - // Generate reloaded annotations. Attaching this to the item's annotation will trigger a rollout - // Note: the data on this struct is purely informational and is not used for future updates - reloadSource := common.NewReloadSourceFromConfig(config, []string{container.Name}) - annotations, patch, err := createReloadedAnnotations(&reloadSource, upgradeFuncs) - if err != nil { - logrus.Errorf("Failed to create reloaded annotations for %s! error = %v", config.ResourceName, err) - return InvokeStrategyResult{constants.NotUpdated, nil} - } - - // Copy the all annotations to the item's annotations - pa := upgradeFuncs.PodAnnotationsFunc(item) - if pa == nil { - return InvokeStrategyResult{constants.NotUpdated, nil} - } - - if config.Type == constants.SecretProviderClassEnvVarPostfix && secretProviderClassAnnotationReloaded(pa, config) { - return InvokeStrategyResult{constants.NotUpdated, nil} - } - - for k, v := range annotations { - pa[k] = v - } - - return InvokeStrategyResult{constants.Updated, &Patch{Type: patchtypes.StrategicMergePatchType, Bytes: patch}} -} - -func secretProviderClassAnnotationReloaded(oldAnnotations map[string]string, newConfig common.Config) bool { - annotation := oldAnnotations[getReloaderAnnotationKey()] - return strings.Contains(annotation, newConfig.ResourceName) && strings.Contains(annotation, newConfig.SHAValue) -} - -func getReloaderAnnotationKey() string { - return fmt.Sprintf("%s/%s", - constants.ReloaderAnnotationPrefix, - constants.LastReloadedFromAnnotation, - ) -} - -func createReloadedAnnotations(target *common.ReloadSource, upgradeFuncs callbacks.RollingUpgradeFuncs) (map[string]string, []byte, error) { - if target == nil { - return nil, nil, errors.New("target is required") - } - - // Create a single "last-invokeReloadStrategy-from" annotation that stores metadata about the - // resource that caused the last invokeReloadStrategy. - // Intentionally only storing the last item in order to keep - // the generated annotations as small as possible. - annotations := make(map[string]string) - lastReloadedResourceName := getReloaderAnnotationKey() - - lastReloadedResource, err := json.Marshal(target) - if err != nil { - return nil, nil, err - } - - annotations[lastReloadedResourceName] = string(lastReloadedResource) - - var patch []byte - if upgradeFuncs.SupportsPatch { - escapedValue, err := jsonEscape(annotations[lastReloadedResourceName]) - if err != nil { - return nil, nil, err - } - patch = fmt.Appendf(nil, upgradeFuncs.PatchTemplatesFunc().AnnotationTemplate, lastReloadedResourceName, escapedValue) - } - - return annotations, patch, nil -} - -func getEnvVarName(resourceName string, typeName string) string { - return constants.EnvVarPrefix + util.ConvertToEnvVarName(resourceName) + "_" + typeName -} - -func updateContainerEnvVars(upgradeFuncs callbacks.RollingUpgradeFuncs, item runtime.Object, config common.Config, autoReload bool) InvokeStrategyResult { - envVar := getEnvVarName(config.ResourceName, config.Type) - container := getContainerUsingResource(upgradeFuncs, item, config, autoReload) - - if container == nil { - return InvokeStrategyResult{constants.NoContainerFound, nil} - } - - if config.Type == constants.SecretProviderClassEnvVarPostfix && secretProviderClassEnvReloaded(upgradeFuncs.ContainersFunc(item), envVar, config.SHAValue) { - return InvokeStrategyResult{constants.NotUpdated, nil} - } - - // update if env var exists - updateResult := updateEnvVar(container, envVar, config.SHAValue) - - // if no existing env var exists lets create one - if updateResult == constants.NoEnvVarFound { - e := v1.EnvVar{ - Name: envVar, - Value: config.SHAValue, - } - container.Env = append(container.Env, e) - updateResult = constants.Updated - } - - var patch []byte - if upgradeFuncs.SupportsPatch { - patch = fmt.Appendf(nil, upgradeFuncs.PatchTemplatesFunc().EnvVarTemplate, container.Name, envVar, config.SHAValue) - } - - return InvokeStrategyResult{updateResult, &Patch{Type: patchtypes.StrategicMergePatchType, Bytes: patch}} -} - -func updateEnvVar(container *v1.Container, envVar string, shaData string) constants.Result { - envs := container.Env - for j := range envs { - if envs[j].Name == envVar { - if envs[j].Value != shaData { - envs[j].Value = shaData - return constants.Updated - } - return constants.NotUpdated - } - } - - return constants.NoEnvVarFound -} - -func secretProviderClassEnvReloaded(containers []v1.Container, envVar string, shaData string) bool { - for _, container := range containers { - for _, env := range container.Env { - if env.Name == envVar { - return env.Value == shaData - } - } - } - return false -} - -func populateAnnotationsFromSecretProviderClass(clients kube.Clients, config *common.Config) { - obj, err := clients.CSIClient.SecretsstoreV1().SecretProviderClasses(config.Namespace).Get(context.Background(), config.ResourceName, metav1.GetOptions{}) - annotations := make(map[string]string) - if err != nil { - if apierrors.IsNotFound(err) { - logrus.Warnf("SecretProviderClass '%s' not found in namespace '%s'", config.ResourceName, config.Namespace) - } else { - logrus.Errorf("Failed to get SecretProviderClass '%s' in namespace '%s': %v", config.ResourceName, config.Namespace, err) - } - } else if obj.Annotations != nil { - annotations = obj.Annotations - } - config.ResourceAnnotations = annotations -} - -func jsonEscape(toEscape string) (string, error) { - data, err := json.Marshal(toEscape) - if err != nil { - return "", err - } - escaped := string(data) - return escaped[1 : len(escaped)-1], nil -} diff --git a/internal/pkg/handler/upgrade_test.go b/internal/pkg/handler/upgrade_test.go deleted file mode 100644 index 82701329e..000000000 --- a/internal/pkg/handler/upgrade_test.go +++ /dev/null @@ -1,1376 +0,0 @@ -package handler - -import ( - "errors" - "testing" - - "github.com/stretchr/testify/assert" - appsv1 "k8s.io/api/apps/v1" - v1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/util/retry" - - "github.com/stakater/Reloader/internal/pkg/callbacks" - "github.com/stakater/Reloader/internal/pkg/constants" - "github.com/stakater/Reloader/internal/pkg/options" - "github.com/stakater/Reloader/pkg/common" -) - -func TestGetRollingUpgradeFuncs(t *testing.T) { - tests := []struct { - name string - getFuncs func() callbacks.RollingUpgradeFuncs - resourceType string - supportsPatch bool - }{ - { - name: "Deployment", - getFuncs: GetDeploymentRollingUpgradeFuncs, - resourceType: "Deployment", - supportsPatch: true, - }, - { - name: "CronJob", - getFuncs: GetCronJobCreateJobFuncs, - resourceType: "CronJob", - supportsPatch: false, - }, - { - name: "Job", - getFuncs: GetJobCreateJobFuncs, - resourceType: "Job", - supportsPatch: false, - }, - { - name: "DaemonSet", - getFuncs: GetDaemonSetRollingUpgradeFuncs, - resourceType: "DaemonSet", - supportsPatch: true, - }, - { - name: "StatefulSet", - getFuncs: GetStatefulSetRollingUpgradeFuncs, - resourceType: "StatefulSet", - supportsPatch: true, - }, - { - name: "ArgoRollout", - getFuncs: GetArgoRolloutRollingUpgradeFuncs, - resourceType: "Rollout", - supportsPatch: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - funcs := tt.getFuncs() - assert.Equal(t, tt.resourceType, funcs.ResourceType) - assert.Equal(t, tt.supportsPatch, funcs.SupportsPatch) - assert.NotNil(t, funcs.ItemFunc) - assert.NotNil(t, funcs.ItemsFunc) - assert.NotNil(t, funcs.AnnotationsFunc) - assert.NotNil(t, funcs.PodAnnotationsFunc) - assert.NotNil(t, funcs.ContainersFunc) - assert.NotNil(t, funcs.InitContainersFunc) - assert.NotNil(t, funcs.UpdateFunc) - assert.NotNil(t, funcs.PatchFunc) - assert.NotNil(t, funcs.PatchTemplatesFunc) - assert.NotNil(t, funcs.VolumesFunc) - }) - } -} - -func TestGetVolumeMountName(t *testing.T) { - tests := []struct { - name string - volumes []v1.Volume - mountType string - volumeName string - expected string - }{ - { - name: "ConfigMap volume match", - volumes: []v1.Volume{ - { - Name: "config-volume", - VolumeSource: v1.VolumeSource{ - ConfigMap: &v1.ConfigMapVolumeSource{ - LocalObjectReference: v1.LocalObjectReference{ - Name: "my-configmap", - }, - }, - }, - }, - }, - mountType: constants.ConfigmapEnvVarPostfix, - volumeName: "my-configmap", - expected: "config-volume", - }, - { - name: "Secret volume match", - volumes: []v1.Volume{ - { - Name: "secret-volume", - VolumeSource: v1.VolumeSource{ - Secret: &v1.SecretVolumeSource{ - SecretName: "my-secret", - }, - }, - }, - }, - mountType: constants.SecretEnvVarPostfix, - volumeName: "my-secret", - expected: "secret-volume", - }, - { - name: "ConfigMap in projected volume", - volumes: []v1.Volume{ - { - Name: "projected-volume", - VolumeSource: v1.VolumeSource{ - Projected: &v1.ProjectedVolumeSource{ - Sources: []v1.VolumeProjection{ - { - ConfigMap: &v1.ConfigMapProjection{ - LocalObjectReference: v1.LocalObjectReference{ - Name: "projected-configmap", - }, - }, - }, - }, - }, - }, - }, - }, - mountType: constants.ConfigmapEnvVarPostfix, - volumeName: "projected-configmap", - expected: "projected-volume", - }, - { - name: "Secret in projected volume", - volumes: []v1.Volume{ - { - Name: "projected-volume", - VolumeSource: v1.VolumeSource{ - Projected: &v1.ProjectedVolumeSource{ - Sources: []v1.VolumeProjection{ - { - Secret: &v1.SecretProjection{ - LocalObjectReference: v1.LocalObjectReference{ - Name: "projected-secret", - }, - }, - }, - }, - }, - }, - }, - }, - mountType: constants.SecretEnvVarPostfix, - volumeName: "projected-secret", - expected: "projected-volume", - }, - { - name: "No match - wrong configmap name", - volumes: []v1.Volume{ - { - Name: "config-volume", - VolumeSource: v1.VolumeSource{ - ConfigMap: &v1.ConfigMapVolumeSource{ - LocalObjectReference: v1.LocalObjectReference{ - Name: "other-configmap", - }, - }, - }, - }, - }, - mountType: constants.ConfigmapEnvVarPostfix, - volumeName: "my-configmap", - expected: "", - }, - { - name: "No match - wrong type", - volumes: []v1.Volume{ - { - Name: "secret-volume", - VolumeSource: v1.VolumeSource{ - Secret: &v1.SecretVolumeSource{ - SecretName: "my-secret", - }, - }, - }, - }, - mountType: constants.ConfigmapEnvVarPostfix, - volumeName: "my-secret", - expected: "", - }, - { - name: "Empty volumes", - volumes: []v1.Volume{}, - mountType: constants.ConfigmapEnvVarPostfix, - volumeName: "any", - expected: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := getVolumeMountName(tt.volumes, tt.mountType, tt.volumeName) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestGetContainerWithVolumeMount(t *testing.T) { - tests := []struct { - name string - containers []v1.Container - volumeMountName string - expectFound bool - expectedName string - }{ - { - name: "Container with matching volume mount", - containers: []v1.Container{ - { - Name: "app", - VolumeMounts: []v1.VolumeMount{ - {Name: "config-volume", MountPath: "/etc/config"}, - }, - }, - }, - volumeMountName: "config-volume", - expectFound: true, - expectedName: "app", - }, - { - name: "Multiple containers, second has mount", - containers: []v1.Container{ - { - Name: "init", - VolumeMounts: []v1.VolumeMount{}, - }, - { - Name: "app", - VolumeMounts: []v1.VolumeMount{ - {Name: "config-volume", MountPath: "/etc/config"}, - }, - }, - }, - volumeMountName: "config-volume", - expectFound: true, - expectedName: "app", - }, - { - name: "No matching volume mount", - containers: []v1.Container{ - { - Name: "app", - VolumeMounts: []v1.VolumeMount{ - {Name: "other-volume", MountPath: "/etc/other"}, - }, - }, - }, - volumeMountName: "config-volume", - expectFound: false, - }, - { - name: "Empty containers", - containers: []v1.Container{}, - volumeMountName: "config-volume", - expectFound: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := getContainerWithVolumeMount(tt.containers, tt.volumeMountName) - if tt.expectFound { - assert.NotNil(t, result) - assert.Equal(t, tt.expectedName, result.Name) - } else { - assert.Nil(t, result) - } - }) - } -} - -func TestGetContainerWithEnvReference(t *testing.T) { - tests := []struct { - name string - containers []v1.Container - resourceName string - resourceType string - expectFound bool - expectedName string - }{ - { - name: "Container with ConfigMapKeyRef", - containers: []v1.Container{ - { - Name: "app", - Env: []v1.EnvVar{ - { - Name: "CONFIG_VALUE", - ValueFrom: &v1.EnvVarSource{ - ConfigMapKeyRef: &v1.ConfigMapKeySelector{ - LocalObjectReference: v1.LocalObjectReference{ - Name: "my-configmap", - }, - Key: "key", - }, - }, - }, - }, - }, - }, - resourceName: "my-configmap", - resourceType: constants.ConfigmapEnvVarPostfix, - expectFound: true, - expectedName: "app", - }, - { - name: "Container with SecretKeyRef", - containers: []v1.Container{ - { - Name: "app", - Env: []v1.EnvVar{ - { - Name: "SECRET_VALUE", - ValueFrom: &v1.EnvVarSource{ - SecretKeyRef: &v1.SecretKeySelector{ - LocalObjectReference: v1.LocalObjectReference{ - Name: "my-secret", - }, - Key: "key", - }, - }, - }, - }, - }, - }, - resourceName: "my-secret", - resourceType: constants.SecretEnvVarPostfix, - expectFound: true, - expectedName: "app", - }, - { - name: "Container with ConfigMapRef (envFrom)", - containers: []v1.Container{ - { - Name: "app", - EnvFrom: []v1.EnvFromSource{ - { - ConfigMapRef: &v1.ConfigMapEnvSource{ - LocalObjectReference: v1.LocalObjectReference{ - Name: "my-configmap", - }, - }, - }, - }, - }, - }, - resourceName: "my-configmap", - resourceType: constants.ConfigmapEnvVarPostfix, - expectFound: true, - expectedName: "app", - }, - { - name: "Container with SecretRef (envFrom)", - containers: []v1.Container{ - { - Name: "app", - EnvFrom: []v1.EnvFromSource{ - { - SecretRef: &v1.SecretEnvSource{ - LocalObjectReference: v1.LocalObjectReference{ - Name: "my-secret", - }, - }, - }, - }, - }, - }, - resourceName: "my-secret", - resourceType: constants.SecretEnvVarPostfix, - expectFound: true, - expectedName: "app", - }, - { - name: "No match - wrong resource name", - containers: []v1.Container{ - { - Name: "app", - EnvFrom: []v1.EnvFromSource{ - { - ConfigMapRef: &v1.ConfigMapEnvSource{ - LocalObjectReference: v1.LocalObjectReference{ - Name: "other-configmap", - }, - }, - }, - }, - }, - }, - resourceName: "my-configmap", - resourceType: constants.ConfigmapEnvVarPostfix, - expectFound: false, - }, - { - name: "No match - wrong type (looking for secret but has configmap)", - containers: []v1.Container{ - { - Name: "app", - EnvFrom: []v1.EnvFromSource{ - { - ConfigMapRef: &v1.ConfigMapEnvSource{ - LocalObjectReference: v1.LocalObjectReference{ - Name: "my-resource", - }, - }, - }, - }, - }, - }, - resourceName: "my-resource", - resourceType: constants.SecretEnvVarPostfix, - expectFound: false, - }, - { - name: "Empty containers", - containers: []v1.Container{}, - resourceName: "any", - resourceType: constants.ConfigmapEnvVarPostfix, - expectFound: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := getContainerWithEnvReference(tt.containers, tt.resourceName, tt.resourceType) - if tt.expectFound { - assert.NotNil(t, result) - assert.Equal(t, tt.expectedName, result.Name) - } else { - assert.Nil(t, result) - } - }) - } -} - -func TestGetEnvVarName(t *testing.T) { - tests := []struct { - name string - resourceName string - typeName string - expected string - }{ - { - name: "ConfigMap with simple name", - resourceName: "my-config", - typeName: constants.ConfigmapEnvVarPostfix, - expected: "STAKATER_MY_CONFIG_CONFIGMAP", - }, - { - name: "Secret with simple name", - resourceName: "my-secret", - typeName: constants.SecretEnvVarPostfix, - expected: "STAKATER_MY_SECRET_SECRET", - }, - { - name: "Name with hyphens", - resourceName: "my-app-config", - typeName: constants.ConfigmapEnvVarPostfix, - expected: "STAKATER_MY_APP_CONFIG_CONFIGMAP", - }, - { - name: "Name with dots", - resourceName: "my.app.config", - typeName: constants.ConfigmapEnvVarPostfix, - expected: "STAKATER_MY_APP_CONFIG_CONFIGMAP", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := getEnvVarName(tt.resourceName, tt.typeName) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestUpdateEnvVar(t *testing.T) { - tests := []struct { - name string - container *v1.Container - envVar string - shaData string - expected constants.Result - newValue string - }{ - { - name: "Update existing env var with different value", - container: &v1.Container{ - Name: "app", - Env: []v1.EnvVar{ - {Name: "STAKATER_CONFIG_CONFIGMAP", Value: "old-sha"}, - }, - }, - envVar: "STAKATER_CONFIG_CONFIGMAP", - shaData: "new-sha", - expected: constants.Updated, - newValue: "new-sha", - }, - { - name: "No update when value is same", - container: &v1.Container{ - Name: "app", - Env: []v1.EnvVar{ - {Name: "STAKATER_CONFIG_CONFIGMAP", Value: "same-sha"}, - }, - }, - envVar: "STAKATER_CONFIG_CONFIGMAP", - shaData: "same-sha", - expected: constants.NotUpdated, - newValue: "same-sha", - }, - { - name: "Env var not found", - container: &v1.Container{ - Name: "app", - Env: []v1.EnvVar{ - {Name: "OTHER_VAR", Value: "value"}, - }, - }, - envVar: "STAKATER_CONFIG_CONFIGMAP", - shaData: "new-sha", - expected: constants.NoEnvVarFound, - }, - { - name: "Empty env list", - container: &v1.Container{ - Name: "app", - Env: []v1.EnvVar{}, - }, - envVar: "STAKATER_CONFIG_CONFIGMAP", - shaData: "new-sha", - expected: constants.NoEnvVarFound, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := updateEnvVar(tt.container, tt.envVar, tt.shaData) - assert.Equal(t, tt.expected, result) - - if tt.expected == constants.Updated || tt.expected == constants.NotUpdated { - for _, env := range tt.container.Env { - if env.Name == tt.envVar { - assert.Equal(t, tt.newValue, env.Value) - break - } - } - } - }) - } -} - -func TestGetReloaderAnnotationKey(t *testing.T) { - result := getReloaderAnnotationKey() - expected := "reloader.stakater.com/last-reloaded-from" - assert.Equal(t, expected, result) -} - -func TestJsonEscape(t *testing.T) { - tests := []struct { - name string - input string - expected string - hasError bool - }{ - { - name: "Simple string", - input: "hello", - expected: "hello", - hasError: false, - }, - { - name: "String with quotes", - input: `say "hello"`, - expected: `say \"hello\"`, - hasError: false, - }, - { - name: "String with backslash", - input: `path\to\file`, - expected: `path\\to\\file`, - hasError: false, - }, - { - name: "String with newline", - input: "line1\nline2", - expected: `line1\nline2`, - hasError: false, - }, - { - name: "JSON-like string", - input: `{"key":"value"}`, - expected: `{\"key\":\"value\"}`, - hasError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := jsonEscape(tt.input) - if tt.hasError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.expected, result) - } - }) - } -} - -func TestCreateReloadedAnnotations(t *testing.T) { - tests := []struct { - name string - target *common.ReloadSource - hasError bool - }{ - { - name: "Nil target", - target: nil, - hasError: true, - }, - { - name: "Valid target", - target: &common.ReloadSource{ - Name: "my-configmap", - Type: "CONFIGMAP", - }, - hasError: false, - }, - } - - funcs := callbacks.RollingUpgradeFuncs{ - SupportsPatch: false, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - annotations, _, err := createReloadedAnnotations(tt.target, funcs) - if tt.hasError { - assert.Error(t, err) - assert.Nil(t, annotations) - } else { - assert.NoError(t, err) - assert.NotNil(t, annotations) - _, exists := annotations[getReloaderAnnotationKey()] - assert.True(t, exists) - } - }) - } -} - -// Helper function to create a mock deployment for testing -func createTestDeployment(containers []v1.Container, initContainers []v1.Container, volumes []v1.Volume) *appsv1.Deployment { - return &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment", - Namespace: "default", - }, - Spec: appsv1.DeploymentSpec{ - Template: v1.PodTemplateSpec{ - Spec: v1.PodSpec{ - Containers: containers, - InitContainers: initContainers, - Volumes: volumes, - }, - }, - }, - } -} - -// mockRollingUpgradeFuncs creates mock callbacks for testing getContainerUsingResource -func mockRollingUpgradeFuncs(deployment *appsv1.Deployment) callbacks.RollingUpgradeFuncs { - return callbacks.RollingUpgradeFuncs{ - VolumesFunc: func(item runtime.Object) []v1.Volume { - return deployment.Spec.Template.Spec.Volumes - }, - ContainersFunc: func(item runtime.Object) []v1.Container { - return deployment.Spec.Template.Spec.Containers - }, - InitContainersFunc: func(item runtime.Object) []v1.Container { - return deployment.Spec.Template.Spec.InitContainers - }, - } -} - -func TestGetContainerUsingResource(t *testing.T) { - tests := []struct { - name string - containers []v1.Container - initContainers []v1.Container - volumes []v1.Volume - config common.Config - autoReload bool - expectNil bool - expectedName string - }{ - { - name: "Volume mount in regular container", - containers: []v1.Container{ - { - Name: "app", - VolumeMounts: []v1.VolumeMount{ - {Name: "config-volume", MountPath: "/etc/config"}, - }, - }, - }, - initContainers: []v1.Container{}, - volumes: []v1.Volume{ - { - Name: "config-volume", - VolumeSource: v1.VolumeSource{ - ConfigMap: &v1.ConfigMapVolumeSource{ - LocalObjectReference: v1.LocalObjectReference{Name: "my-configmap"}, - }, - }, - }, - }, - config: common.Config{ - ResourceName: "my-configmap", - Type: constants.ConfigmapEnvVarPostfix, - }, - autoReload: false, - expectNil: false, - expectedName: "app", - }, - { - name: "Volume mount in init container returns first regular container", - containers: []v1.Container{ - {Name: "main-app"}, - {Name: "sidecar"}, - }, - initContainers: []v1.Container{ - { - Name: "init", - VolumeMounts: []v1.VolumeMount{ - {Name: "secret-volume", MountPath: "/etc/secrets"}, - }, - }, - }, - volumes: []v1.Volume{ - { - Name: "secret-volume", - VolumeSource: v1.VolumeSource{ - Secret: &v1.SecretVolumeSource{SecretName: "my-secret"}, - }, - }, - }, - config: common.Config{ - ResourceName: "my-secret", - Type: constants.SecretEnvVarPostfix, - }, - autoReload: false, - expectNil: false, - expectedName: "main-app", - }, - { - name: "EnvFrom ConfigMap in regular container", - containers: []v1.Container{ - { - Name: "app", - EnvFrom: []v1.EnvFromSource{ - { - ConfigMapRef: &v1.ConfigMapEnvSource{ - LocalObjectReference: v1.LocalObjectReference{Name: "env-configmap"}, - }, - }, - }, - }, - }, - initContainers: []v1.Container{}, - volumes: []v1.Volume{}, - config: common.Config{ - ResourceName: "env-configmap", - Type: constants.ConfigmapEnvVarPostfix, - }, - autoReload: false, - expectNil: false, - expectedName: "app", - }, - { - name: "EnvFrom Secret in init container returns first regular container", - containers: []v1.Container{ - {Name: "main-app"}, - }, - initContainers: []v1.Container{ - { - Name: "init", - EnvFrom: []v1.EnvFromSource{ - { - SecretRef: &v1.SecretEnvSource{ - LocalObjectReference: v1.LocalObjectReference{Name: "init-secret"}, - }, - }, - }, - }, - }, - volumes: []v1.Volume{}, - config: common.Config{ - ResourceName: "init-secret", - Type: constants.SecretEnvVarPostfix, - }, - autoReload: false, - expectNil: false, - expectedName: "main-app", - }, - { - name: "autoReload=false with no mount returns first container (explicit annotation)", - containers: []v1.Container{ - {Name: "first-container"}, - {Name: "second-container"}, - }, - initContainers: []v1.Container{}, - volumes: []v1.Volume{}, - config: common.Config{ - ResourceName: "external-configmap", - Type: constants.ConfigmapEnvVarPostfix, - }, - autoReload: false, - expectNil: false, - expectedName: "first-container", - }, - { - name: "autoReload=true with no mount returns nil", - containers: []v1.Container{ - {Name: "app"}, - }, - initContainers: []v1.Container{}, - volumes: []v1.Volume{}, - config: common.Config{ - ResourceName: "unmounted-configmap", - Type: constants.ConfigmapEnvVarPostfix, - }, - autoReload: true, - expectNil: true, - }, - { - name: "Empty containers returns nil", - containers: []v1.Container{}, - initContainers: []v1.Container{}, - volumes: []v1.Volume{}, - config: common.Config{ - ResourceName: "any-configmap", - Type: constants.ConfigmapEnvVarPostfix, - }, - autoReload: false, - expectNil: true, - }, - { - name: "Init container with volume but no regular containers returns nil", - containers: []v1.Container{}, - initContainers: []v1.Container{ - { - Name: "init", - VolumeMounts: []v1.VolumeMount{ - {Name: "config-volume", MountPath: "/etc/config"}, - }, - }, - }, - volumes: []v1.Volume{ - { - Name: "config-volume", - VolumeSource: v1.VolumeSource{ - ConfigMap: &v1.ConfigMapVolumeSource{ - LocalObjectReference: v1.LocalObjectReference{Name: "init-only-cm"}, - }, - }, - }, - }, - config: common.Config{ - ResourceName: "init-only-cm", - Type: constants.ConfigmapEnvVarPostfix, - }, - autoReload: false, - expectNil: true, - }, - { - name: "CSI SecretProviderClass volume", - containers: []v1.Container{ - { - Name: "app", - VolumeMounts: []v1.VolumeMount{ - {Name: "csi-volume", MountPath: "/mnt/secrets"}, - }, - }, - }, - initContainers: []v1.Container{}, - volumes: []v1.Volume{ - { - Name: "csi-volume", - VolumeSource: v1.VolumeSource{ - CSI: &v1.CSIVolumeSource{ - Driver: "secrets-store.csi.k8s.io", - VolumeAttributes: map[string]string{ - "secretProviderClass": "my-spc", - }, - }, - }, - }, - }, - config: common.Config{ - ResourceName: "my-spc", - Type: constants.SecretProviderClassEnvVarPostfix, - }, - autoReload: false, - expectNil: false, - expectedName: "app", - }, - { - name: "Env ValueFrom ConfigMapKeyRef", - containers: []v1.Container{ - { - Name: "app", - Env: []v1.EnvVar{ - { - Name: "CONFIG_VALUE", - ValueFrom: &v1.EnvVarSource{ - ConfigMapKeyRef: &v1.ConfigMapKeySelector{ - LocalObjectReference: v1.LocalObjectReference{Name: "keyref-cm"}, - Key: "my-key", - }, - }, - }, - }, - }, - }, - initContainers: []v1.Container{}, - volumes: []v1.Volume{}, - config: common.Config{ - ResourceName: "keyref-cm", - Type: constants.ConfigmapEnvVarPostfix, - }, - autoReload: false, - expectNil: false, - expectedName: "app", - }, - { - name: "Env ValueFrom SecretKeyRef", - containers: []v1.Container{ - { - Name: "app", - Env: []v1.EnvVar{ - { - Name: "SECRET_VALUE", - ValueFrom: &v1.EnvVarSource{ - SecretKeyRef: &v1.SecretKeySelector{ - LocalObjectReference: v1.LocalObjectReference{Name: "keyref-secret"}, - Key: "password", - }, - }, - }, - }, - }, - }, - initContainers: []v1.Container{}, - volumes: []v1.Volume{}, - config: common.Config{ - ResourceName: "keyref-secret", - Type: constants.SecretEnvVarPostfix, - }, - autoReload: false, - expectNil: false, - expectedName: "app", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - deployment := createTestDeployment(tt.containers, tt.initContainers, tt.volumes) - funcs := mockRollingUpgradeFuncs(deployment) - - result := getContainerUsingResource(funcs, deployment, tt.config, tt.autoReload) - - if tt.expectNil { - assert.Nil(t, result, "Expected nil container") - } else { - assert.NotNil(t, result, "Expected non-nil container") - assert.Equal(t, tt.expectedName, result.Name) - } - }) - } -} - -func TestRetryOnConflict(t *testing.T) { - tests := []struct { - name string - fnResults []struct { - matched bool - err error - } - expectMatched bool - expectError bool - }{ - { - name: "Success on first try", - fnResults: []struct { - matched bool - err error - }{ - {matched: true, err: nil}, - }, - expectMatched: true, - expectError: false, - }, - { - name: "Conflict then success", - fnResults: []struct { - matched bool - err error - }{ - {matched: false, - err: apierrors.NewConflict(schema.GroupResource{Group: "", Resource: "deployments"}, "test", - errors.New("conflict"))}, - {matched: true, err: nil}, - }, - expectMatched: true, - expectError: false, - }, - { - name: "Non-conflict error returns immediately", - fnResults: []struct { - matched bool - err error - }{ - {matched: false, err: errors.New("some other error")}, - }, - expectMatched: false, - expectError: true, - }, - { - name: "Multiple conflicts then success", - fnResults: []struct { - matched bool - err error - }{ - {matched: false, err: apierrors.NewConflict(schema.GroupResource{}, "test", errors.New("conflict 1"))}, - {matched: false, err: apierrors.NewConflict(schema.GroupResource{}, "test", errors.New("conflict 2"))}, - {matched: true, err: nil}, - }, - expectMatched: true, - expectError: false, - }, - { - name: "Not matched but no error", - fnResults: []struct { - matched bool - err error - }{ - {matched: false, err: nil}, - }, - expectMatched: false, - expectError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - callCount := 0 - fn := func(fetchResource bool) (bool, error) { - if callCount >= len(tt.fnResults) { - return true, nil - } - result := tt.fnResults[callCount] - callCount++ - return result.matched, result.err - } - - matched, err := retryOnConflict(retry.DefaultRetry, fn) - - assert.Equal(t, tt.expectMatched, matched) - if tt.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestGetVolumeMountNameCSI(t *testing.T) { - tests := []struct { - name string - volumes []v1.Volume - mountType string - volumeName string - expected string - }{ - { - name: "CSI SecretProviderClass volume match", - volumes: []v1.Volume{ - { - Name: "csi-secrets", - VolumeSource: v1.VolumeSource{ - CSI: &v1.CSIVolumeSource{ - Driver: "secrets-store.csi.k8s.io", - VolumeAttributes: map[string]string{ - "secretProviderClass": "my-vault-spc", - }, - }, - }, - }, - }, - mountType: constants.SecretProviderClassEnvVarPostfix, - volumeName: "my-vault-spc", - expected: "csi-secrets", - }, - { - name: "CSI volume with different SPC name - no match", - volumes: []v1.Volume{ - { - Name: "csi-secrets", - VolumeSource: v1.VolumeSource{ - CSI: &v1.CSIVolumeSource{ - Driver: "secrets-store.csi.k8s.io", - VolumeAttributes: map[string]string{ - "secretProviderClass": "other-spc", - }, - }, - }, - }, - }, - mountType: constants.SecretProviderClassEnvVarPostfix, - volumeName: "my-vault-spc", - expected: "", - }, - { - name: "CSI volume without secretProviderClass attribute", - volumes: []v1.Volume{ - { - Name: "csi-volume", - VolumeSource: v1.VolumeSource{ - CSI: &v1.CSIVolumeSource{ - Driver: "other-csi-driver", - VolumeAttributes: map[string]string{}, - }, - }, - }, - }, - mountType: constants.SecretProviderClassEnvVarPostfix, - volumeName: "any-spc", - expected: "", - }, - { - name: "CSI volume with nil VolumeAttributes", - volumes: []v1.Volume{ - { - Name: "csi-volume", - VolumeSource: v1.VolumeSource{ - CSI: &v1.CSIVolumeSource{ - Driver: "secrets-store.csi.k8s.io", - }, - }, - }, - }, - mountType: constants.SecretProviderClassEnvVarPostfix, - volumeName: "any-spc", - expected: "", - }, - { - name: "Multiple volumes with CSI match", - volumes: []v1.Volume{ - { - Name: "config-volume", - VolumeSource: v1.VolumeSource{ - ConfigMap: &v1.ConfigMapVolumeSource{ - LocalObjectReference: v1.LocalObjectReference{Name: "my-cm"}, - }, - }, - }, - { - Name: "csi-secrets", - VolumeSource: v1.VolumeSource{ - CSI: &v1.CSIVolumeSource{ - Driver: "secrets-store.csi.k8s.io", - VolumeAttributes: map[string]string{ - "secretProviderClass": "target-spc", - }, - }, - }, - }, - }, - mountType: constants.SecretProviderClassEnvVarPostfix, - volumeName: "target-spc", - expected: "csi-secrets", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := getVolumeMountName(tt.volumes, tt.mountType, tt.volumeName) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestSecretProviderClassAnnotationReloaded(t *testing.T) { - tests := []struct { - name string - oldAnnotations map[string]string - newConfig common.Config - expected bool - }{ - { - name: "Annotation contains matching SPC name and SHA", - oldAnnotations: map[string]string{ - "reloader.stakater.com/last-reloaded-from": `{"name":"my-spc","sha":"abc123"}`, - }, - newConfig: common.Config{ - ResourceName: "my-spc", - SHAValue: "abc123", - }, - expected: true, - }, - { - name: "Annotation contains SPC name but different SHA", - oldAnnotations: map[string]string{ - "reloader.stakater.com/last-reloaded-from": `{"name":"my-spc","sha":"old-sha"}`, - }, - newConfig: common.Config{ - ResourceName: "my-spc", - SHAValue: "new-sha", - }, - expected: false, - }, - { - name: "Annotation contains different SPC name", - oldAnnotations: map[string]string{ - "reloader.stakater.com/last-reloaded-from": `{"name":"other-spc","sha":"abc123"}`, - }, - newConfig: common.Config{ - ResourceName: "my-spc", - SHAValue: "abc123", - }, - expected: false, - }, - { - name: "Empty annotations", - oldAnnotations: map[string]string{}, - newConfig: common.Config{ - ResourceName: "my-spc", - SHAValue: "abc123", - }, - expected: false, - }, - { - name: "Nil annotations", - oldAnnotations: nil, - newConfig: common.Config{ - ResourceName: "my-spc", - SHAValue: "abc123", - }, - expected: false, - }, - { - name: "Annotation key missing", - oldAnnotations: map[string]string{ - "other-annotation": "some-value", - }, - newConfig: common.Config{ - ResourceName: "my-spc", - SHAValue: "abc123", - }, - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := secretProviderClassAnnotationReloaded(tt.oldAnnotations, tt.newConfig) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestInvokeReloadStrategy(t *testing.T) { - originalStrategy := options.ReloadStrategy - defer func() { options.ReloadStrategy = originalStrategy }() - - deployment := createTestDeployment( - []v1.Container{ - { - Name: "app", - EnvFrom: []v1.EnvFromSource{ - { - ConfigMapRef: &v1.ConfigMapEnvSource{ - LocalObjectReference: v1.LocalObjectReference{Name: "my-configmap"}, - }, - }, - }, - }, - }, - []v1.Container{}, - []v1.Volume{}, - ) - deployment.Spec.Template.Annotations = map[string]string{} - - funcs := callbacks.RollingUpgradeFuncs{ - VolumesFunc: func(item runtime.Object) []v1.Volume { - return deployment.Spec.Template.Spec.Volumes - }, - ContainersFunc: func(item runtime.Object) []v1.Container { - return deployment.Spec.Template.Spec.Containers - }, - InitContainersFunc: func(item runtime.Object) []v1.Container { - return deployment.Spec.Template.Spec.InitContainers - }, - PodAnnotationsFunc: func(item runtime.Object) map[string]string { - return deployment.Spec.Template.Annotations - }, - SupportsPatch: false, - } - - config := common.Config{ - ResourceName: "my-configmap", - Type: constants.ConfigmapEnvVarPostfix, - SHAValue: "sha256:abc123", - Namespace: "default", - } - - tests := []struct { - name string - reloadStrategy string - autoReload bool - expectResult constants.Result - }{ - { - name: "Annotations strategy", - reloadStrategy: constants.AnnotationsReloadStrategy, - autoReload: false, - expectResult: constants.Updated, - }, - { - name: "Env vars strategy with container found", - reloadStrategy: constants.EnvVarsReloadStrategy, - autoReload: false, - expectResult: constants.Updated, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - options.ReloadStrategy = tt.reloadStrategy - deployment.Spec.Template.Annotations = map[string]string{} - - result := invokeReloadStrategy(funcs, deployment, config, tt.autoReload) - assert.Equal(t, tt.expectResult, result.Result) - }) - } -} diff --git a/internal/pkg/http/client.go b/internal/pkg/http/client.go new file mode 100644 index 000000000..c1ca613df --- /dev/null +++ b/internal/pkg/http/client.go @@ -0,0 +1,69 @@ +// Package http provides shared HTTP client functionality. +package http + +import ( + "net/http" + "net/url" + "time" +) + +const ( + // DefaultTimeout is the default HTTP client timeout. + DefaultTimeout = 30 * time.Second + + // AlertingTimeout is the shorter timeout used for alerting. + AlertingTimeout = 10 * time.Second +) + +// ClientConfig configures an HTTP client. +type ClientConfig struct { + // Timeout for HTTP requests. + Timeout time.Duration + + // ProxyURL is an optional proxy URL. + ProxyURL string + + // MaxIdleConns controls the maximum number of idle connections. + MaxIdleConns int + + // MaxIdleConnsPerHost controls the maximum idle connections per host. + MaxIdleConnsPerHost int + + // IdleConnTimeout is the maximum time an idle connection remains open. + IdleConnTimeout time.Duration +} + +// DefaultConfig returns the default HTTP client configuration. +func DefaultConfig() ClientConfig { + return ClientConfig{ + Timeout: DefaultTimeout, + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + } +} + +// NewClient creates a new HTTP client with the given configuration. +func NewClient(cfg ClientConfig) *http.Client { + transport := &http.Transport{ + MaxIdleConns: cfg.MaxIdleConns, + MaxIdleConnsPerHost: cfg.MaxIdleConnsPerHost, + IdleConnTimeout: cfg.IdleConnTimeout, + } + + if cfg.ProxyURL != "" { + if proxy, err := url.Parse(cfg.ProxyURL); err == nil { + transport.Proxy = http.ProxyURL(proxy) + } + } + + return &http.Client{ + Transport: transport, + Timeout: cfg.Timeout, + } +} + +// NewDefaultClient creates an HTTP client with default configuration. +func NewDefaultClient() *http.Client { + return NewClient(DefaultConfig()) +} diff --git a/internal/pkg/http/client_test.go b/internal/pkg/http/client_test.go new file mode 100644 index 000000000..2b937b192 --- /dev/null +++ b/internal/pkg/http/client_test.go @@ -0,0 +1,142 @@ +package http + +import ( + "net/http" + "testing" + "time" +) + +func TestDefaultConfig(t *testing.T) { + cfg := DefaultConfig() + + if cfg.Timeout != DefaultTimeout { + t.Errorf("expected timeout %v, got %v", DefaultTimeout, cfg.Timeout) + } + if cfg.MaxIdleConns != 100 { + t.Errorf("expected MaxIdleConns 100, got %d", cfg.MaxIdleConns) + } + if cfg.MaxIdleConnsPerHost != 10 { + t.Errorf("expected MaxIdleConnsPerHost 10, got %d", cfg.MaxIdleConnsPerHost) + } + if cfg.IdleConnTimeout != 90*time.Second { + t.Errorf("expected IdleConnTimeout 90s, got %v", cfg.IdleConnTimeout) + } +} + +func TestNewClient(t *testing.T) { + tests := []struct { + name string + cfg ClientConfig + wantNil bool + }{ + { + name: "default config", + cfg: DefaultConfig(), + wantNil: false, + }, + { + name: "custom timeout", + cfg: ClientConfig{ + Timeout: 5 * time.Second, + MaxIdleConns: 50, + MaxIdleConnsPerHost: 5, + IdleConnTimeout: 30 * time.Second, + }, + wantNil: false, + }, + { + name: "with proxy", + cfg: ClientConfig{ + Timeout: DefaultTimeout, + ProxyURL: "http://proxy.example.com:8080", + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + }, + wantNil: false, + }, + { + name: "with invalid proxy URL", + cfg: ClientConfig{ + Timeout: DefaultTimeout, + ProxyURL: "://invalid", + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + }, + wantNil: false, + }, + { + name: "zero values", + cfg: ClientConfig{ + Timeout: 0, + }, + wantNil: false, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + client := NewClient(tt.cfg) + + if tt.wantNil && client != nil { + t.Error("expected nil client") + } + if !tt.wantNil && client == nil { + t.Error("expected non-nil client") + } + + if client != nil { + if client.Timeout != tt.cfg.Timeout { + t.Errorf("expected timeout %v, got %v", tt.cfg.Timeout, client.Timeout) + } + + transport, ok := client.Transport.(*http.Transport) + if !ok { + t.Fatal("expected *http.Transport") + } + if transport.MaxIdleConns != tt.cfg.MaxIdleConns { + t.Errorf("expected MaxIdleConns %d, got %d", tt.cfg.MaxIdleConns, transport.MaxIdleConns) + } + if transport.MaxIdleConnsPerHost != tt.cfg.MaxIdleConnsPerHost { + t.Errorf("expected MaxIdleConnsPerHost %d, got %d", tt.cfg.MaxIdleConnsPerHost, transport.MaxIdleConnsPerHost) + } + } + }, + ) + } +} + +func TestNewDefaultClient(t *testing.T) { + client := NewDefaultClient() + + if client == nil { + t.Fatal("expected non-nil client") + } + + if client.Timeout != DefaultTimeout { + t.Errorf("expected timeout %v, got %v", DefaultTimeout, client.Timeout) + } + + transport, ok := client.Transport.(*http.Transport) + if !ok { + t.Fatal("expected *http.Transport") + } + + if transport.MaxIdleConns != 100 { + t.Errorf("expected MaxIdleConns 100, got %d", transport.MaxIdleConns) + } + if transport.MaxIdleConnsPerHost != 10 { + t.Errorf("expected MaxIdleConnsPerHost 10, got %d", transport.MaxIdleConnsPerHost) + } +} + +func TestConstants(t *testing.T) { + if DefaultTimeout != 30*time.Second { + t.Errorf("expected DefaultTimeout 30s, got %v", DefaultTimeout) + } + if AlertingTimeout != 10*time.Second { + t.Errorf("expected AlertingTimeout 10s, got %v", AlertingTimeout) + } +} diff --git a/internal/pkg/leadership/leadership.go b/internal/pkg/leadership/leadership.go deleted file mode 100644 index a170ad65a..000000000 --- a/internal/pkg/leadership/leadership.go +++ /dev/null @@ -1,129 +0,0 @@ -package leadership - -import ( - "context" - "net/http" - "sync" - "time" - - "github.com/sirupsen/logrus" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/tools/leaderelection" - "k8s.io/client-go/tools/leaderelection/resourcelock" - - "github.com/stakater/Reloader/internal/pkg/controller" - - coordinationv1 "k8s.io/client-go/kubernetes/typed/coordination/v1" -) - -var ( - // Used for liveness probe - m sync.Mutex - healthy bool = true -) - -func GetNewLock(client coordinationv1.CoordinationV1Interface, lockName, podname, namespace string) *resourcelock.LeaseLock { - return &resourcelock.LeaseLock{ - LeaseMeta: v1.ObjectMeta{ - Name: lockName, - Namespace: namespace, - }, - Client: client, - LockConfig: resourcelock.ResourceLockConfig{ - Identity: podname, - }, - } -} - -// RunLeaderElection runs leadership election in a background goroutine and -// returns a channel that is closed once the goroutine has fully exited -// (i.e., OnStoppedLeading has run and all controller goroutines have returned). -func RunLeaderElection(lock *resourcelock.LeaseLock, ctx context.Context, cancel context.CancelFunc, id string, controllers []*controller.Controller) <-chan struct{} { - stopped := make(chan struct{}) - - go func() { - defer close(stopped) - - var stopChannels []chan struct{} - for range controllers { - stopChannels = append(stopChannels, make(chan struct{})) - } - - // controllerWg tracks the controller.Run goroutines so that - // OnStoppedLeading can wait for them to fully exit before returning. - var controllerWg sync.WaitGroup - - leaderelection.RunOrDie(ctx, leaderelection.LeaderElectionConfig{ - Lock: lock, - ReleaseOnCancel: true, - LeaseDuration: 15 * time.Second, - RenewDeadline: 10 * time.Second, - RetryPeriod: 2 * time.Second, - Callbacks: leaderelection.LeaderCallbacks{ - OnStartedLeading: func(c context.Context) { - m.Lock() - healthy = true - m.Unlock() - logrus.Info("became leader, starting controllers") - for i, ctrl := range controllers { - controllerWg.Add(1) - go func(ctrl *controller.Controller, stopCh chan struct{}) { - defer controllerWg.Done() - ctrl.Run(1, stopCh) - }(ctrl, stopChannels[i]) - } - }, - OnStoppedLeading: func() { - logrus.Info("no longer leader, shutting down") - stopControllers(stopChannels) - // Wait for all controller.Run goroutines to fully exit. - // controller.Run blocks until its informer and workers exit, - // so this guarantees no controller goroutine is still running - // when OnStoppedLeading returns. - logrus.Info("waiting for all controller goroutines to exit") - controllerWg.Wait() - logrus.Info("all controller goroutines exited") - cancel() - m.Lock() - defer m.Unlock() - healthy = false - }, - OnNewLeader: func(current_id string) { - if current_id == id { - logrus.Info("still the leader!") - return - } - logrus.Infof("new leader is %s", current_id) - }, - }, - }) - }() - - return stopped -} - -func stopControllers(stopChannels []chan struct{}) { - for _, c := range stopChannels { - close(c) - } -} - -// Healthz sets up the liveness probe endpoint. If leadership election is -// enabled and a replica stops leading the liveness probe will fail and the -// kubelet will restart the container. -func SetupLivenessEndpoint() { - http.HandleFunc("/live", healthz) -} - -func healthz(w http.ResponseWriter, req *http.Request) { - m.Lock() - defer m.Unlock() - if healthy { - if i, err := w.Write([]byte("alive")); err != nil { - logrus.Infof("failed to write liveness response, wrote: %d bytes, got err: %s", i, err) - } - return - } - - w.WriteHeader(http.StatusInternalServerError) -} diff --git a/internal/pkg/leadership/leadership_test.go b/internal/pkg/leadership/leadership_test.go deleted file mode 100644 index cc372ca2f..000000000 --- a/internal/pkg/leadership/leadership_test.go +++ /dev/null @@ -1,263 +0,0 @@ -package leadership - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - "os" - "testing" - "time" - - "github.com/sirupsen/logrus" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - "github.com/stakater/Reloader/internal/pkg/constants" - "github.com/stakater/Reloader/internal/pkg/controller" - "github.com/stakater/Reloader/internal/pkg/handler" - "github.com/stakater/Reloader/internal/pkg/metrics" - "github.com/stakater/Reloader/internal/pkg/options" - "github.com/stakater/Reloader/internal/pkg/testutil" - "github.com/stakater/Reloader/pkg/common" - "github.com/stakater/Reloader/pkg/kube" -) - -func TestMain(m *testing.M) { - - testutil.CreateNamespace(testutil.Namespace, testutil.Clients.KubernetesClient) - - logrus.Infof("Running Testcases") - retCode := m.Run() - - testutil.DeleteNamespace(testutil.Namespace, testutil.Clients.KubernetesClient) - - os.Exit(retCode) -} - -func TestHealthz(t *testing.T) { - request, err := http.NewRequest(http.MethodGet, "/live", nil) - if err != nil { - t.Fatalf(("failed to create request")) - } - - response := httptest.NewRecorder() - - healthz(response, request) - got := response.Code - want := 200 - - if got != want { - t.Fatalf("got: %d, want: %d", got, want) - } - - // Have the liveness probe serve a 500 - healthy = false - - request, err = http.NewRequest(http.MethodGet, "/live", nil) - if err != nil { - t.Fatalf(("failed to create request")) - } - - response = httptest.NewRecorder() - - healthz(response, request) - got = response.Code - want = 500 - - if got != want { - t.Fatalf("got: %d, want: %d", got, want) - } -} - -// TestRunLeaderElection validates that the liveness endpoint serves 500 when -// leadership election fails -func TestRunLeaderElection(t *testing.T) { - // Reset shared state left by TestHealthz - m.Lock() - healthy = true - m.Unlock() - - ctx, cancel := context.WithCancel(context.TODO()) - - lock := GetNewLock(testutil.Clients.KubernetesClient.CoordinationV1(), constants.LockName, testutil.Pod, testutil.Namespace) - - stopped := RunLeaderElection(lock, ctx, cancel, testutil.Pod, []*controller.Controller{}) - - // Before leadership is acquired the probe still reads the current healthy value (true) - request, err := http.NewRequest(http.MethodGet, "/live", nil) - if err != nil { - t.Fatalf(("failed to create request")) - } - - response := httptest.NewRecorder() - - healthz(response, request) - got := response.Code - want := 200 - - if got != want { - t.Fatalf("got: %d, want: %d", got, want) - } - - // Cancel the leader election context, so leadership is released and - // live endpoint serves 500 - cancel() - <-stopped - - request, err = http.NewRequest(http.MethodGet, "/live", nil) - if err != nil { - t.Fatalf(("failed to create request")) - } - - response = httptest.NewRecorder() - - healthz(response, request) - got = response.Code - want = 500 - - if got != want { - t.Fatalf("got: %d, want: %d", got, want) - } -} - -// TestRunLeaderElectionWithControllers tests that leadership election works -// with real controllers and that on context cancellation the controllers stop -// running. -func TestRunLeaderElectionWithControllers(t *testing.T) { - t.Logf("Creating controller") - var controllers []*controller.Controller - for k := range kube.ResourceMap { - // Skip namespace controller when there is no namespace label selector - // (mirrors production behavior in startReloader). - if k == "namespaces" { - continue - } - // Skip CSI controller when CSI is not installed - // (mirrors production behavior in startReloader). - if k == constants.SecretProviderClassController { - continue - } - c, err := controller.NewController(testutil.Clients.KubernetesClient, k, testutil.Namespace, []string{}, "", "", metrics.NewCollectors()) - if err != nil { - logrus.Fatalf("%s", err) - } - - controllers = append(controllers, c) - } - time.Sleep(3 * time.Second) - - lock := GetNewLock(testutil.Clients.KubernetesClient.CoordinationV1(), fmt.Sprintf("%s-%d", constants.LockName, 1), testutil.Pod, testutil.Namespace) - - ctx, cancel := context.WithCancel(context.TODO()) - - // Start running leadership election, this also starts the controllers - stopped := RunLeaderElection(lock, ctx, cancel, testutil.Pod, controllers) - time.Sleep(3 * time.Second) - - // Create some stuff and do a thing - configmapName := testutil.ConfigmapNamePrefix + "-update-" + testutil.RandSeq(5) - configmapClient, err := testutil.CreateConfigMap(testutil.Clients.KubernetesClient, testutil.Namespace, configmapName, "www.google.com") - if err != nil { - t.Fatalf("Error while creating the configmap %v", err) - } - - // Creating deployment - _, err = testutil.CreateDeployment(testutil.Clients.KubernetesClient, configmapName, testutil.Namespace, true) - if err != nil { - t.Fatalf("Error in deployment creation: %v", err) - } - - // Updating configmap for first time - updateErr := testutil.UpdateConfigMap(configmapClient, testutil.Namespace, configmapName, "", "www.stakater.com") - if updateErr != nil { - t.Fatalf("Configmap was not updated") - } - time.Sleep(3 * time.Second) - - // Verifying deployment update - logrus.Infof("Verifying pod envvars has been created") - shaData := testutil.ConvertResourceToSHA(testutil.ConfigmapResourceType, testutil.Namespace, configmapName, "www.stakater.com") - config := common.Config{ - Namespace: testutil.Namespace, - ResourceName: configmapName, - SHAValue: shaData, - Annotation: options.ConfigmapUpdateOnChangeAnnotation, - } - deploymentFuncs := handler.GetDeploymentRollingUpgradeFuncs() - updated := testutil.VerifyResourceEnvVarUpdate(testutil.Clients, config, constants.ConfigmapEnvVarPostfix, deploymentFuncs) - if !updated { - t.Fatalf("Deployment was not updated") - } - time.Sleep(testutil.SleepDuration) - - // Add reloader.stakater.com/ignore: "true" to the configmap BEFORE cancelling - // leadership. This prevents any Reloader instance running in the cluster - // (including ones external to this test) from processing the second configmap - // update below, making the assertion reliable in shared cluster environments. - // The ignore annotation is on the configmap itself: ShouldReload checks - // config.ResourceAnnotations (= configmap annotations) for this annotation. - // Note: only the annotation is changed here — the data SHA is unchanged so - // the still-running controllers will see no diff and skip the rolling upgrade. - cm, getCMErr := testutil.Clients.KubernetesClient.CoreV1().ConfigMaps(testutil.Namespace).Get( - context.TODO(), configmapName, metav1.GetOptions{}) - if getCMErr != nil { - t.Fatalf("Failed to get configmap to add ignore annotation: %v", getCMErr) - } - if cm.Annotations == nil { - cm.Annotations = make(map[string]string) - } - cm.Annotations[options.IgnoreResourceAnnotation] = "true" - if _, err = testutil.Clients.KubernetesClient.CoreV1().ConfigMaps(testutil.Namespace).Update( - context.TODO(), cm, metav1.UpdateOptions{}); err != nil { - t.Fatalf("Failed to add ignore annotation to configmap: %v", err) - } - - // Cancel the leader election context, so leadership is released - logrus.Info("shutting down controller from test") - cancel() - <-stopped // wait until OnStoppedLeading has run and all controller goroutines have exited - - // Update the configmap data for the second time using a Get+modify+Update - // pattern so that the ignore annotation added above is preserved. - // Any Reloader (including external ones) will see ignore=true and skip the update. - cm, err = testutil.Clients.KubernetesClient.CoreV1().ConfigMaps(testutil.Namespace).Get( - context.TODO(), configmapName, metav1.GetOptions{}) - if err != nil { - t.Fatalf("Failed to get configmap for second update: %v", err) - } - cm.Data["test.url"] = "www.stakater.com/new" - // ignore annotation is still present from the update above - if _, err = testutil.Clients.KubernetesClient.CoreV1().ConfigMaps(testutil.Namespace).Update( - context.TODO(), cm, metav1.UpdateOptions{}); err != nil { - t.Fatalf("Failed to update configmap: %v", err) - } - time.Sleep(3 * time.Second) - - // Verifying that the deployment was not updated as leadership has been lost - logrus.Infof("Verifying pod envvars has not been updated") - shaData = testutil.ConvertResourceToSHA(testutil.ConfigmapResourceType, testutil.Namespace, configmapName, "www.stakater.com/new") - config = common.Config{ - Namespace: testutil.Namespace, - ResourceName: configmapName, - SHAValue: shaData, - Annotation: options.ConfigmapUpdateOnChangeAnnotation, - } - deploymentFuncs = handler.GetDeploymentRollingUpgradeFuncs() - updated = testutil.VerifyResourceEnvVarUpdate(testutil.Clients, config, constants.ConfigmapEnvVarPostfix, deploymentFuncs) - if updated { - t.Fatalf("Deployment was updated") - } - - // Deleting deployment - err = testutil.DeleteDeployment(testutil.Clients.KubernetesClient, testutil.Namespace, configmapName) - if err != nil { - logrus.Errorf("Error while deleting the deployment %v", err) - } - - // Deleting configmap - err = testutil.DeleteConfigMap(testutil.Clients.KubernetesClient, testutil.Namespace, configmapName) - if err != nil { - logrus.Errorf("Error while deleting the configmap %v", err) - } - time.Sleep(testutil.SleepDuration) -} diff --git a/internal/pkg/metadata/metadata.go b/internal/pkg/metadata/metadata.go new file mode 100644 index 000000000..df306af4e --- /dev/null +++ b/internal/pkg/metadata/metadata.go @@ -0,0 +1,125 @@ +// Package metadata provides metadata ConfigMap creation for Reloader. +// The metadata ConfigMap contains build info, configuration options, and deployment info. +package metadata + +import ( + "encoding/json" + "os" + "runtime" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/stakater/Reloader/internal/pkg/config" +) + +const ( + // ConfigMapName is the name of the metadata ConfigMap. + ConfigMapName = "reloader-meta-info" + // ConfigMapLabelKey is the label key for the metadata ConfigMap. + ConfigMapLabelKey = "reloader.stakater.com/meta-info" + // ConfigMapLabelValue is the label value for the metadata ConfigMap. + ConfigMapLabelValue = "reloader-oss" + + // Environment variables for deployment info. + EnvReloaderNamespace = "RELOADER_NAMESPACE" + EnvReloaderDeploymentName = "RELOADER_DEPLOYMENT_NAME" +) + +// Version, Commit, and BuildDate are set during the build process +// using the -X linker flag to inject these values into the binary. +var ( + Version = "dev" + Commit = "unknown" + BuildDate = "unknown" +) + +// MetaInfo contains comprehensive metadata about the Reloader instance. +type MetaInfo struct { + // BuildInfo contains information about the build version, commit, and compilation details. + BuildInfo BuildInfo `json:"buildInfo"` + // Config contains all the configuration options used by this Reloader instance. + Config *config.Config `json:"config"` + // DeploymentInfo contains metadata about the Kubernetes deployment of this instance. + DeploymentInfo DeploymentInfo `json:"deploymentInfo"` +} + +// BuildInfo contains information about the build and version of the Reloader binary. +type BuildInfo struct { + // GoVersion is the version of Go used to compile the binary. + GoVersion string `json:"goVersion"` + // ReleaseVersion is the version tag or branch of the Reloader release. + ReleaseVersion string `json:"releaseVersion"` + // CommitHash is the Git commit hash of the source code used to build this binary. + CommitHash string `json:"commitHash"` + // CommitTime is the timestamp of the Git commit used to build this binary. + CommitTime time.Time `json:"commitTime"` +} + +// DeploymentInfo contains metadata about the Reloader deployment. +type DeploymentInfo struct { + // Name is the name of the Reloader deployment. + Name string `json:"name"` + // Namespace is the namespace where Reloader is deployed. + Namespace string `json:"namespace"` +} + +// NewBuildInfo creates a new BuildInfo with current build information. +func NewBuildInfo() BuildInfo { + return BuildInfo{ + GoVersion: runtime.Version(), + ReleaseVersion: Version, + CommitHash: Commit, + CommitTime: parseUTCTime(BuildDate), + } +} + +// NewMetaInfo creates a new MetaInfo from configuration. +func NewMetaInfo(cfg *config.Config) *MetaInfo { + return &MetaInfo{ + BuildInfo: NewBuildInfo(), + Config: cfg, + DeploymentInfo: DeploymentInfo{ + Name: os.Getenv(EnvReloaderDeploymentName), + Namespace: os.Getenv(EnvReloaderNamespace), + }, + } +} + +// ToConfigMap converts MetaInfo to a Kubernetes ConfigMap. +func (m *MetaInfo) ToConfigMap() *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: ConfigMapName, + Namespace: m.DeploymentInfo.Namespace, + Labels: map[string]string{ + ConfigMapLabelKey: ConfigMapLabelValue, + }, + }, + Data: map[string]string{ + "buildInfo": toJSON(m.BuildInfo), + "config": toJSON(m.Config), + "deploymentInfo": toJSON(m.DeploymentInfo), + }, + } +} + +func toJSON(data interface{}) string { + jsonData, err := json.Marshal(data) + if err != nil { + return "" + } + return string(jsonData) +} + +func parseUTCTime(value string) time.Time { + if value == "" { + return time.Time{} + } + t, err := time.Parse(time.RFC3339, value) + if err != nil { + return time.Time{} + } + return t +} diff --git a/internal/pkg/metadata/metadata_test.go b/internal/pkg/metadata/metadata_test.go new file mode 100644 index 000000000..52c5f1997 --- /dev/null +++ b/internal/pkg/metadata/metadata_test.go @@ -0,0 +1,307 @@ +package metadata + +import ( + "context" + "encoding/json" + "testing" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/stakater/Reloader/internal/pkg/config" +) + +// testLogger returns a no-op logger for testing. +func testLogger() logr.Logger { + return logr.Discard() +} + +func TestNewBuildInfo(t *testing.T) { + oldVersion := Version + oldCommit := Commit + oldBuildDate := BuildDate + defer func() { + Version = oldVersion + Commit = oldCommit + BuildDate = oldBuildDate + }() + + Version = "1.0.0" + Commit = "abc123" + BuildDate = "2024-01-01T12:00:00Z" + + info := NewBuildInfo() + + if info.ReleaseVersion != "1.0.0" { + t.Errorf("ReleaseVersion = %s, want 1.0.0", info.ReleaseVersion) + } + if info.CommitHash != "abc123" { + t.Errorf("CommitHash = %s, want abc123", info.CommitHash) + } + if info.GoVersion == "" { + t.Error("GoVersion should not be empty") + } + if info.CommitTime.IsZero() { + t.Error("CommitTime should not be zero") + } +} + +func TestNewMetaInfo(t *testing.T) { + t.Setenv(EnvReloaderNamespace, "test-ns") + t.Setenv(EnvReloaderDeploymentName, "test-deploy") + + cfg := config.NewDefault() + cfg.AutoReloadAll = true + cfg.ReloadStrategy = config.ReloadStrategyAnnotations + cfg.ArgoRolloutsEnabled = true + cfg.ReloadOnCreate = true + cfg.ReloadOnDelete = true + cfg.EnableHA = true + cfg.WebhookURL = "https://example.com/webhook" + cfg.LogFormat = "json" + cfg.LogLevel = "debug" + cfg.IgnoredResources = []string{"configmaps"} + cfg.IgnoredWorkloads = []string{"jobs"} + cfg.IgnoredNamespaces = []string{"kube-system"} + + metaInfo := NewMetaInfo(cfg) + + if !metaInfo.Config.AutoReloadAll { + t.Error("AutoReloadAll should be true") + } + if metaInfo.Config.ReloadStrategy != config.ReloadStrategyAnnotations { + t.Errorf("ReloadStrategy = %s, want annotations", metaInfo.Config.ReloadStrategy) + } + if !metaInfo.Config.ArgoRolloutsEnabled { + t.Error("ArgoRolloutsEnabled should be true") + } + if !metaInfo.Config.ReloadOnCreate { + t.Error("ReloadOnCreate should be true") + } + if !metaInfo.Config.ReloadOnDelete { + t.Error("ReloadOnDelete should be true") + } + if !metaInfo.Config.EnableHA { + t.Error("EnableHA should be true") + } + if metaInfo.Config.WebhookURL != "https://example.com/webhook" { + t.Errorf("WebhookURL = %s, want https://example.com/webhook", metaInfo.Config.WebhookURL) + } + + if metaInfo.DeploymentInfo.Namespace != "test-ns" { + t.Errorf("DeploymentInfo.Namespace = %s, want test-ns", metaInfo.DeploymentInfo.Namespace) + } + if metaInfo.DeploymentInfo.Name != "test-deploy" { + t.Errorf("DeploymentInfo.Name = %s, want test-deploy", metaInfo.DeploymentInfo.Name) + } +} + +func TestMetaInfo_ToConfigMap(t *testing.T) { + t.Setenv(EnvReloaderNamespace, "reloader-ns") + t.Setenv(EnvReloaderDeploymentName, "reloader-deploy") + + cfg := config.NewDefault() + metaInfo := NewMetaInfo(cfg) + cm := metaInfo.ToConfigMap() + + if cm.Name != ConfigMapName { + t.Errorf("Name = %s, want %s", cm.Name, ConfigMapName) + } + if cm.Namespace != "reloader-ns" { + t.Errorf("Namespace = %s, want reloader-ns", cm.Namespace) + } + if cm.Labels[ConfigMapLabelKey] != ConfigMapLabelValue { + t.Errorf("Label = %s, want %s", cm.Labels[ConfigMapLabelKey], ConfigMapLabelValue) + } + + if _, ok := cm.Data["buildInfo"]; !ok { + t.Error("buildInfo data key missing") + } + if _, ok := cm.Data["config"]; !ok { + t.Error("config data key missing") + } + if _, ok := cm.Data["deploymentInfo"]; !ok { + t.Error("deploymentInfo data key missing") + } + + // Verify buildInfo is valid JSON + var buildInfo BuildInfo + if err := json.Unmarshal([]byte(cm.Data["buildInfo"]), &buildInfo); err != nil { + t.Errorf("buildInfo is not valid JSON: %v", err) + } + + var parsedConfig config.Config + if err := json.Unmarshal([]byte(cm.Data["config"]), &parsedConfig); err != nil { + t.Errorf("config is not valid JSON: %v", err) + } + + // Verify deploymentInfo contains expected values + var deployInfo DeploymentInfo + if err := json.Unmarshal([]byte(cm.Data["deploymentInfo"]), &deployInfo); err != nil { + t.Errorf("deploymentInfo is not valid JSON: %v", err) + } + if deployInfo.Namespace != "reloader-ns" { + t.Errorf("DeploymentInfo.Namespace = %s, want reloader-ns", deployInfo.Namespace) + } + if deployInfo.Name != "reloader-deploy" { + t.Errorf("DeploymentInfo.Name = %s, want reloader-deploy", deployInfo.Name) + } +} + +func TestPublisher_Publish_NoNamespace(t *testing.T) { + t.Setenv(EnvReloaderNamespace, "") + + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + + cfg := config.NewDefault() + publisher := NewPublisher(fakeClient, cfg, testLogger()) + + err := publisher.Publish(context.Background()) + if err != nil { + t.Errorf("Publish() with no namespace should not error, got: %v", err) + } +} + +func TestPublisher_Publish_CreateNew(t *testing.T) { + t.Setenv(EnvReloaderNamespace, "test-ns") + t.Setenv(EnvReloaderDeploymentName, "test-deploy") + + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + + cfg := config.NewDefault() + publisher := NewPublisher(fakeClient, cfg, testLogger()) + + ctx := context.Background() + err := publisher.Publish(ctx) + if err != nil { + t.Errorf("Publish() error = %v", err) + } + + cm := &corev1.ConfigMap{} + err = fakeClient.Get(ctx, client.ObjectKey{Name: ConfigMapName, Namespace: "test-ns"}, cm) + if err != nil { + t.Errorf("Failed to get created ConfigMap: %v", err) + } + if cm.Name != ConfigMapName { + t.Errorf("ConfigMap.Name = %s, want %s", cm.Name, ConfigMapName) + } +} + +func TestPublisher_Publish_UpdateExisting(t *testing.T) { + t.Setenv(EnvReloaderNamespace, "test-ns") + t.Setenv(EnvReloaderDeploymentName, "test-deploy") + + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + + existingCM := &corev1.ConfigMap{} + existingCM.Name = ConfigMapName + existingCM.Namespace = "test-ns" + existingCM.Data = map[string]string{ + "buildInfo": `{"goVersion":"old"}`, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(existingCM). + Build() + + cfg := config.NewDefault() + publisher := NewPublisher(fakeClient, cfg, testLogger()) + + ctx := context.Background() + err := publisher.Publish(ctx) + if err != nil { + t.Errorf("Publish() error = %v", err) + } + + cm := &corev1.ConfigMap{} + err = fakeClient.Get(ctx, client.ObjectKey{Name: ConfigMapName, Namespace: "test-ns"}, cm) + if err != nil { + t.Errorf("Failed to get updated ConfigMap: %v", err) + } + + if _, ok := cm.Data["buildInfo"]; !ok { + t.Error("buildInfo data key missing after update") + } + if _, ok := cm.Data["config"]; !ok { + t.Error("config data key missing after update") + } + if _, ok := cm.Data["deploymentInfo"]; !ok { + t.Error("deploymentInfo data key missing after update") + } + + if cm.Labels[ConfigMapLabelKey] != ConfigMapLabelValue { + t.Errorf("Label not updated: %s", cm.Labels[ConfigMapLabelKey]) + } +} + +func TestPublishMetaInfoConfigMap(t *testing.T) { + t.Setenv(EnvReloaderNamespace, "test-ns") + + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + + cfg := config.NewDefault() + ctx := context.Background() + + err := PublishMetaInfoConfigMap(ctx, fakeClient, cfg, testLogger()) + if err != nil { + t.Errorf("PublishMetaInfoConfigMap() error = %v", err) + } + + cm := &corev1.ConfigMap{} + err = fakeClient.Get(ctx, client.ObjectKey{Name: ConfigMapName, Namespace: "test-ns"}, cm) + if err != nil { + t.Errorf("Failed to get created ConfigMap: %v", err) + } +} + +func TestParseUTCTime(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + { + name: "valid RFC3339 time", + input: "2024-01-01T12:00:00Z", + wantErr: false, + }, + { + name: "empty string", + input: "", + wantErr: true, // returns zero time + }, + { + name: "invalid format", + input: "not-a-time", + wantErr: true, // returns zero time + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + result := parseUTCTime(tt.input) + if tt.wantErr { + if !result.IsZero() { + t.Errorf("parseUTCTime(%s) should return zero time", tt.input) + } + } else { + if result.IsZero() { + t.Errorf("parseUTCTime(%s) should not return zero time", tt.input) + } + } + }, + ) + } +} diff --git a/internal/pkg/metadata/publisher.go b/internal/pkg/metadata/publisher.go new file mode 100644 index 000000000..6c6a42221 --- /dev/null +++ b/internal/pkg/metadata/publisher.go @@ -0,0 +1,99 @@ +package metadata + +import ( + "context" + "fmt" + "os" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/stakater/Reloader/internal/pkg/config" + "github.com/stakater/Reloader/internal/pkg/workload" +) + +// Publisher handles creating and updating the metadata ConfigMap. +type Publisher struct { + client client.Client + cfg *config.Config + log logr.Logger +} + +// NewPublisher creates a new Publisher. +func NewPublisher(c client.Client, cfg *config.Config, log logr.Logger) *Publisher { + return &Publisher{ + client: c, + cfg: cfg, + log: log, + } +} + +// Publish creates or updates the metadata ConfigMap. +func (p *Publisher) Publish(ctx context.Context) error { + namespace := os.Getenv(EnvReloaderNamespace) + if namespace == "" { + p.log.Info("RELOADER_NAMESPACE is not set, skipping meta info configmap creation") + return nil + } + + metaInfo := NewMetaInfo(p.cfg) + configMap := metaInfo.ToConfigMap() + + existing := &corev1.ConfigMap{} + err := p.client.Get( + ctx, client.ObjectKey{ + Name: ConfigMapName, + Namespace: namespace, + }, existing, + ) + + if err != nil { + if !errors.IsNotFound(err) { + return fmt.Errorf("failed to get existing meta info configmap: %w", err) + } + p.log.Info("Creating meta info configmap") + if err := p.client.Create(ctx, configMap, client.FieldOwner(workload.FieldManager)); err != nil { + return fmt.Errorf("failed to create meta info configmap: %w", err) + } + p.log.Info("Meta info configmap created successfully") + return nil + } + + p.log.Info("Meta info configmap already exists, updating it") + existing.Data = configMap.Data + existing.Labels = configMap.Labels + if err := p.client.Update(ctx, existing, client.FieldOwner(workload.FieldManager)); err != nil { + return fmt.Errorf("failed to update meta info configmap: %w", err) + } + p.log.Info("Meta info configmap updated successfully") + return nil +} + +// PublishMetaInfoConfigMap is a convenience function that creates a Publisher and calls Publish. +func PublishMetaInfoConfigMap(ctx context.Context, c client.Client, cfg *config.Config, log logr.Logger) error { + publisher := NewPublisher(c, cfg, log) + return publisher.Publish(ctx) +} + +// Runnable returns a controller-runtime Runnable that publishes the metadata ConfigMap +// when the manager starts. This ensures the cache is ready before accessing the API. +func Runnable(c client.Client, cfg *config.Config, log logr.Logger) RunnableFunc { + return func(ctx context.Context) error { + if err := PublishMetaInfoConfigMap(ctx, c, cfg, log); err != nil { + log.Error(err, "Failed to create metadata ConfigMap") + // Non-fatal, don't return error to avoid crashing the manager + } + <-ctx.Done() + return nil + } +} + +// RunnableFunc is a function that implements the controller-runtime Runnable interface. +type RunnableFunc func(context.Context) error + +// Start implements the Runnable interface. +func (r RunnableFunc) Start(ctx context.Context) error { + return r(ctx) +} diff --git a/internal/pkg/metrics/prometheus.go b/internal/pkg/metrics/prometheus.go index 43103935e..acaaa0dc5 100644 --- a/internal/pkg/metrics/prometheus.go +++ b/internal/pkg/metrics/prometheus.go @@ -1,84 +1,53 @@ package metrics import ( - "context" "net/http" - "net/url" "os" "time" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" - "k8s.io/client-go/tools/metrics" + ctrlmetrics "sigs.k8s.io/controller-runtime/pkg/metrics" ) -// clientGoRequestMetrics implements metrics.LatencyMetric and metrics.ResultMetric -// to expose client-go's rest_client_requests_total metric -type clientGoRequestMetrics struct { - requestCounter *prometheus.CounterVec - requestLatency *prometheus.HistogramVec -} - -func (m *clientGoRequestMetrics) Increment(ctx context.Context, code string, method string, host string) { - m.requestCounter.WithLabelValues(code, method, host).Inc() -} - -func (m *clientGoRequestMetrics) Observe(ctx context.Context, verb string, u url.URL, latency time.Duration) { - m.requestLatency.WithLabelValues(verb, u.Host).Observe(latency.Seconds()) -} - -var clientGoMetrics = &clientGoRequestMetrics{ - requestCounter: prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "rest_client_requests_total", - Help: "Number of HTTP requests, partitioned by status code, method, and host.", - }, - []string{"code", "method", "host"}, - ), - requestLatency: prometheus.NewHistogramVec( - prometheus.HistogramOpts{ - Name: "rest_client_request_duration_seconds", - Help: "Request latency in seconds. Broken down by verb and host.", - Buckets: []float64{0.001, 0.01, 0.05, 0.1, 0.5, 1, 5, 10, 30}, - }, - []string{"verb", "host"}, - ), -} - -func init() { - // Register the metrics collectors - prometheus.MustRegister(clientGoMetrics.requestCounter) - prometheus.MustRegister(clientGoMetrics.requestLatency) - - // Register our metrics implementation with client-go - metrics.RequestResult = clientGoMetrics - metrics.RequestLatency = clientGoMetrics -} - // Collectors holds all Prometheus metrics collectors for Reloader. type Collectors struct { Reloaded *prometheus.CounterVec ReloadedByNamespace *prometheus.CounterVec countByNamespace bool + // === Comprehensive metrics for load testing === + + // Reconcile/Handler metrics ReconcileTotal *prometheus.CounterVec // Total reconcile calls by result ReconcileDuration *prometheus.HistogramVec // Time spent in reconcile/handler - ActionTotal *prometheus.CounterVec // Total actions by workload kind and result - ActionLatency *prometheus.HistogramVec // Time from event to action applied - SkippedTotal *prometheus.CounterVec // Skipped operations by reason - QueueDepth prometheus.Gauge // Current queue depth - QueueAdds prometheus.Counter // Total items added to queue - QueueLatency *prometheus.HistogramVec // Time spent in queue - ErrorsTotal *prometheus.CounterVec // Errors by type - RetriesTotal prometheus.Counter // Total retries - EventsReceived *prometheus.CounterVec // Events received by type (add/update/delete) - EventsProcessed *prometheus.CounterVec // Events processed by type and result - WorkloadsScanned *prometheus.CounterVec // Workloads scanned by kind - WorkloadsMatched *prometheus.CounterVec // Workloads matched for reload by kind + + // Action metrics + ActionTotal *prometheus.CounterVec // Total actions by workload kind and result + ActionLatency *prometheus.HistogramVec // Time from event to action applied + + // Skip metrics + SkippedTotal *prometheus.CounterVec // Skipped operations by reason + + // Queue metrics (controller-runtime exposes some automatically, but we add custom ones) + QueueDepth prometheus.Gauge // Current queue depth + QueueAdds prometheus.Counter // Total items added to queue + QueueLatency *prometheus.HistogramVec // Time spent in queue + + // Error and retry metrics + ErrorsTotal *prometheus.CounterVec // Errors by type + RetriesTotal prometheus.Counter // Total retries + + // Event processing metrics + EventsReceived *prometheus.CounterVec // Events received by type (add/update/delete) + EventsProcessed *prometheus.CounterVec // Events processed by type and result + + // Resource discovery metrics + WorkloadsScanned *prometheus.CounterVec // Workloads scanned by kind + WorkloadsMatched *prometheus.CounterVec // Workloads matched for reload by kind } // RecordReload records a reload event with the given success status and namespace. -// Preserved for backward compatibility. func (c *Collectors) RecordReload(success bool, namespace string) { if c == nil { return @@ -92,10 +61,12 @@ func (c *Collectors) RecordReload(success bool, namespace string) { c.Reloaded.With(prometheus.Labels{"success": successLabel}).Inc() if c.countByNamespace { - c.ReloadedByNamespace.With(prometheus.Labels{ - "success": successLabel, - "namespace": namespace, - }).Inc() + c.ReloadedByNamespace.With( + prometheus.Labels{ + "success": successLabel, + "namespace": namespace, + }, + ).Inc() } } @@ -219,6 +190,8 @@ func NewCollectors() Collectors { []string{"success", "namespace"}, ) + // === Comprehensive metrics === + reconcileTotal := prometheus.NewCounterVec( prometheus.CounterOpts{ Namespace: "reloader", @@ -370,26 +343,29 @@ func NewCollectors() Collectors { func SetupPrometheusEndpoint() Collectors { collectors := NewCollectors() - prometheus.MustRegister(collectors.Reloaded) - prometheus.MustRegister(collectors.ReconcileTotal) - prometheus.MustRegister(collectors.ReconcileDuration) - prometheus.MustRegister(collectors.ActionTotal) - prometheus.MustRegister(collectors.ActionLatency) - prometheus.MustRegister(collectors.SkippedTotal) - prometheus.MustRegister(collectors.QueueDepth) - prometheus.MustRegister(collectors.QueueAdds) - prometheus.MustRegister(collectors.QueueLatency) - prometheus.MustRegister(collectors.ErrorsTotal) - prometheus.MustRegister(collectors.RetriesTotal) - prometheus.MustRegister(collectors.EventsReceived) - prometheus.MustRegister(collectors.EventsProcessed) - prometheus.MustRegister(collectors.WorkloadsScanned) - prometheus.MustRegister(collectors.WorkloadsMatched) + ctrlmetrics.Registry.MustRegister(collectors.Reloaded) + ctrlmetrics.Registry.MustRegister(collectors.ReconcileTotal) + ctrlmetrics.Registry.MustRegister(collectors.ReconcileDuration) + ctrlmetrics.Registry.MustRegister(collectors.ActionTotal) + ctrlmetrics.Registry.MustRegister(collectors.ActionLatency) + ctrlmetrics.Registry.MustRegister(collectors.SkippedTotal) + ctrlmetrics.Registry.MustRegister(collectors.QueueDepth) + ctrlmetrics.Registry.MustRegister(collectors.QueueAdds) + ctrlmetrics.Registry.MustRegister(collectors.QueueLatency) + ctrlmetrics.Registry.MustRegister(collectors.ErrorsTotal) + ctrlmetrics.Registry.MustRegister(collectors.RetriesTotal) + ctrlmetrics.Registry.MustRegister(collectors.EventsReceived) + ctrlmetrics.Registry.MustRegister(collectors.EventsProcessed) + ctrlmetrics.Registry.MustRegister(collectors.WorkloadsScanned) + ctrlmetrics.Registry.MustRegister(collectors.WorkloadsMatched) if os.Getenv("METRICS_COUNT_BY_NAMESPACE") == "enabled" { - prometheus.MustRegister(collectors.ReloadedByNamespace) + ctrlmetrics.Registry.MustRegister(collectors.ReloadedByNamespace) } + // Note: For controller-runtime based Reloader, the metrics are served + // by controller-runtime's metrics server. This http.Handle is kept for + // the legacy informer-based Reloader which uses its own HTTP server. http.Handle("/metrics", promhttp.Handler()) return collectors diff --git a/internal/pkg/metrics/prometheus_test.go b/internal/pkg/metrics/prometheus_test.go new file mode 100644 index 000000000..5715c243e --- /dev/null +++ b/internal/pkg/metrics/prometheus_test.go @@ -0,0 +1,187 @@ +package metrics + +import ( + "testing" + + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" +) + +func TestNewCollectors_CreatesCounters(t *testing.T) { + collectors := NewCollectors() + + if collectors.Reloaded == nil { + t.Error("NewCollectors() should create Reloaded counter") + } + if collectors.ReloadedByNamespace == nil { + t.Error("NewCollectors() should create ReloadedByNamespace counter") + } +} + +func TestNewCollectors_InitializesWithZero(t *testing.T) { + collectors := NewCollectors() + + metric := &dto.Metric{} + err := collectors.Reloaded.With(prometheus.Labels{"success": "true"}).Write(metric) + if err != nil { + t.Fatalf("Failed to get metric: %v", err) + } + if metric.Counter.GetValue() != 0 { + t.Errorf("Initial success=true counter = %v, want 0", metric.Counter.GetValue()) + } + + err = collectors.Reloaded.With(prometheus.Labels{"success": "false"}).Write(metric) + if err != nil { + t.Fatalf("Failed to get metric: %v", err) + } + if metric.Counter.GetValue() != 0 { + t.Errorf("Initial success=false counter = %v, want 0", metric.Counter.GetValue()) + } +} + +func TestRecordReload_Success(t *testing.T) { + collectors := NewCollectors() + collectors.RecordReload(true, "default") + + metric := &dto.Metric{} + err := collectors.Reloaded.With(prometheus.Labels{"success": "true"}).Write(metric) + if err != nil { + t.Fatalf("Failed to get metric: %v", err) + } + if metric.Counter.GetValue() != 1 { + t.Errorf("success=true counter = %v, want 1", metric.Counter.GetValue()) + } +} + +func TestRecordReload_Failure(t *testing.T) { + collectors := NewCollectors() + collectors.RecordReload(false, "default") + + metric := &dto.Metric{} + err := collectors.Reloaded.With(prometheus.Labels{"success": "false"}).Write(metric) + if err != nil { + t.Fatalf("Failed to get metric: %v", err) + } + if metric.Counter.GetValue() != 1 { + t.Errorf("success=false counter = %v, want 1", metric.Counter.GetValue()) + } +} + +func TestRecordReload_MultipleIncrements(t *testing.T) { + collectors := NewCollectors() + collectors.RecordReload(true, "default") + collectors.RecordReload(true, "default") + collectors.RecordReload(false, "default") + + metric := &dto.Metric{} + + err := collectors.Reloaded.With(prometheus.Labels{"success": "true"}).Write(metric) + if err != nil { + t.Fatalf("Failed to get metric: %v", err) + } + if metric.Counter.GetValue() != 2 { + t.Errorf("success=true counter = %v, want 2", metric.Counter.GetValue()) + } + + err = collectors.Reloaded.With(prometheus.Labels{"success": "false"}).Write(metric) + if err != nil { + t.Fatalf("Failed to get metric: %v", err) + } + if metric.Counter.GetValue() != 1 { + t.Errorf("success=false counter = %v, want 1", metric.Counter.GetValue()) + } +} + +func TestRecordReload_WithNamespaceTracking(t *testing.T) { + t.Setenv("METRICS_COUNT_BY_NAMESPACE", "enabled") + + collectors := NewCollectors() + collectors.RecordReload(true, "kube-system") + + metric := &dto.Metric{} + err := collectors.ReloadedByNamespace.With( + prometheus.Labels{ + "success": "true", + "namespace": "kube-system", + }, + ).Write(metric) + if err != nil { + t.Fatalf("Failed to get metric: %v", err) + } + if metric.Counter.GetValue() != 1 { + t.Errorf("namespace counter = %v, want 1", metric.Counter.GetValue()) + } +} + +func TestRecordReload_WithoutNamespaceTracking(t *testing.T) { + t.Setenv("METRICS_COUNT_BY_NAMESPACE", "") + + collectors := NewCollectors() + collectors.RecordReload(true, "kube-system") + + if collectors.countByNamespace { + t.Error("countByNamespace should be false when env var is not set") + } +} + +func TestNilCollectors_NoPanic(t *testing.T) { + var c *Collectors = nil + + c.RecordReload(true, "default") + c.RecordReload(false, "default") +} + +func TestRecordReload_DifferentNamespaces(t *testing.T) { + t.Setenv("METRICS_COUNT_BY_NAMESPACE", "enabled") + + collectors := NewCollectors() + collectors.RecordReload(true, "namespace-a") + collectors.RecordReload(true, "namespace-b") + collectors.RecordReload(true, "namespace-a") + + metric := &dto.Metric{} + + err := collectors.ReloadedByNamespace.With( + prometheus.Labels{ + "success": "true", + "namespace": "namespace-a", + }, + ).Write(metric) + if err != nil { + t.Fatalf("Failed to get metric: %v", err) + } + if metric.Counter.GetValue() != 2 { + t.Errorf("namespace-a counter = %v, want 2", metric.Counter.GetValue()) + } + + err = collectors.ReloadedByNamespace.With( + prometheus.Labels{ + "success": "true", + "namespace": "namespace-b", + }, + ).Write(metric) + if err != nil { + t.Fatalf("Failed to get metric: %v", err) + } + if metric.Counter.GetValue() != 1 { + t.Errorf("namespace-b counter = %v, want 1", metric.Counter.GetValue()) + } +} + +func TestCollectors_MetricNames(t *testing.T) { + collectors := NewCollectors() + + ch := make(chan *prometheus.Desc, 10) + collectors.Reloaded.Describe(ch) + close(ch) + + found := false + for desc := range ch { + if desc.String() != "" { + found = true + } + } + if !found { + t.Error("Expected Reloaded metric to have a description") + } +} diff --git a/internal/pkg/openshift/detect.go b/internal/pkg/openshift/detect.go new file mode 100644 index 000000000..403c0d27f --- /dev/null +++ b/internal/pkg/openshift/detect.go @@ -0,0 +1,34 @@ +package openshift + +import ( + "github.com/go-logr/logr" + "k8s.io/client-go/discovery" +) + +const ( + // DeploymentConfigAPIGroup is the API group for DeploymentConfig. + DeploymentConfigAPIGroup = "apps.openshift.io" + // DeploymentConfigAPIVersion is the API version for DeploymentConfig. + DeploymentConfigAPIVersion = "v1" + // DeploymentConfigResource is the resource name for DeploymentConfig. + DeploymentConfigResource = "deploymentconfigs" +) + +// HasDeploymentConfigSupport checks if the cluster supports DeploymentConfig +func HasDeploymentConfigSupport(client discovery.DiscoveryInterface, log logr.Logger) bool { + resources, err := client.ServerResourcesForGroupVersion(DeploymentConfigAPIGroup + "/" + DeploymentConfigAPIVersion) + if err != nil { + log.V(1).Info("DeploymentConfig API not available", "error", err) + return false + } + + for _, r := range resources.APIResources { + if r.Name == DeploymentConfigResource { + log.Info("DeploymentConfig API detected, enabling support") + return true + } + } + + log.V(1).Info("DeploymentConfig resource not found in apps.openshift.io/v1") + return false +} diff --git a/internal/pkg/options/flags.go b/internal/pkg/options/flags.go deleted file mode 100644 index 62f285302..000000000 --- a/internal/pkg/options/flags.go +++ /dev/null @@ -1,101 +0,0 @@ -package options - -import "github.com/stakater/Reloader/internal/pkg/constants" - -type ArgoRolloutStrategy int - -const ( - // RestartStrategy is the annotation value for restart strategy for rollouts - RestartStrategy ArgoRolloutStrategy = iota - // RolloutStrategy is the annotation value for rollout strategy for rollouts - RolloutStrategy -) - -var ( - // Auto reload all resources when their corresponding configmaps/secrets are updated - AutoReloadAll = false - // ConfigmapUpdateOnChangeAnnotation is an annotation to detect changes in - // configmaps specified by name - ConfigmapUpdateOnChangeAnnotation = "configmap.reloader.stakater.com/reload" - // SecretUpdateOnChangeAnnotation is an annotation to detect changes in - // secrets specified by name - SecretUpdateOnChangeAnnotation = "secret.reloader.stakater.com/reload" - // SecretProviderClassUpdateOnChangeAnnotation is an annotation to detect changes in - // secretproviderclasses specified by name - SecretProviderClassUpdateOnChangeAnnotation = "secretproviderclass.reloader.stakater.com/reload" - // ReloaderAutoAnnotation is an annotation to detect changes in secrets/configmaps - ReloaderAutoAnnotation = "reloader.stakater.com/auto" - // IgnoreResourceAnnotation is an annotation to ignore changes in secrets/configmaps - IgnoreResourceAnnotation = "reloader.stakater.com/ignore" - // ConfigmapReloaderAutoAnnotation is an annotation to detect changes in configmaps - ConfigmapReloaderAutoAnnotation = "configmap.reloader.stakater.com/auto" - // SecretReloaderAutoAnnotation is an annotation to detect changes in secrets - SecretReloaderAutoAnnotation = "secret.reloader.stakater.com/auto" - // SecretProviderClassReloaderAutoAnnotation is an annotation to detect changes in secretproviderclasses - SecretProviderClassReloaderAutoAnnotation = "secretproviderclass.reloader.stakater.com/auto" - // ConfigmapReloaderAutoAnnotation is a comma separated list of configmaps that excludes detecting changes on cms - ConfigmapExcludeReloaderAnnotation = "configmaps.exclude.reloader.stakater.com/reload" - // SecretExcludeReloaderAnnotation is a comma separated list of secrets that excludes detecting changes on secrets - SecretExcludeReloaderAnnotation = "secrets.exclude.reloader.stakater.com/reload" - // SecretProviderClassExcludeReloaderAnnotation is a comma separated list of secret provider classes that excludes detecting changes on secret provider class - SecretProviderClassExcludeReloaderAnnotation = "secretproviderclasses.exclude.reloader.stakater.com/reload" - // AutoSearchAnnotation is an annotation to detect changes in - // configmaps or triggers with the SearchMatchAnnotation - AutoSearchAnnotation = "reloader.stakater.com/search" - // SearchMatchAnnotation is an annotation to tag secrets to be found with - // AutoSearchAnnotation - SearchMatchAnnotation = "reloader.stakater.com/match" - // RolloutStrategyAnnotation is an annotation to define rollout update strategy - RolloutStrategyAnnotation = "reloader.stakater.com/rollout-strategy" - // PauseDeploymentAnnotation is an annotation to define the time period to pause a deployment after - // a configmap/secret change has been detected. Valid values are described here: https://pkg.go.dev/time#ParseDuration - // only positive values are allowed - PauseDeploymentAnnotation = "deployment.reloader.stakater.com/pause-period" - // Annotation set by reloader to indicate that the deployment has been paused - PauseDeploymentTimeAnnotation = "deployment.reloader.stakater.com/paused-at" - // LogFormat is the log format to use (json, or empty string for default) - LogFormat = "" - // LogLevel is the log level to use (trace, debug, info, warning, error, fatal and panic) - LogLevel = "" - // IsArgoRollouts Adds support for argo rollouts - IsArgoRollouts = "false" - // ReloadStrategy Specify the update strategy - ReloadStrategy = constants.EnvVarsReloadStrategy - // ReloadOnCreate Adds support to watch create events - ReloadOnCreate = "false" - // ReloadOnDelete Adds support to watch delete events - ReloadOnDelete = "false" - SyncAfterRestart = false - // EnableHA adds support for running multiple replicas via leadership election - EnableHA = false - // Url to send a request to instead of triggering a reload - WebhookUrl = "" - // EnableCSIIntegration Adds support to watch SecretProviderClassPodStatus and restart deployment based on it - EnableCSIIntegration = false - // ResourcesToIgnore is a list of resources to ignore when watching for changes - ResourcesToIgnore = []string{} - // WorkloadTypesToIgnore is a list of workload types to ignore when watching for changes - WorkloadTypesToIgnore = []string{} - // NamespacesToIgnore is a list of namespace names to ignore when watching for changes - NamespacesToIgnore = []string{} - // NamespaceSelectors is a list of namespace selectors to watch for changes - NamespaceSelectors = []string{} - // ResourceSelectors is a list of resource selectors to watch for changes - ResourceSelectors = []string{} - // EnablePProf enables pprof for profiling - EnablePProf = false - // PProfAddr is the address to start pprof server on - // Default is :6060 - PProfAddr = ":6060" -) - -func ToArgoRolloutStrategy(s string) ArgoRolloutStrategy { - switch s { - case "restart": - return RestartStrategy - case "rollout": - fallthrough - default: - return RolloutStrategy - } -} diff --git a/internal/pkg/reload/change.go b/internal/pkg/reload/change.go new file mode 100644 index 000000000..b7fa4443d --- /dev/null +++ b/internal/pkg/reload/change.go @@ -0,0 +1,56 @@ +package reload + +import ( + corev1 "k8s.io/api/core/v1" +) + +// EventType represents the type of change event. +type EventType string + +const ( + // EventTypeCreate indicates a resource was created. + EventTypeCreate EventType = "create" + // EventTypeUpdate indicates a resource was updated. + EventTypeUpdate EventType = "update" + // EventTypeDelete indicates a resource was deleted. + EventTypeDelete EventType = "delete" +) + +// ResourceChange represents a change event for a ConfigMap or Secret. +type ResourceChange interface { + IsNil() bool + GetEventType() EventType + GetName() string + GetNamespace() string + GetAnnotations() map[string]string + GetResourceType() ResourceType + ComputeHash(hasher *Hasher) string +} + +// ConfigMapChange represents a change event for a ConfigMap. +type ConfigMapChange struct { + ConfigMap *corev1.ConfigMap + EventType EventType +} + +func (c ConfigMapChange) IsNil() bool { return c.ConfigMap == nil } +func (c ConfigMapChange) GetEventType() EventType { return c.EventType } +func (c ConfigMapChange) GetName() string { return c.ConfigMap.Name } +func (c ConfigMapChange) GetNamespace() string { return c.ConfigMap.Namespace } +func (c ConfigMapChange) GetAnnotations() map[string]string { return c.ConfigMap.Annotations } +func (c ConfigMapChange) GetResourceType() ResourceType { return ResourceTypeConfigMap } +func (c ConfigMapChange) ComputeHash(h *Hasher) string { return h.HashConfigMap(c.ConfigMap) } + +// SecretChange represents a change event for a Secret. +type SecretChange struct { + Secret *corev1.Secret + EventType EventType +} + +func (c SecretChange) IsNil() bool { return c.Secret == nil } +func (c SecretChange) GetEventType() EventType { return c.EventType } +func (c SecretChange) GetName() string { return c.Secret.Name } +func (c SecretChange) GetNamespace() string { return c.Secret.Namespace } +func (c SecretChange) GetAnnotations() map[string]string { return c.Secret.Annotations } +func (c SecretChange) GetResourceType() ResourceType { return ResourceTypeSecret } +func (c SecretChange) ComputeHash(h *Hasher) string { return h.HashSecret(c.Secret) } diff --git a/internal/pkg/reload/decision.go b/internal/pkg/reload/decision.go new file mode 100644 index 000000000..625925828 --- /dev/null +++ b/internal/pkg/reload/decision.go @@ -0,0 +1,30 @@ +package reload + +import ( + "github.com/stakater/Reloader/internal/pkg/workload" +) + +// ReloadDecision contains the result of evaluating whether to reload a workload. +type ReloadDecision struct { + // Workload is the workload accessor. + Workload workload.Workload + // ShouldReload indicates whether the workload should be reloaded. + ShouldReload bool + // AutoReload indicates if this is an auto-reload. + AutoReload bool + // Reason provides a human-readable explanation. + Reason string + // Hash is the computed hash of the resource content. + Hash string +} + +// FilterDecisions returns only decisions where ShouldReload is true. +func FilterDecisions(decisions []ReloadDecision) []ReloadDecision { + var result []ReloadDecision + for _, d := range decisions { + if d.ShouldReload { + result = append(result, d) + } + } + return result +} diff --git a/internal/pkg/reload/decision_test.go b/internal/pkg/reload/decision_test.go new file mode 100644 index 000000000..5b7a6135e --- /dev/null +++ b/internal/pkg/reload/decision_test.go @@ -0,0 +1,125 @@ +package reload + +import ( + "testing" + + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/stakater/Reloader/internal/pkg/workload" +) + +func TestFilterDecisions(t *testing.T) { + wl1 := workload.NewDeploymentWorkload( + &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "deploy1", Namespace: "default"}, + }, + ) + wl2 := workload.NewDeploymentWorkload( + &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "deploy2", Namespace: "default"}, + }, + ) + wl3 := workload.NewDeploymentWorkload( + &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "deploy3", Namespace: "default"}, + }, + ) + + tests := []struct { + name string + decisions []ReloadDecision + wantCount int + wantNames []string + }{ + { + name: "empty list", + decisions: []ReloadDecision{}, + wantCount: 0, + wantNames: nil, + }, + { + name: "all should reload", + decisions: []ReloadDecision{ + {Workload: wl1, ShouldReload: true, Reason: "test"}, + {Workload: wl2, ShouldReload: true, Reason: "test"}, + }, + wantCount: 2, + wantNames: []string{"deploy1", "deploy2"}, + }, + { + name: "none should reload", + decisions: []ReloadDecision{ + {Workload: wl1, ShouldReload: false, Reason: "test"}, + {Workload: wl2, ShouldReload: false, Reason: "test"}, + }, + wantCount: 0, + wantNames: nil, + }, + { + name: "mixed - some should reload", + decisions: []ReloadDecision{ + {Workload: wl1, ShouldReload: true, Reason: "test"}, + {Workload: wl2, ShouldReload: false, Reason: "test"}, + {Workload: wl3, ShouldReload: true, Reason: "test"}, + }, + wantCount: 2, + wantNames: []string{"deploy1", "deploy3"}, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + result := FilterDecisions(tt.decisions) + + if len(result) != tt.wantCount { + t.Errorf("FilterDecisions() returned %d decisions, want %d", len(result), tt.wantCount) + } + + if tt.wantNames != nil { + for i, d := range result { + if d.Workload.GetName() != tt.wantNames[i] { + t.Errorf( + "FilterDecisions()[%d].Workload.GetName() = %s, want %s", + i, d.Workload.GetName(), tt.wantNames[i], + ) + } + } + } + }, + ) + } +} + +func TestReloadDecision_Fields(t *testing.T) { + wl := workload.NewDeploymentWorkload( + &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + }, + ) + + decision := ReloadDecision{ + Workload: wl, + ShouldReload: true, + AutoReload: true, + Reason: "test reason", + Hash: "abc123", + } + + if decision.Workload.GetName() != "test" { + t.Errorf("ReloadDecision.Workload.GetName() = %v, want test", decision.Workload.GetName()) + } + if !decision.ShouldReload { + t.Error("ReloadDecision.ShouldReload should be true") + } + if !decision.AutoReload { + t.Error("ReloadDecision.AutoReload should be true") + } + if decision.Reason != "test reason" { + t.Errorf("ReloadDecision.Reason = %v, want 'test reason'", decision.Reason) + } + if decision.Hash != "abc123" { + t.Errorf("ReloadDecision.Hash = %v, want 'abc123'", decision.Hash) + } +} diff --git a/internal/pkg/reload/hasher.go b/internal/pkg/reload/hasher.go new file mode 100644 index 000000000..0d259ac3a --- /dev/null +++ b/internal/pkg/reload/hasher.go @@ -0,0 +1,74 @@ +// Package reload provides core reload logic for ConfigMaps and Secrets. +package reload + +import ( + "crypto/sha1" + "encoding/base64" + "fmt" + "io" + "sort" + "strings" + + corev1 "k8s.io/api/core/v1" +) + +// Hasher computes content hashes for ConfigMaps and Secrets. +type Hasher struct{} + +// NewHasher creates a new Hasher instance. +func NewHasher() *Hasher { + return &Hasher{} +} + +// HashConfigMap computes a SHA1 hash of the ConfigMap's data and binaryData. +func (h *Hasher) HashConfigMap(cm *corev1.ConfigMap) string { + if cm == nil { + return h.computeSHA("") + } + return h.hashConfigMapData(cm.Data, cm.BinaryData) +} + +// HashSecret computes a SHA1 hash of the Secret's data. +func (h *Hasher) HashSecret(secret *corev1.Secret) string { + if secret == nil { + return h.computeSHA("") + } + return h.hashSecretData(secret.Data) +} + +func (h *Hasher) hashConfigMapData(data map[string]string, binaryData map[string][]byte) string { + values := make([]string, 0, len(data)+len(binaryData)) + + for k, v := range data { + values = append(values, k+"="+v) + } + + for k, v := range binaryData { + values = append(values, k+"="+base64.StdEncoding.EncodeToString(v)) + } + + sort.Strings(values) + return h.computeSHA(strings.Join(values, ";")) +} + +func (h *Hasher) hashSecretData(data map[string][]byte) string { + values := make([]string, 0, len(data)) + + for k, v := range data { + values = append(values, k+"="+string(v)) + } + + sort.Strings(values) + return h.computeSHA(strings.Join(values, ";")) +} + +func (h *Hasher) computeSHA(data string) string { + hasher := sha1.New() + _, _ = io.WriteString(hasher, data) + return fmt.Sprintf("%x", hasher.Sum(nil)) +} + +// EmptyHash returns an empty string to signal resource deletion. +func (h *Hasher) EmptyHash() string { + return "" +} diff --git a/internal/pkg/reload/hasher_test.go b/internal/pkg/reload/hasher_test.go new file mode 100644 index 000000000..ff5693ff7 --- /dev/null +++ b/internal/pkg/reload/hasher_test.go @@ -0,0 +1,236 @@ +package reload + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" +) + +func TestHasher_HashConfigMap(t *testing.T) { + hasher := NewHasher() + + tests := []struct { + name string + cm *corev1.ConfigMap + wantHash string + }{ + { + name: "empty configmap", + cm: &corev1.ConfigMap{ + Data: nil, + BinaryData: nil, + }, + wantHash: hasher.HashConfigMap(&corev1.ConfigMap{}), + }, + { + name: "configmap with data", + cm: &corev1.ConfigMap{ + Data: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + }, + wantHash: hasher.HashConfigMap( + &corev1.ConfigMap{ + Data: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + }, + ), + }, + { + name: "configmap with binary data", + cm: &corev1.ConfigMap{ + BinaryData: map[string][]byte{ + "binary1": []byte("binaryvalue1"), + }, + }, + wantHash: hasher.HashConfigMap( + &corev1.ConfigMap{ + BinaryData: map[string][]byte{ + "binary1": []byte("binaryvalue1"), + }, + }, + ), + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + got := hasher.HashConfigMap(tt.cm) + if got != tt.wantHash { + t.Errorf("HashConfigMap() = %v, want %v", got, tt.wantHash) + } + }, + ) + } +} + +func TestHasher_HashConfigMap_Deterministic(t *testing.T) { + hasher := NewHasher() + + cm := &corev1.ConfigMap{ + Data: map[string]string{ + "z-key": "value-z", + "a-key": "value-a", + "m-key": "value-m", + }, + } + + hash1 := hasher.HashConfigMap(cm) + hash2 := hasher.HashConfigMap(cm) + hash3 := hasher.HashConfigMap(cm) + + if hash1 != hash2 || hash2 != hash3 { + t.Errorf("Hash is not deterministic: %s, %s, %s", hash1, hash2, hash3) + } +} + +func TestHasher_HashConfigMap_DifferentValues(t *testing.T) { + hasher := NewHasher() + + cm1 := &corev1.ConfigMap{ + Data: map[string]string{ + "key": "value1", + }, + } + + cm2 := &corev1.ConfigMap{ + Data: map[string]string{ + "key": "value2", + }, + } + + hash1 := hasher.HashConfigMap(cm1) + hash2 := hasher.HashConfigMap(cm2) + + if hash1 == hash2 { + t.Errorf("Different values should produce different hashes") + } +} + +func TestHasher_HashSecret(t *testing.T) { + hasher := NewHasher() + + tests := []struct { + name string + secret *corev1.Secret + wantHash string + }{ + { + name: "empty secret", + secret: &corev1.Secret{ + Data: nil, + }, + wantHash: hasher.HashSecret(&corev1.Secret{}), + }, + { + name: "secret with data", + secret: &corev1.Secret{ + Data: map[string][]byte{ + "key1": []byte("value1"), + "key2": []byte("value2"), + }, + }, + wantHash: hasher.HashSecret( + &corev1.Secret{ + Data: map[string][]byte{ + "key1": []byte("value1"), + "key2": []byte("value2"), + }, + }, + ), + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + got := hasher.HashSecret(tt.secret) + if got != tt.wantHash { + t.Errorf("HashSecret() = %v, want %v", got, tt.wantHash) + } + }, + ) + } +} + +func TestHasher_HashSecret_Deterministic(t *testing.T) { + hasher := NewHasher() + + secret := &corev1.Secret{ + Data: map[string][]byte{ + "z-key": []byte("value-z"), + "a-key": []byte("value-a"), + "m-key": []byte("value-m"), + }, + } + + hash1 := hasher.HashSecret(secret) + hash2 := hasher.HashSecret(secret) + hash3 := hasher.HashSecret(secret) + + if hash1 != hash2 || hash2 != hash3 { + t.Errorf("Hash is not deterministic: %s, %s, %s", hash1, hash2, hash3) + } +} + +func TestHasher_HashSecret_DifferentValues(t *testing.T) { + hasher := NewHasher() + + secret1 := &corev1.Secret{ + Data: map[string][]byte{ + "key": []byte("value1"), + }, + } + + secret2 := &corev1.Secret{ + Data: map[string][]byte{ + "key": []byte("value2"), + }, + } + + hash1 := hasher.HashSecret(secret1) + hash2 := hasher.HashSecret(secret2) + + if hash1 == hash2 { + t.Errorf("Different values should produce different hashes") + } +} + +func TestHasher_EmptyHash(t *testing.T) { + hasher := NewHasher() + + emptyHash := hasher.EmptyHash() + if emptyHash != "" { + t.Errorf("EmptyHash should be empty string, got %s", emptyHash) + } + + cm := &corev1.ConfigMap{} + cmHash := hasher.HashConfigMap(cm) + if cmHash == "" { + t.Error("Empty ConfigMap should have a non-empty hash") + } + + secret := &corev1.Secret{} + secretHash := hasher.HashSecret(secret) + if secretHash == "" { + t.Error("Empty Secret should have a non-empty hash") + } +} + +func TestHasher_NilInput(t *testing.T) { + hasher := NewHasher() + + cmHash := hasher.HashConfigMap(nil) + if cmHash == "" { + t.Error("nil ConfigMap should return a valid hash") + } + + secretHash := hasher.HashSecret(nil) + if secretHash == "" { + t.Error("nil Secret should return a valid hash") + } +} diff --git a/internal/pkg/reload/matcher.go b/internal/pkg/reload/matcher.go new file mode 100644 index 000000000..e817f7f59 --- /dev/null +++ b/internal/pkg/reload/matcher.go @@ -0,0 +1,259 @@ +package reload + +import ( + "regexp" + "strings" + + "github.com/stakater/Reloader/internal/pkg/config" +) + +// MatchResult contains the result of checking if a workload should be reloaded. +type MatchResult struct { + ShouldReload bool + AutoReload bool + Reason string +} + +// Matcher determines whether a workload should be reloaded based on annotations. +type Matcher struct { + cfg *config.Config +} + +// NewMatcher creates a new Matcher with the given configuration. +func NewMatcher(cfg *config.Config) *Matcher { + return &Matcher{cfg: cfg} +} + +// MatchInput contains all the information needed to determine if a reload should occur. +type MatchInput struct { + ResourceName string + ResourceNamespace string + ResourceType ResourceType + ResourceAnnotations map[string]string + WorkloadAnnotations map[string]string + PodAnnotations map[string]string +} + +// ShouldReload determines if a workload should be reloaded based on its annotations. +func (m *Matcher) ShouldReload(input MatchInput) MatchResult { + if m.isResourceIgnored(input.ResourceAnnotations) { + return MatchResult{ + ShouldReload: false, + Reason: "resource has ignore annotation", + } + } + + annotations := m.selectAnnotations(input) + + if m.isResourceExcluded(input.ResourceName, input.ResourceType, annotations) { + return MatchResult{ + ShouldReload: false, + Reason: "resource is in exclude list", + } + } + + if m.matchesExplicitAnnotation(input.ResourceName, input.ResourceType, annotations) { + return MatchResult{ + ShouldReload: true, + AutoReload: false, + Reason: "matches explicit reload annotation", + } + } + + if m.matchesSearchPattern(input.ResourceAnnotations, annotations) { + return MatchResult{ + ShouldReload: true, + AutoReload: true, + Reason: "matches search/match pattern", + } + } + + if m.matchesAutoAnnotation(input.ResourceType, annotations) { + return MatchResult{ + ShouldReload: true, + AutoReload: true, + Reason: "auto annotation enabled", + } + } + + if m.matchesAutoReloadAll(input.ResourceType, annotations) { + return MatchResult{ + ShouldReload: true, + AutoReload: true, + Reason: "auto-reload-all enabled", + } + } + + return MatchResult{ + ShouldReload: false, + Reason: "no matching annotations", + } +} + +func (m *Matcher) isResourceIgnored(resourceAnnotations map[string]string) bool { + if resourceAnnotations == nil { + return false + } + return resourceAnnotations[m.cfg.Annotations.Ignore] == "true" +} + +func (m *Matcher) selectAnnotations(input MatchInput) map[string]string { + if m.hasRelevantAnnotations(input.WorkloadAnnotations, input.ResourceType) { + return input.WorkloadAnnotations + } + if m.hasRelevantAnnotations(input.PodAnnotations, input.ResourceType) { + return input.PodAnnotations + } + return input.WorkloadAnnotations +} + +func (m *Matcher) hasRelevantAnnotations(annotations map[string]string, resourceType ResourceType) bool { + if annotations == nil { + return false + } + + explicitAnn := m.getExplicitAnnotation(resourceType) + if _, ok := annotations[explicitAnn]; ok { + return true + } + + if _, ok := annotations[m.cfg.Annotations.Search]; ok { + return true + } + + if _, ok := annotations[m.cfg.Annotations.Auto]; ok { + return true + } + + typedAutoAnn := m.getTypedAutoAnnotation(resourceType) + if _, ok := annotations[typedAutoAnn]; ok { + return true + } + + return false +} + +func (m *Matcher) isResourceExcluded(resourceName string, resourceType ResourceType, annotations map[string]string) bool { + if annotations == nil { + return false + } + + var excludeAnn string + switch resourceType { + case ResourceTypeConfigMap: + excludeAnn = m.cfg.Annotations.ConfigmapExclude + case ResourceTypeSecret: + excludeAnn = m.cfg.Annotations.SecretExclude + } + + excludeList, ok := annotations[excludeAnn] + if !ok || excludeList == "" { + return false + } + + for _, excluded := range strings.Split(excludeList, ",") { + if strings.TrimSpace(excluded) == resourceName { + return true + } + } + + return false +} + +func (m *Matcher) matchesExplicitAnnotation(resourceName string, resourceType ResourceType, annotations map[string]string) bool { + if annotations == nil { + return false + } + + explicitAnn := m.getExplicitAnnotation(resourceType) + annotationValue, ok := annotations[explicitAnn] + if !ok || annotationValue == "" { + return false + } + + for _, value := range strings.Split(annotationValue, ",") { + value = strings.TrimSpace(value) + if value == "" { + continue + } + re, err := regexp.Compile("^" + value + "$") + if err != nil { + if value == resourceName { + return true + } + continue + } + if re.MatchString(resourceName) { + return true + } + } + + return false +} + +func (m *Matcher) matchesSearchPattern(resourceAnnotations, workloadAnnotations map[string]string) bool { + if workloadAnnotations == nil || resourceAnnotations == nil { + return false + } + + searchValue, ok := workloadAnnotations[m.cfg.Annotations.Search] + if !ok || searchValue != "true" { + return false + } + + matchValue, ok := resourceAnnotations[m.cfg.Annotations.Match] + return ok && matchValue == "true" +} + +func (m *Matcher) matchesAutoAnnotation(resourceType ResourceType, annotations map[string]string) bool { + if annotations == nil { + return false + } + + if annotations[m.cfg.Annotations.Auto] == "true" { + return true + } + + typedAutoAnn := m.getTypedAutoAnnotation(resourceType) + return annotations[typedAutoAnn] == "true" +} + +func (m *Matcher) matchesAutoReloadAll(resourceType ResourceType, annotations map[string]string) bool { + if !m.cfg.AutoReloadAll { + return false + } + + if annotations != nil { + if annotations[m.cfg.Annotations.Auto] == "false" { + return false + } + typedAutoAnn := m.getTypedAutoAnnotation(resourceType) + if annotations[typedAutoAnn] == "false" { + return false + } + } + + return true +} + +func (m *Matcher) getExplicitAnnotation(resourceType ResourceType) string { + switch resourceType { + case ResourceTypeConfigMap: + return m.cfg.Annotations.ConfigmapReload + case ResourceTypeSecret: + return m.cfg.Annotations.SecretReload + default: + return "" + } +} + +func (m *Matcher) getTypedAutoAnnotation(resourceType ResourceType) string { + switch resourceType { + case ResourceTypeConfigMap: + return m.cfg.Annotations.ConfigmapAuto + case ResourceTypeSecret: + return m.cfg.Annotations.SecretAuto + default: + return "" + } +} diff --git a/internal/pkg/reload/matcher_test.go b/internal/pkg/reload/matcher_test.go new file mode 100644 index 000000000..1c58fd303 --- /dev/null +++ b/internal/pkg/reload/matcher_test.go @@ -0,0 +1,474 @@ +package reload + +import ( + "testing" + + "github.com/stakater/Reloader/internal/pkg/config" +) + +func TestMatcher_ShouldReload(t *testing.T) { + defaultCfg := config.NewDefault() + matcher := NewMatcher(defaultCfg) + + tests := []struct { + name string + input MatchInput + wantReload bool + wantAutoReload bool + description string + }{ + { + name: "ignore annotation on resource skips reload", + input: MatchInput{ + ResourceName: "my-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: map[string]string{"reloader.stakater.com/ignore": "true"}, + WorkloadAnnotations: map[string]string{"reloader.stakater.com/auto": "true"}, + PodAnnotations: nil, + }, + wantReload: false, + wantAutoReload: false, + description: "Resources with ignore annotation should never trigger reload", + }, + { + name: "ignore annotation false allows reload", + input: MatchInput{ + ResourceName: "my-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: map[string]string{"reloader.stakater.com/ignore": "false"}, + WorkloadAnnotations: map[string]string{"reloader.stakater.com/auto": "true"}, + PodAnnotations: nil, + }, + wantReload: true, + wantAutoReload: true, + description: "Resources with ignore=false should allow reload", + }, + { + name: "exclude annotation skips reload", + input: MatchInput{ + ResourceName: "my-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: nil, + WorkloadAnnotations: map[string]string{ + "reloader.stakater.com/auto": "true", + "configmaps.exclude.reloader.stakater.com/reload": "my-config", + }, + PodAnnotations: nil, + }, + wantReload: false, + wantAutoReload: false, + description: "Excluded ConfigMaps should not trigger reload", + }, + { + name: "exclude annotation with multiple values", + input: MatchInput{ + ResourceName: "my-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: nil, + WorkloadAnnotations: map[string]string{ + "reloader.stakater.com/auto": "true", + "configmaps.exclude.reloader.stakater.com/reload": "other-config,my-config,another-config", + }, + PodAnnotations: nil, + }, + wantReload: false, + wantAutoReload: false, + description: "ConfigMaps in comma-separated exclude list should not trigger reload", + }, + { + name: "explicit reload annotation with auto enabled - should reload", + input: MatchInput{ + ResourceName: "external-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: nil, + WorkloadAnnotations: map[string]string{ + "reloader.stakater.com/auto": "true", + "configmap.reloader.stakater.com/reload": "external-config", + }, + PodAnnotations: nil, + }, + wantReload: true, + wantAutoReload: false, // Explicit, not auto + description: "BUG FIX: Explicit reload annotation should work even when auto is enabled", + }, + { + name: "explicit reload annotation matches pattern - should reload", + input: MatchInput{ + ResourceName: "app-config-v2", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: nil, + WorkloadAnnotations: map[string]string{ + "configmap.reloader.stakater.com/reload": "app-config-.*", + }, + PodAnnotations: nil, + }, + wantReload: true, + wantAutoReload: false, + description: "Regex pattern in reload annotation should match", + }, + { + name: "explicit reload annotation does not match - should not reload", + input: MatchInput{ + ResourceName: "other-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: nil, + WorkloadAnnotations: map[string]string{ + "configmap.reloader.stakater.com/reload": "app-config", + }, + PodAnnotations: nil, + }, + wantReload: false, + wantAutoReload: false, + description: "ConfigMaps not in reload list should not trigger reload", + }, + { + name: "auto annotation on workload triggers reload", + input: MatchInput{ + ResourceName: "my-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: nil, + WorkloadAnnotations: map[string]string{"reloader.stakater.com/auto": "true"}, + PodAnnotations: nil, + }, + wantReload: true, + wantAutoReload: true, + description: "Auto annotation on workload should trigger reload", + }, + { + name: "auto annotation on pod template triggers reload", + input: MatchInput{ + ResourceName: "my-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: nil, + WorkloadAnnotations: nil, + PodAnnotations: map[string]string{"reloader.stakater.com/auto": "true"}, + }, + wantReload: true, + wantAutoReload: true, + description: "Auto annotation on pod template should trigger reload", + }, + { + name: "configmap-specific auto annotation", + input: MatchInput{ + ResourceName: "my-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: nil, + WorkloadAnnotations: map[string]string{"configmap.reloader.stakater.com/auto": "true"}, + PodAnnotations: nil, + }, + wantReload: true, + wantAutoReload: true, + description: "ConfigMap-specific auto annotation should trigger reload", + }, + { + name: "secret-specific auto annotation for secret", + input: MatchInput{ + ResourceName: "my-secret", + ResourceNamespace: "default", + ResourceType: ResourceTypeSecret, + ResourceAnnotations: nil, + WorkloadAnnotations: map[string]string{"secret.reloader.stakater.com/auto": "true"}, + PodAnnotations: nil, + }, + wantReload: true, + wantAutoReload: true, + description: "Secret-specific auto annotation should trigger reload for secrets", + }, + { + name: "configmap-specific auto annotation does not match secret", + input: MatchInput{ + ResourceName: "my-secret", + ResourceNamespace: "default", + ResourceType: ResourceTypeSecret, + ResourceAnnotations: nil, + WorkloadAnnotations: map[string]string{"configmap.reloader.stakater.com/auto": "true"}, + PodAnnotations: nil, + }, + wantReload: false, + wantAutoReload: false, + description: "ConfigMap-specific auto annotation should not match secrets", + }, + { + name: "search annotation with matching resource", + input: MatchInput{ + ResourceName: "app-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: map[string]string{"reloader.stakater.com/match": "true"}, + WorkloadAnnotations: map[string]string{"reloader.stakater.com/search": "true"}, + PodAnnotations: nil, + }, + wantReload: true, + wantAutoReload: true, // Search mode is an auto-discovery mechanism + description: "Search annotation with matching resource should trigger reload", + }, + { + name: "search annotation without matching resource", + input: MatchInput{ + ResourceName: "app-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: nil, + WorkloadAnnotations: map[string]string{"reloader.stakater.com/search": "true"}, + PodAnnotations: nil, + }, + wantReload: false, + wantAutoReload: false, + description: "Search annotation without matching resource should not trigger reload", + }, + { + name: "no annotations does not trigger reload", + input: MatchInput{ + ResourceName: "my-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: nil, + WorkloadAnnotations: nil, + PodAnnotations: nil, + }, + wantReload: false, + wantAutoReload: false, + description: "Without any annotations, should not trigger reload", + }, + { + name: "secret reload annotation", + input: MatchInput{ + ResourceName: "my-secret", + ResourceNamespace: "default", + ResourceType: ResourceTypeSecret, + ResourceAnnotations: nil, + WorkloadAnnotations: map[string]string{ + "secret.reloader.stakater.com/reload": "my-secret", + }, + PodAnnotations: nil, + }, + wantReload: true, + wantAutoReload: false, + description: "Secret reload annotation should trigger reload", + }, + { + name: "secret exclude annotation", + input: MatchInput{ + ResourceName: "my-secret", + ResourceNamespace: "default", + ResourceType: ResourceTypeSecret, + ResourceAnnotations: nil, + WorkloadAnnotations: map[string]string{ + "reloader.stakater.com/auto": "true", + "secrets.exclude.reloader.stakater.com/reload": "my-secret", + }, + PodAnnotations: nil, + }, + wantReload: false, + wantAutoReload: false, + description: "Secret exclude annotation should prevent reload", + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + result := matcher.ShouldReload(tt.input) + + if result.ShouldReload != tt.wantReload { + t.Errorf("ShouldReload = %v, want %v (%s)", result.ShouldReload, tt.wantReload, tt.description) + } + + if result.AutoReload != tt.wantAutoReload { + t.Errorf("AutoReload = %v, want %v (%s)", result.AutoReload, tt.wantAutoReload, tt.description) + } + + t.Logf("✓ %s", tt.description) + }, + ) + } +} + +func TestMatcher_ShouldReload_AutoReloadAll(t *testing.T) { + cfg := config.NewDefault() + cfg.AutoReloadAll = true + matcher := NewMatcher(cfg) + + tests := []struct { + name string + input MatchInput + wantReload bool + wantAutoReload bool + description string + }{ + { + name: "auto-reload-all triggers reload", + input: MatchInput{ + ResourceName: "my-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: nil, + WorkloadAnnotations: nil, + PodAnnotations: nil, + }, + wantReload: true, + wantAutoReload: true, + description: "With auto-reload-all enabled, all ConfigMaps should trigger reload", + }, + { + name: "auto-reload-all respects ignore annotation", + input: MatchInput{ + ResourceName: "my-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: map[string]string{"reloader.stakater.com/ignore": "true"}, + WorkloadAnnotations: nil, + PodAnnotations: nil, + }, + wantReload: false, + wantAutoReload: false, + description: "Even with auto-reload-all, ignore annotation should be respected", + }, + { + name: "auto-reload-all respects exclude annotation", + input: MatchInput{ + ResourceName: "my-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: nil, + WorkloadAnnotations: map[string]string{ + "configmaps.exclude.reloader.stakater.com/reload": "my-config", + }, + PodAnnotations: nil, + }, + wantReload: false, + wantAutoReload: false, + description: "Even with auto-reload-all, exclude annotation should be respected", + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + result := matcher.ShouldReload(tt.input) + + if result.ShouldReload != tt.wantReload { + t.Errorf("ShouldReload = %v, want %v (%s)", result.ShouldReload, tt.wantReload, tt.description) + } + + if result.AutoReload != tt.wantAutoReload { + t.Errorf("AutoReload = %v, want %v (%s)", result.AutoReload, tt.wantAutoReload, tt.description) + } + + t.Logf("✓ %s", tt.description) + }, + ) + } +} + +// TestMatcher_AutoDoesNotIgnoreExplicit tests the fix for the bug where +// having reloader.stakater.com/auto: "true" would cause explicit reload annotations +// to be ignored due to an early return. +func TestMatcher_AutoDoesNotIgnoreExplicit(t *testing.T) { + cfg := config.NewDefault() + matcher := NewMatcher(cfg) + + input := MatchInput{ + ResourceName: "external-config", // Not referenced by workload + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: nil, + WorkloadAnnotations: map[string]string{ + "reloader.stakater.com/auto": "true", // Enables auto-reload + "configmap.reloader.stakater.com/reload": "external-config", // Explicit list + }, + PodAnnotations: nil, + } + + result := matcher.ShouldReload(input) + + if !result.ShouldReload { + t.Errorf("BUG: Explicit reload annotation ignored when auto is enabled") + t.Errorf("Expected ShouldReload=true for explicitly listed ConfigMap, got false") + } + + if result.AutoReload { + t.Errorf("Expected AutoReload=false for explicit match, got true") + } + + t.Log("✓ Explicit reload annotation works even when auto is enabled") +} + +// TestMatcher_PrecedenceOrder verifies the correct order of precedence: +// 1. Ignore annotation → skip +// 2. Exclude annotation → skip +// 3. Explicit reload annotation → reload (BUG FIX: before auto!) +// 4. Search/Match → reload +// 5. Auto annotation → reload +// 6. Auto-reload-all → reload +func TestMatcher_PrecedenceOrder(t *testing.T) { + cfg := config.NewDefault() + matcher := NewMatcher(cfg) + + t.Run( + "explicit takes precedence over auto", func(t *testing.T) { + input := MatchInput{ + ResourceName: "my-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + WorkloadAnnotations: map[string]string{ + "reloader.stakater.com/auto": "true", + "configmap.reloader.stakater.com/reload": "my-config", + }, + } + result := matcher.ShouldReload(input) + if result.AutoReload { + t.Error("Expected explicit match (AutoReload=false), got auto match") + } + if !result.ShouldReload { + t.Error("Expected ShouldReload=true") + } + }, + ) + + t.Run( + "ignore takes precedence over explicit", func(t *testing.T) { + input := MatchInput{ + ResourceName: "my-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: map[string]string{"reloader.stakater.com/ignore": "true"}, + WorkloadAnnotations: map[string]string{ + "configmap.reloader.stakater.com/reload": "my-config", + }, + } + result := matcher.ShouldReload(input) + if result.ShouldReload { + t.Error("Expected ignore to take precedence, but got ShouldReload=true") + } + }, + ) + + t.Run( + "exclude takes precedence over explicit", func(t *testing.T) { + input := MatchInput{ + ResourceName: "my-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + WorkloadAnnotations: map[string]string{ + "configmap.reloader.stakater.com/reload": "my-config", + "configmaps.exclude.reloader.stakater.com/reload": "my-config", + }, + } + result := matcher.ShouldReload(input) + if result.ShouldReload { + t.Error("Expected exclude to take precedence, but got ShouldReload=true") + } + }, + ) +} diff --git a/internal/pkg/reload/pause.go b/internal/pkg/reload/pause.go new file mode 100644 index 000000000..e995dc33c --- /dev/null +++ b/internal/pkg/reload/pause.go @@ -0,0 +1,128 @@ +package reload + +import ( + "fmt" + "time" + + appsv1 "k8s.io/api/apps/v1" + + "github.com/stakater/Reloader/internal/pkg/config" + "github.com/stakater/Reloader/internal/pkg/workload" +) + +// PauseHandler handles pause deployment logic. +type PauseHandler struct { + cfg *config.Config +} + +// NewPauseHandler creates a new PauseHandler. +func NewPauseHandler(cfg *config.Config) *PauseHandler { + return &PauseHandler{cfg: cfg} +} + +// ShouldPause checks if a deployment should be paused after reload. +func (h *PauseHandler) ShouldPause(wl workload.Workload) bool { + if wl.Kind() != workload.KindDeployment { + return false + } + + annotations := wl.GetAnnotations() + if annotations == nil { + return false + } + + pausePeriod := annotations[h.cfg.Annotations.PausePeriod] + return pausePeriod != "" +} + +// GetPausePeriod returns the configured pause period for a workload. +func (h *PauseHandler) GetPausePeriod(wl workload.Workload) (time.Duration, error) { + annotations := wl.GetAnnotations() + if annotations == nil { + return 0, fmt.Errorf("no annotations on workload") + } + + pausePeriodStr := annotations[h.cfg.Annotations.PausePeriod] + if pausePeriodStr == "" { + return 0, fmt.Errorf("no pause period annotation") + } + + return time.ParseDuration(pausePeriodStr) +} + +// ApplyPause pauses a deployment and sets the paused-at annotation. +func (h *PauseHandler) ApplyPause(wl workload.Workload) error { + deployWl, ok := wl.(*workload.DeploymentWorkload) + if !ok { + return fmt.Errorf("workload is not a deployment") + } + + deploy := deployWl.GetDeployment() + + deploy.Spec.Paused = true + + if deploy.Annotations == nil { + deploy.Annotations = make(map[string]string) + } + deploy.Annotations[h.cfg.Annotations.PausedAt] = time.Now().UTC().Format(time.RFC3339) + + return nil +} + +// CheckPauseExpired checks if the pause period has expired for a deployment. +func (h *PauseHandler) CheckPauseExpired(deploy *appsv1.Deployment) (expired bool, remainingTime time.Duration, err error) { + annotations := deploy.GetAnnotations() + if annotations == nil { + return false, 0, fmt.Errorf("no annotations on deployment") + } + + pausePeriodStr := annotations[h.cfg.Annotations.PausePeriod] + if pausePeriodStr == "" { + return false, 0, fmt.Errorf("no pause period annotation") + } + + pausedAtStr := annotations[h.cfg.Annotations.PausedAt] + if pausedAtStr == "" { + return false, 0, fmt.Errorf("no paused-at annotation") + } + + pausePeriod, err := time.ParseDuration(pausePeriodStr) + if err != nil { + return false, 0, fmt.Errorf("invalid pause period %q: %w", pausePeriodStr, err) + } + + pausedAt, err := time.Parse(time.RFC3339, pausedAtStr) + if err != nil { + return false, 0, fmt.Errorf("invalid paused-at time %q: %w", pausedAtStr, err) + } + + elapsed := time.Since(pausedAt) + if elapsed >= pausePeriod { + return true, 0, nil + } + + return false, pausePeriod - elapsed, nil +} + +// ClearPause removes the pause from a deployment. +func (h *PauseHandler) ClearPause(deploy *appsv1.Deployment) { + deploy.Spec.Paused = false + delete(deploy.Annotations, h.cfg.Annotations.PausedAt) +} + +// IsPausedByReloader checks if a deployment was paused by Reloader. +func (h *PauseHandler) IsPausedByReloader(deploy *appsv1.Deployment) bool { + if !deploy.Spec.Paused { + return false + } + + annotations := deploy.GetAnnotations() + if annotations == nil { + return false + } + + _, hasPausedAt := annotations[h.cfg.Annotations.PausedAt] + _, hasPausePeriod := annotations[h.cfg.Annotations.PausePeriod] + + return hasPausedAt && hasPausePeriod +} diff --git a/internal/pkg/reload/pause_test.go b/internal/pkg/reload/pause_test.go new file mode 100644 index 000000000..1962194d1 --- /dev/null +++ b/internal/pkg/reload/pause_test.go @@ -0,0 +1,328 @@ +package reload + +import ( + "testing" + "time" + + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/stakater/Reloader/internal/pkg/config" + "github.com/stakater/Reloader/internal/pkg/workload" +) + +func TestPauseHandler_ShouldPause(t *testing.T) { + cfg := config.NewDefault() + handler := NewPauseHandler(cfg) + + tests := []struct { + name string + workload workload.Workload + want bool + }{ + { + name: "deployment with pause period", + workload: workload.NewDeploymentWorkload(&appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + cfg.Annotations.PausePeriod: "5m", + }, + }, + }), + want: true, + }, + { + name: "deployment without pause period", + workload: workload.NewDeploymentWorkload(&appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{}, + }), + want: false, + }, + { + name: "daemonset with pause period (ignored)", + workload: workload.NewDaemonSetWorkload(&appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + cfg.Annotations.PausePeriod: "5m", + }, + }, + }), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := handler.ShouldPause(tt.workload) + if got != tt.want { + t.Errorf("ShouldPause() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestPauseHandler_GetPausePeriod(t *testing.T) { + cfg := config.NewDefault() + handler := NewPauseHandler(cfg) + + tests := []struct { + name string + workload workload.Workload + wantPeriod time.Duration + wantErr bool + }{ + { + name: "valid pause period", + workload: workload.NewDeploymentWorkload(&appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + cfg.Annotations.PausePeriod: "5m", + }, + }, + }), + wantPeriod: 5 * time.Minute, + wantErr: false, + }, + { + name: "invalid pause period", + workload: workload.NewDeploymentWorkload(&appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + cfg.Annotations.PausePeriod: "invalid", + }, + }, + }), + wantErr: true, + }, + { + name: "no pause period annotation", + workload: workload.NewDeploymentWorkload(&appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{}, + }), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := handler.GetPausePeriod(tt.workload) + if (err != nil) != tt.wantErr { + t.Errorf("GetPausePeriod() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && got != tt.wantPeriod { + t.Errorf("GetPausePeriod() = %v, want %v", got, tt.wantPeriod) + } + }) + } +} + +func TestPauseHandler_ApplyPause(t *testing.T) { + cfg := config.NewDefault() + handler := NewPauseHandler(cfg) + + deploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-deploy", + }, + Spec: appsv1.DeploymentSpec{ + Paused: false, + }, + } + + wl := workload.NewDeploymentWorkload(deploy) + err := handler.ApplyPause(wl) + if err != nil { + t.Fatalf("ApplyPause() error = %v", err) + } + + if !deploy.Spec.Paused { + t.Error("Expected deployment to be paused") + } + + pausedAt := deploy.Annotations[cfg.Annotations.PausedAt] + if pausedAt == "" { + t.Error("Expected paused-at annotation to be set") + } + + // Verify the timestamp is valid + _, err = time.Parse(time.RFC3339, pausedAt) + if err != nil { + t.Errorf("Invalid paused-at timestamp: %v", err) + } +} + +func TestPauseHandler_CheckPauseExpired(t *testing.T) { + cfg := config.NewDefault() + handler := NewPauseHandler(cfg) + + tests := []struct { + name string + deploy *appsv1.Deployment + wantExpired bool + wantErr bool + }{ + { + name: "pause expired", + deploy: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + cfg.Annotations.PausePeriod: "1ms", + cfg.Annotations.PausedAt: time.Now().Add(-time.Second).UTC().Format(time.RFC3339), + }, + }, + Spec: appsv1.DeploymentSpec{Paused: true}, + }, + wantExpired: true, + wantErr: false, + }, + { + name: "pause not expired", + deploy: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + cfg.Annotations.PausePeriod: "1h", + cfg.Annotations.PausedAt: time.Now().UTC().Format(time.RFC3339), + }, + }, + Spec: appsv1.DeploymentSpec{Paused: true}, + }, + wantExpired: false, + wantErr: false, + }, + { + name: "no paused-at annotation", + deploy: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + cfg.Annotations.PausePeriod: "5m", + }, + }, + }, + wantErr: true, + }, + { + name: "invalid pause period", + deploy: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + cfg.Annotations.PausePeriod: "invalid", + cfg.Annotations.PausedAt: time.Now().UTC().Format(time.RFC3339), + }, + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expired, _, err := handler.CheckPauseExpired(tt.deploy) + if (err != nil) != tt.wantErr { + t.Errorf("CheckPauseExpired() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && expired != tt.wantExpired { + t.Errorf("CheckPauseExpired() expired = %v, want %v", expired, tt.wantExpired) + } + }) + } +} + +func TestPauseHandler_ClearPause(t *testing.T) { + cfg := config.NewDefault() + handler := NewPauseHandler(cfg) + + deploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + cfg.Annotations.PausePeriod: "5m", + cfg.Annotations.PausedAt: time.Now().UTC().Format(time.RFC3339), + }, + }, + Spec: appsv1.DeploymentSpec{ + Paused: true, + }, + } + + handler.ClearPause(deploy) + + if deploy.Spec.Paused { + t.Error("Expected deployment to be unpaused") + } + + if _, exists := deploy.Annotations[cfg.Annotations.PausedAt]; exists { + t.Error("Expected paused-at annotation to be removed") + } + + // Pause period should be preserved (user's config) + if deploy.Annotations[cfg.Annotations.PausePeriod] != "5m" { + t.Error("Expected pause-period annotation to be preserved") + } +} + +func TestPauseHandler_IsPausedByReloader(t *testing.T) { + cfg := config.NewDefault() + handler := NewPauseHandler(cfg) + + tests := []struct { + name string + deploy *appsv1.Deployment + want bool + }{ + { + name: "paused by reloader", + deploy: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + cfg.Annotations.PausePeriod: "5m", + cfg.Annotations.PausedAt: time.Now().UTC().Format(time.RFC3339), + }, + }, + Spec: appsv1.DeploymentSpec{Paused: true}, + }, + want: true, + }, + { + name: "paused but not by reloader (no paused-at)", + deploy: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + cfg.Annotations.PausePeriod: "5m", + }, + }, + Spec: appsv1.DeploymentSpec{Paused: true}, + }, + want: false, + }, + { + name: "not paused", + deploy: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + cfg.Annotations.PausePeriod: "5m", + cfg.Annotations.PausedAt: time.Now().UTC().Format(time.RFC3339), + }, + }, + Spec: appsv1.DeploymentSpec{Paused: false}, + }, + want: false, + }, + { + name: "no annotations", + deploy: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{Paused: true}, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := handler.IsPausedByReloader(tt.deploy) + if got != tt.want { + t.Errorf("IsPausedByReloader() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/pkg/reload/predicate.go b/internal/pkg/reload/predicate.go new file mode 100644 index 000000000..c866364aa --- /dev/null +++ b/internal/pkg/reload/predicate.go @@ -0,0 +1,159 @@ +package reload + +import ( + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + "github.com/stakater/Reloader/internal/pkg/config" +) + +// resourcePredicates returns predicates for filtering resource events. +// The hashFn computes a hash from old and new objects to detect content changes. +func resourcePredicates(cfg *config.Config, hashFn func(old, new client.Object) (string, string, bool)) predicate.Predicate { + return predicate.Funcs{ + CreateFunc: func(e event.CreateEvent) bool { + return cfg.ReloadOnCreate || cfg.SyncAfterRestart + }, + UpdateFunc: func(e event.UpdateEvent) bool { + oldHash, newHash, ok := hashFn(e.ObjectOld, e.ObjectNew) + if !ok { + return false + } + return oldHash != newHash + }, + DeleteFunc: func(e event.DeleteEvent) bool { + return cfg.ReloadOnDelete + }, + GenericFunc: func(e event.GenericEvent) bool { + return false + }, + } +} + +// ConfigMapPredicates returns predicates for filtering ConfigMap events. +func ConfigMapPredicates(cfg *config.Config, hasher *Hasher) predicate.Predicate { + return resourcePredicates( + cfg, func(old, new client.Object) (string, string, bool) { + oldCM, okOld := old.(*corev1.ConfigMap) + newCM, okNew := new.(*corev1.ConfigMap) + if !okOld || !okNew { + return "", "", false + } + return hasher.HashConfigMap(oldCM), hasher.HashConfigMap(newCM), true + }, + ) +} + +// SecretPredicates returns predicates for filtering Secret events. +func SecretPredicates(cfg *config.Config, hasher *Hasher) predicate.Predicate { + return resourcePredicates( + cfg, func(old, new client.Object) (string, string, bool) { + oldSecret, okOld := old.(*corev1.Secret) + newSecret, okNew := new.(*corev1.Secret) + if !okOld || !okNew { + return "", "", false + } + return hasher.HashSecret(oldSecret), hasher.HashSecret(newSecret), true + }, + ) +} + +// NamespaceChecker defines the interface for checking if a namespace is allowed. +type NamespaceChecker interface { + Contains(name string) bool +} + +// NamespaceFilterPredicate returns a predicate that filters resources by namespace. +func NamespaceFilterPredicate(cfg *config.Config) predicate.Predicate { + return NamespaceFilterPredicateWithCache(cfg, nil) +} + +// NamespaceFilterPredicateWithCache returns a predicate that filters resources by namespace, +// using the provided NamespaceChecker for namespace selector filtering. +func NamespaceFilterPredicateWithCache(cfg *config.Config, nsCache NamespaceChecker) predicate.Predicate { + return predicate.NewPredicateFuncs( + func(obj client.Object) bool { + namespace := obj.GetNamespace() + + if cfg.IsNamespaceIgnored(namespace) { + return false + } + + if nsCache != nil && !nsCache.Contains(namespace) { + return false + } + + return true + }, + ) +} + +// LabelSelectorPredicate returns a predicate that filters resources by labels. +func LabelSelectorPredicate(cfg *config.Config) predicate.Predicate { + if len(cfg.ResourceSelectors) == 0 { + return predicate.NewPredicateFuncs( + func(obj client.Object) bool { + return true + }, + ) + } + + return predicate.NewPredicateFuncs( + func(obj client.Object) bool { + labels := obj.GetLabels() + if labels == nil { + labels = make(map[string]string) + } + + for _, selector := range cfg.ResourceSelectors { + if selector.Matches(LabelsSet(labels)) { + return true + } + } + + return false + }, + ) +} + +// LabelsSet implements the k8s.io/apimachinery/pkg/labels.Labels interface +// for a map[string]string. This allows using label maps with label selectors. +type LabelsSet map[string]string + +// Has returns whether the provided label key exists in the set. +func (ls LabelsSet) Has(key string) bool { + _, ok := ls[key] + return ok +} + +// Get returns the value for the provided label key. +func (ls LabelsSet) Get(key string) string { + return ls[key] +} + +// Lookup returns the value for the provided label key and whether it exists. +func (ls LabelsSet) Lookup(key string) (string, bool) { + value, ok := ls[key] + return value, ok +} + +// IgnoreAnnotationPredicate returns a predicate that filters out resources with the ignore annotation. +func IgnoreAnnotationPredicate(cfg *config.Config) predicate.Predicate { + return predicate.NewPredicateFuncs( + func(obj client.Object) bool { + annotations := obj.GetAnnotations() + if annotations == nil { + return true + } + + return annotations[cfg.Annotations.Ignore] != "true" + }, + ) +} + +// CombinedPredicates combines multiple predicates with AND logic. +func CombinedPredicates(predicates ...predicate.Predicate) predicate.Predicate { + return predicate.And(predicates...) +} diff --git a/internal/pkg/reload/predicate_test.go b/internal/pkg/reload/predicate_test.go new file mode 100644 index 000000000..b6d48340c --- /dev/null +++ b/internal/pkg/reload/predicate_test.go @@ -0,0 +1,936 @@ +package reload + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "sigs.k8s.io/controller-runtime/pkg/event" + + "github.com/stakater/Reloader/internal/pkg/config" +) + +func TestNamespaceFilterPredicate_Create(t *testing.T) { + tests := []struct { + name string + ignoredNamespaces []string + eventNamespace string + wantAllow bool + }{ + { + name: "allow non-ignored namespace", + ignoredNamespaces: []string{"kube-system"}, + eventNamespace: "default", + wantAllow: true, + }, + { + name: "block ignored namespace", + ignoredNamespaces: []string{"kube-system"}, + eventNamespace: "kube-system", + wantAllow: false, + }, + { + name: "allow when no namespaces ignored", + ignoredNamespaces: []string{}, + eventNamespace: "kube-system", + wantAllow: true, + }, + { + name: "block multiple ignored namespaces", + ignoredNamespaces: []string{"kube-system", "kube-public", "test-ns"}, + eventNamespace: "test-ns", + wantAllow: false, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + cfg := config.NewDefault() + cfg.IgnoredNamespaces = tt.ignoredNamespaces + predicate := NamespaceFilterPredicate(cfg) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: tt.eventNamespace, + }, + } + + e := event.CreateEvent{Object: cm} + got := predicate.Create(e) + + if got != tt.wantAllow { + t.Errorf("Create() = %v, want %v", got, tt.wantAllow) + } + }, + ) + } +} + +func TestNamespaceFilterPredicate_Update(t *testing.T) { + cfg := config.NewDefault() + cfg.IgnoredNamespaces = []string{"kube-system"} + predicate := NamespaceFilterPredicate(cfg) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + }, + } + + e := event.UpdateEvent{ObjectNew: cm} + if !predicate.Update(e) { + t.Error("Update() should allow non-ignored namespace") + } + + cm.Namespace = "kube-system" + e = event.UpdateEvent{ObjectNew: cm} + if predicate.Update(e) { + t.Error("Update() should block ignored namespace") + } +} + +func TestNamespaceFilterPredicate_Delete(t *testing.T) { + cfg := config.NewDefault() + cfg.IgnoredNamespaces = []string{"kube-system"} + predicate := NamespaceFilterPredicate(cfg) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + }, + } + + e := event.DeleteEvent{Object: cm} + if !predicate.Delete(e) { + t.Error("Delete() should allow non-ignored namespace") + } +} + +func TestNamespaceFilterPredicate_Generic(t *testing.T) { + cfg := config.NewDefault() + cfg.IgnoredNamespaces = []string{"kube-system"} + predicate := NamespaceFilterPredicate(cfg) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + }, + } + + e := event.GenericEvent{Object: cm} + if !predicate.Generic(e) { + t.Error("Generic() should allow non-ignored namespace") + } +} + +func TestLabelSelectorPredicate_Create(t *testing.T) { + tests := []struct { + name string + selector string + objectLabels map[string]string + wantAllow bool + }{ + { + name: "match single label", + selector: "app=reloader", + objectLabels: map[string]string{"app": "reloader"}, + wantAllow: true, + }, + { + name: "no match single label", + selector: "app=reloader", + objectLabels: map[string]string{"app": "other"}, + wantAllow: false, + }, + { + name: "match multiple labels", + selector: "app=reloader,env=prod", + objectLabels: map[string]string{"app": "reloader", "env": "prod", "extra": "value"}, + wantAllow: true, + }, + { + name: "partial match fails", + selector: "app=reloader,env=prod", + objectLabels: map[string]string{"app": "reloader"}, + wantAllow: false, + }, + { + name: "empty labels no match", + selector: "app=reloader", + objectLabels: map[string]string{}, + wantAllow: false, + }, + { + name: "nil labels no match", + selector: "app=reloader", + objectLabels: nil, + wantAllow: false, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + cfg := config.NewDefault() + selector, err := labels.Parse(tt.selector) + if err != nil { + t.Fatalf("Failed to parse selector: %v", err) + } + cfg.ResourceSelectors = []labels.Selector{selector} + predicate := LabelSelectorPredicate(cfg) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + Labels: tt.objectLabels, + }, + } + + e := event.CreateEvent{Object: cm} + got := predicate.Create(e) + + if got != tt.wantAllow { + t.Errorf("Create() = %v, want %v", got, tt.wantAllow) + } + }, + ) + } +} + +func TestLabelSelectorPredicate_NoSelectors(t *testing.T) { + cfg := config.NewDefault() + predicate := LabelSelectorPredicate(cfg) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + Labels: map[string]string{"any": "label"}, + }, + } + + e := event.CreateEvent{Object: cm} + if !predicate.Create(e) { + t.Error("Create() should allow all when no selectors configured") + } +} + +func TestLabelSelectorPredicate_MultipleSelectors(t *testing.T) { + cfg := config.NewDefault() + selector1, _ := labels.Parse("app=reloader") + selector2, _ := labels.Parse("type=config") + cfg.ResourceSelectors = []labels.Selector{selector1, selector2} + predicate := LabelSelectorPredicate(cfg) + + tests := []struct { + name string + labels map[string]string + wantAllow bool + }{ + { + name: "matches first selector", + labels: map[string]string{"app": "reloader"}, + wantAllow: true, + }, + { + name: "matches second selector", + labels: map[string]string{"type": "config"}, + wantAllow: true, + }, + { + name: "matches both selectors", + labels: map[string]string{"app": "reloader", "type": "config"}, + wantAllow: true, + }, + { + name: "matches neither selector", + labels: map[string]string{"other": "value"}, + wantAllow: false, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + Labels: tt.labels, + }, + } + + e := event.CreateEvent{Object: cm} + got := predicate.Create(e) + + if got != tt.wantAllow { + t.Errorf("Create() = %v, want %v", got, tt.wantAllow) + } + }, + ) + } +} + +func TestLabelSelectorPredicate_Update(t *testing.T) { + cfg := config.NewDefault() + selector, _ := labels.Parse("app=reloader") + cfg.ResourceSelectors = []labels.Selector{selector} + predicate := LabelSelectorPredicate(cfg) + + cmMatching := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + Labels: map[string]string{"app": "reloader"}, + }, + } + + cmNotMatching := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + Labels: map[string]string{"app": "other"}, + }, + } + + e := event.UpdateEvent{ObjectNew: cmMatching} + if !predicate.Update(e) { + t.Error("Update() should allow matching labels") + } + + e = event.UpdateEvent{ObjectNew: cmNotMatching} + if predicate.Update(e) { + t.Error("Update() should block non-matching labels") + } +} + +func TestLabelSelectorPredicate_Delete(t *testing.T) { + cfg := config.NewDefault() + selector, _ := labels.Parse("app=reloader") + cfg.ResourceSelectors = []labels.Selector{selector} + predicate := LabelSelectorPredicate(cfg) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + Labels: map[string]string{"app": "reloader"}, + }, + } + + e := event.DeleteEvent{Object: cm} + if !predicate.Delete(e) { + t.Error("Delete() should allow matching labels") + } +} + +func TestLabelSelectorPredicate_Generic(t *testing.T) { + cfg := config.NewDefault() + selector, _ := labels.Parse("app=reloader") + cfg.ResourceSelectors = []labels.Selector{selector} + predicate := LabelSelectorPredicate(cfg) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + Labels: map[string]string{"app": "reloader"}, + }, + } + + e := event.GenericEvent{Object: cm} + if !predicate.Generic(e) { + t.Error("Generic() should allow matching labels") + } +} + +func TestCombinedFiltering(t *testing.T) { + cfg := config.NewDefault() + cfg.IgnoredNamespaces = []string{"kube-system"} + selector, _ := labels.Parse("managed=true") + cfg.ResourceSelectors = []labels.Selector{selector} + + nsPredicate := NamespaceFilterPredicate(cfg) + labelPredicate := LabelSelectorPredicate(cfg) + + tests := []struct { + name string + namespace string + labels map[string]string + wantNSAllow bool + wantLabelAllow bool + }{ + { + name: "allowed namespace and matching labels", + namespace: "default", + labels: map[string]string{"managed": "true"}, + wantNSAllow: true, + wantLabelAllow: true, + }, + { + name: "allowed namespace but non-matching labels", + namespace: "default", + labels: map[string]string{"managed": "false"}, + wantNSAllow: true, + wantLabelAllow: false, + }, + { + name: "ignored namespace with matching labels", + namespace: "kube-system", + labels: map[string]string{"managed": "true"}, + wantNSAllow: false, + wantLabelAllow: true, + }, + { + name: "ignored namespace and non-matching labels", + namespace: "kube-system", + labels: map[string]string{"managed": "false"}, + wantNSAllow: false, + wantLabelAllow: false, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: tt.namespace, + Labels: tt.labels, + }, + } + + e := event.CreateEvent{Object: cm} + + gotNS := nsPredicate.Create(e) + if gotNS != tt.wantNSAllow { + t.Errorf("Namespace predicate Create() = %v, want %v", gotNS, tt.wantNSAllow) + } + + gotLabel := labelPredicate.Create(e) + if gotLabel != tt.wantLabelAllow { + t.Errorf("Label predicate Create() = %v, want %v", gotLabel, tt.wantLabelAllow) + } + + combinedAllow := gotNS && gotLabel + expectedCombined := tt.wantNSAllow && tt.wantLabelAllow + if combinedAllow != expectedCombined { + t.Errorf("Combined allow = %v, want %v", combinedAllow, expectedCombined) + } + }, + ) + } +} + +func TestFilteringWithSecrets(t *testing.T) { + cfg := config.NewDefault() + cfg.IgnoredNamespaces = []string{"kube-system"} + nsPredicate := NamespaceFilterPredicate(cfg) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "default", + }, + } + + e := event.CreateEvent{Object: secret} + if !nsPredicate.Create(e) { + t.Error("Should allow secret in non-ignored namespace") + } + + secret.Namespace = "kube-system" + e = event.CreateEvent{Object: secret} + if nsPredicate.Create(e) { + t.Error("Should block secret in ignored namespace") + } +} + +func TestExistsLabelSelector(t *testing.T) { + cfg := config.NewDefault() + selector, _ := labels.Parse("managed") + cfg.ResourceSelectors = []labels.Selector{selector} + predicate := LabelSelectorPredicate(cfg) + + tests := []struct { + name string + labels map[string]string + wantAllow bool + }{ + { + name: "label exists with value true", + labels: map[string]string{"managed": "true"}, + wantAllow: true, + }, + { + name: "label exists with value false", + labels: map[string]string{"managed": "false"}, + wantAllow: true, + }, + { + name: "label exists with empty value", + labels: map[string]string{"managed": ""}, + wantAllow: true, + }, + { + name: "label does not exist", + labels: map[string]string{"other": "value"}, + wantAllow: false, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + Labels: tt.labels, + }, + } + + e := event.CreateEvent{Object: cm} + got := predicate.Create(e) + + if got != tt.wantAllow { + t.Errorf("Create() = %v, want %v", got, tt.wantAllow) + } + }, + ) + } +} + +// mockNamespaceChecker implements NamespaceChecker for testing. +type mockNamespaceChecker struct { + allowed map[string]bool +} + +func (m *mockNamespaceChecker) Contains(name string) bool { + return m.allowed[name] +} + +func TestNamespaceFilterPredicateWithCache(t *testing.T) { + tests := []struct { + name string + ignoredNamespaces []string + cacheAllowed map[string]bool + eventNamespace string + wantAllow bool + }{ + { + name: "allowed by cache and not ignored", + ignoredNamespaces: []string{"kube-system"}, + cacheAllowed: map[string]bool{"production": true}, + eventNamespace: "production", + wantAllow: true, + }, + { + name: "blocked by cache", + ignoredNamespaces: []string{}, + cacheAllowed: map[string]bool{"production": true}, + eventNamespace: "staging", + wantAllow: false, + }, + { + name: "blocked by ignore list even if in cache", + ignoredNamespaces: []string{"kube-system"}, + cacheAllowed: map[string]bool{"kube-system": true}, + eventNamespace: "kube-system", + wantAllow: false, + }, + { + name: "ignore list checked before cache", + ignoredNamespaces: []string{"blocked-ns"}, + cacheAllowed: map[string]bool{"blocked-ns": true}, + eventNamespace: "blocked-ns", + wantAllow: false, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + cfg := config.NewDefault() + cfg.IgnoredNamespaces = tt.ignoredNamespaces + + cache := &mockNamespaceChecker{allowed: tt.cacheAllowed} + predicate := NamespaceFilterPredicateWithCache(cfg, cache) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: tt.eventNamespace, + }, + } + + e := event.CreateEvent{Object: cm} + got := predicate.Create(e) + + if got != tt.wantAllow { + t.Errorf("Create() = %v, want %v", got, tt.wantAllow) + } + }, + ) + } +} + +func TestNamespaceFilterPredicateWithCache_NilCache(t *testing.T) { + cfg := config.NewDefault() + cfg.IgnoredNamespaces = []string{"kube-system"} + + predicate := NamespaceFilterPredicateWithCache(cfg, nil) + + tests := []struct { + namespace string + wantAllow bool + }{ + {"default", true}, + {"production", true}, + {"kube-system", false}, // Should still respect ignore list + } + + for _, tt := range tests { + t.Run( + tt.namespace, func(t *testing.T) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: tt.namespace, + }, + } + + e := event.CreateEvent{Object: cm} + got := predicate.Create(e) + + if got != tt.wantAllow { + t.Errorf("Create() = %v, want %v for namespace %s", got, tt.wantAllow, tt.namespace) + } + }, + ) + } +} + +func TestIgnoreAnnotationPredicate_Create(t *testing.T) { + cfg := config.NewDefault() + predicate := IgnoreAnnotationPredicate(cfg) + + tests := []struct { + name string + annotations map[string]string + wantAllow bool + }{ + { + name: "no annotations", + annotations: nil, + wantAllow: true, + }, + { + name: "empty annotations", + annotations: map[string]string{}, + wantAllow: true, + }, + { + name: "other annotations only", + annotations: map[string]string{"other": "value"}, + wantAllow: true, + }, + { + name: "ignore annotation true", + annotations: map[string]string{cfg.Annotations.Ignore: "true"}, + wantAllow: false, + }, + { + name: "ignore annotation false", + annotations: map[string]string{cfg.Annotations.Ignore: "false"}, + wantAllow: true, + }, + { + name: "ignore annotation with other value", + annotations: map[string]string{cfg.Annotations.Ignore: "yes"}, + wantAllow: true, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + Annotations: tt.annotations, + }, + } + + e := event.CreateEvent{Object: cm} + got := predicate.Create(e) + + if got != tt.wantAllow { + t.Errorf("Create() = %v, want %v", got, tt.wantAllow) + } + }, + ) + } +} + +func TestIgnoreAnnotationPredicate_AllEventTypes(t *testing.T) { + cfg := config.NewDefault() + predicate := IgnoreAnnotationPredicate(cfg) + + ignoredCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ignored-cm", + Namespace: "default", + Annotations: map[string]string{cfg.Annotations.Ignore: "true"}, + }, + } + + allowedCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "allowed-cm", + Namespace: "default", + }, + } + + if predicate.Update(event.UpdateEvent{ObjectNew: ignoredCM}) { + t.Error("Update() should block ignored resource") + } + if !predicate.Update(event.UpdateEvent{ObjectNew: allowedCM}) { + t.Error("Update() should allow non-ignored resource") + } + + if predicate.Delete(event.DeleteEvent{Object: ignoredCM}) { + t.Error("Delete() should block ignored resource") + } + if !predicate.Delete(event.DeleteEvent{Object: allowedCM}) { + t.Error("Delete() should allow non-ignored resource") + } + + if predicate.Generic(event.GenericEvent{Object: ignoredCM}) { + t.Error("Generic() should block ignored resource") + } + if !predicate.Generic(event.GenericEvent{Object: allowedCM}) { + t.Error("Generic() should allow non-ignored resource") + } +} + +func TestCombinedPredicates(t *testing.T) { + cfg := config.NewDefault() + cfg.IgnoredNamespaces = []string{"kube-system"} + + nsPredicate := NamespaceFilterPredicate(cfg) + ignorePredicate := IgnoreAnnotationPredicate(cfg) + + combined := CombinedPredicates(nsPredicate, ignorePredicate) + + tests := []struct { + name string + namespace string + annotations map[string]string + wantAllow bool + }{ + { + name: "both predicates pass", + namespace: "default", + annotations: nil, + wantAllow: true, + }, + { + name: "namespace predicate fails", + namespace: "kube-system", + annotations: nil, + wantAllow: false, + }, + { + name: "ignore predicate fails", + namespace: "default", + annotations: map[string]string{cfg.Annotations.Ignore: "true"}, + wantAllow: false, + }, + { + name: "both predicates fail", + namespace: "kube-system", + annotations: map[string]string{cfg.Annotations.Ignore: "true"}, + wantAllow: false, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: tt.namespace, + Annotations: tt.annotations, + }, + } + + e := event.CreateEvent{Object: cm} + got := combined.Create(e) + + if got != tt.wantAllow { + t.Errorf("Create() = %v, want %v", got, tt.wantAllow) + } + }, + ) + } +} + +func TestConfigMapPredicates_Update(t *testing.T) { + cfg := config.NewDefault() + hasher := NewHasher() + predicate := ConfigMapPredicates(cfg, hasher) + + oldCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Data: map[string]string{"key": "value1"}, + } + newCMSameContent := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Data: map[string]string{"key": "value1"}, + } + newCMDifferentContent := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Data: map[string]string{"key": "value2"}, + } + + e := event.UpdateEvent{ObjectOld: oldCM, ObjectNew: newCMSameContent} + if predicate.Update(e) { + t.Error("Update() should return false when content is the same") + } + + e = event.UpdateEvent{ObjectOld: oldCM, ObjectNew: newCMDifferentContent} + if !predicate.Update(e) { + t.Error("Update() should return true when content changed") + } +} + +func TestConfigMapPredicates_InvalidTypes(t *testing.T) { + cfg := config.NewDefault() + hasher := NewHasher() + predicate := ConfigMapPredicates(cfg, hasher) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + e := event.UpdateEvent{ObjectOld: secret, ObjectNew: cm} + if predicate.Update(e) { + t.Error("Update() should return false for mismatched types") + } + + e = event.UpdateEvent{ObjectOld: secret, ObjectNew: secret} + if predicate.Update(e) { + t.Error("Update() should return false for non-ConfigMap types") + } +} + +func TestConfigMapPredicates_CreateDeleteGeneric(t *testing.T) { + cfg := config.NewDefault() + cfg.ReloadOnCreate = true + cfg.ReloadOnDelete = true + hasher := NewHasher() + predicate := ConfigMapPredicates(cfg, hasher) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + if !predicate.Create(event.CreateEvent{Object: cm}) { + t.Error("Create() should return true when ReloadOnCreate is true") + } + + if !predicate.Delete(event.DeleteEvent{Object: cm}) { + t.Error("Delete() should return true when ReloadOnDelete is true") + } + + if predicate.Generic(event.GenericEvent{Object: cm}) { + t.Error("Generic() should always return false") + } +} + +func TestSecretPredicates_Update(t *testing.T) { + cfg := config.NewDefault() + hasher := NewHasher() + predicate := SecretPredicates(cfg, hasher) + + oldSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Data: map[string][]byte{"key": []byte("value1")}, + } + newSecretSameContent := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Data: map[string][]byte{"key": []byte("value1")}, + } + newSecretDifferentContent := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Data: map[string][]byte{"key": []byte("value2")}, + } + + e := event.UpdateEvent{ObjectOld: oldSecret, ObjectNew: newSecretSameContent} + if predicate.Update(e) { + t.Error("Update() should return false when content is the same") + } + + e = event.UpdateEvent{ObjectOld: oldSecret, ObjectNew: newSecretDifferentContent} + if !predicate.Update(e) { + t.Error("Update() should return true when content changed") + } +} + +func TestSecretPredicates_InvalidTypes(t *testing.T) { + cfg := config.NewDefault() + hasher := NewHasher() + predicate := SecretPredicates(cfg, hasher) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + e := event.UpdateEvent{ObjectOld: cm, ObjectNew: secret} + if predicate.Update(e) { + t.Error("Update() should return false for mismatched types") + } + + e = event.UpdateEvent{ObjectOld: cm, ObjectNew: cm} + if predicate.Update(e) { + t.Error("Update() should return false for non-Secret types") + } +} + +func TestLabelsSet(t *testing.T) { + ls := LabelsSet{"app": "test", "env": "prod"} + + if !ls.Has("app") { + t.Error("Has(app) should return true") + } + if ls.Has("nonexistent") { + t.Error("Has(nonexistent) should return false") + } + + if ls.Get("app") != "test" { + t.Errorf("Get(app) = %v, want test", ls.Get("app")) + } + if ls.Get("env") != "prod" { + t.Errorf("Get(env) = %v, want prod", ls.Get("env")) + } + if ls.Get("nonexistent") != "" { + t.Errorf("Get(nonexistent) = %v, want empty string", ls.Get("nonexistent")) + } +} diff --git a/internal/pkg/reload/resource_type.go b/internal/pkg/reload/resource_type.go new file mode 100644 index 000000000..0404e815f --- /dev/null +++ b/internal/pkg/reload/resource_type.go @@ -0,0 +1,23 @@ +package reload + +// ResourceType represents the type of Kubernetes resource. +type ResourceType string + +const ( + // ResourceTypeConfigMap represents a ConfigMap resource. + ResourceTypeConfigMap ResourceType = "configmap" + // ResourceTypeSecret represents a Secret resource. + ResourceTypeSecret ResourceType = "secret" +) + +// Kind returns the capitalized Kubernetes Kind (e.g., "ConfigMap", "Secret"). +func (r ResourceType) Kind() string { + switch r { + case ResourceTypeConfigMap: + return "ConfigMap" + case ResourceTypeSecret: + return "Secret" + default: + return string(r) + } +} diff --git a/internal/pkg/reload/resource_type_test.go b/internal/pkg/reload/resource_type_test.go new file mode 100644 index 000000000..e577e82b6 --- /dev/null +++ b/internal/pkg/reload/resource_type_test.go @@ -0,0 +1,28 @@ +package reload + +import ( + "testing" +) + +func TestResourceType_Kind(t *testing.T) { + tests := []struct { + resourceType ResourceType + want string + }{ + {ResourceTypeConfigMap, "ConfigMap"}, + {ResourceTypeSecret, "Secret"}, + {ResourceType("unknown"), "unknown"}, + {ResourceType("custom"), "custom"}, + } + + for _, tt := range tests { + t.Run( + string(tt.resourceType), func(t *testing.T) { + got := tt.resourceType.Kind() + if got != tt.want { + t.Errorf("ResourceType(%q).Kind() = %v, want %v", tt.resourceType, got, tt.want) + } + }, + ) + } +} diff --git a/internal/pkg/reload/service.go b/internal/pkg/reload/service.go new file mode 100644 index 000000000..076cf786e --- /dev/null +++ b/internal/pkg/reload/service.go @@ -0,0 +1,320 @@ +package reload + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + + "github.com/stakater/Reloader/internal/pkg/config" + "github.com/stakater/Reloader/internal/pkg/workload" +) + +// Service orchestrates the reload logic for ConfigMaps and Secrets. +type Service struct { + cfg *config.Config + log logr.Logger + hasher *Hasher + matcher *Matcher + strategy Strategy +} + +// NewService creates a new reload Service with the given configuration. +func NewService(cfg *config.Config, log logr.Logger) *Service { + return &Service{ + cfg: cfg, + log: log, + hasher: NewHasher(), + matcher: NewMatcher(cfg), + strategy: NewStrategy(cfg), + } +} + +// Process evaluates all workloads to determine which should be reloaded. +func (s *Service) Process(change ResourceChange, workloads []workload.Workload) []ReloadDecision { + if change.IsNil() { + return nil + } + + if !s.shouldProcessEvent(change.GetEventType()) { + return nil + } + + hash := change.ComputeHash(s.hasher) + if change.GetEventType() == EventTypeDelete { + hash = s.hasher.EmptyHash() + } + + return s.processResource( + change.GetName(), + change.GetNamespace(), + change.GetAnnotations(), + change.GetResourceType(), + hash, + workloads, + ) +} + +func (s *Service) processResource( + resourceName string, + resourceNamespace string, + resourceAnnotations map[string]string, + resourceType ResourceType, + hash string, + workloads []workload.Workload, +) []ReloadDecision { + var decisions []ReloadDecision + + for _, wl := range workloads { + if wl.GetNamespace() != resourceNamespace { + continue + } + + if s.cfg.IsWorkloadIgnored(string(wl.Kind())) { + continue + } + + var usesResource bool + switch resourceType { + case ResourceTypeConfigMap: + usesResource = wl.UsesConfigMap(resourceName) + case ResourceTypeSecret: + usesResource = wl.UsesSecret(resourceName) + } + + input := MatchInput{ + ResourceName: resourceName, + ResourceNamespace: resourceNamespace, + ResourceType: resourceType, + ResourceAnnotations: resourceAnnotations, + WorkloadAnnotations: wl.GetAnnotations(), + PodAnnotations: wl.GetPodTemplateAnnotations(), + } + + matchResult := s.matcher.ShouldReload(input) + + shouldReload := matchResult.ShouldReload + if matchResult.AutoReload && !usesResource { + shouldReload = false + } + + decisions = append( + decisions, ReloadDecision{ + Workload: wl, + ShouldReload: shouldReload, + AutoReload: matchResult.AutoReload, + Reason: matchResult.Reason, + Hash: hash, + }, + ) + } + + return decisions +} + +func (s *Service) shouldProcessEvent(eventType EventType) bool { + switch eventType { + case EventTypeCreate: + return s.cfg.ReloadOnCreate + case EventTypeDelete: + return s.cfg.ReloadOnDelete + case EventTypeUpdate: + return true + default: + return false + } +} + +// ApplyReload applies the reload strategy to a workload. +func (s *Service) ApplyReload( + ctx context.Context, + wl workload.Workload, + resourceName string, + resourceType ResourceType, + namespace string, + hash string, + autoReload bool, +) (bool, error) { + container := s.findTargetContainer(wl, resourceName, resourceType, autoReload) + + input := StrategyInput{ + ResourceName: resourceName, + ResourceType: resourceType, + Namespace: namespace, + Hash: hash, + Container: container, + PodAnnotations: wl.GetPodTemplateAnnotations(), + AutoReload: autoReload, + } + + updated, err := s.strategy.Apply(input) + if err != nil { + return false, err + } + + if updated { + // Attribution annotation is informational; log errors but don't fail reloads + if err := s.setAttributionAnnotation(wl, resourceName, resourceType, namespace, hash, container); err != nil { + s.log.V(1).Info("failed to set attribution annotation", "error", err, "workload", wl.GetName()) + } + } + + return updated, nil +} + +func (s *Service) setAttributionAnnotation( + wl workload.Workload, + resourceName string, + resourceType ResourceType, + namespace string, + hash string, + container *corev1.Container, +) error { + containerName := "" + if container != nil { + containerName = container.Name + } + + source := ReloadSource{ + Kind: string(resourceType), + Name: resourceName, + Namespace: namespace, + Hash: hash, + Containers: []string{containerName}, + ReloadedAt: time.Now().UTC(), + } + + sourceJSON, err := json.Marshal(source) + if err != nil { + return fmt.Errorf("failed to marshal reload source: %w", err) + } + + wl.SetPodTemplateAnnotation(s.cfg.Annotations.LastReloadedFrom, string(sourceJSON)) + return nil +} + +func (s *Service) findTargetContainer( + wl workload.Workload, + resourceName string, + resourceType ResourceType, + autoReload bool, +) *corev1.Container { + containers := wl.GetContainers() + if len(containers) == 0 { + return nil + } + + if !autoReload { + return &containers[0] + } + + volumes := wl.GetVolumes() + initContainers := wl.GetInitContainers() + + volumeName := s.findVolumeUsingResource(volumes, resourceName, resourceType) + if volumeName != "" { + container := s.findContainerWithVolumeMount(containers, volumeName) + if container != nil { + return container + } + container = s.findContainerWithVolumeMount(initContainers, volumeName) + if container != nil { + return &containers[0] + } + } + + container := s.findContainerWithEnvRef(containers, resourceName, resourceType) + if container != nil { + return container + } + + container = s.findContainerWithEnvRef(initContainers, resourceName, resourceType) + if container != nil { + return &containers[0] + } + + return &containers[0] +} + +func (s *Service) findVolumeUsingResource(volumes []corev1.Volume, resourceName string, resourceType ResourceType) string { + for _, vol := range volumes { + switch resourceType { + case ResourceTypeConfigMap: + if vol.ConfigMap != nil && vol.ConfigMap.Name == resourceName { + return vol.Name + } + if vol.Projected != nil { + for _, src := range vol.Projected.Sources { + if src.ConfigMap != nil && src.ConfigMap.Name == resourceName { + return vol.Name + } + } + } + case ResourceTypeSecret: + if vol.Secret != nil && vol.Secret.SecretName == resourceName { + return vol.Name + } + if vol.Projected != nil { + for _, src := range vol.Projected.Sources { + if src.Secret != nil && src.Secret.Name == resourceName { + return vol.Name + } + } + } + } + } + return "" +} + +func (s *Service) findContainerWithVolumeMount(containers []corev1.Container, volumeName string) *corev1.Container { + for i := range containers { + for _, mount := range containers[i].VolumeMounts { + if mount.Name == volumeName { + return &containers[i] + } + } + } + return nil +} + +func (s *Service) findContainerWithEnvRef(containers []corev1.Container, resourceName string, resourceType ResourceType) *corev1.Container { + for i := range containers { + for _, env := range containers[i].Env { + if env.ValueFrom == nil { + continue + } + switch resourceType { + case ResourceTypeConfigMap: + if env.ValueFrom.ConfigMapKeyRef != nil && env.ValueFrom.ConfigMapKeyRef.Name == resourceName { + return &containers[i] + } + case ResourceTypeSecret: + if env.ValueFrom.SecretKeyRef != nil && env.ValueFrom.SecretKeyRef.Name == resourceName { + return &containers[i] + } + } + } + + for _, envFrom := range containers[i].EnvFrom { + switch resourceType { + case ResourceTypeConfigMap: + if envFrom.ConfigMapRef != nil && envFrom.ConfigMapRef.Name == resourceName { + return &containers[i] + } + case ResourceTypeSecret: + if envFrom.SecretRef != nil && envFrom.SecretRef.Name == resourceName { + return &containers[i] + } + } + } + } + return nil +} + +// Hasher returns the hasher used by this service. +func (s *Service) Hasher() *Hasher { + return s.hasher +} diff --git a/internal/pkg/reload/service_test.go b/internal/pkg/reload/service_test.go new file mode 100644 index 000000000..24356b1e6 --- /dev/null +++ b/internal/pkg/reload/service_test.go @@ -0,0 +1,1361 @@ +package reload + +import ( + "context" + "testing" + + "github.com/go-logr/logr/testr" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/stakater/Reloader/internal/pkg/config" + "github.com/stakater/Reloader/internal/pkg/testutil" + "github.com/stakater/Reloader/internal/pkg/workload" +) + +func TestService_ProcessConfigMap_AutoReload(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg, testr.New(t)) + + // Create a deployment with auto annotation that uses the configmap + deploy := testutil.NewDeployment( + "test-deploy", "default", map[string]string{ + "reloader.stakater.com/auto": "true", + }, + ) + deploy.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "test-cm", + }, + }, + }, + }, + } + + workloads := []workload.Workload{ + workload.NewDeploymentWorkload(deploy), + } + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + }, + Data: map[string]string{ + "key": "value", + }, + } + + change := ConfigMapChange{ + ConfigMap: cm, + EventType: EventTypeUpdate, + } + + decisions := svc.Process(change, workloads) + + if len(decisions) != 1 { + t.Fatalf("Expected 1 decision, got %d", len(decisions)) + } + + if !decisions[0].ShouldReload { + t.Error("Expected ShouldReload to be true") + } + + if !decisions[0].AutoReload { + t.Error("Expected AutoReload to be true") + } + + if decisions[0].Hash == "" { + t.Error("Expected Hash to be non-empty") + } +} + +func TestService_ProcessConfigMap_ExplicitAnnotation(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg, testr.New(t)) + + deploy := testutil.NewDeployment( + "test-deploy", "default", map[string]string{ + "configmap.reloader.stakater.com/reload": "test-cm", + }, + ) + + workloads := []workload.Workload{ + workload.NewDeploymentWorkload(deploy), + } + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + }, + Data: map[string]string{ + "key": "value", + }, + } + + change := ConfigMapChange{ + ConfigMap: cm, + EventType: EventTypeUpdate, + } + + decisions := svc.Process(change, workloads) + + if len(decisions) != 1 { + t.Fatalf("Expected 1 decision, got %d", len(decisions)) + } + + if !decisions[0].ShouldReload { + t.Error("Expected ShouldReload to be true for explicit annotation") + } + + if decisions[0].AutoReload { + t.Error("Expected AutoReload to be false for explicit annotation") + } +} + +func TestService_ProcessConfigMap_IgnoredResource(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg, testr.New(t)) + + // Create a deployment with auto annotation + deploy := testutil.NewDeployment( + "test-deploy", "default", map[string]string{ + "reloader.stakater.com/auto": "true", + }, + ) + deploy.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "test-cm", + }, + }, + }, + }, + } + + workloads := []workload.Workload{ + workload.NewDeploymentWorkload(deploy), + } + + // ConfigMap with ignore annotation + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + Annotations: map[string]string{ + "reloader.stakater.com/ignore": "true", + }, + }, + Data: map[string]string{ + "key": "value", + }, + } + + change := ConfigMapChange{ + ConfigMap: cm, + EventType: EventTypeUpdate, + } + + decisions := svc.Process(change, workloads) + + // Should still get a decision, but ShouldReload should be false + for _, d := range decisions { + if d.ShouldReload { + t.Error("Expected ShouldReload to be false for ignored resource") + } + } +} + +func TestService_ProcessSecret_AutoReload(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg, testr.New(t)) + + // Create a deployment with auto annotation that uses the secret + deploy := testutil.NewDeployment( + "test-deploy", "default", map[string]string{ + "reloader.stakater.com/auto": "true", + }, + ) + deploy.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "secret-vol", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: "test-secret", + }, + }, + }, + } + + workloads := []workload.Workload{ + workload.NewDeploymentWorkload(deploy), + } + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "key": []byte("value"), + }, + } + + change := SecretChange{ + Secret: secret, + EventType: EventTypeUpdate, + } + + decisions := svc.Process(change, workloads) + + if len(decisions) != 1 { + t.Fatalf("Expected 1 decision, got %d", len(decisions)) + } + + if !decisions[0].ShouldReload { + t.Error("Expected ShouldReload to be true") + } + + if !decisions[0].AutoReload { + t.Error("Expected AutoReload to be true") + } +} + +func TestService_ProcessConfigMap_DeleteEvent(t *testing.T) { + cfg := config.NewDefault() + cfg.ReloadOnDelete = true + svc := NewService(cfg, testr.New(t)) + + // Create a deployment with explicit configmap annotation + deploy := testutil.NewDeployment( + "test-deploy", "default", map[string]string{ + "configmap.reloader.stakater.com/reload": "test-cm", + }, + ) + + workloads := []workload.Workload{ + workload.NewDeploymentWorkload(deploy), + } + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + }, + } + + change := ConfigMapChange{ + ConfigMap: cm, + EventType: EventTypeDelete, + } + + decisions := svc.Process(change, workloads) + + if len(decisions) != 1 { + t.Fatalf("Expected 1 decision, got %d", len(decisions)) + } + + if !decisions[0].ShouldReload { + t.Error("Expected ShouldReload to be true for delete event") + } + + // Hash should be empty for delete events + if decisions[0].Hash != "" { + t.Errorf("Expected empty hash for delete event, got %s", decisions[0].Hash) + } +} + +func TestService_ProcessConfigMap_DeleteEventDisabled(t *testing.T) { + cfg := config.NewDefault() + cfg.ReloadOnDelete = false // Disabled by default + svc := NewService(cfg, testr.New(t)) + + deploy := testutil.NewDeployment( + "test-deploy", "default", map[string]string{ + "configmap.reloader.stakater.com/reload": "test-cm", + }, + ) + + workloads := []workload.Workload{ + workload.NewDeploymentWorkload(deploy), + } + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + }, + } + + change := ConfigMapChange{ + ConfigMap: cm, + EventType: EventTypeDelete, + } + + decisions := svc.Process(change, workloads) + + // Should return nil when delete events are disabled + if decisions != nil { + t.Error("Expected nil decisions when delete events are disabled") + } +} + +func TestService_ApplyReload_EnvVarStrategy(t *testing.T) { + cfg := config.NewDefault() + cfg.ReloadStrategy = config.ReloadStrategyEnvVars + svc := NewService(cfg, testr.New(t)) + + deploy := testutil.NewDeployment("test-deploy", "default", nil) + accessor := workload.NewDeploymentWorkload(deploy) + + ctx := context.Background() + updated, err := svc.ApplyReload(ctx, accessor, "test-cm", ResourceTypeConfigMap, "default", "abc123hash", false) + + if err != nil { + t.Fatalf("ApplyReload failed: %v", err) + } + + if !updated { + t.Error("Expected updated to be true") + } + + // Verify env var was added + containers := accessor.GetContainers() + if len(containers) == 0 { + t.Fatal("No containers found") + } + + found := false + for _, env := range containers[0].Env { + if env.Name == "STAKATER_TEST_CM_CONFIGMAP" && env.Value == "abc123hash" { + found = true + break + } + } + + if !found { + t.Error("Expected env var STAKATER_TEST_CM_CONFIGMAP to be set") + } + + // Verify attribution annotation was set + annotations := accessor.GetPodTemplateAnnotations() + if annotations["reloader.stakater.com/last-reloaded-from"] == "" { + t.Error("Expected last-reloaded-from annotation to be set") + } +} + +func TestService_ApplyReload_AnnotationStrategy(t *testing.T) { + cfg := config.NewDefault() + cfg.ReloadStrategy = config.ReloadStrategyAnnotations + svc := NewService(cfg, testr.New(t)) + + deploy := testutil.NewDeployment("test-deploy", "default", nil) + accessor := workload.NewDeploymentWorkload(deploy) + + ctx := context.Background() + updated, err := svc.ApplyReload(ctx, accessor, "test-cm", ResourceTypeConfigMap, "default", "abc123hash", false) + + if err != nil { + t.Fatalf("ApplyReload failed: %v", err) + } + + if !updated { + t.Error("Expected updated to be true") + } + + // Verify annotation was added + annotations := accessor.GetPodTemplateAnnotations() + if annotations["reloader.stakater.com/last-reloaded-from"] == "" { + t.Error("Expected last-reloaded-from annotation to be set") + } +} + +func TestService_ApplyReload_EnvVarDeletion(t *testing.T) { + cfg := config.NewDefault() + cfg.ReloadStrategy = config.ReloadStrategyEnvVars + svc := NewService(cfg, testr.New(t)) + + deploy := testutil.NewDeployment("test-deploy", "default", nil) + // Pre-add an env var + deploy.Spec.Template.Spec.Containers[0].Env = []corev1.EnvVar{ + {Name: "STAKATER_TEST_CM_CONFIGMAP", Value: "oldhash"}, + {Name: "OTHER_VAR", Value: "keep"}, + } + accessor := workload.NewDeploymentWorkload(deploy) + + ctx := context.Background() + // Empty hash signals deletion + updated, err := svc.ApplyReload(ctx, accessor, "test-cm", ResourceTypeConfigMap, "default", "", false) + + if err != nil { + t.Fatalf("ApplyReload failed: %v", err) + } + + if !updated { + t.Error("Expected updated to be true for env var removal") + } + + // Verify env var was removed + containers := accessor.GetContainers() + for _, env := range containers[0].Env { + if env.Name == "STAKATER_TEST_CM_CONFIGMAP" { + t.Error("Expected env var STAKATER_TEST_CM_CONFIGMAP to be removed") + } + } + + // Verify other env var was kept + found := false + for _, env := range containers[0].Env { + if env.Name == "OTHER_VAR" { + found = true + break + } + } + if !found { + t.Error("Expected OTHER_VAR to be kept") + } +} + +func TestService_ApplyReload_NoChangeIfSameHash(t *testing.T) { + cfg := config.NewDefault() + cfg.ReloadStrategy = config.ReloadStrategyEnvVars + svc := NewService(cfg, testr.New(t)) + + deploy := testutil.NewDeployment("test-deploy", "default", nil) + // Pre-add env var with same hash + deploy.Spec.Template.Spec.Containers[0].Env = []corev1.EnvVar{ + {Name: "STAKATER_TEST_CM_CONFIGMAP", Value: "abc123hash"}, + } + accessor := workload.NewDeploymentWorkload(deploy) + + ctx := context.Background() + updated, err := svc.ApplyReload(ctx, accessor, "test-cm", ResourceTypeConfigMap, "default", "abc123hash", false) + + if err != nil { + t.Fatalf("ApplyReload failed: %v", err) + } + + if updated { + t.Error("Expected updated to be false when hash is unchanged") + } +} + +func TestService_ProcessConfigMap_MultipleWorkloads(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg, testr.New(t)) + + // Create multiple workloads + deploy1 := testutil.NewDeployment( + "deploy1", "default", map[string]string{ + "reloader.stakater.com/auto": "true", + }, + ) + deploy1.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "shared-cm", + }, + }, + }, + }, + } + + deploy2 := testutil.NewDeployment( + "deploy2", "default", map[string]string{ + "reloader.stakater.com/auto": "true", + }, + ) + deploy2.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "shared-cm", + }, + }, + }, + }, + } + + // Deploy3 doesn't use the configmap + deploy3 := testutil.NewDeployment( + "deploy3", "default", map[string]string{ + "reloader.stakater.com/auto": "true", + }, + ) + + workloads := []workload.Workload{ + workload.NewDeploymentWorkload(deploy1), + workload.NewDeploymentWorkload(deploy2), + workload.NewDeploymentWorkload(deploy3), + } + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "shared-cm", + Namespace: "default", + }, + Data: map[string]string{"key": "value"}, + } + + change := ConfigMapChange{ + ConfigMap: cm, + EventType: EventTypeUpdate, + } + + decisions := svc.Process(change, workloads) + + if len(decisions) != 3 { + t.Fatalf("Expected 3 decisions, got %d", len(decisions)) + } + + // Count how many should reload + reloadCount := 0 + for _, d := range decisions { + if d.ShouldReload { + reloadCount++ + } + } + + // Only deploy1 and deploy2 should reload (they use the configmap) + if reloadCount != 2 { + t.Errorf("Expected 2 workloads to reload, got %d", reloadCount) + } +} + +func TestService_ProcessConfigMap_DifferentNamespaces(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg, testr.New(t)) + + // Create deployments in different namespaces + deploy1 := testutil.NewDeployment( + "deploy1", "namespace-a", map[string]string{ + "reloader.stakater.com/auto": "true", + }, + ) + deploy1.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "test-cm", + }, + }, + }, + }, + } + + deploy2 := testutil.NewDeployment( + "deploy2", "namespace-b", map[string]string{ + "reloader.stakater.com/auto": "true", + }, + ) + deploy2.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "test-cm", + }, + }, + }, + }, + } + + workloads := []workload.Workload{ + workload.NewDeploymentWorkload(deploy1), + workload.NewDeploymentWorkload(deploy2), + } + + // ConfigMap in namespace-a + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "namespace-a", + }, + Data: map[string]string{"key": "value"}, + } + + change := ConfigMapChange{ + ConfigMap: cm, + EventType: EventTypeUpdate, + } + + decisions := svc.Process(change, workloads) + + // Should only affect deploy1 (same namespace) + reloadCount := 0 + for _, d := range decisions { + if d.ShouldReload { + reloadCount++ + } + } + + if reloadCount != 1 { + t.Errorf("Expected 1 workload to reload (same namespace), got %d", reloadCount) + } +} + +func TestService_Hasher(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg, testr.New(t)) + + hasher := svc.Hasher() + if hasher == nil { + t.Fatal("Expected Hasher to return non-nil hasher") + } + + // Verify it's functional + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Data: map[string]string{"key": "value"}, + } + hash := hasher.HashConfigMap(cm) + if hash == "" { + t.Error("Expected hasher to produce non-empty hash") + } +} + +func TestService_shouldProcessEvent(t *testing.T) { + tests := []struct { + name string + reloadOnCreate bool + reloadOnDelete bool + eventType EventType + expected bool + }{ + {"create enabled", true, false, EventTypeCreate, true}, + {"create disabled", false, false, EventTypeCreate, false}, + {"delete enabled", false, true, EventTypeDelete, true}, + {"delete disabled", false, false, EventTypeDelete, false}, + {"update always true", false, false, EventTypeUpdate, true}, + {"unknown event", false, false, EventType("unknown"), false}, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + cfg := config.NewDefault() + cfg.ReloadOnCreate = tt.reloadOnCreate + cfg.ReloadOnDelete = tt.reloadOnDelete + svc := NewService(cfg, testr.New(t)) + + result := svc.shouldProcessEvent(tt.eventType) + if result != tt.expected { + t.Errorf("shouldProcessEvent(%s) = %v, want %v", tt.eventType, result, tt.expected) + } + }, + ) + } +} + +func TestService_findVolumeUsingResource_ConfigMap(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg, testr.New(t)) + + tests := []struct { + name string + volumes []corev1.Volume + resourceName string + resourceType ResourceType + wantVolume string + }{ + { + name: "direct configmap volume", + volumes: []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-cm"}, + }, + }, + }, + }, + resourceName: "my-cm", + resourceType: ResourceTypeConfigMap, + wantVolume: "config-vol", + }, + { + name: "projected configmap volume", + volumes: []corev1.Volume{ + { + Name: "projected-vol", + VolumeSource: corev1.VolumeSource{ + Projected: &corev1.ProjectedVolumeSource{ + Sources: []corev1.VolumeProjection{ + { + ConfigMap: &corev1.ConfigMapProjection{ + LocalObjectReference: corev1.LocalObjectReference{Name: "projected-cm"}, + }, + }, + }, + }, + }, + }, + }, + resourceName: "projected-cm", + resourceType: ResourceTypeConfigMap, + wantVolume: "projected-vol", + }, + { + name: "no match", + volumes: []corev1.Volume{ + { + Name: "other-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "other-cm"}, + }, + }, + }, + }, + resourceName: "my-cm", + resourceType: ResourceTypeConfigMap, + wantVolume: "", + }, + { + name: "empty volumes", + volumes: []corev1.Volume{}, + resourceName: "my-cm", + resourceType: ResourceTypeConfigMap, + wantVolume: "", + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + got := svc.findVolumeUsingResource(tt.volumes, tt.resourceName, tt.resourceType) + if got != tt.wantVolume { + t.Errorf("findVolumeUsingResource() = %q, want %q", got, tt.wantVolume) + } + }, + ) + } +} + +func TestService_findVolumeUsingResource_Secret(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg, testr.New(t)) + + tests := []struct { + name string + volumes []corev1.Volume + resourceName string + wantVolume string + }{ + { + name: "direct secret volume", + volumes: []corev1.Volume{ + { + Name: "secret-vol", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: "my-secret", + }, + }, + }, + }, + resourceName: "my-secret", + wantVolume: "secret-vol", + }, + { + name: "projected secret volume", + volumes: []corev1.Volume{ + { + Name: "projected-vol", + VolumeSource: corev1.VolumeSource{ + Projected: &corev1.ProjectedVolumeSource{ + Sources: []corev1.VolumeProjection{ + { + Secret: &corev1.SecretProjection{ + LocalObjectReference: corev1.LocalObjectReference{Name: "projected-secret"}, + }, + }, + }, + }, + }, + }, + }, + resourceName: "projected-secret", + wantVolume: "projected-vol", + }, + { + name: "no match", + volumes: []corev1.Volume{ + { + Name: "other-vol", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: "other-secret", + }, + }, + }, + }, + resourceName: "my-secret", + wantVolume: "", + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + got := svc.findVolumeUsingResource(tt.volumes, tt.resourceName, ResourceTypeSecret) + if got != tt.wantVolume { + t.Errorf("findVolumeUsingResource() = %q, want %q", got, tt.wantVolume) + } + }, + ) + } +} + +func TestService_findContainerWithVolumeMount(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg, testr.New(t)) + + tests := []struct { + name string + containers []corev1.Container + volumeName string + wantName string + shouldMatch bool + }{ + { + name: "container with matching volume mount", + containers: []corev1.Container{ + { + Name: "container1", + VolumeMounts: []corev1.VolumeMount{ + {Name: "config-vol", MountPath: "/config"}, + }, + }, + }, + volumeName: "config-vol", + wantName: "container1", + shouldMatch: true, + }, + { + name: "second container with matching mount", + containers: []corev1.Container{ + { + Name: "container1", + VolumeMounts: []corev1.VolumeMount{}, + }, + { + Name: "container2", + VolumeMounts: []corev1.VolumeMount{ + {Name: "config-vol", MountPath: "/config"}, + }, + }, + }, + volumeName: "config-vol", + wantName: "container2", + shouldMatch: true, + }, + { + name: "no matching mount", + containers: []corev1.Container{ + { + Name: "container1", + VolumeMounts: []corev1.VolumeMount{ + {Name: "other-vol", MountPath: "/other"}, + }, + }, + }, + volumeName: "config-vol", + shouldMatch: false, + }, + { + name: "empty containers", + containers: []corev1.Container{}, + volumeName: "config-vol", + shouldMatch: false, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + got := svc.findContainerWithVolumeMount(tt.containers, tt.volumeName) + if tt.shouldMatch { + if got == nil { + t.Error("Expected to find a container, got nil") + } else if got.Name != tt.wantName { + t.Errorf("findContainerWithVolumeMount() container name = %q, want %q", got.Name, tt.wantName) + } + } else { + if got != nil { + t.Errorf("Expected nil, got container %q", got.Name) + } + } + }, + ) + } +} + +func TestService_findContainerWithEnvRef_ConfigMap(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg, testr.New(t)) + + tests := []struct { + name string + containers []corev1.Container + resourceName string + wantName string + shouldMatch bool + }{ + { + name: "container with ConfigMapKeyRef", + containers: []corev1.Container{ + { + Name: "app", + Env: []corev1.EnvVar{ + { + Name: "CONFIG_VALUE", + ValueFrom: &corev1.EnvVarSource{ + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-cm"}, + Key: "key", + }, + }, + }, + }, + }, + }, + resourceName: "my-cm", + wantName: "app", + shouldMatch: true, + }, + { + name: "container with ConfigMapRef in EnvFrom", + containers: []corev1.Container{ + { + Name: "app", + EnvFrom: []corev1.EnvFromSource{ + { + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-cm"}, + }, + }, + }, + }, + }, + resourceName: "my-cm", + wantName: "app", + shouldMatch: true, + }, + { + name: "no matching env ref", + containers: []corev1.Container{ + { + Name: "app", + Env: []corev1.EnvVar{ + { + Name: "SIMPLE_VAR", + Value: "value", + }, + }, + }, + }, + resourceName: "my-cm", + shouldMatch: false, + }, + { + name: "env without ValueFrom", + containers: []corev1.Container{ + { + Name: "app", + Env: []corev1.EnvVar{ + {Name: "VAR1", Value: "val"}, + }, + }, + }, + resourceName: "my-cm", + shouldMatch: false, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + got := svc.findContainerWithEnvRef(tt.containers, tt.resourceName, ResourceTypeConfigMap) + if tt.shouldMatch { + if got == nil { + t.Error("Expected to find a container, got nil") + } else if got.Name != tt.wantName { + t.Errorf("findContainerWithEnvRef() container name = %q, want %q", got.Name, tt.wantName) + } + } else { + if got != nil { + t.Errorf("Expected nil, got container %q", got.Name) + } + } + }, + ) + } +} + +func TestService_findContainerWithEnvRef_Secret(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg, testr.New(t)) + + tests := []struct { + name string + containers []corev1.Container + resourceName string + wantName string + shouldMatch bool + }{ + { + name: "container with SecretKeyRef", + containers: []corev1.Container{ + { + Name: "app", + Env: []corev1.EnvVar{ + { + Name: "SECRET_VALUE", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-secret"}, + Key: "password", + }, + }, + }, + }, + }, + }, + resourceName: "my-secret", + wantName: "app", + shouldMatch: true, + }, + { + name: "container with SecretRef in EnvFrom", + containers: []corev1.Container{ + { + Name: "app", + EnvFrom: []corev1.EnvFromSource{ + { + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-secret"}, + }, + }, + }, + }, + }, + resourceName: "my-secret", + wantName: "app", + shouldMatch: true, + }, + { + name: "no matching env ref", + containers: []corev1.Container{ + { + Name: "app", + Env: []corev1.EnvVar{ + { + Name: "SIMPLE_VAR", + Value: "value", + }, + }, + }, + }, + resourceName: "my-secret", + shouldMatch: false, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + got := svc.findContainerWithEnvRef(tt.containers, tt.resourceName, ResourceTypeSecret) + if tt.shouldMatch { + if got == nil { + t.Error("Expected to find a container, got nil") + } else if got.Name != tt.wantName { + t.Errorf("findContainerWithEnvRef() container name = %q, want %q", got.Name, tt.wantName) + } + } else { + if got != nil { + t.Errorf("Expected nil, got container %q", got.Name) + } + } + }, + ) + } +} + +func TestService_findTargetContainer_AutoReload(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg, testr.New(t)) + + // Test with autoReload=true and volume mount + deploy := testutil.NewDeployment("test", "default", nil) + deploy.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-cm"}, + }, + }, + }, + } + deploy.Spec.Template.Spec.Containers = []corev1.Container{ + { + Name: "app", + Image: "nginx", + VolumeMounts: []corev1.VolumeMount{ + {Name: "config-vol", MountPath: "/config"}, + }, + }, + } + accessor := workload.NewDeploymentWorkload(deploy) + + container := svc.findTargetContainer(accessor, "my-cm", ResourceTypeConfigMap, true) + if container == nil { + t.Fatal("Expected to find a container") + } + if container.Name != "app" { + t.Errorf("Expected container 'app', got %q", container.Name) + } +} + +func TestService_findTargetContainer_AutoReload_EnvRef(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg, testr.New(t)) + + // Test with autoReload=true and env ref (no volume) + deploy := testutil.NewDeployment("test", "default", nil) + deploy.Spec.Template.Spec.Containers = []corev1.Container{ + { + Name: "sidecar", + Image: "busybox", + }, + { + Name: "app", + Image: "nginx", + Env: []corev1.EnvVar{ + { + Name: "CONFIG_VAL", + ValueFrom: &corev1.EnvVarSource{ + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-cm"}, + Key: "key", + }, + }, + }, + }, + }, + } + accessor := workload.NewDeploymentWorkload(deploy) + + container := svc.findTargetContainer(accessor, "my-cm", ResourceTypeConfigMap, true) + if container == nil { + t.Fatal("Expected to find a container") + } + if container.Name != "app" { + t.Errorf("Expected container 'app', got %q", container.Name) + } +} + +func TestService_findTargetContainer_AutoReload_InitContainer(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg, testr.New(t)) + + // Test with autoReload=true where init container uses the volume + deploy := testutil.NewDeployment("test", "default", nil) + deploy.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-cm"}, + }, + }, + }, + } + deploy.Spec.Template.Spec.InitContainers = []corev1.Container{ + { + Name: "init", + Image: "busybox", + VolumeMounts: []corev1.VolumeMount{ + {Name: "config-vol", MountPath: "/config"}, + }, + }, + } + deploy.Spec.Template.Spec.Containers = []corev1.Container{ + { + Name: "app", + Image: "nginx", + }, + } + accessor := workload.NewDeploymentWorkload(deploy) + + container := svc.findTargetContainer(accessor, "my-cm", ResourceTypeConfigMap, true) + if container == nil { + t.Fatal("Expected to find a container") + } + // Should return first main container when init container uses the volume + if container.Name != "app" { + t.Errorf("Expected container 'app', got %q", container.Name) + } +} + +func TestService_findTargetContainer_AutoReload_InitContainerEnvRef(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg, testr.New(t)) + + // Test with autoReload=true where init container has env ref + deploy := testutil.NewDeployment("test", "default", nil) + deploy.Spec.Template.Spec.InitContainers = []corev1.Container{ + { + Name: "init", + Image: "busybox", + Env: []corev1.EnvVar{ + { + Name: "CONFIG_VAL", + ValueFrom: &corev1.EnvVarSource{ + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-cm"}, + Key: "key", + }, + }, + }, + }, + }, + } + deploy.Spec.Template.Spec.Containers = []corev1.Container{ + { + Name: "app", + Image: "nginx", + }, + } + accessor := workload.NewDeploymentWorkload(deploy) + + container := svc.findTargetContainer(accessor, "my-cm", ResourceTypeConfigMap, true) + if container == nil { + t.Fatal("Expected to find a container") + } + // Should return first main container when init container has the env ref + if container.Name != "app" { + t.Errorf("Expected container 'app', got %q", container.Name) + } +} + +func TestService_findTargetContainer_NoContainers(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg, testr.New(t)) + + deploy := testutil.NewDeployment("test", "default", nil) + deploy.Spec.Template.Spec.Containers = []corev1.Container{} + accessor := workload.NewDeploymentWorkload(deploy) + + container := svc.findTargetContainer(accessor, "my-cm", ResourceTypeConfigMap, false) + if container != nil { + t.Error("Expected nil container for empty container list") + } +} + +func TestService_findTargetContainer_NonAutoReload(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg, testr.New(t)) + + deploy := testutil.NewDeployment("test", "default", nil) + deploy.Spec.Template.Spec.Containers = []corev1.Container{ + {Name: "first", Image: "nginx"}, + {Name: "second", Image: "busybox"}, + } + accessor := workload.NewDeploymentWorkload(deploy) + + // Without autoReload, should return first container + container := svc.findTargetContainer(accessor, "my-cm", ResourceTypeConfigMap, false) + if container == nil { + t.Fatal("Expected to find a container") + } + if container.Name != "first" { + t.Errorf("Expected first container, got %q", container.Name) + } +} + +func TestService_findTargetContainer_AutoReload_FallbackToFirst(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg, testr.New(t)) + + // autoReload=true but no matching volume or env ref - should fallback to first container + deploy := testutil.NewDeployment("test", "default", nil) + deploy.Spec.Template.Spec.Containers = []corev1.Container{ + {Name: "first", Image: "nginx"}, + {Name: "second", Image: "busybox"}, + } + accessor := workload.NewDeploymentWorkload(deploy) + + container := svc.findTargetContainer(accessor, "non-existent", ResourceTypeConfigMap, true) + if container == nil { + t.Fatal("Expected to find a container") + } + if container.Name != "first" { + t.Errorf("Expected first container as fallback, got %q", container.Name) + } +} + +func TestService_ProcessNilChange(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg, testr.New(t)) + + deploy := testutil.NewDeployment("test", "default", nil) + workloads := []workload.Workload{workload.NewDeploymentWorkload(deploy)} + + // Test with nil ConfigMap + change := ConfigMapChange{ + ConfigMap: nil, + EventType: EventTypeUpdate, + } + + decisions := svc.Process(change, workloads) + if decisions != nil { + t.Errorf("Expected nil decisions for nil change, got %v", decisions) + } +} + +func TestService_ProcessCreateEventDisabled(t *testing.T) { + cfg := config.NewDefault() + cfg.ReloadOnCreate = false + svc := NewService(cfg, testr.New(t)) + + deploy := testutil.NewDeployment( + "test", "default", map[string]string{ + "reloader.stakater.com/auto": "true", + }, + ) + workloads := []workload.Workload{workload.NewDeploymentWorkload(deploy)} + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cm", Namespace: "default"}, + Data: map[string]string{"key": "value"}, + } + + change := ConfigMapChange{ + ConfigMap: cm, + EventType: EventTypeCreate, + } + + decisions := svc.Process(change, workloads) + if decisions != nil { + t.Errorf("Expected nil decisions when create events disabled, got %v", decisions) + } +} diff --git a/internal/pkg/reload/strategy.go b/internal/pkg/reload/strategy.go new file mode 100644 index 000000000..4386bf4b6 --- /dev/null +++ b/internal/pkg/reload/strategy.go @@ -0,0 +1,194 @@ +package reload + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "time" + + corev1 "k8s.io/api/core/v1" + + "github.com/stakater/Reloader/internal/pkg/config" +) + +const ( + // EnvVarPrefix is the prefix for environment variables added by Reloader. + EnvVarPrefix = "STAKATER_" + // ConfigmapEnvVarPostfix is the postfix for ConfigMap environment variables. + ConfigmapEnvVarPostfix = "CONFIGMAP" + // SecretEnvVarPostfix is the postfix for Secret environment variables. + SecretEnvVarPostfix = "SECRET" +) + +// Strategy defines how workload restarts are triggered. +type Strategy interface { + Apply(input StrategyInput) (bool, error) + Name() string +} + +// StrategyInput contains the information needed to apply a reload strategy. +type StrategyInput struct { + ResourceName string + ResourceType ResourceType + Namespace string + Hash string + Container *corev1.Container + PodAnnotations map[string]string + AutoReload bool +} + +// ReloadSource contains metadata about what triggered a reload. +type ReloadSource struct { + Kind string `json:"kind"` + Name string `json:"name"` + Namespace string `json:"namespace"` + Hash string `json:"hash"` + Containers []string `json:"containers"` + ReloadedAt time.Time `json:"reloadedAt"` +} + +// EnvVarStrategy triggers reloads by adding/updating environment variables. +type EnvVarStrategy struct{} + +// NewEnvVarStrategy creates a new EnvVarStrategy. +func NewEnvVarStrategy() *EnvVarStrategy { + return &EnvVarStrategy{} +} + +func (s *EnvVarStrategy) Name() string { + return string(config.ReloadStrategyEnvVars) +} + +// Apply adds, updates, or removes an environment variable to trigger a restart. +func (s *EnvVarStrategy) Apply(input StrategyInput) (bool, error) { + if input.Container == nil { + return false, fmt.Errorf("container is required for env-var strategy") + } + + envVarName := s.envVarName(input.ResourceName, input.ResourceType) + + if input.Hash == "" { + return s.removeEnvVar(input.Container, envVarName), nil + } + + for i := range input.Container.Env { + if input.Container.Env[i].Name == envVarName { + if input.Container.Env[i].Value == input.Hash { + return false, nil + } + input.Container.Env[i].Value = input.Hash + return true, nil + } + } + + input.Container.Env = append(input.Container.Env, corev1.EnvVar{ + Name: envVarName, + Value: input.Hash, + }) + + return true, nil +} + +func (s *EnvVarStrategy) removeEnvVar(container *corev1.Container, name string) bool { + for i := range container.Env { + if container.Env[i].Name == name { + container.Env[i] = container.Env[len(container.Env)-1] + container.Env = container.Env[:len(container.Env)-1] + return true + } + } + return false +} + +func (s *EnvVarStrategy) envVarName(resourceName string, resourceType ResourceType) string { + var postfix string + switch resourceType { + case ResourceTypeConfigMap: + postfix = ConfigmapEnvVarPostfix + case ResourceTypeSecret: + postfix = SecretEnvVarPostfix + } + return EnvVarPrefix + convertToEnvVarName(resourceName) + "_" + postfix +} + +func convertToEnvVarName(text string) string { + var buffer bytes.Buffer + upper := strings.ToUpper(text) + lastCharValid := false + + for i := 0; i < len(upper); i++ { + ch := upper[i] + if (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') { + buffer.WriteByte(ch) + lastCharValid = true + } else { + if lastCharValid { + buffer.WriteByte('_') + } + lastCharValid = false + } + } + + return buffer.String() +} + +// AnnotationStrategy triggers reloads by adding/updating pod template annotations. +type AnnotationStrategy struct { + cfg *config.Config +} + +// NewAnnotationStrategy creates a new AnnotationStrategy. +func NewAnnotationStrategy(cfg *config.Config) *AnnotationStrategy { + return &AnnotationStrategy{cfg: cfg} +} + +func (s *AnnotationStrategy) Name() string { + return string(config.ReloadStrategyAnnotations) +} + +// Apply adds or updates a pod annotation to trigger a restart. +func (s *AnnotationStrategy) Apply(input StrategyInput) (bool, error) { + if input.PodAnnotations == nil { + return false, fmt.Errorf("pod annotations map is required for annotation strategy") + } + + containerName := "" + if input.Container != nil { + containerName = input.Container.Name + } + + source := ReloadSource{ + Kind: string(input.ResourceType), + Name: input.ResourceName, + Namespace: input.Namespace, + Hash: input.Hash, + Containers: []string{containerName}, + ReloadedAt: time.Now().UTC(), + } + + sourceJSON, err := json.Marshal(source) + if err != nil { + return false, fmt.Errorf("failed to marshal reload source: %w", err) + } + + annotationKey := s.cfg.Annotations.LastReloadedFrom + existingValue := input.PodAnnotations[annotationKey] + + if existingValue == string(sourceJSON) { + return false, nil + } + + input.PodAnnotations[annotationKey] = string(sourceJSON) + return true, nil +} + +// NewStrategy creates a Strategy based on the configuration. +func NewStrategy(cfg *config.Config) Strategy { + switch cfg.ReloadStrategy { + case config.ReloadStrategyAnnotations: + return NewAnnotationStrategy(cfg) + default: + return NewEnvVarStrategy() + } +} diff --git a/internal/pkg/reload/strategy_test.go b/internal/pkg/reload/strategy_test.go new file mode 100644 index 000000000..3ea4f2458 --- /dev/null +++ b/internal/pkg/reload/strategy_test.go @@ -0,0 +1,293 @@ +package reload + +import ( + "encoding/json" + "testing" + + corev1 "k8s.io/api/core/v1" + + "github.com/stakater/Reloader/internal/pkg/config" +) + +func TestEnvVarStrategy_Apply(t *testing.T) { + strategy := NewEnvVarStrategy() + + t.Run("adds new env var", func(t *testing.T) { + container := &corev1.Container{ + Name: "test-container", + Env: []corev1.EnvVar{}, + } + + input := StrategyInput{ + ResourceName: "my-config", + ResourceType: ResourceTypeConfigMap, + Namespace: "default", + Hash: "abc123", + Container: container, + } + + changed, err := strategy.Apply(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !changed { + t.Error("expected changed=true for new env var") + } + + // Verify env var was added + found := false + for _, env := range container.Env { + if env.Name == "STAKATER_MY_CONFIG_CONFIGMAP" && env.Value == "abc123" { + found = true + break + } + } + if !found { + t.Errorf("expected env var STAKATER_MY_CONFIG_CONFIGMAP=abc123, got %+v", container.Env) + } + }) + + t.Run("updates existing env var", func(t *testing.T) { + container := &corev1.Container{ + Name: "test-container", + Env: []corev1.EnvVar{ + {Name: "STAKATER_MY_CONFIG_CONFIGMAP", Value: "old-hash"}, + }, + } + + input := StrategyInput{ + ResourceName: "my-config", + ResourceType: ResourceTypeConfigMap, + Namespace: "default", + Hash: "new-hash", + Container: container, + } + + changed, err := strategy.Apply(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !changed { + t.Error("expected changed=true for updated env var") + } + + // Verify env var was updated + if container.Env[0].Value != "new-hash" { + t.Errorf("expected env var value=new-hash, got %s", container.Env[0].Value) + } + }) + + t.Run("no change when hash is same", func(t *testing.T) { + container := &corev1.Container{ + Name: "test-container", + Env: []corev1.EnvVar{ + {Name: "STAKATER_MY_CONFIG_CONFIGMAP", Value: "same-hash"}, + }, + } + + input := StrategyInput{ + ResourceName: "my-config", + ResourceType: ResourceTypeConfigMap, + Namespace: "default", + Hash: "same-hash", + Container: container, + } + + changed, err := strategy.Apply(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if changed { + t.Error("expected changed=false when hash is unchanged") + } + }) + + t.Run("error when container is nil", func(t *testing.T) { + input := StrategyInput{ + ResourceName: "my-config", + ResourceType: ResourceTypeConfigMap, + Namespace: "default", + Hash: "abc123", + Container: nil, + } + + _, err := strategy.Apply(input) + if err == nil { + t.Error("expected error for nil container") + } + }) + + t.Run("secret env var has correct postfix", func(t *testing.T) { + container := &corev1.Container{ + Name: "test-container", + Env: []corev1.EnvVar{}, + } + + input := StrategyInput{ + ResourceName: "my-secret", + ResourceType: ResourceTypeSecret, + Namespace: "default", + Hash: "abc123", + Container: container, + } + + changed, err := strategy.Apply(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !changed { + t.Error("expected changed=true") + } + + // Verify env var name has SECRET postfix + found := false + for _, env := range container.Env { + if env.Name == "STAKATER_MY_SECRET_SECRET" && env.Value == "abc123" { + found = true + break + } + } + if !found { + t.Errorf("expected env var STAKATER_MY_SECRET_SECRET=abc123, got %+v", container.Env) + } + }) +} + +func TestEnvVarStrategy_EnvVarName(t *testing.T) { + strategy := NewEnvVarStrategy() + + tests := []struct { + resourceName string + resourceType ResourceType + expected string + }{ + {"my-config", ResourceTypeConfigMap, "STAKATER_MY_CONFIG_CONFIGMAP"}, + {"my-secret", ResourceTypeSecret, "STAKATER_MY_SECRET_SECRET"}, + {"app-config-v2", ResourceTypeConfigMap, "STAKATER_APP_CONFIG_V2_CONFIGMAP"}, + {"my.dotted.config", ResourceTypeConfigMap, "STAKATER_MY_DOTTED_CONFIG_CONFIGMAP"}, + {"MyMixedCase", ResourceTypeConfigMap, "STAKATER_MYMIXEDCASE_CONFIGMAP"}, + {"config-with-123-numbers", ResourceTypeConfigMap, "STAKATER_CONFIG_WITH_123_NUMBERS_CONFIGMAP"}, + } + + for _, tt := range tests { + t.Run(tt.resourceName, func(t *testing.T) { + got := strategy.envVarName(tt.resourceName, tt.resourceType) + if got != tt.expected { + t.Errorf("envVarName(%q, %q) = %q, want %q", + tt.resourceName, tt.resourceType, got, tt.expected) + } + }) + } +} + +func TestConvertToEnvVarName(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"my-config", "MY_CONFIG"}, + {"my.config", "MY_CONFIG"}, + {"my_config", "MY_CONFIG"}, + {"MY-CONFIG", "MY_CONFIG"}, + {"config123", "CONFIG123"}, + {"123config", "123CONFIG"}, + {"my--config", "MY_CONFIG"}, + {"my..config", "MY_CONFIG"}, + {"", ""}, + {"-leading-dash", "LEADING_DASH"}, + {"trailing-dash-", "TRAILING_DASH_"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := convertToEnvVarName(tt.input) + if got != tt.expected { + t.Errorf("convertToEnvVarName(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestAnnotationStrategy_Apply(t *testing.T) { + cfg := config.NewDefault() + strategy := NewAnnotationStrategy(cfg) + + t.Run("adds new annotation", func(t *testing.T) { + annotations := make(map[string]string) + container := &corev1.Container{Name: "test-container"} + + input := StrategyInput{ + ResourceName: "my-config", + ResourceType: ResourceTypeConfigMap, + Namespace: "default", + Hash: "abc123", + Container: container, + PodAnnotations: annotations, + } + + changed, err := strategy.Apply(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !changed { + t.Error("expected changed=true for new annotation") + } + + // Verify annotation was added + annotationValue := annotations[cfg.Annotations.LastReloadedFrom] + if annotationValue == "" { + t.Error("expected annotation to be set") + } + + // Verify annotation content + var source ReloadSource + if err := json.Unmarshal([]byte(annotationValue), &source); err != nil { + t.Fatalf("failed to unmarshal annotation: %v", err) + } + if source.Kind != string(ResourceTypeConfigMap) { + t.Errorf("expected kind=%s, got %s", ResourceTypeConfigMap, source.Kind) + } + if source.Name != "my-config" { + t.Errorf("expected name=my-config, got %s", source.Name) + } + if source.Hash != "abc123" { + t.Errorf("expected hash=abc123, got %s", source.Hash) + } + }) + + t.Run("error when annotations map is nil", func(t *testing.T) { + input := StrategyInput{ + ResourceName: "my-config", + ResourceType: ResourceTypeConfigMap, + Namespace: "default", + Hash: "abc123", + PodAnnotations: nil, + } + + _, err := strategy.Apply(input) + if err == nil { + t.Error("expected error for nil annotations map") + } + }) +} + +func TestNewStrategy(t *testing.T) { + t.Run("default strategy is env-vars", func(t *testing.T) { + cfg := config.NewDefault() + strategy := NewStrategy(cfg) + + if strategy.Name() != string(config.ReloadStrategyEnvVars) { + t.Errorf("expected env-vars strategy, got %s", strategy.Name()) + } + }) + + t.Run("annotations strategy when configured", func(t *testing.T) { + cfg := config.NewDefault() + cfg.ReloadStrategy = config.ReloadStrategyAnnotations + strategy := NewStrategy(cfg) + + if strategy.Name() != string(config.ReloadStrategyAnnotations) { + t.Errorf("expected annotations strategy, got %s", strategy.Name()) + } + }) +} diff --git a/internal/pkg/testutil/fixtures.go b/internal/pkg/testutil/fixtures.go new file mode 100644 index 000000000..6ba8587a7 --- /dev/null +++ b/internal/pkg/testutil/fixtures.go @@ -0,0 +1,388 @@ +package testutil + +import ( + openshiftv1 "github.com/openshift/api/apps/v1" + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// NewDeploymentConfig creates a minimal DeploymentConfig for unit testing. +func NewDeploymentConfig(name, namespace string, annotations map[string]string) *openshiftv1.DeploymentConfig { + replicas := int32(1) + return &openshiftv1.DeploymentConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Annotations: annotations, + }, + Spec: openshiftv1.DeploymentConfigSpec{ + Replicas: replicas, + Selector: map[string]string{"app": name}, + Template: &corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": name}, + Annotations: map[string]string{}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + Image: "nginx", + }, + }, + }, + }, + }, + } +} + +// NewDeploymentConfigWithEnvFrom creates a DeploymentConfig with EnvFrom referencing a ConfigMap or Secret. +func NewDeploymentConfigWithEnvFrom(name, namespace string, configMapName, secretName string) *openshiftv1.DeploymentConfig { + dc := NewDeploymentConfig(name, namespace, nil) + if configMapName != "" { + dc.Spec.Template.Spec.Containers[0].EnvFrom = append( + dc.Spec.Template.Spec.Containers[0].EnvFrom, + corev1.EnvFromSource{ + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: configMapName}, + }, + }, + ) + } + if secretName != "" { + dc.Spec.Template.Spec.Containers[0].EnvFrom = append( + dc.Spec.Template.Spec.Containers[0].EnvFrom, + corev1.EnvFromSource{ + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, + }, + }, + ) + } + return dc +} + +// NewScheme creates a scheme with common types for testing. +func NewScheme() *runtime.Scheme { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + _ = appsv1.AddToScheme(scheme) + _ = batchv1.AddToScheme(scheme) + _ = openshiftv1.AddToScheme(scheme) + return scheme +} + +// NewDeployment creates a minimal Deployment for unit testing. +func NewDeployment(name, namespace string, annotations map[string]string) *appsv1.Deployment { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Annotations: annotations, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": name}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": name}, + Annotations: map[string]string{}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + Image: "nginx", + }, + }, + }, + }, + }, + } +} + +// NewDeploymentWithEnvFrom creates a Deployment with EnvFrom referencing a ConfigMap or Secret. +func NewDeploymentWithEnvFrom(name, namespace string, configMapName, secretName string) *appsv1.Deployment { + d := NewDeployment(name, namespace, nil) + if configMapName != "" { + d.Spec.Template.Spec.Containers[0].EnvFrom = append( + d.Spec.Template.Spec.Containers[0].EnvFrom, + corev1.EnvFromSource{ + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: configMapName}, + }, + }, + ) + } + if secretName != "" { + d.Spec.Template.Spec.Containers[0].EnvFrom = append( + d.Spec.Template.Spec.Containers[0].EnvFrom, + corev1.EnvFromSource{ + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, + }, + }, + ) + } + return d +} + +// NewDeploymentWithVolume creates a Deployment with a volume from ConfigMap or Secret. +func NewDeploymentWithVolume(name, namespace string, configMapName, secretName string) *appsv1.Deployment { + d := NewDeployment(name, namespace, nil) + d.Spec.Template.Spec.Containers[0].VolumeMounts = []corev1.VolumeMount{ + { + Name: "config", + MountPath: "/etc/config", + }, + } + + if configMapName != "" { + d.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: configMapName}, + }, + }, + }, + } + } + if secretName != "" { + d.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "config", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: secretName, + }, + }, + }, + } + } + return d +} + +// NewDeploymentWithProjectedVolume creates a Deployment with a projected volume. +func NewDeploymentWithProjectedVolume(name, namespace string, configMapName, secretName string) *appsv1.Deployment { + d := NewDeployment(name, namespace, nil) + d.Spec.Template.Spec.Containers[0].VolumeMounts = []corev1.VolumeMount{ + { + Name: "config", + MountPath: "/etc/config", + }, + } + + sources := []corev1.VolumeProjection{} + if configMapName != "" { + sources = append( + sources, corev1.VolumeProjection{ + ConfigMap: &corev1.ConfigMapProjection{ + LocalObjectReference: corev1.LocalObjectReference{Name: configMapName}, + }, + }, + ) + } + if secretName != "" { + sources = append( + sources, corev1.VolumeProjection{ + Secret: &corev1.SecretProjection{ + LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, + }, + }, + ) + } + + d.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "config", + VolumeSource: corev1.VolumeSource{ + Projected: &corev1.ProjectedVolumeSource{Sources: sources}, + }, + }, + } + return d +} + +// NewDaemonSet creates a minimal DaemonSet for unit testing. +func NewDaemonSet(name, namespace string, annotations map[string]string) *appsv1.DaemonSet { + return &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Annotations: annotations, + }, + Spec: appsv1.DaemonSetSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": name}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": name}, + Annotations: map[string]string{}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + Image: "nginx", + }, + }, + }, + }, + }, + } +} + +// NewStatefulSet creates a minimal StatefulSet for unit testing. +func NewStatefulSet(name, namespace string, annotations map[string]string) *appsv1.StatefulSet { + return &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Annotations: annotations, + }, + Spec: appsv1.StatefulSetSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": name}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": name}, + Annotations: map[string]string{}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + Image: "nginx", + }, + }, + }, + }, + }, + } +} + +// NewJob creates a minimal Job for unit testing. +func NewJob(name, namespace string) *batchv1.Job { + return &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{}, + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{ + { + Name: "main", + Image: "busybox", + }, + }, + }, + }, + }, + } +} + +// NewJobWithAnnotations creates a Job with annotations. +func NewJobWithAnnotations(name, namespace string, annotations map[string]string) *batchv1.Job { + job := NewJob(name, namespace) + job.Annotations = annotations + return job +} + +// NewCronJob creates a minimal CronJob for unit testing. +func NewCronJob(name, namespace string) *batchv1.CronJob { + return &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + UID: "test-uid", + }, + Spec: batchv1.CronJobSpec{ + Schedule: "*/5 * * * *", + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{}, + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{ + { + Name: "main", + Image: "busybox", + }, + }, + }, + }, + }, + }, + }, + } +} + +// NewCronJobWithAnnotations creates a CronJob with annotations. +func NewCronJobWithAnnotations(name, namespace string, annotations map[string]string) *batchv1.CronJob { + cj := NewCronJob(name, namespace) + cj.Annotations = annotations + return cj +} + +// NewConfigMap creates a ConfigMap for unit testing. +func NewConfigMap(name, namespace string) *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Data: map[string]string{"key": "value"}, + } +} + +// NewConfigMapWithAnnotations creates a ConfigMap with annotations. +func NewConfigMapWithAnnotations(name, namespace string, annotations map[string]string) *corev1.ConfigMap { + cm := NewConfigMap(name, namespace) + cm.Annotations = annotations + return cm +} + +// NewSecret creates a Secret for unit testing. +func NewSecret(name, namespace string) *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Data: map[string][]byte{"key": []byte("value")}, + } +} + +// NewSecretWithAnnotations creates a Secret with annotations. +func NewSecretWithAnnotations(name, namespace string, annotations map[string]string) *corev1.Secret { + secret := NewSecret(name, namespace) + secret.Annotations = annotations + return secret +} + +// NewNamespace creates a Namespace with optional labels. +func NewNamespace(name string, labels map[string]string) *corev1.Namespace { + return &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: labels, + }, + } +} diff --git a/internal/pkg/testutil/kube.go b/internal/pkg/testutil/kube.go deleted file mode 100644 index ab64d84e7..000000000 --- a/internal/pkg/testutil/kube.go +++ /dev/null @@ -1,1361 +0,0 @@ -package testutil - -import ( - "context" - "encoding/json" - "fmt" - "math/rand" - "sort" - "strconv" - "strings" - "time" - - argorolloutv1alpha1 "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1" - argorollout "github.com/argoproj/argo-rollouts/pkg/client/clientset/versioned" - openshiftv1 "github.com/openshift/api/apps/v1" - appsclient "github.com/openshift/client-go/apps/clientset/versioned" - "github.com/sirupsen/logrus" - appsv1 "k8s.io/api/apps/v1" - batchv1 "k8s.io/api/batch/v1" - v1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/kubernetes" - core_v1 "k8s.io/client-go/kubernetes/typed/core/v1" - csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1" - csiclient "sigs.k8s.io/secrets-store-csi-driver/pkg/client/clientset/versioned" - csiclient_v1 "sigs.k8s.io/secrets-store-csi-driver/pkg/client/clientset/versioned/typed/apis/v1" - - "github.com/stakater/Reloader/internal/pkg/callbacks" - "github.com/stakater/Reloader/internal/pkg/constants" - "github.com/stakater/Reloader/internal/pkg/crypto" - "github.com/stakater/Reloader/internal/pkg/metrics" - "github.com/stakater/Reloader/internal/pkg/options" - "github.com/stakater/Reloader/internal/pkg/util" - "github.com/stakater/Reloader/pkg/common" - "github.com/stakater/Reloader/pkg/kube" -) - -var ( - letters = []rune("abcdefghijklmnopqrstuvwxyz") - // ConfigmapResourceType is a resource type which controller watches for changes - ConfigmapResourceType = "configMaps" - // SecretResourceType is a resource type which controller watches for changes - SecretResourceType = "secrets" - // SecretproviderclasspodstatusResourceType is a resource type which controller watches for changes - SecretProviderClassPodStatusResourceType = "secretproviderclasspodstatuses" -) - -var ( - Clients = kube.GetClients() - Pod = "test-reloader-" + RandSeq(5) - Namespace = "test-reloader-" + RandSeq(5) - ConfigmapNamePrefix = "testconfigmap-reloader" - SecretNamePrefix = "testsecret-reloader" - Data = "dGVzdFNlY3JldEVuY29kaW5nRm9yUmVsb2FkZXI=" - NewData = "dGVzdE5ld1NlY3JldEVuY29kaW5nRm9yUmVsb2FkZXI=" - UpdatedData = "dGVzdFVwZGF0ZWRTZWNyZXRFbmNvZGluZ0ZvclJlbG9hZGVy" - Collectors = metrics.NewCollectors() - SleepDuration = 3 * time.Second -) - -// CreateNamespace creates namespace for testing -func CreateNamespace(namespace string, client kubernetes.Interface) { - _, err := client.CoreV1().Namespaces().Create(context.TODO(), &v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}}, metav1.CreateOptions{}) - if err != nil { - logrus.Fatalf("Failed to create namespace for testing %v", err) - } else { - logrus.Infof("Creating namespace for testing = %s", namespace) - } -} - -// DeleteNamespace deletes namespace for testing -func DeleteNamespace(namespace string, client kubernetes.Interface) { - err := client.CoreV1().Namespaces().Delete(context.TODO(), namespace, metav1.DeleteOptions{}) - if err != nil { - logrus.Fatalf("Failed to delete namespace that was created for testing %v", err) - } else { - logrus.Infof("Deleting namespace for testing = %s", namespace) - } -} - -func getObjectMeta(namespace string, name string, autoReload bool, secretAutoReload bool, configmapAutoReload bool, secretproviderclass bool, extraAnnotations map[string]string) metav1.ObjectMeta { - return metav1.ObjectMeta{ - Name: name, - Namespace: namespace, - Labels: map[string]string{"firstLabel": "temp"}, - Annotations: getAnnotations(name, autoReload, secretAutoReload, configmapAutoReload, secretproviderclass, extraAnnotations), - } -} - -func getAnnotations(name string, autoReload bool, secretAutoReload bool, configmapAutoReload bool, secretproviderclass bool, extraAnnotations map[string]string) map[string]string { - annotations := make(map[string]string) - if autoReload { - annotations[options.ReloaderAutoAnnotation] = "true" - } - if secretAutoReload { - annotations[options.SecretReloaderAutoAnnotation] = "true" - } - if configmapAutoReload { - annotations[options.ConfigmapReloaderAutoAnnotation] = "true" - } - if secretproviderclass { - annotations[options.SecretProviderClassReloaderAutoAnnotation] = "true" - } - - if len(annotations) == 0 { - annotations = map[string]string{ - options.ConfigmapUpdateOnChangeAnnotation: name, - options.SecretUpdateOnChangeAnnotation: name, - options.SecretProviderClassUpdateOnChangeAnnotation: name, - } - } - for k, v := range extraAnnotations { - annotations[k] = v - } - return annotations -} - -func getEnvVarSources(name string) []v1.EnvFromSource { - return []v1.EnvFromSource{ - { - ConfigMapRef: &v1.ConfigMapEnvSource{ - LocalObjectReference: v1.LocalObjectReference{ - Name: name, - }, - }, - }, - { - SecretRef: &v1.SecretEnvSource{ - LocalObjectReference: v1.LocalObjectReference{ - Name: name, - }, - }, - }, - } -} - -func getVolumes(name string) []v1.Volume { - return []v1.Volume{ - { - Name: "projectedconfigmap", - VolumeSource: v1.VolumeSource{ - Projected: &v1.ProjectedVolumeSource{ - Sources: []v1.VolumeProjection{ - { - ConfigMap: &v1.ConfigMapProjection{ - LocalObjectReference: v1.LocalObjectReference{ - Name: name, - }, - }, - }, - }, - }, - }, - }, - { - Name: "projectedsecret", - VolumeSource: v1.VolumeSource{ - Projected: &v1.ProjectedVolumeSource{ - Sources: []v1.VolumeProjection{ - { - Secret: &v1.SecretProjection{ - LocalObjectReference: v1.LocalObjectReference{ - Name: name, - }, - }, - }, - }, - }, - }, - }, - { - Name: "configmap", - VolumeSource: v1.VolumeSource{ - ConfigMap: &v1.ConfigMapVolumeSource{ - LocalObjectReference: v1.LocalObjectReference{ - Name: name, - }, - }, - }, - }, - { - Name: "secret", - VolumeSource: v1.VolumeSource{ - Secret: &v1.SecretVolumeSource{ - SecretName: name, - }, - }, - }, - { - Name: "secretproviderclass", - VolumeSource: v1.VolumeSource{ - CSI: &v1.CSIVolumeSource{ - Driver: "secrets-store.csi.k8s.io", - VolumeAttributes: map[string]string{"secretProviderClass": name}, - }, - }, - }, - } -} - -func getVolumeMounts() []v1.VolumeMount { - return []v1.VolumeMount{ - { - MountPath: "etc/config", - Name: "configmap", - }, - { - MountPath: "etc/sec", - Name: "secret", - }, - { - MountPath: "etc/spc", - Name: "secretproviderclass", - }, - { - MountPath: "etc/projectedconfig", - Name: "projectedconfigmap", - }, - { - MountPath: "etc/projectedsec", - Name: "projectedsecret", - }, - } -} - -func getPodTemplateSpecWithEnvVars(name string) v1.PodTemplateSpec { - return v1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"secondLabel": "temp"}, - }, - Spec: v1.PodSpec{ - Containers: []v1.Container{ - { - Image: "tutum/hello-world", - Name: name, - Env: []v1.EnvVar{ - { - Name: "BUCKET_NAME", - Value: "test", - }, - { - Name: "CONFIGMAP_" + util.ConvertToEnvVarName(name), - ValueFrom: &v1.EnvVarSource{ - ConfigMapKeyRef: &v1.ConfigMapKeySelector{ - LocalObjectReference: v1.LocalObjectReference{ - Name: name, - }, - Key: "test.url", - }, - }, - }, - { - Name: "SECRET_" + util.ConvertToEnvVarName(name), - ValueFrom: &v1.EnvVarSource{ - SecretKeyRef: &v1.SecretKeySelector{ - LocalObjectReference: v1.LocalObjectReference{ - Name: name, - }, - Key: "test.url", - }, - }, - }, - }, - }, - }, - }, - } -} - -func getPodTemplateSpecWithEnvVarSources(name string) v1.PodTemplateSpec { - return v1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"secondLabel": "temp"}, - }, - Spec: v1.PodSpec{ - Containers: []v1.Container{ - { - Image: "tutum/hello-world", - Name: name, - EnvFrom: getEnvVarSources(name), - }, - }, - }, - } -} - -func getPodTemplateSpecWithVolumes(name string) v1.PodTemplateSpec { - return v1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"secondLabel": "temp"}, - }, - Spec: v1.PodSpec{ - Containers: []v1.Container{ - { - Image: "tutum/hello-world", - Name: name, - Env: []v1.EnvVar{ - { - Name: "BUCKET_NAME", - Value: "test", - }, - }, - VolumeMounts: getVolumeMounts(), - }, - }, - Volumes: getVolumes(name), - }, - } -} - -func getPodTemplateSpecWithInitContainer(name string) v1.PodTemplateSpec { - return v1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"secondLabel": "temp"}, - }, - Spec: v1.PodSpec{ - InitContainers: []v1.Container{ - { - Image: "busybox", - Name: "busyBox", - VolumeMounts: getVolumeMounts(), - }, - }, - Containers: []v1.Container{ - { - Image: "tutum/hello-world", - Name: name, - Env: []v1.EnvVar{ - { - Name: "BUCKET_NAME", - Value: "test", - }, - }, - }, - }, - Volumes: getVolumes(name), - }, - } -} - -func getPodTemplateSpecWithInitContainerAndEnv(name string) v1.PodTemplateSpec { - return v1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"secondLabel": "temp"}, - }, - Spec: v1.PodSpec{ - InitContainers: []v1.Container{ - { - Image: "busybox", - Name: "busyBox", - EnvFrom: getEnvVarSources(name), - }, - }, - Containers: []v1.Container{ - { - Image: "tutum/hello-world", - Name: name, - Env: []v1.EnvVar{ - { - Name: "BUCKET_NAME", - Value: "test", - }, - }, - }, - }, - }, - } -} - -// GetDeployment provides deployment for testing -func GetDeployment(namespace string, deploymentName string) *appsv1.Deployment { - replicaset := int32(1) - return &appsv1.Deployment{ - ObjectMeta: getObjectMeta(namespace, deploymentName, false, false, false, false, map[string]string{}), - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"secondLabel": "temp"}, - }, - Replicas: &replicaset, - Strategy: appsv1.DeploymentStrategy{ - Type: appsv1.RollingUpdateDeploymentStrategyType, - }, - Template: getPodTemplateSpecWithVolumes(deploymentName), - }, - } -} - -// GetDeploymentConfig provides deployment for testing -func GetDeploymentConfig(namespace string, deploymentConfigName string) *openshiftv1.DeploymentConfig { - replicaset := int32(1) - podTemplateSpecWithVolume := getPodTemplateSpecWithVolumes(deploymentConfigName) - return &openshiftv1.DeploymentConfig{ - ObjectMeta: getObjectMeta(namespace, deploymentConfigName, false, false, false, false, map[string]string{}), - Spec: openshiftv1.DeploymentConfigSpec{ - Replicas: replicaset, - Strategy: openshiftv1.DeploymentStrategy{ - Type: openshiftv1.DeploymentStrategyTypeRolling, - }, - Template: &podTemplateSpecWithVolume, - }, - } -} - -// GetDeploymentWithInitContainer provides deployment with init container and volumeMounts -func GetDeploymentWithInitContainer(namespace string, deploymentName string) *appsv1.Deployment { - replicaset := int32(1) - return &appsv1.Deployment{ - ObjectMeta: getObjectMeta(namespace, deploymentName, false, false, false, false, map[string]string{}), - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"secondLabel": "temp"}, - }, - Replicas: &replicaset, - Strategy: appsv1.DeploymentStrategy{ - Type: appsv1.RollingUpdateDeploymentStrategyType, - }, - Template: getPodTemplateSpecWithInitContainer(deploymentName), - }, - } -} - -// GetDeploymentWithInitContainerAndEnv provides deployment with init container and EnvSource -func GetDeploymentWithInitContainerAndEnv(namespace string, deploymentName string) *appsv1.Deployment { - replicaset := int32(1) - return &appsv1.Deployment{ - ObjectMeta: getObjectMeta(namespace, deploymentName, true, false, false, false, map[string]string{}), - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"secondLabel": "temp"}, - }, - Replicas: &replicaset, - Strategy: appsv1.DeploymentStrategy{ - Type: appsv1.RollingUpdateDeploymentStrategyType, - }, - Template: getPodTemplateSpecWithInitContainerAndEnv(deploymentName), - }, - } -} - -func GetDeploymentWithEnvVars(namespace string, deploymentName string) *appsv1.Deployment { - replicaset := int32(1) - return &appsv1.Deployment{ - ObjectMeta: getObjectMeta(namespace, deploymentName, true, false, false, false, map[string]string{}), - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"secondLabel": "temp"}, - }, - Replicas: &replicaset, - Strategy: appsv1.DeploymentStrategy{ - Type: appsv1.RollingUpdateDeploymentStrategyType, - }, - Template: getPodTemplateSpecWithEnvVars(deploymentName), - }, - } -} - -func GetDeploymentConfigWithEnvVars(namespace string, deploymentConfigName string) *openshiftv1.DeploymentConfig { - replicaset := int32(1) - podTemplateSpecWithEnvVars := getPodTemplateSpecWithEnvVars(deploymentConfigName) - return &openshiftv1.DeploymentConfig{ - ObjectMeta: getObjectMeta(namespace, deploymentConfigName, false, false, false, false, map[string]string{}), - Spec: openshiftv1.DeploymentConfigSpec{ - Replicas: replicaset, - Strategy: openshiftv1.DeploymentStrategy{ - Type: openshiftv1.DeploymentStrategyTypeRolling, - }, - Template: &podTemplateSpecWithEnvVars, - }, - } -} - -func GetDeploymentWithEnvVarSources(namespace string, deploymentName string) *appsv1.Deployment { - replicaset := int32(1) - return &appsv1.Deployment{ - ObjectMeta: getObjectMeta(namespace, deploymentName, true, false, false, false, map[string]string{}), - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"secondLabel": "temp"}, - }, - Replicas: &replicaset, - Strategy: appsv1.DeploymentStrategy{ - Type: appsv1.RollingUpdateDeploymentStrategyType, - }, - Template: getPodTemplateSpecWithEnvVarSources(deploymentName), - }, - } -} - -func GetDeploymentWithPodAnnotations(namespace string, deploymentName string, both bool) *appsv1.Deployment { - replicaset := int32(1) - deployment := &appsv1.Deployment{ - ObjectMeta: getObjectMeta(namespace, deploymentName, false, false, false, false, map[string]string{}), - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"secondLabel": "temp"}, - }, - Replicas: &replicaset, - Strategy: appsv1.DeploymentStrategy{ - Type: appsv1.RollingUpdateDeploymentStrategyType, - }, - Template: getPodTemplateSpecWithEnvVarSources(deploymentName), - }, - } - if !both { - deployment.Annotations = nil - } - deployment.Spec.Template.Annotations = getAnnotations(deploymentName, true, false, false, false, map[string]string{}) - return deployment -} - -func GetDeploymentWithTypedAutoAnnotation(namespace string, deploymentName string, resourceType string) *appsv1.Deployment { - replicaset := int32(1) - var objectMeta metav1.ObjectMeta - switch resourceType { - case SecretResourceType: - objectMeta = getObjectMeta(namespace, deploymentName, false, true, false, false, map[string]string{}) - case ConfigmapResourceType: - objectMeta = getObjectMeta(namespace, deploymentName, false, false, true, false, map[string]string{}) - case SecretProviderClassPodStatusResourceType: - objectMeta = getObjectMeta(namespace, deploymentName, false, false, false, true, map[string]string{}) - } - - return &appsv1.Deployment{ - ObjectMeta: objectMeta, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"secondLabel": "temp"}, - }, - Replicas: &replicaset, - Strategy: appsv1.DeploymentStrategy{ - Type: appsv1.RollingUpdateDeploymentStrategyType, - }, - Template: getPodTemplateSpecWithVolumes(deploymentName), - }, - } -} - -func GetDeploymentWithExcludeAnnotation(namespace string, deploymentName string, resourceType string) *appsv1.Deployment { - replicaset := int32(1) - - annotation := map[string]string{} - - switch resourceType { - case SecretResourceType: - annotation[options.SecretExcludeReloaderAnnotation] = deploymentName - case ConfigmapResourceType: - annotation[options.ConfigmapExcludeReloaderAnnotation] = deploymentName - case SecretProviderClassPodStatusResourceType: - annotation[options.SecretProviderClassExcludeReloaderAnnotation] = deploymentName - } - - return &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: deploymentName, - Namespace: namespace, - Labels: map[string]string{"firstLabel": "temp"}, - Annotations: annotation, - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"secondLabel": "temp"}, - }, - Replicas: &replicaset, - Strategy: appsv1.DeploymentStrategy{ - Type: appsv1.RollingUpdateDeploymentStrategyType, - }, - Template: getPodTemplateSpecWithVolumes(deploymentName), - }, - } -} - -// GetDaemonSet provides daemonset for testing -func GetDaemonSet(namespace string, daemonsetName string) *appsv1.DaemonSet { - return &appsv1.DaemonSet{ - ObjectMeta: getObjectMeta(namespace, daemonsetName, false, false, false, false, map[string]string{}), - Spec: appsv1.DaemonSetSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"secondLabel": "temp"}, - }, - UpdateStrategy: appsv1.DaemonSetUpdateStrategy{ - Type: appsv1.RollingUpdateDaemonSetStrategyType, - }, - Template: getPodTemplateSpecWithVolumes(daemonsetName), - }, - } -} - -func GetDaemonSetWithEnvVars(namespace string, daemonSetName string) *appsv1.DaemonSet { - return &appsv1.DaemonSet{ - ObjectMeta: getObjectMeta(namespace, daemonSetName, true, false, false, false, map[string]string{}), - Spec: appsv1.DaemonSetSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"secondLabel": "temp"}, - }, - UpdateStrategy: appsv1.DaemonSetUpdateStrategy{ - Type: appsv1.RollingUpdateDaemonSetStrategyType, - }, - Template: getPodTemplateSpecWithEnvVars(daemonSetName), - }, - } -} - -// GetStatefulSet provides statefulset for testing -func GetStatefulSet(namespace string, statefulsetName string) *appsv1.StatefulSet { - return &appsv1.StatefulSet{ - ObjectMeta: getObjectMeta(namespace, statefulsetName, false, false, false, false, map[string]string{}), - Spec: appsv1.StatefulSetSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"secondLabel": "temp"}, - }, - UpdateStrategy: appsv1.StatefulSetUpdateStrategy{ - Type: appsv1.RollingUpdateStatefulSetStrategyType, - }, - Template: getPodTemplateSpecWithVolumes(statefulsetName), - }, - } -} - -// GetStatefulSet provides statefulset for testing -func GetStatefulSetWithEnvVar(namespace string, statefulsetName string) *appsv1.StatefulSet { - return &appsv1.StatefulSet{ - ObjectMeta: getObjectMeta(namespace, statefulsetName, true, false, false, false, map[string]string{}), - Spec: appsv1.StatefulSetSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"secondLabel": "temp"}, - }, - UpdateStrategy: appsv1.StatefulSetUpdateStrategy{ - Type: appsv1.RollingUpdateStatefulSetStrategyType, - }, - Template: getPodTemplateSpecWithEnvVars(statefulsetName), - }, - } -} - -// GetConfigmap provides configmap for testing -func GetConfigmap(namespace string, configmapName string, testData string) *v1.ConfigMap { - return &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: configmapName, - Namespace: namespace, - Labels: map[string]string{"firstLabel": "temp"}, - }, - Data: map[string]string{"test.url": testData}, - } -} - -func GetSecretProviderClass(namespace string, secretProviderClassName string, data string) *csiv1.SecretProviderClass { - return &csiv1.SecretProviderClass{ - ObjectMeta: metav1.ObjectMeta{ - Name: secretProviderClassName, - Namespace: namespace, - }, - Spec: csiv1.SecretProviderClassSpec{ - Provider: "Test", - Parameters: map[string]string{ - "parameter1": data, - }, - }, - } -} - -func GetSecretProviderClassPodStatus(namespace string, secretProviderClassPodStatusName string, data string) *csiv1.SecretProviderClassPodStatus { - return &csiv1.SecretProviderClassPodStatus{ - ObjectMeta: metav1.ObjectMeta{ - Name: secretProviderClassPodStatusName, - Namespace: namespace, - }, - Status: csiv1.SecretProviderClassPodStatusStatus{ - PodName: "test123", - SecretProviderClassName: secretProviderClassPodStatusName, - TargetPath: "/var/lib/kubelet/d8771ddf-935a-4199-a20b-f35f71c1d9e7/volumes/kubernetes.io~csi/secrets-store-inline/mount", - Mounted: true, - Objects: []csiv1.SecretProviderClassObject{ - { - ID: "parameter1", - Version: data, - }, - }, - }, - } -} - -// GetConfigmapWithUpdatedLabel provides configmap for testing -func GetConfigmapWithUpdatedLabel(namespace string, configmapName string, testLabel string, testData string) *v1.ConfigMap { - return &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: configmapName, - Namespace: namespace, - Labels: map[string]string{"firstLabel": testLabel}, - }, - Data: map[string]string{"test.url": testData}, - } -} - -// GetSecret provides secret for testing -func GetSecret(namespace string, secretName string, data string) *v1.Secret { - return &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: secretName, - Namespace: namespace, - Labels: map[string]string{"firstLabel": "temp"}, - }, - Data: map[string][]byte{"test.url": []byte(data)}, - } -} - -func GetCronJob(namespace string, cronJobName string) *batchv1.CronJob { - return &batchv1.CronJob{ - ObjectMeta: getObjectMeta(namespace, cronJobName, false, false, false, false, map[string]string{}), - Spec: batchv1.CronJobSpec{ - Schedule: "*/5 * * * *", // Run every 5 minutes - JobTemplate: batchv1.JobTemplateSpec{ - Spec: batchv1.JobSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"secondLabel": "temp"}, - }, - Template: getPodTemplateSpecWithVolumes(cronJobName), - }, - }, - }, - } -} - -func GetJob(namespace string, jobName string) *batchv1.Job { - return &batchv1.Job{ - ObjectMeta: getObjectMeta(namespace, jobName, false, false, false, false, map[string]string{}), - Spec: batchv1.JobSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"secondLabel": "temp"}, - }, - Template: getPodTemplateSpecWithVolumes(jobName), - }, - } -} - -func GetCronJobWithEnvVar(namespace string, cronJobName string) *batchv1.CronJob { - return &batchv1.CronJob{ - ObjectMeta: getObjectMeta(namespace, cronJobName, true, false, false, false, map[string]string{}), - Spec: batchv1.CronJobSpec{ - Schedule: "*/5 * * * *", // Run every 5 minutes - JobTemplate: batchv1.JobTemplateSpec{ - Spec: batchv1.JobSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"secondLabel": "temp"}, - }, - Template: getPodTemplateSpecWithEnvVars(cronJobName), - }, - }, - }, - } -} - -func GetJobWithEnvVar(namespace string, jobName string) *batchv1.Job { - return &batchv1.Job{ - ObjectMeta: getObjectMeta(namespace, jobName, true, false, false, false, map[string]string{}), - Spec: batchv1.JobSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"secondLabel": "temp"}, - }, - Template: getPodTemplateSpecWithEnvVars(jobName), - }, - } -} - -// GetSecretWithUpdatedLabel provides secret for testing -func GetSecretWithUpdatedLabel(namespace string, secretName string, label string, data string) *v1.Secret { - return &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: secretName, - Namespace: namespace, - Labels: map[string]string{"firstLabel": label}, - }, - Data: map[string][]byte{"test.url": []byte(data)}, - } -} - -// GetResourceSHAFromEnvVar returns the SHA value of given environment variable -func GetResourceSHAFromEnvVar(containers []v1.Container, envVar string) string { - for i := range containers { - envs := containers[i].Env - for j := range envs { - if envs[j].Name == envVar { - return envs[j].Value - } - } - } - return "" -} - -// GetResourceSHAFromAnnotation returns the SHA value of given environment variable -func GetResourceSHAFromAnnotation(podAnnotations map[string]string) string { - lastReloadedResourceName := fmt.Sprintf("%s/%s", - constants.ReloaderAnnotationPrefix, - constants.LastReloadedFromAnnotation, - ) - - annotationJson, ok := podAnnotations[lastReloadedResourceName] - if !ok { - return "" - } - - var last common.ReloadSource - bytes := []byte(annotationJson) - err := json.Unmarshal(bytes, &last) - if err != nil { - return "" - } - - return last.Hash -} - -// ConvertResourceToSHA generates SHA from secret, configmap or secretproviderclasspodstatus data -func ConvertResourceToSHA(resourceType string, namespace string, resourceName string, data string) string { - values := []string{} - switch resourceType { - case SecretResourceType: - secret := GetSecret(namespace, resourceName, data) - for k, v := range secret.Data { - values = append(values, k+"="+string(v[:])) - } - case ConfigmapResourceType: - configmap := GetConfigmap(namespace, resourceName, data) - for k, v := range configmap.Data { - values = append(values, k+"="+v) - } - case SecretProviderClassPodStatusResourceType: - secretproviderclasspodstatus := GetSecretProviderClassPodStatus(namespace, resourceName, data) - for _, v := range secretproviderclasspodstatus.Status.Objects { - values = append(values, v.ID+"="+v.Version) - } - values = append(values, "SecretProviderClassName="+secretproviderclasspodstatus.Status.SecretProviderClassName) - } - sort.Strings(values) - return crypto.GenerateSHA(strings.Join(values, ";")) -} - -// CreateConfigMap creates a configmap in given namespace and returns the ConfigMapInterface -func CreateConfigMap(client kubernetes.Interface, namespace string, configmapName string, data string) (core_v1.ConfigMapInterface, error) { - logrus.Infof("Creating configmap") - configmapClient := client.CoreV1().ConfigMaps(namespace) - _, err := configmapClient.Create(context.TODO(), GetConfigmap(namespace, configmapName, data), metav1.CreateOptions{}) - time.Sleep(3 * time.Second) - return configmapClient, err -} - -// CreateSecretProviderClass creates a SecretProviderClass in given namespace and returns the SecretProviderClassInterface -func CreateSecretProviderClass(client csiclient.Interface, namespace string, secretProviderClassName string, data string) (csiclient_v1.SecretProviderClassInterface, error) { - logrus.Infof("Creating SecretProviderClass") - secretProviderClassClient := client.SecretsstoreV1().SecretProviderClasses(namespace) - _, err := secretProviderClassClient.Create(context.TODO(), GetSecretProviderClass(namespace, secretProviderClassName, data), metav1.CreateOptions{}) - time.Sleep(3 * time.Second) - return secretProviderClassClient, err -} - -// CreateSecretProviderClassPodStatus creates a SecretProviderClassPodStatus in given namespace and returns the SecretProviderClassPodStatusInterface -func CreateSecretProviderClassPodStatus(client csiclient.Interface, namespace string, secretProviderClassPodStatusName string, data string) (csiclient_v1.SecretProviderClassPodStatusInterface, error) { - logrus.Infof("Creating SecretProviderClassPodStatus") - secretProviderClassPodStatusClient := client.SecretsstoreV1().SecretProviderClassPodStatuses(namespace) - secretProviderClassPodStatus := GetSecretProviderClassPodStatus(namespace, secretProviderClassPodStatusName, data) - _, err := secretProviderClassPodStatusClient.Create(context.TODO(), secretProviderClassPodStatus, metav1.CreateOptions{}) - time.Sleep(3 * time.Second) - return secretProviderClassPodStatusClient, err -} - -// CreateSecret creates a secret in given namespace and returns the SecretInterface -func CreateSecret(client kubernetes.Interface, namespace string, secretName string, data string) (core_v1.SecretInterface, error) { - logrus.Infof("Creating secret") - secretClient := client.CoreV1().Secrets(namespace) - _, err := secretClient.Create(context.TODO(), GetSecret(namespace, secretName, data), metav1.CreateOptions{}) - time.Sleep(3 * time.Second) - return secretClient, err -} - -// CreateDeployment creates a deployment in given namespace and returns the Deployment -func CreateDeployment(client kubernetes.Interface, deploymentName string, namespace string, volumeMount bool) (*appsv1.Deployment, error) { - logrus.Infof("Creating Deployment") - deploymentClient := client.AppsV1().Deployments(namespace) - var deploymentObj *appsv1.Deployment - if volumeMount { - deploymentObj = GetDeployment(namespace, deploymentName) - } else { - deploymentObj = GetDeploymentWithEnvVars(namespace, deploymentName) - } - deployment, err := deploymentClient.Create(context.TODO(), deploymentObj, metav1.CreateOptions{}) - time.Sleep(3 * time.Second) - return deployment, err -} - -// CreateDeployment creates a deployment in given namespace and returns the Deployment -func CreateDeploymentWithAnnotations(client kubernetes.Interface, deploymentName string, namespace string, additionalAnnotations map[string]string, volumeMount bool) (*appsv1.Deployment, error) { - logrus.Infof("Creating Deployment") - deploymentClient := client.AppsV1().Deployments(namespace) - var deploymentObj *appsv1.Deployment - if volumeMount { - deploymentObj = GetDeployment(namespace, deploymentName) - } else { - deploymentObj = GetDeploymentWithEnvVars(namespace, deploymentName) - } - - for annotationKey, annotationValue := range additionalAnnotations { - deploymentObj.Annotations[annotationKey] = annotationValue - } - - deployment, err := deploymentClient.Create(context.TODO(), deploymentObj, metav1.CreateOptions{}) - time.Sleep(3 * time.Second) - return deployment, err -} - -// CreateDeploymentConfig creates a deploymentConfig in given namespace and returns the DeploymentConfig -func CreateDeploymentConfig(client appsclient.Interface, deploymentName string, namespace string, volumeMount bool) (*openshiftv1.DeploymentConfig, error) { - logrus.Infof("Creating DeploymentConfig") - deploymentConfigsClient := client.AppsV1().DeploymentConfigs(namespace) - var deploymentConfigObj *openshiftv1.DeploymentConfig - if volumeMount { - deploymentConfigObj = GetDeploymentConfig(namespace, deploymentName) - } else { - deploymentConfigObj = GetDeploymentConfigWithEnvVars(namespace, deploymentName) - } - deploymentConfig, err := deploymentConfigsClient.Create(context.TODO(), deploymentConfigObj, metav1.CreateOptions{}) - time.Sleep(5 * time.Second) - return deploymentConfig, err -} - -// CreateDeploymentWithInitContainer creates a deployment in given namespace with init container and returns the Deployment -func CreateDeploymentWithInitContainer(client kubernetes.Interface, deploymentName string, namespace string, volumeMount bool) (*appsv1.Deployment, error) { - logrus.Infof("Creating Deployment") - deploymentClient := client.AppsV1().Deployments(namespace) - var deploymentObj *appsv1.Deployment - if volumeMount { - deploymentObj = GetDeploymentWithInitContainer(namespace, deploymentName) - } else { - deploymentObj = GetDeploymentWithInitContainerAndEnv(namespace, deploymentName) - } - deployment, err := deploymentClient.Create(context.TODO(), deploymentObj, metav1.CreateOptions{}) - time.Sleep(3 * time.Second) - return deployment, err -} - -// CreateDeploymentWithEnvVarSource creates a deployment in given namespace and returns the Deployment -func CreateDeploymentWithEnvVarSource(client kubernetes.Interface, deploymentName string, namespace string) (*appsv1.Deployment, error) { - logrus.Infof("Creating Deployment") - deploymentClient := client.AppsV1().Deployments(namespace) - deploymentObj := GetDeploymentWithEnvVarSources(namespace, deploymentName) - deployment, err := deploymentClient.Create(context.TODO(), deploymentObj, metav1.CreateOptions{}) - time.Sleep(3 * time.Second) - return deployment, err - -} - -// CreateDeploymentWithPodAnnotations creates a deployment in given namespace and returns the Deployment -func CreateDeploymentWithPodAnnotations(client kubernetes.Interface, deploymentName string, namespace string, both bool) (*appsv1.Deployment, error) { - logrus.Infof("Creating Deployment") - deploymentClient := client.AppsV1().Deployments(namespace) - deploymentObj := GetDeploymentWithPodAnnotations(namespace, deploymentName, both) - deployment, err := deploymentClient.Create(context.TODO(), deploymentObj, metav1.CreateOptions{}) - time.Sleep(3 * time.Second) - return deployment, err -} - -// CreateDeploymentWithEnvVarSourceAndAnnotations returns a deployment in given -// namespace with given annotations. -func CreateDeploymentWithEnvVarSourceAndAnnotations(client kubernetes.Interface, deploymentName string, namespace string, annotations map[string]string) (*appsv1.Deployment, error) { - logrus.Infof("Creating Deployment") - deploymentClient := client.AppsV1().Deployments(namespace) - deploymentObj := GetDeploymentWithEnvVarSources(namespace, deploymentName) - deploymentObj.Annotations = annotations - deployment, err := deploymentClient.Create(context.TODO(), deploymentObj, metav1.CreateOptions{}) - time.Sleep(3 * time.Second) - return deployment, err -} - -// CreateDeploymentWithTypedAutoAnnotation creates a deployment in given namespace and returns the Deployment with typed auto annotation -func CreateDeploymentWithTypedAutoAnnotation(client kubernetes.Interface, deploymentName string, namespace string, resourceType string) (*appsv1.Deployment, error) { - logrus.Infof("Creating Deployment") - deploymentClient := client.AppsV1().Deployments(namespace) - deploymentObj := GetDeploymentWithTypedAutoAnnotation(namespace, deploymentName, resourceType) - deployment, err := deploymentClient.Create(context.TODO(), deploymentObj, metav1.CreateOptions{}) - time.Sleep(3 * time.Second) - return deployment, err -} - -// CreateDeploymentWithExcludeAnnotation creates a deployment in given namespace and returns the Deployment with typed auto annotation -func CreateDeploymentWithExcludeAnnotation(client kubernetes.Interface, deploymentName string, namespace string, resourceType string) (*appsv1.Deployment, error) { - logrus.Infof("Creating Deployment") - deploymentClient := client.AppsV1().Deployments(namespace) - deploymentObj := GetDeploymentWithExcludeAnnotation(namespace, deploymentName, resourceType) - deployment, err := deploymentClient.Create(context.TODO(), deploymentObj, metav1.CreateOptions{}) - return deployment, err -} - -// CreateDaemonSet creates a deployment in given namespace and returns the DaemonSet -func CreateDaemonSet(client kubernetes.Interface, daemonsetName string, namespace string, volumeMount bool) (*appsv1.DaemonSet, error) { - logrus.Infof("Creating DaemonSet") - daemonsetClient := client.AppsV1().DaemonSets(namespace) - var daemonsetObj *appsv1.DaemonSet - if volumeMount { - daemonsetObj = GetDaemonSet(namespace, daemonsetName) - } else { - daemonsetObj = GetDaemonSetWithEnvVars(namespace, daemonsetName) - } - daemonset, err := daemonsetClient.Create(context.TODO(), daemonsetObj, metav1.CreateOptions{}) - time.Sleep(3 * time.Second) - return daemonset, err -} - -// CreateStatefulSet creates a deployment in given namespace and returns the StatefulSet -func CreateStatefulSet(client kubernetes.Interface, statefulsetName string, namespace string, volumeMount bool) (*appsv1.StatefulSet, error) { - logrus.Infof("Creating StatefulSet") - statefulsetClient := client.AppsV1().StatefulSets(namespace) - var statefulsetObj *appsv1.StatefulSet - if volumeMount { - statefulsetObj = GetStatefulSet(namespace, statefulsetName) - } else { - statefulsetObj = GetStatefulSetWithEnvVar(namespace, statefulsetName) - } - statefulset, err := statefulsetClient.Create(context.TODO(), statefulsetObj, metav1.CreateOptions{}) - time.Sleep(3 * time.Second) - return statefulset, err -} - -// CreateCronJob creates a cronjob in given namespace and returns the CronJob -func CreateCronJob(client kubernetes.Interface, cronJobName string, namespace string, volumeMount bool) (*batchv1.CronJob, error) { - logrus.Infof("Creating CronJob") - cronJobClient := client.BatchV1().CronJobs(namespace) - var cronJobObj *batchv1.CronJob - if volumeMount { - cronJobObj = GetCronJob(namespace, cronJobName) - } else { - cronJobObj = GetCronJobWithEnvVar(namespace, cronJobName) - } - cronJob, err := cronJobClient.Create(context.TODO(), cronJobObj, metav1.CreateOptions{}) - time.Sleep(3 * time.Second) - return cronJob, err -} - -// CreateJob creates a job in given namespace and returns the Job -func CreateJob(client kubernetes.Interface, jobName string, namespace string, volumeMount bool) (*batchv1.Job, error) { - logrus.Infof("Creating Job") - jobClient := client.BatchV1().Jobs(namespace) - var jobObj *batchv1.Job - if volumeMount { - jobObj = GetJob(namespace, jobName) - } else { - jobObj = GetJobWithEnvVar(namespace, jobName) - } - job, err := jobClient.Create(context.TODO(), jobObj, metav1.CreateOptions{}) - time.Sleep(3 * time.Second) - return job, err -} - -// DeleteDeployment creates a deployment in given namespace and returns the error if any -func DeleteDeployment(client kubernetes.Interface, namespace string, deploymentName string) error { - logrus.Infof("Deleting Deployment") - deploymentError := client.AppsV1().Deployments(namespace).Delete(context.TODO(), deploymentName, metav1.DeleteOptions{}) - time.Sleep(3 * time.Second) - return deploymentError -} - -// DeleteDeploymentConfig deletes a deploymentConfig in given namespace and returns the error if any -func DeleteDeploymentConfig(client appsclient.Interface, namespace string, deploymentConfigName string) error { - logrus.Infof("Deleting DeploymentConfig") - deploymentConfigError := client.AppsV1().DeploymentConfigs(namespace).Delete(context.TODO(), deploymentConfigName, metav1.DeleteOptions{}) - time.Sleep(3 * time.Second) - return deploymentConfigError -} - -// DeleteDaemonSet creates a daemonset in given namespace and returns the error if any -func DeleteDaemonSet(client kubernetes.Interface, namespace string, daemonsetName string) error { - logrus.Infof("Deleting DaemonSet %s", daemonsetName) - daemonsetError := client.AppsV1().DaemonSets(namespace).Delete(context.TODO(), daemonsetName, metav1.DeleteOptions{}) - time.Sleep(3 * time.Second) - return daemonsetError -} - -// DeleteStatefulSet creates a statefulset in given namespace and returns the error if any -func DeleteStatefulSet(client kubernetes.Interface, namespace string, statefulsetName string) error { - logrus.Infof("Deleting StatefulSet %s", statefulsetName) - statefulsetError := client.AppsV1().StatefulSets(namespace).Delete(context.TODO(), statefulsetName, metav1.DeleteOptions{}) - time.Sleep(3 * time.Second) - return statefulsetError -} - -// DeleteCronJob deletes a cronJob in given namespace and returns the error if any -func DeleteCronJob(client kubernetes.Interface, namespace string, cronJobName string) error { - logrus.Infof("Deleting CronJob %s", cronJobName) - cronJobError := client.BatchV1().CronJobs(namespace).Delete(context.TODO(), cronJobName, metav1.DeleteOptions{}) - time.Sleep(3 * time.Second) - return cronJobError -} - -// Deleteob deletes a job in given namespace and returns the error if any -func DeleteJob(client kubernetes.Interface, namespace string, jobName string) error { - logrus.Infof("Deleting Job %s", jobName) - jobError := client.BatchV1().Jobs(namespace).Delete(context.TODO(), jobName, metav1.DeleteOptions{}) - time.Sleep(3 * time.Second) - return jobError -} - -// UpdateConfigMap updates a configmap in given namespace and returns the error if any -func UpdateConfigMap(configmapClient core_v1.ConfigMapInterface, namespace string, configmapName string, label string, data string) error { - logrus.Infof("Updating configmap %q.\n", configmapName) - var configmap *v1.ConfigMap - if label != "" { - configmap = GetConfigmapWithUpdatedLabel(namespace, configmapName, label, data) - } else { - configmap = GetConfigmap(namespace, configmapName, data) - } - _, updateErr := configmapClient.Update(context.TODO(), configmap, metav1.UpdateOptions{}) - time.Sleep(3 * time.Second) - return updateErr -} - -// UpdateSecret updates a secret in given namespace and returns the error if any -func UpdateSecret(secretClient core_v1.SecretInterface, namespace string, secretName string, label string, data string) error { - logrus.Infof("Updating secret %q.\n", secretName) - var secret *v1.Secret - if label != "" { - secret = GetSecretWithUpdatedLabel(namespace, secretName, label, data) - } else { - secret = GetSecret(namespace, secretName, data) - } - _, updateErr := secretClient.Update(context.TODO(), secret, metav1.UpdateOptions{}) - time.Sleep(3 * time.Second) - return updateErr -} - -// UpdateSecretProviderClassPodStatus updates a secretproviderclasspodstatus in given namespace and returns the error if any -func UpdateSecretProviderClassPodStatus(spcpsClient csiclient_v1.SecretProviderClassPodStatusInterface, namespace string, spcpsName string, label string, data string) error { - logrus.Infof("Updating secretproviderclasspodstatus %q.\n", spcpsName) - updatedStatus := GetSecretProviderClassPodStatus(namespace, spcpsName, data).Status - secretproviderclasspodstatus, err := spcpsClient.Get(context.TODO(), spcpsName, metav1.GetOptions{}) - if err != nil { - return err - } - secretproviderclasspodstatus.Status = updatedStatus - if label != "" { - labels := secretproviderclasspodstatus.Labels - if labels == nil { - labels = make(map[string]string) - } - labels["firstLabel"] = label - } - _, updateErr := spcpsClient.Update(context.TODO(), secretproviderclasspodstatus, metav1.UpdateOptions{}) - time.Sleep(3 * time.Second) - return updateErr -} - -// DeleteConfigMap deletes a configmap in given namespace and returns the error if any -func DeleteConfigMap(client kubernetes.Interface, namespace string, configmapName string) error { - logrus.Infof("Deleting configmap %q.\n", configmapName) - err := client.CoreV1().ConfigMaps(namespace).Delete(context.TODO(), configmapName, metav1.DeleteOptions{}) - time.Sleep(3 * time.Second) - return err -} - -// DeleteSecret deletes a secret in given namespace and returns the error if any -func DeleteSecret(client kubernetes.Interface, namespace string, secretName string) error { - logrus.Infof("Deleting secret %q.\n", secretName) - err := client.CoreV1().Secrets(namespace).Delete(context.TODO(), secretName, metav1.DeleteOptions{}) - time.Sleep(3 * time.Second) - return err -} - -// DeleteSecretProviderClass deletes a secretproviderclass in given namespace and returns the error if any -func DeleteSecretProviderClass(client csiclient.Interface, namespace string, secretProviderClassName string) error { - logrus.Infof("Deleting secretproviderclass %q.\n", secretProviderClassName) - err := client.SecretsstoreV1().SecretProviderClasses(namespace).Delete(context.TODO(), secretProviderClassName, metav1.DeleteOptions{}) - time.Sleep(3 * time.Second) - return err -} - -// DeleteSecretProviderClassPodStatus deletes a secretproviderclasspodstatus in given namespace and returns the error if any -func DeleteSecretProviderClassPodStatus(client csiclient.Interface, namespace string, secretProviderClassPodStatusName string) error { - logrus.Infof("Deleting secretproviderclasspodstatus %q.\n", secretProviderClassPodStatusName) - err := client.SecretsstoreV1().SecretProviderClassPodStatuses(namespace).Delete(context.TODO(), secretProviderClassPodStatusName, metav1.DeleteOptions{}) - time.Sleep(3 * time.Second) - return err -} - -// RandSeq generates a random sequence -func RandSeq(n int) string { - b := make([]rune, n) - for i := range b { - b[i] = letters[rand.Intn(len(letters))] - } - return string(b) -} - -// VerifyResourceEnvVarUpdate verifies whether the rolling upgrade happened or not -func VerifyResourceEnvVarUpdate(clients kube.Clients, config common.Config, envVarPostfix string, upgradeFuncs callbacks.RollingUpgradeFuncs) bool { - items := upgradeFuncs.ItemsFunc(clients, config.Namespace) - for _, i := range items { - containers := upgradeFuncs.ContainersFunc(i) - accessor, err := meta.Accessor(i) - if err != nil { - return false - } - annotations := accessor.GetAnnotations() - // match statefulsets with the correct annotation - annotationValue := annotations[config.Annotation] - searchAnnotationValue := annotations[options.AutoSearchAnnotation] - reloaderEnabledValue := annotations[options.ReloaderAutoAnnotation] - typedAutoAnnotationEnabledValue := annotations[config.TypedAutoAnnotation] - reloaderEnabled, err := strconv.ParseBool(reloaderEnabledValue) - typedAutoAnnotationEnabled, errTyped := strconv.ParseBool(typedAutoAnnotationEnabledValue) - matches := false - if err == nil && reloaderEnabled || errTyped == nil && typedAutoAnnotationEnabled { - matches = true - } else if annotationValue != "" { - values := strings.Split(annotationValue, ",") - for _, value := range values { - value = strings.Trim(value, " ") - if value == config.ResourceName { - matches = true - break - } - } - } else if searchAnnotationValue == "true" { - if config.ResourceAnnotations[options.SearchMatchAnnotation] == "true" { - matches = true - } - } - - if matches { - envName := constants.EnvVarPrefix + util.ConvertToEnvVarName(config.ResourceName) + "_" + envVarPostfix - updated := GetResourceSHAFromEnvVar(containers, envName) - if updated == config.SHAValue { - return true - } - } - } - return false -} - -// VerifyResourceEnvVarRemoved verifies whether the rolling upgrade happened or not and all Envvars SKAKATER_name_CONFIGMAP/SECRET are removed -func VerifyResourceEnvVarRemoved(clients kube.Clients, config common.Config, envVarPostfix string, upgradeFuncs callbacks.RollingUpgradeFuncs) bool { - items := upgradeFuncs.ItemsFunc(clients, config.Namespace) - for _, i := range items { - containers := upgradeFuncs.ContainersFunc(i) - accessor, err := meta.Accessor(i) - if err != nil { - return false - } - - annotations := accessor.GetAnnotations() - // match statefulsets with the correct annotation - - annotationValue := annotations[config.Annotation] - searchAnnotationValue := annotations[options.AutoSearchAnnotation] - reloaderEnabledValue := annotations[options.ReloaderAutoAnnotation] - typedAutoAnnotationEnabledValue := annotations[config.TypedAutoAnnotation] - reloaderEnabled, err := strconv.ParseBool(reloaderEnabledValue) - typedAutoAnnotationEnabled, errTyped := strconv.ParseBool(typedAutoAnnotationEnabledValue) - - matches := false - if err == nil && reloaderEnabled || errTyped == nil && typedAutoAnnotationEnabled { - matches = true - } else if annotationValue != "" { - values := strings.Split(annotationValue, ",") - for _, value := range values { - value = strings.Trim(value, " ") - if value == config.ResourceName { - matches = true - break - } - } - } else if searchAnnotationValue == "true" { - if config.ResourceAnnotations[options.SearchMatchAnnotation] == "true" { - matches = true - } - } - - if matches { - envName := constants.EnvVarPrefix + util.ConvertToEnvVarName(config.ResourceName) + "_" + envVarPostfix - value := GetResourceSHAFromEnvVar(containers, envName) - if value == "" { - return true - } - } - } - return false -} - -// VerifyResourceAnnotationUpdate verifies whether the rolling upgrade happened or not -func VerifyResourceAnnotationUpdate(clients kube.Clients, config common.Config, upgradeFuncs callbacks.RollingUpgradeFuncs) bool { - items := upgradeFuncs.ItemsFunc(clients, config.Namespace) - for _, i := range items { - podAnnotations := upgradeFuncs.PodAnnotationsFunc(i) - accessor, err := meta.Accessor(i) - if err != nil { - return false - } - annotations := accessor.GetAnnotations() - // match statefulsets with the correct annotation - annotationValue := annotations[config.Annotation] - searchAnnotationValue := annotations[options.AutoSearchAnnotation] - reloaderEnabledValue := annotations[options.ReloaderAutoAnnotation] - typedAutoAnnotationEnabledValue := annotations[config.TypedAutoAnnotation] - reloaderEnabled, _ := strconv.ParseBool(reloaderEnabledValue) - typedAutoAnnotationEnabled, _ := strconv.ParseBool(typedAutoAnnotationEnabledValue) - matches := false - if reloaderEnabled || typedAutoAnnotationEnabled || reloaderEnabledValue == "" && typedAutoAnnotationEnabledValue == "" && options.AutoReloadAll { - matches = true - } else if annotationValue != "" { - values := strings.Split(annotationValue, ",") - for _, value := range values { - value = strings.Trim(value, " ") - if value == config.ResourceName { - matches = true - break - } - } - } else if searchAnnotationValue == "true" { - if config.ResourceAnnotations[options.SearchMatchAnnotation] == "true" { - matches = true - } - } - - if matches { - updated := GetResourceSHAFromAnnotation(podAnnotations) - if updated == config.SHAValue { - return true - } - } - } - return false -} - -func GetSHAfromEmptyData() string { - // Use a special marker that represents "deleted" or "empty" state - // This ensures we have a distinct, deterministic hash for the delete strategy - // Note: We could use GenerateSHA("") which now returns a hash, but using a marker - // makes the intent clearer and avoids potential confusion with actual empty data - return crypto.GenerateSHA("__RELOADER_EMPTY_DELETE_MARKER__") -} - -// GetRollout provides rollout for testing -func GetRollout(namespace string, rolloutName string, annotations map[string]string) *argorolloutv1alpha1.Rollout { - replicaset := int32(1) - return &argorolloutv1alpha1.Rollout{ - ObjectMeta: getObjectMeta(namespace, rolloutName, false, false, false, false, annotations), - Spec: argorolloutv1alpha1.RolloutSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"secondLabel": "temp"}, - }, - Replicas: &replicaset, - Template: getPodTemplateSpecWithVolumes(rolloutName), - }, - } -} - -// CreateRollout creates a rolout in given namespace and returns the Rollout -func CreateRollout(client argorollout.Interface, rolloutName string, namespace string, annotations map[string]string) (*argorolloutv1alpha1.Rollout, error) { - logrus.Infof("Creating Rollout") - rolloutClient := client.ArgoprojV1alpha1().Rollouts(namespace) - rolloutObj := GetRollout(namespace, rolloutName, annotations) - rollout, err := rolloutClient.Create(context.TODO(), rolloutObj, metav1.CreateOptions{}) - time.Sleep(3 * time.Second) - return rollout, err -} diff --git a/internal/pkg/testutil/rand.go b/internal/pkg/testutil/rand.go new file mode 100644 index 000000000..bf88d4261 --- /dev/null +++ b/internal/pkg/testutil/rand.go @@ -0,0 +1,16 @@ +package testutil + +import ( + "math/rand/v2" +) + +const letterBytes = "abcdefghijklmnopqrstuvwxyz" + +// RandSeq generates a random string of the specified length. +func RandSeq(n int) string { + b := make([]byte, n) + for i := range b { + b[i] = letterBytes[rand.IntN(len(letterBytes))] + } + return string(b) +} diff --git a/internal/pkg/testutil/testutil.go b/internal/pkg/testutil/testutil.go new file mode 100644 index 000000000..c15aacb41 --- /dev/null +++ b/internal/pkg/testutil/testutil.go @@ -0,0 +1,630 @@ +package testutil + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "fmt" + "strings" + "time" + + openshiftv1 "github.com/openshift/api/apps/v1" + openshiftclient "github.com/openshift/client-go/apps/clientset/versioned" + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +const ( + // ConfigmapResourceType represents ConfigMap resource type + ConfigmapResourceType = "configmap" + // SecretResourceType represents Secret resource type + SecretResourceType = "secret" +) + +// CreateNamespace creates a namespace with the given name. +func CreateNamespace(name string, client kubernetes.Interface) error { + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + } + _, err := client.CoreV1().Namespaces().Create(context.Background(), ns, metav1.CreateOptions{}) + return err +} + +// DeleteNamespace deletes the namespace with the given name. +func DeleteNamespace(name string, client kubernetes.Interface) error { + return client.CoreV1().Namespaces().Delete(context.Background(), name, metav1.DeleteOptions{}) +} + +// CreateConfigMap creates a ConfigMap with the given name and data. +func CreateConfigMap(client kubernetes.Interface, namespace, name, data string) (*corev1.ConfigMap, error) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Data: map[string]string{ + "url": data, + }, + } + return client.CoreV1().ConfigMaps(namespace).Create(context.Background(), cm, metav1.CreateOptions{}) +} + +// CreateConfigMapWithAnnotations creates a ConfigMap with the given name, data, and annotations. +func CreateConfigMapWithAnnotations(client kubernetes.Interface, namespace, name, data string, annotations map[string]string) ( + *corev1.ConfigMap, error, +) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Annotations: annotations, + }, + Data: map[string]string{ + "url": data, + }, + } + return client.CoreV1().ConfigMaps(namespace).Create(context.Background(), cm, metav1.CreateOptions{}) +} + +// UpdateConfigMap updates the ConfigMap with new label and/or data. +func UpdateConfigMap(cm *corev1.ConfigMap, namespace, name, label, data string) error { + if label != "" { + if cm.Labels == nil { + cm.Labels = make(map[string]string) + } + cm.Labels["test-label"] = label + } + if data != "" { + cm.Data["url"] = data + } + return nil +} + +// UpdateConfigMapWithClient updates the ConfigMap with new label and/or data. +func UpdateConfigMapWithClient(client kubernetes.Interface, namespace, name, label, data string) error { + ctx := context.Background() + cm, err := client.CoreV1().ConfigMaps(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return err + } + if label != "" { + if cm.Labels == nil { + cm.Labels = make(map[string]string) + } + cm.Labels["test-label"] = label + } + if data != "" { + cm.Data["url"] = data + } + _, err = client.CoreV1().ConfigMaps(namespace).Update(ctx, cm, metav1.UpdateOptions{}) + return err +} + +// DeleteConfigMap deletes the ConfigMap with the given name. +func DeleteConfigMap(client kubernetes.Interface, namespace, name string) error { + return client.CoreV1().ConfigMaps(namespace).Delete(context.Background(), name, metav1.DeleteOptions{}) +} + +// CreateSecret creates a Secret with the given name and data. +func CreateSecret(client kubernetes.Interface, namespace, name, data string) (*corev1.Secret, error) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Data: map[string][]byte{ + "password": []byte(data), + }, + } + return client.CoreV1().Secrets(namespace).Create(context.Background(), secret, metav1.CreateOptions{}) +} + +// UpdateSecretWithClient updates the Secret with new label and/or data. +func UpdateSecretWithClient(client kubernetes.Interface, namespace, name, label, data string) error { + ctx := context.Background() + secret, err := client.CoreV1().Secrets(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return err + } + if label != "" { + if secret.Labels == nil { + secret.Labels = make(map[string]string) + } + secret.Labels["test-label"] = label + } + if data != "" { + secret.Data["password"] = []byte(data) + } + _, err = client.CoreV1().Secrets(namespace).Update(ctx, secret, metav1.UpdateOptions{}) + return err +} + +// DeleteSecret deletes the Secret with the given name. +func DeleteSecret(client kubernetes.Interface, namespace, name string) error { + return client.CoreV1().Secrets(namespace).Delete(context.Background(), name, metav1.DeleteOptions{}) +} + +// CreateDeployment creates a Deployment that references a ConfigMap/Secret. +func CreateDeployment(client kubernetes.Interface, name, namespace string, useConfigMap bool, annotations map[string]string) ( + *appsv1.Deployment, error, +) { + var deployment *appsv1.Deployment + if useConfigMap { + deployment = NewDeploymentWithEnvFrom(name, namespace, name, "") + } else { + deployment = NewDeploymentWithEnvFrom(name, namespace, "", name) + } + deployment.Annotations = annotations + // Override image for integration tests + deployment.Spec.Template.Spec.Containers[0].Image = "busybox:1.36" + deployment.Spec.Template.Spec.Containers[0].Command = []string{"sh", "-c", "while true; do sleep 3600; done"} + + return client.AppsV1().Deployments(namespace).Create(context.Background(), deployment, metav1.CreateOptions{}) +} + +// DeleteDeployment deletes the Deployment with the given name. +func DeleteDeployment(client kubernetes.Interface, namespace, name string) error { + return client.AppsV1().Deployments(namespace).Delete(context.Background(), name, metav1.DeleteOptions{}) +} + +// CreateDeploymentWithBoth creates a Deployment that references both a ConfigMap and a Secret. +func CreateDeploymentWithBoth(client kubernetes.Interface, name, namespace, configMapName, secretName string, annotations map[string]string) ( + *appsv1.Deployment, error, +) { + deployment := NewDeploymentWithEnvFrom(name, namespace, configMapName, secretName) + deployment.Annotations = annotations + // Override image for integration tests + deployment.Spec.Template.Spec.Containers[0].Image = "busybox:1.36" + deployment.Spec.Template.Spec.Containers[0].Command = []string{"sh", "-c", "while true; do sleep 3600; done"} + + return client.AppsV1().Deployments(namespace).Create(context.Background(), deployment, metav1.CreateOptions{}) +} + +// CreateDaemonSet creates a DaemonSet that references a ConfigMap/Secret. +func CreateDaemonSet(client kubernetes.Interface, name, namespace string, useConfigMap bool, annotations map[string]string) ( + *appsv1.DaemonSet, error, +) { + daemonset := NewDaemonSet(name, namespace, annotations) + // Override image for integration tests + daemonset.Spec.Template.Spec.Containers[0].Image = "busybox:1.36" + daemonset.Spec.Template.Spec.Containers[0].Command = []string{"sh", "-c", "while true; do sleep 3600; done"} + + if useConfigMap { + daemonset.Spec.Template.Spec.Containers[0].EnvFrom = []corev1.EnvFromSource{ + { + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: name}, + }, + }, + } + } else { + daemonset.Spec.Template.Spec.Containers[0].EnvFrom = []corev1.EnvFromSource{ + { + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: name}, + }, + }, + } + } + + return client.AppsV1().DaemonSets(namespace).Create(context.Background(), daemonset, metav1.CreateOptions{}) +} + +// DeleteDaemonSet deletes the DaemonSet with the given name. +func DeleteDaemonSet(client kubernetes.Interface, namespace, name string) error { + return client.AppsV1().DaemonSets(namespace).Delete(context.Background(), name, metav1.DeleteOptions{}) +} + +// CreateStatefulSet creates a StatefulSet that references a ConfigMap/Secret. +func CreateStatefulSet(client kubernetes.Interface, name, namespace string, useConfigMap bool, annotations map[string]string) ( + *appsv1.StatefulSet, error, +) { + statefulset := NewStatefulSet(name, namespace, annotations) + statefulset.Spec.ServiceName = name + // Override image for integration tests + statefulset.Spec.Template.Spec.Containers[0].Image = "busybox:1.36" + statefulset.Spec.Template.Spec.Containers[0].Command = []string{"sh", "-c", "while true; do sleep 3600; done"} + + if useConfigMap { + statefulset.Spec.Template.Spec.Containers[0].EnvFrom = []corev1.EnvFromSource{ + { + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: name}, + }, + }, + } + } else { + statefulset.Spec.Template.Spec.Containers[0].EnvFrom = []corev1.EnvFromSource{ + { + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: name}, + }, + }, + } + } + + return client.AppsV1().StatefulSets(namespace).Create(context.Background(), statefulset, metav1.CreateOptions{}) +} + +// DeleteStatefulSet deletes the StatefulSet with the given name. +func DeleteStatefulSet(client kubernetes.Interface, namespace, name string) error { + return client.AppsV1().StatefulSets(namespace).Delete(context.Background(), name, metav1.DeleteOptions{}) +} + +// CreateCronJob creates a CronJob that references a ConfigMap/Secret. +func CreateCronJob(client kubernetes.Interface, name, namespace string, useConfigMap bool, annotations map[string]string) (*batchv1.CronJob, error) { + cronjob := NewCronJob(name, namespace) + cronjob.Annotations = annotations + // Override image for integration tests + cronjob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Image = "busybox:1.36" + cronjob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Command = []string{"sh", "-c", "echo hello"} + cronjob.Spec.JobTemplate.Spec.Template.Spec.RestartPolicy = corev1.RestartPolicyOnFailure + + if useConfigMap { + cronjob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].EnvFrom = []corev1.EnvFromSource{ + { + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: name}, + }, + }, + } + } else { + cronjob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].EnvFrom = []corev1.EnvFromSource{ + { + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: name}, + }, + }, + } + } + + return client.BatchV1().CronJobs(namespace).Create(context.Background(), cronjob, metav1.CreateOptions{}) +} + +// DeleteCronJob deletes the CronJob with the given name. +func DeleteCronJob(client kubernetes.Interface, namespace, name string) error { + return client.BatchV1().CronJobs(namespace).Delete(context.Background(), name, metav1.DeleteOptions{}) +} + +// ConvertResourceToSHA converts a resource data to SHA256 hash. +func ConvertResourceToSHA(resourceType, namespace, name, data string) string { + content := fmt.Sprintf("%s/%s/%s:%s", resourceType, namespace, name, data) + hash := sha256.Sum256([]byte(content)) + return base64.StdEncoding.EncodeToString(hash[:]) +} + +// WaitForDeploymentAnnotation waits for a deployment to have the specified annotation value. +func WaitForDeploymentAnnotation(client kubernetes.Interface, namespace, name, annotation, expectedValue string, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + return wait.PollUntilContextTimeout( + ctx, time.Second, timeout, true, func(ctx context.Context) (bool, error) { + deployment, err := client.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return false, nil // Keep waiting + } + value, ok := deployment.Spec.Template.Annotations[annotation] + if !ok { + return false, nil // Keep waiting + } + return value == expectedValue, nil + }, + ) +} + +// WaitForDeploymentReloadedAnnotation waits for a deployment to have the specified reloaded annotation. +func WaitForDeploymentReloadedAnnotation(client kubernetes.Interface, namespace, name, annotationName string, timeout time.Duration) ( + bool, error, +) { + var found bool + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + err := wait.PollUntilContextTimeout( + ctx, time.Second, timeout, true, func(ctx context.Context) (bool, error) { + deployment, err := client.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return false, nil // Keep waiting + } + // Check for the last-reloaded-from annotation in pod template + if deployment.Spec.Template.Annotations != nil { + if _, ok := deployment.Spec.Template.Annotations[annotationName]; ok { + found = true + return true, nil + } + } + return false, nil + }, + ) + if wait.Interrupted(err) { + return found, nil + } + return found, err +} + +// WaitForDaemonSetReloadedAnnotation waits for a daemonset to have the specified reloaded annotation. +func WaitForDaemonSetReloadedAnnotation(client kubernetes.Interface, namespace, name, annotationName string, timeout time.Duration) ( + bool, error, +) { + var found bool + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + err := wait.PollUntilContextTimeout( + ctx, time.Second, timeout, true, func(ctx context.Context) (bool, error) { + daemonset, err := client.AppsV1().DaemonSets(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return false, nil // Keep waiting + } + // Check for the last-reloaded-from annotation in pod template + if daemonset.Spec.Template.Annotations != nil { + if _, ok := daemonset.Spec.Template.Annotations[annotationName]; ok { + found = true + return true, nil + } + } + return false, nil + }, + ) + if wait.Interrupted(err) { + return found, nil + } + return found, err +} + +// WaitForStatefulSetReloadedAnnotation waits for a statefulset to have the specified reloaded annotation. +func WaitForStatefulSetReloadedAnnotation(client kubernetes.Interface, namespace, name, annotationName string, timeout time.Duration) ( + bool, error, +) { + var found bool + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + err := wait.PollUntilContextTimeout( + ctx, time.Second, timeout, true, func(ctx context.Context) (bool, error) { + statefulset, err := client.AppsV1().StatefulSets(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return false, nil // Keep waiting + } + // Check for the last-reloaded-from annotation in pod template + if statefulset.Spec.Template.Annotations != nil { + if _, ok := statefulset.Spec.Template.Annotations[annotationName]; ok { + found = true + return true, nil + } + } + return false, nil + }, + ) + if wait.Interrupted(err) { + return found, nil + } + return found, err +} + +// NewOpenshiftClient creates an OpenShift client from the given rest config. +func NewOpenshiftClient(restCfg *rest.Config) (openshiftclient.Interface, error) { + return openshiftclient.NewForConfig(restCfg) +} + +// CreateDeploymentConfig creates a DeploymentConfig that references a ConfigMap/Secret. +func CreateDeploymentConfig(client openshiftclient.Interface, name, namespace string, useConfigMap bool, annotations map[string]string) ( + *openshiftv1.DeploymentConfig, error, +) { + var dc *openshiftv1.DeploymentConfig + if useConfigMap { + dc = NewDeploymentConfigWithEnvFrom(name, namespace, name, "") + } else { + dc = NewDeploymentConfigWithEnvFrom(name, namespace, "", name) + } + dc.Annotations = annotations + dc.Spec.Template.Spec.Containers[0].Image = "busybox:1.36" + dc.Spec.Template.Spec.Containers[0].Command = []string{"sh", "-c", "while true; do sleep 3600; done"} + + return client.AppsV1().DeploymentConfigs(namespace).Create(context.Background(), dc, metav1.CreateOptions{}) +} + +// DeleteDeploymentConfig deletes the DeploymentConfig with the given name. +func DeleteDeploymentConfig(client openshiftclient.Interface, namespace, name string) error { + return client.AppsV1().DeploymentConfigs(namespace).Delete(context.Background(), name, metav1.DeleteOptions{}) +} + +// WaitForDeploymentConfigReloadedAnnotation waits for a DeploymentConfig to have the specified reloaded annotation. +func WaitForDeploymentConfigReloadedAnnotation(client openshiftclient.Interface, namespace, name, annotationName string, timeout time.Duration) ( + bool, error, +) { + var found bool + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + err := wait.PollUntilContextTimeout( + ctx, time.Second, timeout, true, func(ctx context.Context) (bool, error) { + dc, err := client.AppsV1().DeploymentConfigs(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return false, nil // Keep waiting + } + if dc.Spec.Template != nil && dc.Spec.Template.Annotations != nil { + if _, ok := dc.Spec.Template.Annotations[annotationName]; ok { + found = true + return true, nil + } + } + return false, nil + }, + ) + if wait.Interrupted(err) { + return found, nil + } + return found, err +} + +// WaitForDeploymentPaused waits for a deployment to be paused (spec.Paused=true). +func WaitForDeploymentPaused(client kubernetes.Interface, namespace, name string, timeout time.Duration) (bool, error) { + var paused bool + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + err := wait.PollUntilContextTimeout( + ctx, time.Second, timeout, true, func(ctx context.Context) (bool, error) { + deployment, err := client.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return false, nil // Keep waiting + } + if deployment.Spec.Paused { + paused = true + return true, nil + } + return false, nil + }, + ) + if wait.Interrupted(err) { + return paused, nil + } + return paused, err +} + +// WaitForDeploymentUnpaused waits for a deployment to be unpaused (spec.Paused=false). +func WaitForDeploymentUnpaused(client kubernetes.Interface, namespace, name string, timeout time.Duration) (bool, error) { + var unpaused bool + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + err := wait.PollUntilContextTimeout( + ctx, time.Second, timeout, true, func(ctx context.Context) (bool, error) { + deployment, err := client.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return false, nil // Keep waiting + } + if !deployment.Spec.Paused { + unpaused = true + return true, nil + } + return false, nil + }, + ) + if wait.Interrupted(err) { + return unpaused, nil + } + return unpaused, err +} + +// WaitForCronJobTriggeredJob waits for a Job to be created by a CronJob (triggered by Reloader). +func WaitForCronJobTriggeredJob(client kubernetes.Interface, namespace, cronJobName string, timeout time.Duration) (bool, error) { + var found bool + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + err := wait.PollUntilContextTimeout( + ctx, time.Second, timeout, true, func(ctx context.Context) (bool, error) { + jobs, err := client.BatchV1().Jobs(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return false, nil // Keep waiting + } + for _, job := range jobs.Items { + if strings.HasPrefix(job.Name, cronJobName+"-") { + if job.Annotations != nil { + if _, ok := job.Annotations["cronjob.kubernetes.io/instantiate"]; ok { + found = true + return true, nil + } + } + } + } + return false, nil + }, + ) + if wait.Interrupted(err) { + return found, nil + } + return found, err +} + +// WaitForDeploymentEnvVar waits for a deployment's containers to have the specified env var with a non-empty value. +func WaitForDeploymentEnvVar(client kubernetes.Interface, namespace, name, envVarPrefix string, timeout time.Duration) ( + bool, error, +) { + var found bool + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + err := wait.PollUntilContextTimeout( + ctx, time.Second, timeout, true, func(ctx context.Context) (bool, error) { + deployment, err := client.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return false, nil + } + for _, container := range deployment.Spec.Template.Spec.Containers { + for _, env := range container.Env { + if strings.HasPrefix(env.Name, envVarPrefix) && env.Value != "" { + found = true + return true, nil + } + } + } + return false, nil + }, + ) + if wait.Interrupted(err) { + return found, nil + } + return found, err +} + +// WaitForDaemonSetEnvVar waits for a daemonset's containers to have the specified env var with a non-empty value. +func WaitForDaemonSetEnvVar(client kubernetes.Interface, namespace, name, envVarPrefix string, timeout time.Duration) ( + bool, error, +) { + var found bool + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + err := wait.PollUntilContextTimeout( + ctx, time.Second, timeout, true, func(ctx context.Context) (bool, error) { + daemonset, err := client.AppsV1().DaemonSets(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return false, nil + } + for _, container := range daemonset.Spec.Template.Spec.Containers { + for _, env := range container.Env { + if strings.HasPrefix(env.Name, envVarPrefix) && env.Value != "" { + found = true + return true, nil + } + } + } + return false, nil + }, + ) + if wait.Interrupted(err) { + return found, nil + } + return found, err +} + +// WaitForStatefulSetEnvVar waits for a statefulset's containers to have the specified env var with a non-empty value. +func WaitForStatefulSetEnvVar(client kubernetes.Interface, namespace, name, envVarPrefix string, timeout time.Duration) ( + bool, error, +) { + var found bool + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + err := wait.PollUntilContextTimeout( + ctx, time.Second, timeout, true, func(ctx context.Context) (bool, error) { + statefulset, err := client.AppsV1().StatefulSets(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return false, nil + } + for _, container := range statefulset.Spec.Template.Spec.Containers { + for _, env := range container.Env { + if strings.HasPrefix(env.Name, envVarPrefix) && env.Value != "" { + found = true + return true, nil + } + } + } + return false, nil + }, + ) + if wait.Interrupted(err) { + return found, nil + } + return found, err +} diff --git a/internal/pkg/util/util.go b/internal/pkg/util/util.go deleted file mode 100644 index 8a1bedf6f..000000000 --- a/internal/pkg/util/util.go +++ /dev/null @@ -1,151 +0,0 @@ -package util - -import ( - "bytes" - "encoding/base64" - "errors" - "fmt" - "sort" - "strings" - - "github.com/spf13/cobra" - v1 "k8s.io/api/core/v1" - csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1" - - "github.com/stakater/Reloader/internal/pkg/constants" - "github.com/stakater/Reloader/internal/pkg/crypto" - "github.com/stakater/Reloader/internal/pkg/options" -) - -// ConvertToEnvVarName converts the given text into a usable env var -// removing any special chars with '_' and transforming text to upper case -func ConvertToEnvVarName(text string) string { - var buffer bytes.Buffer - upper := strings.ToUpper(text) - lastCharValid := false - for i := 0; i < len(upper); i++ { - ch := upper[i] - if (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') { - buffer.WriteString(string(ch)) - lastCharValid = true - } else { - if lastCharValid { - buffer.WriteString("_") - } - lastCharValid = false - } - } - return buffer.String() -} - -func GetSHAfromConfigmap(configmap *v1.ConfigMap) string { - values := []string{} - for k, v := range configmap.Data { - values = append(values, k+"="+v) - } - for k, v := range configmap.BinaryData { - values = append(values, k+"="+base64.StdEncoding.EncodeToString(v)) - } - sort.Strings(values) - return crypto.GenerateSHA(strings.Join(values, ";")) -} - -func GetSHAfromSecret(data map[string][]byte) string { - values := []string{} - for k, v := range data { - values = append(values, k+"="+string(v[:])) - } - sort.Strings(values) - return crypto.GenerateSHA(strings.Join(values, ";")) -} - -func GetSHAfromSecretProviderClassPodStatus(data csiv1.SecretProviderClassPodStatusStatus) string { - values := []string{} - for _, v := range data.Objects { - values = append(values, v.ID+"="+v.Version) - } - values = append(values, "SecretProviderClassName="+data.SecretProviderClassName) - sort.Strings(values) - return crypto.GenerateSHA(strings.Join(values, ";")) -} - -type List []string - -func (l *List) Contains(s string) bool { - for _, v := range *l { - if v == s { - return true - } - } - return false -} - -func ConfigureReloaderFlags(cmd *cobra.Command) { - cmd.PersistentFlags().BoolVar(&options.AutoReloadAll, "auto-reload-all", false, "Auto reload all resources") - cmd.PersistentFlags().StringVar(&options.ConfigmapUpdateOnChangeAnnotation, "configmap-annotation", "configmap.reloader.stakater.com/reload", "annotation to detect changes in configmaps, specified by name") - cmd.PersistentFlags().StringVar(&options.SecretUpdateOnChangeAnnotation, "secret-annotation", "secret.reloader.stakater.com/reload", "annotation to detect changes in secrets, specified by name") - cmd.PersistentFlags().StringVar(&options.ReloaderAutoAnnotation, "auto-annotation", "reloader.stakater.com/auto", "annotation to detect changes in secrets/configmaps") - cmd.PersistentFlags().StringVar(&options.IgnoreResourceAnnotation, "ignore-annotation", "reloader.stakater.com/ignore", "annotation to ignore a resource when watching for changes in secrets/configmaps") - cmd.PersistentFlags().StringVar(&options.ConfigmapReloaderAutoAnnotation, "configmap-auto-annotation", "configmap.reloader.stakater.com/auto", "annotation to detect changes in configmaps") - cmd.PersistentFlags().StringVar(&options.SecretReloaderAutoAnnotation, "secret-auto-annotation", "secret.reloader.stakater.com/auto", "annotation to detect changes in secrets") - cmd.PersistentFlags().StringVar(&options.AutoSearchAnnotation, "auto-search-annotation", "reloader.stakater.com/search", "annotation to detect changes in configmaps or secrets tagged with special match annotation") - cmd.PersistentFlags().StringVar(&options.SearchMatchAnnotation, "search-match-annotation", "reloader.stakater.com/match", "annotation to mark secrets or configmaps to match the search") - cmd.PersistentFlags().StringVar(&options.PauseDeploymentAnnotation, "pause-deployment-annotation", "deployment.reloader.stakater.com/pause-period", "annotation to define the time period to pause a deployment after a configmap/secret change has been detected") - cmd.PersistentFlags().StringVar(&options.PauseDeploymentTimeAnnotation, "pause-deployment-time-annotation", "deployment.reloader.stakater.com/paused-at", "annotation to indicate when a deployment was paused by Reloader") - cmd.PersistentFlags().StringVar(&options.LogFormat, "log-format", "", "Log format to use (empty string for text, or JSON)") - cmd.PersistentFlags().StringVar(&options.LogLevel, "log-level", "info", "Log level to use (trace, debug, info, warning, error, fatal and panic)") - cmd.PersistentFlags().StringVar(&options.WebhookUrl, "webhook-url", "", "webhook to trigger instead of performing a reload") - cmd.PersistentFlags().StringSliceVar(&options.ResourcesToIgnore, "resources-to-ignore", options.ResourcesToIgnore, "list of resources to ignore (valid options 'configmaps' or 'secrets')") - cmd.PersistentFlags().StringSliceVar(&options.WorkloadTypesToIgnore, "ignored-workload-types", options.WorkloadTypesToIgnore, "list of workload types to ignore (valid options: 'jobs', 'cronjobs', or both)") - cmd.PersistentFlags().StringSliceVar(&options.NamespacesToIgnore, "namespaces-to-ignore", options.NamespacesToIgnore, "list of namespaces to ignore") - cmd.PersistentFlags().StringSliceVar(&options.NamespaceSelectors, "namespace-selector", options.NamespaceSelectors, "list of key:value labels to filter on for namespaces") - cmd.PersistentFlags().StringSliceVar(&options.ResourceSelectors, "resource-label-selector", options.ResourceSelectors, "list of key:value labels to filter on for configmaps and secrets") - cmd.PersistentFlags().StringVar(&options.IsArgoRollouts, "is-Argo-Rollouts", "false", "Add support for argo rollouts") - cmd.PersistentFlags().StringVar(&options.ReloadStrategy, constants.ReloadStrategyFlag, constants.EnvVarsReloadStrategy, "Specifies the desired reload strategy") - cmd.PersistentFlags().StringVar(&options.ReloadOnCreate, "reload-on-create", "false", "Add support to watch create events") - cmd.PersistentFlags().StringVar(&options.ReloadOnDelete, "reload-on-delete", "false", "Add support to watch delete events") - cmd.PersistentFlags().BoolVar(&options.EnableHA, "enable-ha", false, "Adds support for running multiple replicas via leadership election") - cmd.PersistentFlags().BoolVar(&options.SyncAfterRestart, "sync-after-restart", false, "Sync add events after reloader restarts") - cmd.PersistentFlags().BoolVar(&options.EnablePProf, "enable-pprof", false, "Enable pprof for profiling") - cmd.PersistentFlags().StringVar(&options.PProfAddr, "pprof-addr", ":6060", "Address to start pprof server on. Default is :6060") - cmd.PersistentFlags().BoolVar(&options.EnableCSIIntegration, "enable-csi-integration", false, "Enables CSI integration. Default is :false") -} - -func GetIgnoredResourcesList() (List, error) { - - ignoredResourcesList := options.ResourcesToIgnore // getStringSliceFromFlags(cmd, "resources-to-ignore") - - // Normalize to the canonical lowercase keys used in kube.ResourceMap so the - // comparison is case-insensitive (e.g. "configMaps", "ConfigMaps", "sEcrets" - // all map to their canonical lowercase form). - normalized := make(List, 0, len(ignoredResourcesList)) - for _, v := range ignoredResourcesList { - switch strings.ToLower(v) { - case "configmaps": - normalized = append(normalized, "configmaps") - case "secrets": - normalized = append(normalized, "secrets") - default: - return nil, fmt.Errorf("'resources-to-ignore' only accepts 'configmaps' or 'secrets', not '%s'", v) - } - } - - if len(normalized) > 1 { - return nil, errors.New("'resources-to-ignore' only accepts 'configmaps' or 'secrets', not both") - } - - return normalized, nil -} - -func GetIgnoredWorkloadTypesList() (List, error) { - - ignoredWorkloadTypesList := options.WorkloadTypesToIgnore - - for _, v := range ignoredWorkloadTypesList { - if v != "jobs" && v != "cronjobs" { - return nil, fmt.Errorf("'ignored-workload-types' accepts 'jobs', 'cronjobs', or both, not '%s'", v) - } - } - - return ignoredWorkloadTypesList, nil -} diff --git a/internal/pkg/util/util_test.go b/internal/pkg/util/util_test.go deleted file mode 100644 index 6af4d0673..000000000 --- a/internal/pkg/util/util_test.go +++ /dev/null @@ -1,278 +0,0 @@ -package util - -import ( - "testing" - - v1 "k8s.io/api/core/v1" - - "github.com/stakater/Reloader/internal/pkg/options" -) - -func TestConvertToEnvVarName(t *testing.T) { - data := "www.stakater.com" - envVar := ConvertToEnvVarName(data) - if envVar != "WWW_STAKATER_COM" { - t.Errorf("Failed to convert data into environment variable") - } -} - -func TestGetHashFromConfigMap(t *testing.T) { - data := map[*v1.ConfigMap]string{ - { - Data: map[string]string{"test": "test"}, - }: "Only Data", - { - Data: map[string]string{"test": "test"}, - BinaryData: map[string][]byte{"bintest": []byte("test")}, - }: "Both Data and BinaryData", - { - BinaryData: map[string][]byte{"bintest": []byte("test")}, - }: "Only BinaryData", - } - converted := map[string]string{} - for cm, cmName := range data { - converted[cmName] = GetSHAfromConfigmap(cm) - } - - // Test that the has for each configmap is really unique - for cmName, cmHash := range converted { - count := 0 - for _, cmHash2 := range converted { - if cmHash == cmHash2 { - count++ - } - } - if count > 1 { - t.Errorf("Found duplicate hashes for %v", cmName) - } - } -} - -func TestGetIgnoredWorkloadTypesList(t *testing.T) { - // Save original state - originalWorkloadTypes := options.WorkloadTypesToIgnore - defer func() { - options.WorkloadTypesToIgnore = originalWorkloadTypes - }() - - tests := []struct { - name string - workloadTypes []string - expectError bool - expected []string - }{ - { - name: "Both jobs and cronjobs", - workloadTypes: []string{"jobs", "cronjobs"}, - expectError: false, - expected: []string{"jobs", "cronjobs"}, - }, - { - name: "Only jobs", - workloadTypes: []string{"jobs"}, - expectError: false, - expected: []string{"jobs"}, - }, - { - name: "Only cronjobs", - workloadTypes: []string{"cronjobs"}, - expectError: false, - expected: []string{"cronjobs"}, - }, - { - name: "Empty list", - workloadTypes: []string{}, - expectError: false, - expected: []string{}, - }, - { - name: "Invalid workload type", - workloadTypes: []string{"invalid"}, - expectError: true, - expected: nil, - }, - { - name: "Mixed valid and invalid", - workloadTypes: []string{"jobs", "invalid"}, - expectError: true, - expected: nil, - }, - { - name: "Duplicate values", - workloadTypes: []string{"jobs", "jobs"}, - expectError: false, - expected: []string{"jobs", "jobs"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Set the global option - options.WorkloadTypesToIgnore = tt.workloadTypes - - result, err := GetIgnoredWorkloadTypesList() - - if tt.expectError && err == nil { - t.Errorf("Expected error but got none") - } - - if !tt.expectError && err != nil { - t.Errorf("Expected no error but got: %v", err) - } - - if !tt.expectError { - if len(result) != len(tt.expected) { - t.Errorf("Expected %v, got %v", tt.expected, result) - return - } - - for i, expected := range tt.expected { - if i >= len(result) || result[i] != expected { - t.Errorf("Expected %v, got %v", tt.expected, result) - break - } - } - } - }) - } -} - -func TestGetIgnoredResourcesList(t *testing.T) { - // Save original state - originalResources := options.ResourcesToIgnore - defer func() { - options.ResourcesToIgnore = originalResources - }() - - tests := []struct { - name string - resources []string - expectError bool - expected []string - }{ - { - name: "Lowercase configmaps (canonical) normalizes to configmaps", - resources: []string{"configmaps"}, - expectError: false, - expected: []string{"configmaps"}, - }, - { - name: "Legacy camelCase configMaps normalizes to configmaps", - resources: []string{"configMaps"}, - expectError: false, - expected: []string{"configmaps"}, - }, - { - name: "Mixed-case ConfigMaps normalizes to configmaps", - resources: []string{"ConfigMaps"}, - expectError: false, - expected: []string{"configmaps"}, - }, - { - name: "secrets", - resources: []string{"secrets"}, - expectError: false, - expected: []string{"secrets"}, - }, - { - name: "Mixed-case sEcrets normalizes to secrets", - resources: []string{"sEcrets"}, - expectError: false, - expected: []string{"secrets"}, - }, - { - name: "Empty list", - resources: []string{}, - expectError: false, - expected: []string{}, - }, - { - name: "Invalid resource", - resources: []string{"deployments"}, - expectError: true, - expected: nil, - }, - { - name: "Both configmaps and secrets rejected", - resources: []string{"configmaps", "secrets"}, - expectError: true, - expected: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - options.ResourcesToIgnore = tt.resources - result, err := GetIgnoredResourcesList() - - if tt.expectError && err == nil { - t.Errorf("Expected error but got none") - } - if !tt.expectError && err != nil { - t.Errorf("Expected no error but got: %v", err) - } - - if !tt.expectError { - if len(result) != len(tt.expected) { - t.Errorf("Expected %v, got %v", tt.expected, result) - return - } - for i, expected := range tt.expected { - if result[i] != expected { - t.Errorf("Expected %v, got %v", tt.expected, result) - break - } - } - } - }) - } -} - -func TestListContains(t *testing.T) { - tests := []struct { - name string - list List - item string - expected bool - }{ - { - name: "List contains item", - list: List{"jobs", "cronjobs"}, - item: "jobs", - expected: true, - }, - { - name: "List does not contain item", - list: List{"jobs"}, - item: "cronjobs", - expected: false, - }, - { - name: "Empty list", - list: List{}, - item: "jobs", - expected: false, - }, - { - name: "Case sensitive matching", - list: List{"jobs", "cronjobs"}, - item: "Jobs", - expected: false, - }, - { - name: "Multiple occurrences", - list: List{"jobs", "jobs", "cronjobs"}, - item: "jobs", - expected: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := tt.list.Contains(tt.item) - if result != tt.expected { - t.Errorf("Expected %v, got %v", tt.expected, result) - } - }) - } -} diff --git a/internal/pkg/webhook/webhook.go b/internal/pkg/webhook/webhook.go new file mode 100644 index 000000000..3653b22e2 --- /dev/null +++ b/internal/pkg/webhook/webhook.go @@ -0,0 +1,95 @@ +// Package webhook handles sending reload notifications to external endpoints. +// When --webhook-url is set, Reloader sends HTTP POST requests instead of modifying workloads. +package webhook + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/go-logr/logr" + + httputil "github.com/stakater/Reloader/internal/pkg/http" +) + +// Payload represents the data sent to the webhook endpoint. +type Payload struct { + Kind string `json:"kind"` + Namespace string `json:"namespace"` + ResourceName string `json:"resourceName"` + ResourceType string `json:"resourceType"` + Hash string `json:"hash"` + Timestamp time.Time `json:"timestamp"` + + // Workloads contains the list of workloads that would be reloaded. + Workloads []WorkloadInfo `json:"workloads"` +} + +// WorkloadInfo describes a workload that would be reloaded. +type WorkloadInfo struct { + Kind string `json:"kind"` + Name string `json:"name"` + Namespace string `json:"namespace"` +} + +// Client sends reload notifications to webhook endpoints. +type Client struct { + httpClient *http.Client + url string + log logr.Logger +} + +// NewClient creates a new webhook client. +func NewClient(url string, log logr.Logger) *Client { + return &Client{ + httpClient: httputil.NewDefaultClient(), + url: url, + log: log, + } +} + +// Send posts the payload to the configured webhook URL. +func (c *Client) Send(ctx context.Context, payload Payload) error { + if c.url == "" { + return nil + } + + data, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshaling payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "Reloader/2.0") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("sending request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("webhook returned status %d", resp.StatusCode) + } + + c.log.V(1).Info("webhook notification sent", + "url", c.url, + "resourceType", payload.ResourceType, + "resourceName", payload.ResourceName, + "workloadCount", len(payload.Workloads), + ) + + return nil +} + +// IsConfigured returns true if the webhook URL is set. +func (c *Client) IsConfigured() bool { + return c != nil && c.url != "" +} diff --git a/internal/pkg/webhook/webhook_test.go b/internal/pkg/webhook/webhook_test.go new file mode 100644 index 000000000..b88ed246a --- /dev/null +++ b/internal/pkg/webhook/webhook_test.go @@ -0,0 +1,283 @@ +package webhook + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-logr/logr" +) + +func TestNewClient_SetsURL(t *testing.T) { + c := NewClient("http://example.com/webhook", logr.Discard()) + + if c == nil { + t.Fatal("NewClient should not return nil") + } + if c.url != "http://example.com/webhook" { + t.Errorf("URL = %q, want %q", c.url, "http://example.com/webhook") + } + if c.httpClient == nil { + t.Error("httpClient should not be nil") + } + if c.httpClient.Timeout != 30*time.Second { + t.Errorf("Timeout = %v, want %v", c.httpClient.Timeout, 30*time.Second) + } +} + +func TestIsConfigured_NilClient(t *testing.T) { + var c *Client = nil + + if c.IsConfigured() { + t.Error("IsConfigured() should return false for nil client") + } +} + +func TestIsConfigured_EmptyURL(t *testing.T) { + c := NewClient("", logr.Discard()) + + if c.IsConfigured() { + t.Error("IsConfigured() should return false for empty URL") + } +} + +func TestIsConfigured_ValidURL(t *testing.T) { + c := NewClient("http://example.com/webhook", logr.Discard()) + + if !c.IsConfigured() { + t.Error("IsConfigured() should return true for valid URL") + } +} + +func TestSend_EmptyURL_ReturnsNil(t *testing.T) { + c := NewClient("", logr.Discard()) + + payload := Payload{ + Kind: "ConfigMap", + Namespace: "default", + ResourceName: "my-config", + ResourceType: "configmap", + } + + err := c.Send(context.Background(), payload) + if err != nil { + t.Errorf("Send() with empty URL should return nil, got %v", err) + } +} + +func TestSend_MarshalPayload(t *testing.T) { + var receivedPayload Payload + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &receivedPayload) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + c := NewClient(server.URL, logr.Discard()) + + payload := Payload{ + Kind: "ConfigMap", + Namespace: "default", + ResourceName: "my-config", + ResourceType: "configmap", + Hash: "abc123", + Timestamp: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC), + Workloads: []WorkloadInfo{ + {Kind: "Deployment", Name: "my-deploy", Namespace: "default"}, + }, + } + + err := c.Send(context.Background(), payload) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + + if receivedPayload.Kind != "ConfigMap" { + t.Errorf("Received Kind = %q, want %q", receivedPayload.Kind, "ConfigMap") + } + if receivedPayload.Namespace != "default" { + t.Errorf("Received Namespace = %q, want %q", receivedPayload.Namespace, "default") + } + if receivedPayload.ResourceName != "my-config" { + t.Errorf("Received ResourceName = %q, want %q", receivedPayload.ResourceName, "my-config") + } + if receivedPayload.Hash != "abc123" { + t.Errorf("Received Hash = %q, want %q", receivedPayload.Hash, "abc123") + } + if len(receivedPayload.Workloads) != 1 { + t.Errorf("Received Workloads count = %d, want 1", len(receivedPayload.Workloads)) + } +} + +func TestSend_SetsCorrectHeaders(t *testing.T) { + var contentType, userAgent string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + contentType = r.Header.Get("Content-Type") + userAgent = r.Header.Get("User-Agent") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + c := NewClient(server.URL, logr.Discard()) + + err := c.Send(context.Background(), Payload{}) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + + if contentType != "application/json" { + t.Errorf("Content-Type = %q, want %q", contentType, "application/json") + } + if userAgent != "Reloader/2.0" { + t.Errorf("User-Agent = %q, want %q", userAgent, "Reloader/2.0") + } +} + +func TestSend_UsesPostMethod(t *testing.T) { + var method string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + method = r.Method + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + c := NewClient(server.URL, logr.Discard()) + + err := c.Send(context.Background(), Payload{}) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + + if method != http.MethodPost { + t.Errorf("Method = %q, want %q", method, http.MethodPost) + } +} + +func TestSend_Non2xxResponse(t *testing.T) { + tests := []struct { + name string + statusCode int + wantErr bool + }{ + {"200 OK", 200, false}, + {"201 Created", 201, false}, + {"204 No Content", 204, false}, + {"299 upper bound", 299, false}, + {"300 redirect", 300, true}, + {"400 Bad Request", 400, true}, + {"404 Not Found", 404, true}, + {"500 Internal Error", 500, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.statusCode) + })) + defer server.Close() + + c := NewClient(server.URL, logr.Discard()) + err := c.Send(context.Background(), Payload{}) + + if (err != nil) != tt.wantErr { + t.Errorf("Send() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestSend_NetworkError(t *testing.T) { + // Use a URL that won't connect + c := NewClient("http://127.0.0.1:1", logr.Discard()) + + err := c.Send(context.Background(), Payload{}) + if err == nil { + t.Error("Send() should return error for network failure") + } +} + +func TestSend_ContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(100 * time.Millisecond) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + c := NewClient(server.URL, logr.Discard()) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + err := c.Send(ctx, Payload{}) + if err == nil { + t.Error("Send() should return error for cancelled context") + } +} + +func TestPayload_JSONSerialization(t *testing.T) { + payload := Payload{ + Kind: "ConfigMap", + Namespace: "default", + ResourceName: "my-config", + ResourceType: "configmap", + Hash: "abc123", + Timestamp: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC), + Workloads: []WorkloadInfo{ + {Kind: "Deployment", Name: "my-deploy", Namespace: "default"}, + {Kind: "StatefulSet", Name: "my-sts", Namespace: "default"}, + }, + } + + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("Failed to marshal payload: %v", err) + } + + var unmarshaled Payload + if err := json.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("Failed to unmarshal payload: %v", err) + } + + if unmarshaled.Kind != payload.Kind { + t.Errorf("Kind = %q, want %q", unmarshaled.Kind, payload.Kind) + } + if len(unmarshaled.Workloads) != 2 { + t.Errorf("Workloads count = %d, want 2", len(unmarshaled.Workloads)) + } +} + +func TestWorkloadInfo_JSONSerialization(t *testing.T) { + info := WorkloadInfo{ + Kind: "Deployment", + Name: "my-deploy", + Namespace: "production", + } + + data, err := json.Marshal(info) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + + var unmarshaled WorkloadInfo + if err := json.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + + if unmarshaled.Kind != "Deployment" { + t.Errorf("Kind = %q, want %q", unmarshaled.Kind, "Deployment") + } + if unmarshaled.Name != "my-deploy" { + t.Errorf("Name = %q, want %q", unmarshaled.Name, "my-deploy") + } + if unmarshaled.Namespace != "production" { + t.Errorf("Namespace = %q, want %q", unmarshaled.Namespace, "production") + } +} diff --git a/internal/pkg/workload/base.go b/internal/pkg/workload/base.go new file mode 100644 index 000000000..e8479bfb4 --- /dev/null +++ b/internal/pkg/workload/base.go @@ -0,0 +1,189 @@ +package workload + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// PodTemplateAccessor provides access to a workload's pod template. +// Each workload type implements this to provide access to its specific template location. +type PodTemplateAccessor interface { + // GetPodTemplateSpec returns a pointer to the pod template spec. + // Returns nil if the workload doesn't have a pod template + GetPodTemplateSpec() *corev1.PodTemplateSpec + + // GetObjectMeta returns the workload's object metadata. + GetObjectMeta() *metav1.ObjectMeta +} + +// BaseWorkload provides common functionality for all workload types. +// It uses composition with a PodTemplateAccessor to access type-specific fields. +type BaseWorkload[T client.Object] struct { + object T + original T + accessor PodTemplateAccessor + kind Kind +} + +// NewBaseWorkload creates a new BaseWorkload with the given object and accessor. +func NewBaseWorkload[T client.Object](obj T, original T, accessor PodTemplateAccessor, kind Kind) *BaseWorkload[T] { + return &BaseWorkload[T]{ + object: obj, + original: original, + accessor: accessor, + kind: kind, + } +} + +func (b *BaseWorkload[T]) Kind() Kind { + return b.kind +} + +func (b *BaseWorkload[T]) GetObject() client.Object { + return b.object +} + +func (b *BaseWorkload[T]) GetName() string { + return b.accessor.GetObjectMeta().Name +} + +func (b *BaseWorkload[T]) GetNamespace() string { + return b.accessor.GetObjectMeta().Namespace +} + +func (b *BaseWorkload[T]) GetAnnotations() map[string]string { + return b.accessor.GetObjectMeta().Annotations +} + +func (b *BaseWorkload[T]) GetPodTemplateAnnotations() map[string]string { + template := b.accessor.GetPodTemplateSpec() + if template == nil { + return nil + } + if template.Annotations == nil { + template.Annotations = make(map[string]string) + } + return template.Annotations +} + +func (b *BaseWorkload[T]) SetPodTemplateAnnotation(key, value string) { + template := b.accessor.GetPodTemplateSpec() + if template == nil { + return + } + if template.Annotations == nil { + template.Annotations = make(map[string]string) + } + template.Annotations[key] = value +} + +func (b *BaseWorkload[T]) GetContainers() []corev1.Container { + template := b.accessor.GetPodTemplateSpec() + if template == nil { + return nil + } + return template.Spec.Containers +} + +func (b *BaseWorkload[T]) SetContainers(containers []corev1.Container) { + template := b.accessor.GetPodTemplateSpec() + if template == nil { + return + } + template.Spec.Containers = containers +} + +func (b *BaseWorkload[T]) GetInitContainers() []corev1.Container { + template := b.accessor.GetPodTemplateSpec() + if template == nil { + return nil + } + return template.Spec.InitContainers +} + +func (b *BaseWorkload[T]) SetInitContainers(containers []corev1.Container) { + template := b.accessor.GetPodTemplateSpec() + if template == nil { + return + } + template.Spec.InitContainers = containers +} + +func (b *BaseWorkload[T]) GetVolumes() []corev1.Volume { + template := b.accessor.GetPodTemplateSpec() + if template == nil { + return nil + } + return template.Spec.Volumes +} + +func (b *BaseWorkload[T]) GetEnvFromSources() []corev1.EnvFromSource { + template := b.accessor.GetPodTemplateSpec() + if template == nil { + return nil + } + var sources []corev1.EnvFromSource + for _, container := range template.Spec.Containers { + sources = append(sources, container.EnvFrom...) + } + for _, container := range template.Spec.InitContainers { + sources = append(sources, container.EnvFrom...) + } + return sources +} + +func (b *BaseWorkload[T]) UsesConfigMap(name string) bool { + template := b.accessor.GetPodTemplateSpec() + if template == nil { + return false + } + return SpecUsesConfigMap(&template.Spec, name) +} + +func (b *BaseWorkload[T]) UsesSecret(name string) bool { + template := b.accessor.GetPodTemplateSpec() + if template == nil { + return false + } + return SpecUsesSecret(&template.Spec, name) +} + +func (b *BaseWorkload[T]) GetOwnerReferences() []metav1.OwnerReference { + return b.accessor.GetObjectMeta().OwnerReferences +} + +// Update performs a strategic merge patch update. +func (b *BaseWorkload[T]) Update(ctx context.Context, c client.Client) error { + return c.Patch(ctx, b.object, client.StrategicMergeFrom(b.original), client.FieldOwner(FieldManager)) +} + +// ResetOriginal resets the original state to the current object state. +func (b *BaseWorkload[T]) ResetOriginal() { + //nolint:errcheck // Type assertion is safe: DeepCopyObject returns same type T + b.original = b.object.DeepCopyObject().(T) +} + +// UpdateStrategy returns the default patch strategy. +// Workloads with special update logic should override this. +func (b *BaseWorkload[T]) UpdateStrategy() UpdateStrategy { + return UpdateStrategyPatch +} + +// PerformSpecialUpdate returns false for standard workloads. +// Workloads with special update logic should override this. +func (b *BaseWorkload[T]) PerformSpecialUpdate(ctx context.Context, c client.Client) (bool, error) { + return false, nil +} + +// Object returns the underlying Kubernetes object. +func (b *BaseWorkload[T]) Object() T { + return b.object +} + +// Original returns the original state of the object. +func (b *BaseWorkload[T]) Original() T { + return b.original +} diff --git a/internal/pkg/workload/cronjob.go b/internal/pkg/workload/cronjob.go new file mode 100644 index 000000000..222d4c61e --- /dev/null +++ b/internal/pkg/workload/cronjob.go @@ -0,0 +1,98 @@ +package workload + +import ( + "context" + "maps" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// cronJobAccessor implements PodTemplateAccessor for CronJob. +type cronJobAccessor struct { + cronjob *batchv1.CronJob +} + +func (a *cronJobAccessor) GetPodTemplateSpec() *corev1.PodTemplateSpec { + // CronJob has the pod template nested under JobTemplate.Spec.Template + return &a.cronjob.Spec.JobTemplate.Spec.Template +} + +func (a *cronJobAccessor) GetObjectMeta() *metav1.ObjectMeta { + return &a.cronjob.ObjectMeta +} + +// CronJobWorkload wraps a Kubernetes CronJob. +// Note: CronJobs have a special update mechanism - instead of updating the CronJob itself, +// Reloader creates a new Job from the CronJob's template. +type CronJobWorkload struct { + *BaseWorkload[*batchv1.CronJob] +} + +// NewCronJobWorkload creates a new CronJobWorkload. +func NewCronJobWorkload(c *batchv1.CronJob) *CronJobWorkload { + original := c.DeepCopy() + accessor := &cronJobAccessor{cronjob: c} + return &CronJobWorkload{ + BaseWorkload: NewBaseWorkload(c, original, accessor, KindCronJob), + } +} + +// Ensure CronJobWorkload implements Workload. +var _ Workload = (*CronJobWorkload)(nil) + +// Update for CronJob is a no-op - use PerformSpecialUpdate instead. +// CronJobs trigger reloads by creating a new Job from their template. +func (w *CronJobWorkload) Update(ctx context.Context, c client.Client) error { + // CronJobs don't get updated directly - a new Job is created instead + // This is handled by PerformSpecialUpdate + return nil +} + +// ResetOriginal is a no-op for CronJobs since they don't use strategic merge patch. +// CronJobs create new Jobs instead of being patched. +func (w *CronJobWorkload) ResetOriginal() {} + +func (w *CronJobWorkload) UpdateStrategy() UpdateStrategy { + return UpdateStrategyCreateNew +} + +// PerformSpecialUpdate creates a new Job from the CronJob's template. +// This triggers an immediate execution of the CronJob with updated config. +func (w *CronJobWorkload) PerformSpecialUpdate(ctx context.Context, c client.Client) (bool, error) { + cronJob := w.Object() + + annotations := make(map[string]string) + annotations["cronjob.kubernetes.io/instantiate"] = "manual" + maps.Copy(annotations, cronJob.Spec.JobTemplate.Annotations) + + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: cronJob.Name + "-", + Namespace: cronJob.Namespace, + Annotations: annotations, + Labels: cronJob.Spec.JobTemplate.Labels, + OwnerReferences: []metav1.OwnerReference{ + *metav1.NewControllerRef(cronJob, batchv1.SchemeGroupVersion.WithKind("CronJob")), + }, + }, + Spec: cronJob.Spec.JobTemplate.Spec, + } + + if err := c.Create(ctx, job, client.FieldOwner(FieldManager)); err != nil { + return false, err + } + + return true, nil +} + +func (w *CronJobWorkload) DeepCopy() Workload { + return NewCronJobWorkload(w.Object().DeepCopy()) +} + +// GetCronJob returns the underlying CronJob for special handling. +func (w *CronJobWorkload) GetCronJob() *batchv1.CronJob { + return w.Object() +} diff --git a/internal/pkg/workload/daemonset.go b/internal/pkg/workload/daemonset.go new file mode 100644 index 000000000..ee6b121e7 --- /dev/null +++ b/internal/pkg/workload/daemonset.go @@ -0,0 +1,46 @@ +package workload + +import ( + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// daemonSetAccessor implements PodTemplateAccessor for DaemonSet. +type daemonSetAccessor struct { + daemonset *appsv1.DaemonSet +} + +func (a *daemonSetAccessor) GetPodTemplateSpec() *corev1.PodTemplateSpec { + return &a.daemonset.Spec.Template +} + +func (a *daemonSetAccessor) GetObjectMeta() *metav1.ObjectMeta { + return &a.daemonset.ObjectMeta +} + +// DaemonSetWorkload wraps a Kubernetes DaemonSet. +type DaemonSetWorkload struct { + *BaseWorkload[*appsv1.DaemonSet] +} + +// NewDaemonSetWorkload creates a new DaemonSetWorkload. +func NewDaemonSetWorkload(d *appsv1.DaemonSet) *DaemonSetWorkload { + original := d.DeepCopy() + accessor := &daemonSetAccessor{daemonset: d} + return &DaemonSetWorkload{ + BaseWorkload: NewBaseWorkload(d, original, accessor, KindDaemonSet), + } +} + +// Ensure DaemonSetWorkload implements Workload. +var _ Workload = (*DaemonSetWorkload)(nil) + +func (w *DaemonSetWorkload) DeepCopy() Workload { + return NewDaemonSetWorkload(w.Object().DeepCopy()) +} + +// GetDaemonSet returns the underlying DaemonSet for special handling. +func (w *DaemonSetWorkload) GetDaemonSet() *appsv1.DaemonSet { + return w.Object() +} diff --git a/internal/pkg/workload/deployment.go b/internal/pkg/workload/deployment.go new file mode 100644 index 000000000..ddb621cf3 --- /dev/null +++ b/internal/pkg/workload/deployment.go @@ -0,0 +1,46 @@ +package workload + +import ( + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// deploymentAccessor implements PodTemplateAccessor for Deployment. +type deploymentAccessor struct { + deployment *appsv1.Deployment +} + +func (a *deploymentAccessor) GetPodTemplateSpec() *corev1.PodTemplateSpec { + return &a.deployment.Spec.Template +} + +func (a *deploymentAccessor) GetObjectMeta() *metav1.ObjectMeta { + return &a.deployment.ObjectMeta +} + +// DeploymentWorkload wraps a Kubernetes Deployment. +type DeploymentWorkload struct { + *BaseWorkload[*appsv1.Deployment] +} + +// NewDeploymentWorkload creates a new DeploymentWorkload. +func NewDeploymentWorkload(d *appsv1.Deployment) *DeploymentWorkload { + original := d.DeepCopy() + accessor := &deploymentAccessor{deployment: d} + return &DeploymentWorkload{ + BaseWorkload: NewBaseWorkload(d, original, accessor, KindDeployment), + } +} + +// Ensure DeploymentWorkload implements Workload. +var _ Workload = (*DeploymentWorkload)(nil) + +func (w *DeploymentWorkload) DeepCopy() Workload { + return NewDeploymentWorkload(w.Object().DeepCopy()) +} + +// GetDeployment returns the underlying Deployment for special handling. +func (w *DeploymentWorkload) GetDeployment() *appsv1.Deployment { + return w.Object() +} diff --git a/internal/pkg/workload/deploymentconfig.go b/internal/pkg/workload/deploymentconfig.go new file mode 100644 index 000000000..736a486ed --- /dev/null +++ b/internal/pkg/workload/deploymentconfig.go @@ -0,0 +1,77 @@ +package workload + +import ( + openshiftv1 "github.com/openshift/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// deploymentConfigAccessor implements PodTemplateAccessor for DeploymentConfig. +type deploymentConfigAccessor struct { + dc *openshiftv1.DeploymentConfig +} + +func (a *deploymentConfigAccessor) GetPodTemplateSpec() *corev1.PodTemplateSpec { + // DeploymentConfig has a pointer to PodTemplateSpec which may be nil + return a.dc.Spec.Template +} + +func (a *deploymentConfigAccessor) GetObjectMeta() *metav1.ObjectMeta { + return &a.dc.ObjectMeta +} + +// DeploymentConfigWorkload wraps an OpenShift DeploymentConfig. +type DeploymentConfigWorkload struct { + *BaseWorkload[*openshiftv1.DeploymentConfig] +} + +// NewDeploymentConfigWorkload creates a new DeploymentConfigWorkload. +func NewDeploymentConfigWorkload(dc *openshiftv1.DeploymentConfig) *DeploymentConfigWorkload { + original := dc.DeepCopy() + accessor := &deploymentConfigAccessor{dc: dc} + return &DeploymentConfigWorkload{ + BaseWorkload: NewBaseWorkload(dc, original, accessor, KindDeploymentConfig), + } +} + +// Ensure DeploymentConfigWorkload implements Workload. +var _ Workload = (*DeploymentConfigWorkload)(nil) + +// SetPodTemplateAnnotation overrides the base to ensure Template is initialized. +func (w *DeploymentConfigWorkload) SetPodTemplateAnnotation(key, value string) { + dc := w.Object() + if dc.Spec.Template == nil { + dc.Spec.Template = &corev1.PodTemplateSpec{} + } + if dc.Spec.Template.Annotations == nil { + dc.Spec.Template.Annotations = make(map[string]string) + } + dc.Spec.Template.Annotations[key] = value +} + +// SetContainers overrides the base to ensure Template is initialized. +func (w *DeploymentConfigWorkload) SetContainers(containers []corev1.Container) { + dc := w.Object() + if dc.Spec.Template == nil { + dc.Spec.Template = &corev1.PodTemplateSpec{} + } + dc.Spec.Template.Spec.Containers = containers +} + +// SetInitContainers overrides the base to ensure Template is initialized. +func (w *DeploymentConfigWorkload) SetInitContainers(containers []corev1.Container) { + dc := w.Object() + if dc.Spec.Template == nil { + dc.Spec.Template = &corev1.PodTemplateSpec{} + } + dc.Spec.Template.Spec.InitContainers = containers +} + +func (w *DeploymentConfigWorkload) DeepCopy() Workload { + return NewDeploymentConfigWorkload(w.Object().DeepCopy()) +} + +// GetDeploymentConfig returns the underlying DeploymentConfig for special handling. +func (w *DeploymentConfigWorkload) GetDeploymentConfig() *openshiftv1.DeploymentConfig { + return w.Object() +} diff --git a/internal/pkg/workload/interface.go b/internal/pkg/workload/interface.go new file mode 100644 index 000000000..6b4ccf7d4 --- /dev/null +++ b/internal/pkg/workload/interface.go @@ -0,0 +1,141 @@ +// Package workload provides an abstraction layer for Kubernetes workload types. +// It allows uniform handling of Deployments, DaemonSets, StatefulSets, Jobs, CronJobs, and Argo Rollouts. +// +// Note: Jobs and CronJobs have special update mechanisms: +// - Job: deleted and recreated with the same spec +// - CronJob: a new Job is created from the CronJob's template +package workload + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// FieldManager is the field manager name used for server-side apply and patch operations. +// This identifies Reloader as the actor making changes to workload resources. +const FieldManager = "reloader" + +// Kind represents the type of workload. +type Kind string + +const ( + KindDeployment Kind = "Deployment" + KindDaemonSet Kind = "DaemonSet" + KindStatefulSet Kind = "StatefulSet" + KindArgoRollout Kind = "Rollout" + KindJob Kind = "Job" + KindCronJob Kind = "CronJob" + KindDeploymentConfig Kind = "DeploymentConfig" +) + +// UpdateStrategy defines how a workload should be updated. +type UpdateStrategy int + +const ( + // UpdateStrategyPatch uses strategic merge patch (default for most workloads). + UpdateStrategyPatch UpdateStrategy = iota + // UpdateStrategyRecreate deletes and recreates the workload (Jobs). + UpdateStrategyRecreate + // UpdateStrategyCreateNew creates a new resource from template (CronJobs). + UpdateStrategyCreateNew +) + +// WorkloadIdentity provides basic identification for a workload. +type WorkloadIdentity interface { + // Kind returns the workload type. + Kind() Kind + + // GetObject returns the underlying Kubernetes object. + GetObject() client.Object + + // GetName returns the workload name. + GetName() string + + // GetNamespace returns the workload namespace. + GetNamespace() string +} + +// WorkloadReader provides read-only access to workload state. +type WorkloadReader interface { + WorkloadIdentity + + // GetAnnotations returns the workload's annotations. + GetAnnotations() map[string]string + + // GetPodTemplateAnnotations returns annotations from the pod template spec. + GetPodTemplateAnnotations() map[string]string + + // GetContainers returns all containers (including init containers). + GetContainers() []corev1.Container + + // GetInitContainers returns all init containers. + GetInitContainers() []corev1.Container + + // GetVolumes returns the pod template volumes. + GetVolumes() []corev1.Volume + + // GetEnvFromSources returns all envFrom sources from all containers. + GetEnvFromSources() []corev1.EnvFromSource + + // GetOwnerReferences returns the owner references of the workload. + GetOwnerReferences() []metav1.OwnerReference +} + +// WorkloadMatcher provides methods for checking resource usage. +type WorkloadMatcher interface { + // UsesConfigMap checks if the workload uses a specific ConfigMap. + UsesConfigMap(name string) bool + + // UsesSecret checks if the workload uses a specific Secret. + UsesSecret(name string) bool +} + +// WorkloadMutator provides methods for modifying workload state. +type WorkloadMutator interface { + // SetPodTemplateAnnotation sets an annotation on the pod template. + SetPodTemplateAnnotation(key, value string) + + // SetContainers updates the containers. + SetContainers(containers []corev1.Container) + + // SetInitContainers updates the init containers. + SetInitContainers(containers []corev1.Container) +} + +// WorkloadUpdater provides methods for persisting workload changes. +type WorkloadUpdater interface { + // Update persists changes to the workload. + Update(ctx context.Context, c client.Client) error + + // UpdateStrategy returns how this workload should be updated. + // Most workloads use UpdateStrategyPatch (strategic merge patch). + // Jobs use UpdateStrategyRecreate (delete and recreate). + // CronJobs use UpdateStrategyCreateNew (create a new Job from template). + UpdateStrategy() UpdateStrategy + + // PerformSpecialUpdate handles non-standard update logic. + // This is called when UpdateStrategy() != UpdateStrategyPatch. + // For UpdateStrategyPatch workloads, this returns (false, nil). + PerformSpecialUpdate(ctx context.Context, c client.Client) (updated bool, err error) + + // ResetOriginal resets the original state to the current object state. + // This should be called after re-fetching the object (e.g., after a conflict) + // to ensure strategic merge patch diffs are calculated correctly. + ResetOriginal() + + // DeepCopy returns a deep copy of the workload. + DeepCopy() Workload +} + +// Workload combines all workload interfaces for full workload access. +// Use specific interfaces (WorkloadReader, WorkloadMatcher, etc.) when possible +// to limit scope and improve testability. +type Workload interface { + WorkloadReader + WorkloadMatcher + WorkloadMutator + WorkloadUpdater +} diff --git a/internal/pkg/workload/job.go b/internal/pkg/workload/job.go new file mode 100644 index 000000000..557c8f6c4 --- /dev/null +++ b/internal/pkg/workload/job.go @@ -0,0 +1,107 @@ +package workload + +import ( + "context" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// jobAccessor implements PodTemplateAccessor for Job. +type jobAccessor struct { + job *batchv1.Job +} + +func (a *jobAccessor) GetPodTemplateSpec() *corev1.PodTemplateSpec { + return &a.job.Spec.Template +} + +func (a *jobAccessor) GetObjectMeta() *metav1.ObjectMeta { + return &a.job.ObjectMeta +} + +// JobWorkload wraps a Kubernetes Job. +// Note: Jobs have a special update mechanism - instead of updating the Job, +// Reloader deletes and recreates it with the same spec. +type JobWorkload struct { + *BaseWorkload[*batchv1.Job] +} + +// NewJobWorkload creates a new JobWorkload. +func NewJobWorkload(j *batchv1.Job) *JobWorkload { + original := j.DeepCopy() + accessor := &jobAccessor{job: j} + return &JobWorkload{ + BaseWorkload: NewBaseWorkload(j, original, accessor, KindJob), + } +} + +// Ensure JobWorkload implements Workload. +var _ Workload = (*JobWorkload)(nil) + +// Update for Job is a no-op - use PerformSpecialUpdate instead. +// Jobs trigger reloads by being deleted and recreated. +func (w *JobWorkload) Update(ctx context.Context, c client.Client) error { + // Jobs don't get updated directly - they are deleted and recreated + // This is handled by PerformSpecialUpdate + return nil +} + +// ResetOriginal is a no-op for Jobs since they don't use strategic merge patch. +// Jobs are deleted and recreated instead of being patched. +func (w *JobWorkload) ResetOriginal() {} + +func (w *JobWorkload) UpdateStrategy() UpdateStrategy { + return UpdateStrategyRecreate +} + +// PerformSpecialUpdate deletes the Job and recreates it with the updated spec. +// This is necessary because Jobs are immutable after creation. +func (w *JobWorkload) PerformSpecialUpdate(ctx context.Context, c client.Client) (bool, error) { + oldJob := w.Object() + newJob := oldJob.DeepCopy() + + // Delete the old job with background propagation + policy := metav1.DeletePropagationBackground + if err := c.Delete(ctx, oldJob, &client.DeleteOptions{ + PropagationPolicy: &policy, + }); err != nil { + if !errors.IsNotFound(err) { + return false, err + } + } + + // Clear fields that should not be specified when creating a new Job + newJob.ResourceVersion = "" + newJob.UID = "" + newJob.CreationTimestamp = metav1.Time{} + newJob.Status = batchv1.JobStatus{} + + // Remove problematic labels that are auto-generated + delete(newJob.Spec.Template.Labels, "controller-uid") + delete(newJob.Spec.Template.Labels, batchv1.ControllerUidLabel) + delete(newJob.Spec.Template.Labels, batchv1.JobNameLabel) + delete(newJob.Spec.Template.Labels, "job-name") + + // Remove the selector to allow it to be auto-generated + newJob.Spec.Selector = nil + + // Create the new job with same spec + if err := c.Create(ctx, newJob, client.FieldOwner(FieldManager)); err != nil { + return false, err + } + + return true, nil +} + +func (w *JobWorkload) DeepCopy() Workload { + return NewJobWorkload(w.Object().DeepCopy()) +} + +// GetJob returns the underlying Job for special handling. +func (w *JobWorkload) GetJob() *batchv1.Job { + return w.Object() +} diff --git a/internal/pkg/workload/lister.go b/internal/pkg/workload/lister.go new file mode 100644 index 000000000..1b982fead --- /dev/null +++ b/internal/pkg/workload/lister.go @@ -0,0 +1,130 @@ +package workload + +import ( + "context" + + openshiftv1 "github.com/openshift/api/apps/v1" + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// IgnoreChecker checks if a workload kind should be ignored. +type IgnoreChecker interface { + IsWorkloadIgnored(kind string) bool +} + +// Lister lists workloads from the cluster. +type Lister struct { + Client client.Client + Registry *Registry + Checker IgnoreChecker +} + +// NewLister creates a new workload lister. +func NewLister(c client.Client, registry *Registry, checker IgnoreChecker) *Lister { + return &Lister{ + Client: c, + Registry: registry, + Checker: checker, + } +} + +// List returns all workloads in the given namespace. +func (l *Lister) List(ctx context.Context, namespace string) ([]Workload, error) { + var result []Workload + + for _, kind := range l.Registry.SupportedKinds() { + if l.Checker != nil && l.Checker.IsWorkloadIgnored(string(kind)) { + continue + } + + workloads, err := l.listByKind(ctx, namespace, kind) + if err != nil { + return nil, err + } + result = append(result, workloads...) + } + + return result, nil +} + +func (l *Lister) listByKind(ctx context.Context, namespace string, kind Kind) ([]Workload, error) { + lister := l.Registry.ListerFor(kind) + if lister == nil { + return nil, nil + } + return lister(ctx, l.Client, namespace) +} + +func listDeployments(ctx context.Context, c client.Client, namespace string) ([]Workload, error) { + var list appsv1.DeploymentList + if err := c.List(ctx, &list, client.InNamespace(namespace)); err != nil { + return nil, err + } + result := make([]Workload, len(list.Items)) + for i := range list.Items { + result[i] = NewDeploymentWorkload(&list.Items[i]) + } + return result, nil +} + +func listDaemonSets(ctx context.Context, c client.Client, namespace string) ([]Workload, error) { + var list appsv1.DaemonSetList + if err := c.List(ctx, &list, client.InNamespace(namespace)); err != nil { + return nil, err + } + result := make([]Workload, len(list.Items)) + for i := range list.Items { + result[i] = NewDaemonSetWorkload(&list.Items[i]) + } + return result, nil +} + +func listStatefulSets(ctx context.Context, c client.Client, namespace string) ([]Workload, error) { + var list appsv1.StatefulSetList + if err := c.List(ctx, &list, client.InNamespace(namespace)); err != nil { + return nil, err + } + result := make([]Workload, len(list.Items)) + for i := range list.Items { + result[i] = NewStatefulSetWorkload(&list.Items[i]) + } + return result, nil +} + +func listJobs(ctx context.Context, c client.Client, namespace string) ([]Workload, error) { + var list batchv1.JobList + if err := c.List(ctx, &list, client.InNamespace(namespace)); err != nil { + return nil, err + } + result := make([]Workload, len(list.Items)) + for i := range list.Items { + result[i] = NewJobWorkload(&list.Items[i]) + } + return result, nil +} + +func listCronJobs(ctx context.Context, c client.Client, namespace string) ([]Workload, error) { + var list batchv1.CronJobList + if err := c.List(ctx, &list, client.InNamespace(namespace)); err != nil { + return nil, err + } + result := make([]Workload, len(list.Items)) + for i := range list.Items { + result[i] = NewCronJobWorkload(&list.Items[i]) + } + return result, nil +} + +func listDeploymentConfigs(ctx context.Context, c client.Client, namespace string) ([]Workload, error) { + var list openshiftv1.DeploymentConfigList + if err := c.List(ctx, &list, client.InNamespace(namespace)); err != nil { + return nil, err + } + result := make([]Workload, len(list.Items)) + for i := range list.Items { + result[i] = NewDeploymentConfigWorkload(&list.Items[i]) + } + return result, nil +} diff --git a/internal/pkg/workload/registry.go b/internal/pkg/workload/registry.go new file mode 100644 index 000000000..5392eca1b --- /dev/null +++ b/internal/pkg/workload/registry.go @@ -0,0 +1,144 @@ +package workload + +import ( + "context" + "fmt" + "strings" + + argorolloutv1alpha1 "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1" + openshiftv1 "github.com/openshift/api/apps/v1" + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// WorkloadLister is a function that lists workloads of a specific kind. +type WorkloadLister func(ctx context.Context, c client.Client, namespace string) ([]Workload, error) + +// RegistryOptions configures the workload registry. +type RegistryOptions struct { + ArgoRolloutsEnabled bool + DeploymentConfigEnabled bool + RolloutStrategyAnnotation string +} + +// Registry provides factory methods for creating Workload instances. +type Registry struct { + argoRolloutsEnabled bool + deploymentConfigEnabled bool + rolloutStrategyAnnotation string + listers map[Kind]WorkloadLister +} + +// NewRegistry creates a new workload registry. +func NewRegistry(opts RegistryOptions) *Registry { + r := &Registry{ + argoRolloutsEnabled: opts.ArgoRolloutsEnabled, + deploymentConfigEnabled: opts.DeploymentConfigEnabled, + rolloutStrategyAnnotation: opts.RolloutStrategyAnnotation, + listers: map[Kind]WorkloadLister{ + KindDeployment: listDeployments, + KindDaemonSet: listDaemonSets, + KindStatefulSet: listStatefulSets, + KindJob: listJobs, + KindCronJob: listCronJobs, + }, + } + if opts.ArgoRolloutsEnabled { + // Use closure to capture the strategy annotation + strategyAnnotation := opts.RolloutStrategyAnnotation + r.listers[KindArgoRollout] = func(ctx context.Context, c client.Client, namespace string) ([]Workload, error) { + var list argorolloutv1alpha1.RolloutList + if err := c.List(ctx, &list, client.InNamespace(namespace)); err != nil { + return nil, err + } + result := make([]Workload, len(list.Items)) + for i := range list.Items { + result[i] = NewRolloutWorkload(&list.Items[i], strategyAnnotation) + } + return result, nil + } + } + if opts.DeploymentConfigEnabled { + r.listers[KindDeploymentConfig] = listDeploymentConfigs + } + return r +} + +// ListerFor returns the lister function for the given kind, or nil if not found. +func (r *Registry) ListerFor(kind Kind) WorkloadLister { + return r.listers[kind] +} + +// SupportedKinds returns all supported workload kinds. +func (r *Registry) SupportedKinds() []Kind { + kinds := []Kind{ + KindDeployment, + KindDaemonSet, + KindStatefulSet, + KindJob, + KindCronJob, + } + if r.argoRolloutsEnabled { + kinds = append(kinds, KindArgoRollout) + } + if r.deploymentConfigEnabled { + kinds = append(kinds, KindDeploymentConfig) + } + return kinds +} + +// FromObject creates a Workload from a Kubernetes object. +func (r *Registry) FromObject(obj client.Object) (Workload, error) { + switch o := obj.(type) { + case *appsv1.Deployment: + return NewDeploymentWorkload(o), nil + case *appsv1.DaemonSet: + return NewDaemonSetWorkload(o), nil + case *appsv1.StatefulSet: + return NewStatefulSetWorkload(o), nil + case *batchv1.Job: + return NewJobWorkload(o), nil + case *batchv1.CronJob: + return NewCronJobWorkload(o), nil + case *argorolloutv1alpha1.Rollout: + if !r.argoRolloutsEnabled { + return nil, fmt.Errorf("argo Rollouts support is not enabled") + } + return NewRolloutWorkload(o, r.rolloutStrategyAnnotation), nil + case *openshiftv1.DeploymentConfig: + if !r.deploymentConfigEnabled { + return nil, fmt.Errorf("openShift DeploymentConfig support is not enabled") + } + return NewDeploymentConfigWorkload(o), nil + default: + return nil, fmt.Errorf("unsupported object type: %T", obj) + } +} + +// kindAliases maps string representations to Kind constants. +// Supports lowercase, title case, and plural forms for user convenience. +var kindAliases = map[string]Kind{ + "deployment": KindDeployment, + "deployments": KindDeployment, + "daemonset": KindDaemonSet, + "daemonsets": KindDaemonSet, + "statefulset": KindStatefulSet, + "statefulsets": KindStatefulSet, + "rollout": KindArgoRollout, + "rollouts": KindArgoRollout, + "job": KindJob, + "jobs": KindJob, + "cronjob": KindCronJob, + "cronjobs": KindCronJob, + "deploymentconfig": KindDeploymentConfig, + "deploymentconfigs": KindDeploymentConfig, +} + +// KindFromString converts a string to a Kind. +func KindFromString(s string) (Kind, error) { + if k, ok := kindAliases[strings.ToLower(s)]; ok { + return k, nil + } + return "", fmt.Errorf("unknown workload kind: %s", s) +} diff --git a/internal/pkg/workload/registry_test.go b/internal/pkg/workload/registry_test.go new file mode 100644 index 000000000..b84830fae --- /dev/null +++ b/internal/pkg/workload/registry_test.go @@ -0,0 +1,366 @@ +package workload + +import ( + "testing" + + argorolloutv1alpha1 "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1" + openshiftv1 "github.com/openshift/api/apps/v1" + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestNewRegistry_WithoutArgoRollouts(t *testing.T) { + r := NewRegistry(RegistryOptions{ArgoRolloutsEnabled: false}) + + kinds := r.SupportedKinds() + if len(kinds) != 5 { + t.Errorf("SupportedKinds() = %d kinds, want 5", len(kinds)) + } + + for _, k := range kinds { + if k == KindArgoRollout { + t.Error("SupportedKinds() should not include ArgoRollout when disabled") + } + } + + if r.ListerFor(KindArgoRollout) != nil { + t.Error("ListerFor(KindArgoRollout) should return nil when disabled") + } +} + +func TestNewRegistry_WithArgoRollouts(t *testing.T) { + r := NewRegistry(RegistryOptions{ArgoRolloutsEnabled: true}) + + kinds := r.SupportedKinds() + if len(kinds) != 6 { + t.Errorf("SupportedKinds() = %d kinds, want 6", len(kinds)) + } + + found := false + for _, k := range kinds { + if k == KindArgoRollout { + found = true + break + } + } + if !found { + t.Error("SupportedKinds() should include ArgoRollout when enabled") + } + + if r.ListerFor(KindArgoRollout) == nil { + t.Error("ListerFor(KindArgoRollout) should return a function when enabled") + } +} + +func TestRegistry_ListerFor_AllKinds(t *testing.T) { + r := NewRegistry(RegistryOptions{ArgoRolloutsEnabled: true}) + + tests := []struct { + kind Kind + wantNil bool + }{ + {KindDeployment, false}, + {KindDaemonSet, false}, + {KindStatefulSet, false}, + {KindJob, false}, + {KindCronJob, false}, + {KindArgoRollout, false}, + {Kind("unknown"), true}, + } + + for _, tt := range tests { + lister := r.ListerFor(tt.kind) + if (lister == nil) != tt.wantNil { + t.Errorf("ListerFor(%s) = nil? %v, want nil? %v", tt.kind, lister == nil, tt.wantNil) + } + } +} + +func TestRegistry_FromObject_Deployment(t *testing.T) { + r := NewRegistry(RegistryOptions{}) + deploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + w, err := r.FromObject(deploy) + if err != nil { + t.Fatalf("FromObject(Deployment) error = %v", err) + } + if w.Kind() != KindDeployment { + t.Errorf("FromObject(Deployment).Kind() = %v, want %v", w.Kind(), KindDeployment) + } +} + +func TestRegistry_FromObject_DaemonSet(t *testing.T) { + r := NewRegistry(RegistryOptions{}) + ds := &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + w, err := r.FromObject(ds) + if err != nil { + t.Fatalf("FromObject(DaemonSet) error = %v", err) + } + if w.Kind() != KindDaemonSet { + t.Errorf("FromObject(DaemonSet).Kind() = %v, want %v", w.Kind(), KindDaemonSet) + } +} + +func TestRegistry_FromObject_StatefulSet(t *testing.T) { + r := NewRegistry(RegistryOptions{}) + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + w, err := r.FromObject(sts) + if err != nil { + t.Fatalf("FromObject(StatefulSet) error = %v", err) + } + if w.Kind() != KindStatefulSet { + t.Errorf("FromObject(StatefulSet).Kind() = %v, want %v", w.Kind(), KindStatefulSet) + } +} + +func TestRegistry_FromObject_Job(t *testing.T) { + r := NewRegistry(RegistryOptions{}) + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + w, err := r.FromObject(job) + if err != nil { + t.Fatalf("FromObject(Job) error = %v", err) + } + if w.Kind() != KindJob { + t.Errorf("FromObject(Job).Kind() = %v, want %v", w.Kind(), KindJob) + } +} + +func TestRegistry_FromObject_CronJob(t *testing.T) { + r := NewRegistry(RegistryOptions{}) + cj := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + w, err := r.FromObject(cj) + if err != nil { + t.Fatalf("FromObject(CronJob) error = %v", err) + } + if w.Kind() != KindCronJob { + t.Errorf("FromObject(CronJob).Kind() = %v, want %v", w.Kind(), KindCronJob) + } +} + +func TestRegistry_FromObject_Rollout_Enabled(t *testing.T) { + r := NewRegistry(RegistryOptions{ArgoRolloutsEnabled: true}) + rollout := &argorolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + w, err := r.FromObject(rollout) + if err != nil { + t.Fatalf("FromObject(Rollout) error = %v", err) + } + if w.Kind() != KindArgoRollout { + t.Errorf("FromObject(Rollout).Kind() = %v, want %v", w.Kind(), KindArgoRollout) + } +} + +func TestRegistry_FromObject_Rollout_Disabled(t *testing.T) { + r := NewRegistry(RegistryOptions{}) + rollout := &argorolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + _, err := r.FromObject(rollout) + if err == nil { + t.Error("FromObject(Rollout) should return error when Argo Rollouts disabled") + } +} + +func TestRegistry_FromObject_UnsupportedType(t *testing.T) { + r := NewRegistry(RegistryOptions{}) + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + _, err := r.FromObject(cm) + if err == nil { + t.Error("FromObject(ConfigMap) should return error for unsupported type") + } +} + +func TestKindFromString(t *testing.T) { + tests := []struct { + input string + want Kind + wantErr bool + }{ + // Lowercase + {"deployment", KindDeployment, false}, + {"daemonset", KindDaemonSet, false}, + {"statefulset", KindStatefulSet, false}, + {"job", KindJob, false}, + {"cronjob", KindCronJob, false}, + {"rollout", KindArgoRollout, false}, + // Plural forms + {"deployments", KindDeployment, false}, + {"daemonsets", KindDaemonSet, false}, + {"statefulsets", KindStatefulSet, false}, + {"jobs", KindJob, false}, + {"cronjobs", KindCronJob, false}, + {"rollouts", KindArgoRollout, false}, + // Mixed case + {"Deployment", KindDeployment, false}, + {"DAEMONSET", KindDaemonSet, false}, + {"StatefulSet", KindStatefulSet, false}, + // Unknown + {"unknown", "", true}, + {"replicaset", "", true}, + {"", "", true}, + } + + for _, tt := range tests { + got, err := KindFromString(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("KindFromString(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + continue + } + if got != tt.want { + t.Errorf("KindFromString(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestNewLister(t *testing.T) { + r := NewRegistry(RegistryOptions{}) + l := NewLister(nil, r, nil) + + if l == nil { + t.Fatal("NewLister should not return nil") + } + if l.Registry != r { + t.Error("NewLister should set Registry") + } +} + +// DeploymentConfig registry tests +func TestNewRegistry_WithDeploymentConfig(t *testing.T) { + r := NewRegistry(RegistryOptions{DeploymentConfigEnabled: true}) + + kinds := r.SupportedKinds() + if len(kinds) != 6 { + t.Errorf("SupportedKinds() = %d kinds, want 6", len(kinds)) + } + + found := false + for _, k := range kinds { + if k == KindDeploymentConfig { + found = true + break + } + } + if !found { + t.Error("SupportedKinds() should include DeploymentConfig when enabled") + } + + if r.ListerFor(KindDeploymentConfig) == nil { + t.Error("ListerFor(KindDeploymentConfig) should return a function when enabled") + } +} + +func TestNewRegistry_WithoutDeploymentConfig(t *testing.T) { + r := NewRegistry(RegistryOptions{DeploymentConfigEnabled: false}) + + for _, k := range r.SupportedKinds() { + if k == KindDeploymentConfig { + t.Error("SupportedKinds() should not include DeploymentConfig when disabled") + } + } + + if r.ListerFor(KindDeploymentConfig) != nil { + t.Error("ListerFor(KindDeploymentConfig) should return nil when disabled") + } +} + +func TestNewRegistry_WithBothOptionalWorkloads(t *testing.T) { + r := NewRegistry(RegistryOptions{ + ArgoRolloutsEnabled: true, + DeploymentConfigEnabled: true, + }) + + kinds := r.SupportedKinds() + if len(kinds) != 7 { + t.Errorf("SupportedKinds() = %d kinds, want 7 (5 base + ArgoRollout + DeploymentConfig)", len(kinds)) + } + + foundRollout := false + foundDC := false + for _, k := range kinds { + if k == KindArgoRollout { + foundRollout = true + } + if k == KindDeploymentConfig { + foundDC = true + } + } + if !foundRollout { + t.Error("SupportedKinds() should include ArgoRollout") + } + if !foundDC { + t.Error("SupportedKinds() should include DeploymentConfig") + } +} + +func TestRegistry_FromObject_DeploymentConfig_Enabled(t *testing.T) { + r := NewRegistry(RegistryOptions{DeploymentConfigEnabled: true}) + dc := &openshiftv1.DeploymentConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + w, err := r.FromObject(dc) + if err != nil { + t.Fatalf("FromObject(DeploymentConfig) error = %v", err) + } + if w.Kind() != KindDeploymentConfig { + t.Errorf("FromObject(DeploymentConfig).Kind() = %v, want %v", w.Kind(), KindDeploymentConfig) + } +} + +func TestRegistry_FromObject_DeploymentConfig_Disabled(t *testing.T) { + r := NewRegistry(RegistryOptions{DeploymentConfigEnabled: false}) + dc := &openshiftv1.DeploymentConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + _, err := r.FromObject(dc) + if err == nil { + t.Error("FromObject(DeploymentConfig) should return error when DeploymentConfig disabled") + } +} + +func TestKindFromString_DeploymentConfig(t *testing.T) { + tests := []struct { + input string + want Kind + wantErr bool + }{ + {"deploymentconfig", KindDeploymentConfig, false}, + {"deploymentconfigs", KindDeploymentConfig, false}, + {"DeploymentConfig", KindDeploymentConfig, false}, + {"DEPLOYMENTCONFIG", KindDeploymentConfig, false}, + } + + for _, tt := range tests { + got, err := KindFromString(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("KindFromString(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + continue + } + if got != tt.want { + t.Errorf("KindFromString(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} diff --git a/internal/pkg/workload/rollout.go b/internal/pkg/workload/rollout.go new file mode 100644 index 000000000..ad8d8989f --- /dev/null +++ b/internal/pkg/workload/rollout.go @@ -0,0 +1,126 @@ +package workload + +import ( + "context" + "fmt" + "time" + + argorolloutv1alpha1 "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// RolloutStrategy defines how Argo Rollouts are updated. +type RolloutStrategy string + +const ( + // RolloutStrategyRollout performs a standard rollout update. + RolloutStrategyRollout RolloutStrategy = "rollout" + + // RolloutStrategyRestart sets the restartAt field to trigger a restart. + RolloutStrategyRestart RolloutStrategy = "restart" +) + +// rolloutAccessor implements PodTemplateAccessor for Rollout. +type rolloutAccessor struct { + rollout *argorolloutv1alpha1.Rollout +} + +func (a *rolloutAccessor) GetPodTemplateSpec() *corev1.PodTemplateSpec { + return &a.rollout.Spec.Template +} + +func (a *rolloutAccessor) GetObjectMeta() *metav1.ObjectMeta { + return &a.rollout.ObjectMeta +} + +// RolloutWorkload wraps an Argo Rollout. +type RolloutWorkload struct { + *BaseWorkload[*argorolloutv1alpha1.Rollout] + strategyAnnotation string +} + +// NewRolloutWorkload creates a new RolloutWorkload. +// The strategyAnnotation parameter specifies the annotation key used to determine +// the rollout strategy (from config.Annotations.RolloutStrategy). +func NewRolloutWorkload(r *argorolloutv1alpha1.Rollout, strategyAnnotation string) *RolloutWorkload { + original := r.DeepCopy() + accessor := &rolloutAccessor{rollout: r} + return &RolloutWorkload{ + BaseWorkload: NewBaseWorkload(r, original, accessor, KindArgoRollout), + strategyAnnotation: strategyAnnotation, + } +} + +// Ensure RolloutWorkload implements Workload. +var _ Workload = (*RolloutWorkload)(nil) + +// Update updates the Rollout. It uses the rollout strategy annotation to determine +// whether to do a standard rollout or set the restartAt field. +func (w *RolloutWorkload) Update(ctx context.Context, c client.Client) error { + strategy := w.getStrategy() + switch strategy { + case RolloutStrategyRestart: + // Set restartAt field to trigger a restart + restartAt := metav1.NewTime(time.Now()) + w.Object().Spec.RestartAt = &restartAt + } + return c.Patch(ctx, w.Object(), client.MergeFrom(w.Original()), client.FieldOwner(FieldManager)) +} + +// getStrategy returns the rollout strategy from the annotation. +func (w *RolloutWorkload) getStrategy() RolloutStrategy { + annotations := w.Object().GetAnnotations() + if annotations == nil { + return RolloutStrategyRollout + } + strategy := annotations[w.strategyAnnotation] + switch RolloutStrategy(strategy) { + case RolloutStrategyRestart: + return RolloutStrategyRestart + default: + return RolloutStrategyRollout + } +} + +func (w *RolloutWorkload) DeepCopy() Workload { + return NewRolloutWorkload(w.Object().DeepCopy(), w.strategyAnnotation) +} + +// GetRollout returns the underlying Rollout for special handling. +func (w *RolloutWorkload) GetRollout() *argorolloutv1alpha1.Rollout { + return w.Object() +} + +// GetStrategy returns the configured rollout strategy. +func (w *RolloutWorkload) GetStrategy() RolloutStrategy { + return w.getStrategy() +} + +// String returns a string representation of the strategy. +func (s RolloutStrategy) String() string { + return string(s) +} + +// ToRolloutStrategy converts a string to RolloutStrategy. +func ToRolloutStrategy(s string) RolloutStrategy { + switch RolloutStrategy(s) { + case RolloutStrategyRestart: + return RolloutStrategyRestart + case RolloutStrategyRollout: + return RolloutStrategyRollout + default: + return RolloutStrategyRollout + } +} + +// Validate checks if the rollout strategy is valid. +func (s RolloutStrategy) Validate() error { + switch s { + case RolloutStrategyRollout, RolloutStrategyRestart: + return nil + default: + return fmt.Errorf("invalid rollout strategy: %s", s) + } +} diff --git a/internal/pkg/workload/statefulset.go b/internal/pkg/workload/statefulset.go new file mode 100644 index 000000000..8e9d1e48c --- /dev/null +++ b/internal/pkg/workload/statefulset.go @@ -0,0 +1,46 @@ +package workload + +import ( + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// statefulSetAccessor implements PodTemplateAccessor for StatefulSet. +type statefulSetAccessor struct { + statefulset *appsv1.StatefulSet +} + +func (a *statefulSetAccessor) GetPodTemplateSpec() *corev1.PodTemplateSpec { + return &a.statefulset.Spec.Template +} + +func (a *statefulSetAccessor) GetObjectMeta() *metav1.ObjectMeta { + return &a.statefulset.ObjectMeta +} + +// StatefulSetWorkload wraps a Kubernetes StatefulSet. +type StatefulSetWorkload struct { + *BaseWorkload[*appsv1.StatefulSet] +} + +// NewStatefulSetWorkload creates a new StatefulSetWorkload. +func NewStatefulSetWorkload(s *appsv1.StatefulSet) *StatefulSetWorkload { + original := s.DeepCopy() + accessor := &statefulSetAccessor{statefulset: s} + return &StatefulSetWorkload{ + BaseWorkload: NewBaseWorkload(s, original, accessor, KindStatefulSet), + } +} + +// Ensure StatefulSetWorkload implements Workload. +var _ Workload = (*StatefulSetWorkload)(nil) + +func (w *StatefulSetWorkload) DeepCopy() Workload { + return NewStatefulSetWorkload(w.Object().DeepCopy()) +} + +// GetStatefulSet returns the underlying StatefulSet for special handling. +func (w *StatefulSetWorkload) GetStatefulSet() *appsv1.StatefulSet { + return w.Object() +} diff --git a/internal/pkg/workload/uses.go b/internal/pkg/workload/uses.go new file mode 100644 index 000000000..fd37a2f3c --- /dev/null +++ b/internal/pkg/workload/uses.go @@ -0,0 +1,77 @@ +package workload + +import corev1 "k8s.io/api/core/v1" + +// SpecUsesConfigMap checks if a PodSpec references the named ConfigMap. +func SpecUsesConfigMap(spec *corev1.PodSpec, name string) bool { + for _, vol := range spec.Volumes { + if vol.ConfigMap != nil && vol.ConfigMap.Name == name { + return true + } + if vol.Projected != nil { + for _, source := range vol.Projected.Sources { + if source.ConfigMap != nil && source.ConfigMap.Name == name { + return true + } + } + } + } + + if containersUseConfigMap(spec.Containers, name) { + return true + } + return containersUseConfigMap(spec.InitContainers, name) +} + +func containersUseConfigMap(containers []corev1.Container, name string) bool { + for _, container := range containers { + for _, envFrom := range container.EnvFrom { + if envFrom.ConfigMapRef != nil && envFrom.ConfigMapRef.Name == name { + return true + } + } + for _, env := range container.Env { + if env.ValueFrom != nil && env.ValueFrom.ConfigMapKeyRef != nil && env.ValueFrom.ConfigMapKeyRef.Name == name { + return true + } + } + } + return false +} + +// SpecUsesSecret checks if a PodSpec references the named Secret. +func SpecUsesSecret(spec *corev1.PodSpec, name string) bool { + for _, vol := range spec.Volumes { + if vol.Secret != nil && vol.Secret.SecretName == name { + return true + } + if vol.Projected != nil { + for _, source := range vol.Projected.Sources { + if source.Secret != nil && source.Secret.Name == name { + return true + } + } + } + } + + if containersUseSecret(spec.Containers, name) { + return true + } + return containersUseSecret(spec.InitContainers, name) +} + +func containersUseSecret(containers []corev1.Container, name string) bool { + for _, container := range containers { + for _, envFrom := range container.EnvFrom { + if envFrom.SecretRef != nil && envFrom.SecretRef.Name == name { + return true + } + } + for _, env := range container.Env { + if env.ValueFrom != nil && env.ValueFrom.SecretKeyRef != nil && env.ValueFrom.SecretKeyRef.Name == name { + return true + } + } + } + return false +} diff --git a/internal/pkg/workload/workload_test.go b/internal/pkg/workload/workload_test.go new file mode 100644 index 000000000..084eb1e57 --- /dev/null +++ b/internal/pkg/workload/workload_test.go @@ -0,0 +1,1768 @@ +package workload + +import ( + "testing" + + argorolloutv1alpha1 "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/stakater/Reloader/internal/pkg/testutil" +) + +// testRolloutStrategyAnnotation is the annotation key used in tests for rollout strategy. +const testRolloutStrategyAnnotation = "reloader.stakater.com/rollout-strategy" + +// addEnvVar adds an environment variable with a ConfigMapKeyRef or SecretKeyRef to a container. +func addEnvVarConfigMapRef(containers []corev1.Container, envName, configMapName, key string) { + if len(containers) > 0 { + containers[0].Env = append(containers[0].Env, corev1.EnvVar{ + Name: envName, + ValueFrom: &corev1.EnvVarSource{ + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: configMapName}, + Key: key, + }, + }, + }) + } +} + +func addEnvVarSecretRef(containers []corev1.Container, envName, secretName, key string) { + if len(containers) > 0 { + containers[0].Env = append(containers[0].Env, corev1.EnvVar{ + Name: envName, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, + Key: key, + }, + }, + }) + } +} + +func TestDeploymentWorkload_BasicGetters(t *testing.T) { + deploy := testutil.NewDeployment("test-deploy", "test-ns", map[string]string{"key": "value"}) + + w := NewDeploymentWorkload(deploy) + + if w.Kind() != KindDeployment { + t.Errorf("Kind() = %v, want %v", w.Kind(), KindDeployment) + } + if w.GetName() != "test-deploy" { + t.Errorf("GetName() = %v, want test-deploy", w.GetName()) + } + if w.GetNamespace() != "test-ns" { + t.Errorf("GetNamespace() = %v, want test-ns", w.GetNamespace()) + } + if w.GetAnnotations()["key"] != "value" { + t.Errorf("GetAnnotations()[key] = %v, want value", w.GetAnnotations()["key"]) + } + if w.GetObject() != deploy { + t.Error("GetObject() should return the underlying deployment") + } +} + +func TestDeploymentWorkload_PodTemplateAnnotations(t *testing.T) { + deploy := testutil.NewDeployment("test", "default", nil) + deploy.Spec.Template.Annotations["existing"] = "annotation" + + w := NewDeploymentWorkload(deploy) + + // Test get + annotations := w.GetPodTemplateAnnotations() + if annotations["existing"] != "annotation" { + t.Errorf("GetPodTemplateAnnotations()[existing] = %v, want annotation", annotations["existing"]) + } + + // Test set + w.SetPodTemplateAnnotation("new-key", "new-value") + if w.GetPodTemplateAnnotations()["new-key"] != "new-value" { + t.Error("SetPodTemplateAnnotation should add new annotation") + } +} + +func TestDeploymentWorkload_PodTemplateAnnotations_NilInit(t *testing.T) { + deploy := testutil.NewDeployment("test", "default", nil) + deploy.Spec.Template.Annotations = nil + + w := NewDeploymentWorkload(deploy) + + // Should initialize nil map + annotations := w.GetPodTemplateAnnotations() + if annotations == nil { + t.Error("GetPodTemplateAnnotations should initialize nil map") + } + + // Should work with nil initial map + w.SetPodTemplateAnnotation("key", "value") + if w.GetPodTemplateAnnotations()["key"] != "value" { + t.Error("SetPodTemplateAnnotation should work with nil initial map") + } +} + +func TestDeploymentWorkload_Containers(t *testing.T) { + deploy := testutil.NewDeployment("test", "default", nil) + deploy.Spec.Template.Spec.InitContainers = []corev1.Container{{Name: "init", Image: "busybox"}} + + w := NewDeploymentWorkload(deploy) + + // Test get containers + containers := w.GetContainers() + if len(containers) != 1 || containers[0].Name != "main" { + t.Errorf("GetContainers() = %v, want [main]", containers) + } + + // Test get init containers + initContainers := w.GetInitContainers() + if len(initContainers) != 1 || initContainers[0].Name != "init" { + t.Errorf("GetInitContainers() = %v, want [init]", initContainers) + } + + // Test set containers + newContainers := []corev1.Container{{Name: "new-main", Image: "alpine"}} + w.SetContainers(newContainers) + if w.GetContainers()[0].Name != "new-main" { + t.Error("SetContainers should update containers") + } + + // Test set init containers + newInitContainers := []corev1.Container{{Name: "new-init", Image: "alpine"}} + w.SetInitContainers(newInitContainers) + if w.GetInitContainers()[0].Name != "new-init" { + t.Error("SetInitContainers should update init containers") + } +} + +func TestDeploymentWorkload_Volumes(t *testing.T) { + deploy := testutil.NewDeployment("test", "default", nil) + deploy.Spec.Template.Spec.Volumes = []corev1.Volume{ + {Name: "config-vol"}, + {Name: "secret-vol"}, + } + + w := NewDeploymentWorkload(deploy) + + volumes := w.GetVolumes() + if len(volumes) != 2 { + t.Errorf("GetVolumes() length = %d, want 2", len(volumes)) + } +} + +func TestDeploymentWorkload_UsesConfigMap_Volume(t *testing.T) { + deploy := testutil.NewDeploymentWithVolume("test", "default", "my-config", "") + + w := NewDeploymentWorkload(deploy) + + if !w.UsesConfigMap("my-config") { + t.Error("UsesConfigMap should return true for ConfigMap volume") + } + if w.UsesConfigMap("other-config") { + t.Error("UsesConfigMap should return false for non-existent ConfigMap") + } +} + +func TestDeploymentWorkload_UsesConfigMap_ProjectedVolume(t *testing.T) { + deploy := testutil.NewDeploymentWithProjectedVolume("test", "default", "projected-config", "") + + w := NewDeploymentWorkload(deploy) + + if !w.UsesConfigMap("projected-config") { + t.Error("UsesConfigMap should return true for projected ConfigMap volume") + } +} + +func TestDeploymentWorkload_UsesConfigMap_EnvFrom(t *testing.T) { + deploy := testutil.NewDeploymentWithEnvFrom("test", "default", "env-config", "") + + w := NewDeploymentWorkload(deploy) + + if !w.UsesConfigMap("env-config") { + t.Error("UsesConfigMap should return true for envFrom ConfigMap") + } +} + +func TestDeploymentWorkload_UsesConfigMap_EnvVar(t *testing.T) { + deploy := testutil.NewDeployment("test", "default", nil) + addEnvVarConfigMapRef(deploy.Spec.Template.Spec.Containers, "CONFIG_VALUE", "var-config", "some-key") + + w := NewDeploymentWorkload(deploy) + + if !w.UsesConfigMap("var-config") { + t.Error("UsesConfigMap should return true for env var ConfigMapKeyRef") + } +} + +func TestDeploymentWorkload_UsesConfigMap_InitContainer(t *testing.T) { + deploy := testutil.NewDeployment("test", "default", nil) + deploy.Spec.Template.Spec.InitContainers = []corev1.Container{ + { + Name: "init", + EnvFrom: []corev1.EnvFromSource{ + { + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "init-config"}, + }, + }, + }, + }, + } + + w := NewDeploymentWorkload(deploy) + + if !w.UsesConfigMap("init-config") { + t.Error("UsesConfigMap should return true for init container ConfigMap") + } +} + +func TestDeploymentWorkload_UsesSecret_Volume(t *testing.T) { + deploy := testutil.NewDeploymentWithVolume("test", "default", "", "my-secret") + + w := NewDeploymentWorkload(deploy) + + if !w.UsesSecret("my-secret") { + t.Error("UsesSecret should return true for Secret volume") + } + if w.UsesSecret("other-secret") { + t.Error("UsesSecret should return false for non-existent Secret") + } +} + +func TestDeploymentWorkload_UsesSecret_ProjectedVolume(t *testing.T) { + deploy := testutil.NewDeploymentWithProjectedVolume("test", "default", "", "projected-secret") + + w := NewDeploymentWorkload(deploy) + + if !w.UsesSecret("projected-secret") { + t.Error("UsesSecret should return true for projected Secret volume") + } +} + +func TestDeploymentWorkload_UsesSecret_EnvFrom(t *testing.T) { + deploy := testutil.NewDeploymentWithEnvFrom("test", "default", "", "env-secret") + + w := NewDeploymentWorkload(deploy) + + if !w.UsesSecret("env-secret") { + t.Error("UsesSecret should return true for envFrom Secret") + } +} + +func TestDeploymentWorkload_UsesSecret_EnvVar(t *testing.T) { + deploy := testutil.NewDeployment("test", "default", nil) + addEnvVarSecretRef(deploy.Spec.Template.Spec.Containers, "SECRET_VALUE", "var-secret", "some-key") + + w := NewDeploymentWorkload(deploy) + + if !w.UsesSecret("var-secret") { + t.Error("UsesSecret should return true for env var SecretKeyRef") + } +} + +func TestDeploymentWorkload_UsesSecret_InitContainer(t *testing.T) { + deploy := testutil.NewDeployment("test", "default", nil) + deploy.Spec.Template.Spec.InitContainers = []corev1.Container{ + { + Name: "init", + EnvFrom: []corev1.EnvFromSource{ + { + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "init-secret"}, + }, + }, + }, + }, + } + + w := NewDeploymentWorkload(deploy) + + if !w.UsesSecret("init-secret") { + t.Error("UsesSecret should return true for init container Secret") + } +} + +func TestDeploymentWorkload_GetEnvFromSources(t *testing.T) { + deploy := testutil.NewDeployment("test", "default", nil) + deploy.Spec.Template.Spec.Containers = []corev1.Container{ + { + Name: "main", + EnvFrom: []corev1.EnvFromSource{{ConfigMapRef: &corev1.ConfigMapEnvSource{LocalObjectReference: corev1.LocalObjectReference{Name: "cm1"}}}}, + }, + { + Name: "sidecar", + EnvFrom: []corev1.EnvFromSource{{SecretRef: &corev1.SecretEnvSource{LocalObjectReference: corev1.LocalObjectReference{Name: "secret1"}}}}, + }, + } + deploy.Spec.Template.Spec.InitContainers = []corev1.Container{ + { + Name: "init", + EnvFrom: []corev1.EnvFromSource{{ConfigMapRef: &corev1.ConfigMapEnvSource{LocalObjectReference: corev1.LocalObjectReference{Name: "init-cm"}}}}, + }, + } + + w := NewDeploymentWorkload(deploy) + + sources := w.GetEnvFromSources() + if len(sources) != 3 { + t.Errorf("GetEnvFromSources() returned %d sources, want 3", len(sources)) + } +} + +func TestDeploymentWorkload_DeepCopy(t *testing.T) { + deploy := testutil.NewDeployment("test", "default", nil) + + w := NewDeploymentWorkload(deploy) + copy := w.DeepCopy() + + // Modify original + w.SetPodTemplateAnnotation("modified", "true") + + // Copy should not be affected + copyAnnotations := copy.GetPodTemplateAnnotations() + if copyAnnotations["modified"] == "true" { + t.Error("DeepCopy should create independent copy") + } +} + +func TestDeploymentWorkload_GetOwnerReferences(t *testing.T) { + deploy := testutil.NewDeployment("test", "default", nil) + deploy.OwnerReferences = []metav1.OwnerReference{ + {APIVersion: "apps/v1", Kind: "ReplicaSet", Name: "test-rs"}, + } + + w := NewDeploymentWorkload(deploy) + + refs := w.GetOwnerReferences() + if len(refs) != 1 || refs[0].Name != "test-rs" { + t.Errorf("GetOwnerReferences() = %v, want owner ref to test-rs", refs) + } +} + +// DaemonSet tests +func TestDaemonSetWorkload_BasicGetters(t *testing.T) { + ds := testutil.NewDaemonSet("test-ds", "test-ns", map[string]string{"key": "value"}) + + w := NewDaemonSetWorkload(ds) + + if w.Kind() != KindDaemonSet { + t.Errorf("Kind() = %v, want %v", w.Kind(), KindDaemonSet) + } + if w.GetName() != "test-ds" { + t.Errorf("GetName() = %v, want test-ds", w.GetName()) + } + if w.GetNamespace() != "test-ns" { + t.Errorf("GetNamespace() = %v, want test-ns", w.GetNamespace()) + } + if w.GetAnnotations()["key"] != "value" { + t.Errorf("GetAnnotations()[key] = %v, want value", w.GetAnnotations()["key"]) + } + if w.GetObject() != ds { + t.Error("GetObject() should return the underlying daemonset") + } +} + +func TestDaemonSetWorkload_PodTemplateAnnotations(t *testing.T) { + ds := testutil.NewDaemonSet("test", "default", nil) + ds.Spec.Template.Annotations["existing"] = "annotation" + + w := NewDaemonSetWorkload(ds) + + annotations := w.GetPodTemplateAnnotations() + if annotations["existing"] != "annotation" { + t.Errorf("GetPodTemplateAnnotations()[existing] = %v, want annotation", annotations["existing"]) + } + + w.SetPodTemplateAnnotation("new-key", "new-value") + if w.GetPodTemplateAnnotations()["new-key"] != "new-value" { + t.Error("SetPodTemplateAnnotation should add new annotation") + } +} + +func TestDaemonSetWorkload_PodTemplateAnnotations_NilInit(t *testing.T) { + ds := testutil.NewDaemonSet("test", "default", nil) + ds.Spec.Template.Annotations = nil + + w := NewDaemonSetWorkload(ds) + + annotations := w.GetPodTemplateAnnotations() + if annotations == nil { + t.Error("GetPodTemplateAnnotations should initialize nil map") + } + + w.SetPodTemplateAnnotation("key", "value") + if w.GetPodTemplateAnnotations()["key"] != "value" { + t.Error("SetPodTemplateAnnotation should work with nil initial map") + } +} + +func TestDaemonSetWorkload_Containers(t *testing.T) { + ds := testutil.NewDaemonSet("test", "default", nil) + ds.Spec.Template.Spec.InitContainers = []corev1.Container{{Name: "init", Image: "busybox"}} + + w := NewDaemonSetWorkload(ds) + + containers := w.GetContainers() + if len(containers) != 1 || containers[0].Name != "main" { + t.Errorf("GetContainers() = %v, want [main]", containers) + } + + initContainers := w.GetInitContainers() + if len(initContainers) != 1 || initContainers[0].Name != "init" { + t.Errorf("GetInitContainers() = %v, want [init]", initContainers) + } + + newContainers := []corev1.Container{{Name: "new-main", Image: "alpine"}} + w.SetContainers(newContainers) + if w.GetContainers()[0].Name != "new-main" { + t.Error("SetContainers should update containers") + } + + newInitContainers := []corev1.Container{{Name: "new-init", Image: "alpine"}} + w.SetInitContainers(newInitContainers) + if w.GetInitContainers()[0].Name != "new-init" { + t.Error("SetInitContainers should update init containers") + } +} + +func TestDaemonSetWorkload_Volumes(t *testing.T) { + ds := testutil.NewDaemonSet("test", "default", nil) + ds.Spec.Template.Spec.Volumes = []corev1.Volume{ + {Name: "config-vol"}, + {Name: "secret-vol"}, + } + + w := NewDaemonSetWorkload(ds) + + volumes := w.GetVolumes() + if len(volumes) != 2 { + t.Errorf("GetVolumes() length = %d, want 2", len(volumes)) + } +} + +func TestDaemonSetWorkload_UsesConfigMap(t *testing.T) { + ds := testutil.NewDaemonSet("test", "default", nil) + ds.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "ds-config"}, + }, + }, + }, + } + + w := NewDaemonSetWorkload(ds) + + if !w.UsesConfigMap("ds-config") { + t.Error("DaemonSet UsesConfigMap should return true for ConfigMap volume") + } + if w.UsesConfigMap("other-config") { + t.Error("UsesConfigMap should return false for non-existent ConfigMap") + } +} + +func TestDaemonSetWorkload_UsesConfigMap_EnvFrom(t *testing.T) { + ds := testutil.NewDaemonSet("test", "default", nil) + ds.Spec.Template.Spec.Containers[0].EnvFrom = []corev1.EnvFromSource{ + {ConfigMapRef: &corev1.ConfigMapEnvSource{LocalObjectReference: corev1.LocalObjectReference{Name: "ds-env-config"}}}, + } + + w := NewDaemonSetWorkload(ds) + + if !w.UsesConfigMap("ds-env-config") { + t.Error("DaemonSet UsesConfigMap should return true for envFrom ConfigMap") + } +} + +func TestDaemonSetWorkload_UsesSecret(t *testing.T) { + ds := testutil.NewDaemonSet("test", "default", nil) + ds.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "secret-vol", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{SecretName: "ds-secret"}, + }, + }, + } + + w := NewDaemonSetWorkload(ds) + + if !w.UsesSecret("ds-secret") { + t.Error("DaemonSet UsesSecret should return true for Secret volume") + } + if w.UsesSecret("other-secret") { + t.Error("UsesSecret should return false for non-existent Secret") + } +} + +func TestDaemonSetWorkload_GetEnvFromSources(t *testing.T) { + ds := testutil.NewDaemonSet("test", "default", nil) + ds.Spec.Template.Spec.Containers[0].EnvFrom = []corev1.EnvFromSource{ + {ConfigMapRef: &corev1.ConfigMapEnvSource{LocalObjectReference: corev1.LocalObjectReference{Name: "cm1"}}}, + } + ds.Spec.Template.Spec.InitContainers = []corev1.Container{ + { + Name: "init", + EnvFrom: []corev1.EnvFromSource{{SecretRef: &corev1.SecretEnvSource{LocalObjectReference: corev1.LocalObjectReference{Name: "secret1"}}}}, + }, + } + + w := NewDaemonSetWorkload(ds) + + sources := w.GetEnvFromSources() + if len(sources) != 2 { + t.Errorf("GetEnvFromSources() returned %d sources, want 2", len(sources)) + } +} + +func TestDaemonSetWorkload_DeepCopy(t *testing.T) { + ds := testutil.NewDaemonSet("test", "default", nil) + + w := NewDaemonSetWorkload(ds) + copy := w.DeepCopy() + + w.SetPodTemplateAnnotation("modified", "true") + + copyAnnotations := copy.GetPodTemplateAnnotations() + if copyAnnotations["modified"] == "true" { + t.Error("DeepCopy should create independent copy") + } +} + +func TestDaemonSetWorkload_GetOwnerReferences(t *testing.T) { + ds := testutil.NewDaemonSet("test", "default", nil) + ds.OwnerReferences = []metav1.OwnerReference{ + {APIVersion: "apps/v1", Kind: "DaemonSet", Name: "test-owner"}, + } + + w := NewDaemonSetWorkload(ds) + + refs := w.GetOwnerReferences() + if len(refs) != 1 || refs[0].Name != "test-owner" { + t.Errorf("GetOwnerReferences() = %v, want owner ref to test-owner", refs) + } +} + +// StatefulSet tests +func TestStatefulSetWorkload_BasicGetters(t *testing.T) { + sts := testutil.NewStatefulSet("test-sts", "test-ns", map[string]string{"key": "value"}) + + w := NewStatefulSetWorkload(sts) + + if w.Kind() != KindStatefulSet { + t.Errorf("Kind() = %v, want %v", w.Kind(), KindStatefulSet) + } + if w.GetName() != "test-sts" { + t.Errorf("GetName() = %v, want test-sts", w.GetName()) + } + if w.GetNamespace() != "test-ns" { + t.Errorf("GetNamespace() = %v, want test-ns", w.GetNamespace()) + } + if w.GetAnnotations()["key"] != "value" { + t.Errorf("GetAnnotations()[key] = %v, want value", w.GetAnnotations()["key"]) + } + if w.GetObject() != sts { + t.Error("GetObject() should return the underlying statefulset") + } +} + +func TestStatefulSetWorkload_PodTemplateAnnotations(t *testing.T) { + sts := testutil.NewStatefulSet("test", "default", nil) + sts.Spec.Template.Annotations["existing"] = "annotation" + + w := NewStatefulSetWorkload(sts) + + annotations := w.GetPodTemplateAnnotations() + if annotations["existing"] != "annotation" { + t.Errorf("GetPodTemplateAnnotations()[existing] = %v, want annotation", annotations["existing"]) + } + + w.SetPodTemplateAnnotation("new-key", "new-value") + if w.GetPodTemplateAnnotations()["new-key"] != "new-value" { + t.Error("SetPodTemplateAnnotation should add new annotation") + } +} + +func TestStatefulSetWorkload_PodTemplateAnnotations_NilInit(t *testing.T) { + sts := testutil.NewStatefulSet("test", "default", nil) + sts.Spec.Template.Annotations = nil + + w := NewStatefulSetWorkload(sts) + + annotations := w.GetPodTemplateAnnotations() + if annotations == nil { + t.Error("GetPodTemplateAnnotations should initialize nil map") + } + + w.SetPodTemplateAnnotation("key", "value") + if w.GetPodTemplateAnnotations()["key"] != "value" { + t.Error("SetPodTemplateAnnotation should work with nil initial map") + } +} + +func TestStatefulSetWorkload_Containers(t *testing.T) { + sts := testutil.NewStatefulSet("test", "default", nil) + sts.Spec.Template.Spec.InitContainers = []corev1.Container{{Name: "init", Image: "busybox"}} + + w := NewStatefulSetWorkload(sts) + + containers := w.GetContainers() + if len(containers) != 1 || containers[0].Name != "main" { + t.Errorf("GetContainers() = %v, want [main]", containers) + } + + initContainers := w.GetInitContainers() + if len(initContainers) != 1 || initContainers[0].Name != "init" { + t.Errorf("GetInitContainers() = %v, want [init]", initContainers) + } + + newContainers := []corev1.Container{{Name: "new-main", Image: "alpine"}} + w.SetContainers(newContainers) + if w.GetContainers()[0].Name != "new-main" { + t.Error("SetContainers should update containers") + } + + newInitContainers := []corev1.Container{{Name: "new-init", Image: "alpine"}} + w.SetInitContainers(newInitContainers) + if w.GetInitContainers()[0].Name != "new-init" { + t.Error("SetInitContainers should update init containers") + } +} + +func TestStatefulSetWorkload_Volumes(t *testing.T) { + sts := testutil.NewStatefulSet("test", "default", nil) + sts.Spec.Template.Spec.Volumes = []corev1.Volume{ + {Name: "config-vol"}, + {Name: "secret-vol"}, + } + + w := NewStatefulSetWorkload(sts) + + volumes := w.GetVolumes() + if len(volumes) != 2 { + t.Errorf("GetVolumes() length = %d, want 2", len(volumes)) + } +} + +func TestStatefulSetWorkload_UsesConfigMap(t *testing.T) { + sts := testutil.NewStatefulSet("test", "default", nil) + sts.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "sts-config"}, + }, + }, + }, + } + + w := NewStatefulSetWorkload(sts) + + if !w.UsesConfigMap("sts-config") { + t.Error("StatefulSet UsesConfigMap should return true for ConfigMap volume") + } + if w.UsesConfigMap("other-config") { + t.Error("UsesConfigMap should return false for non-existent ConfigMap") + } +} + +func TestStatefulSetWorkload_UsesConfigMap_EnvFrom(t *testing.T) { + sts := testutil.NewStatefulSet("test", "default", nil) + sts.Spec.Template.Spec.Containers[0].EnvFrom = []corev1.EnvFromSource{ + {ConfigMapRef: &corev1.ConfigMapEnvSource{LocalObjectReference: corev1.LocalObjectReference{Name: "sts-env-config"}}}, + } + + w := NewStatefulSetWorkload(sts) + + if !w.UsesConfigMap("sts-env-config") { + t.Error("StatefulSet UsesConfigMap should return true for envFrom ConfigMap") + } +} + +func TestStatefulSetWorkload_UsesSecret(t *testing.T) { + sts := testutil.NewStatefulSet("test", "default", nil) + sts.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "secret-vol", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{SecretName: "sts-secret"}, + }, + }, + } + + w := NewStatefulSetWorkload(sts) + + if !w.UsesSecret("sts-secret") { + t.Error("StatefulSet UsesSecret should return true for Secret volume") + } + if w.UsesSecret("other-secret") { + t.Error("UsesSecret should return false for non-existent Secret") + } +} + +func TestStatefulSetWorkload_UsesSecret_EnvFrom(t *testing.T) { + sts := testutil.NewStatefulSet("test", "default", nil) + sts.Spec.Template.Spec.Containers[0].EnvFrom = []corev1.EnvFromSource{ + {SecretRef: &corev1.SecretEnvSource{LocalObjectReference: corev1.LocalObjectReference{Name: "sts-env-secret"}}}, + } + + w := NewStatefulSetWorkload(sts) + + if !w.UsesSecret("sts-env-secret") { + t.Error("StatefulSet UsesSecret should return true for envFrom Secret") + } +} + +func TestStatefulSetWorkload_GetEnvFromSources(t *testing.T) { + sts := testutil.NewStatefulSet("test", "default", nil) + sts.Spec.Template.Spec.Containers[0].EnvFrom = []corev1.EnvFromSource{ + {ConfigMapRef: &corev1.ConfigMapEnvSource{LocalObjectReference: corev1.LocalObjectReference{Name: "cm1"}}}, + } + sts.Spec.Template.Spec.InitContainers = []corev1.Container{ + { + Name: "init", + EnvFrom: []corev1.EnvFromSource{{SecretRef: &corev1.SecretEnvSource{LocalObjectReference: corev1.LocalObjectReference{Name: "secret1"}}}}, + }, + } + + w := NewStatefulSetWorkload(sts) + + sources := w.GetEnvFromSources() + if len(sources) != 2 { + t.Errorf("GetEnvFromSources() returned %d sources, want 2", len(sources)) + } +} + +func TestStatefulSetWorkload_DeepCopy(t *testing.T) { + sts := testutil.NewStatefulSet("test", "default", nil) + + w := NewStatefulSetWorkload(sts) + copy := w.DeepCopy() + + w.SetPodTemplateAnnotation("modified", "true") + + copyAnnotations := copy.GetPodTemplateAnnotations() + if copyAnnotations["modified"] == "true" { + t.Error("DeepCopy should create independent copy") + } +} + +func TestStatefulSetWorkload_GetOwnerReferences(t *testing.T) { + sts := testutil.NewStatefulSet("test", "default", nil) + sts.OwnerReferences = []metav1.OwnerReference{ + {APIVersion: "apps/v1", Kind: "StatefulSet", Name: "test-owner"}, + } + + w := NewStatefulSetWorkload(sts) + + refs := w.GetOwnerReferences() + if len(refs) != 1 || refs[0].Name != "test-owner" { + t.Errorf("GetOwnerReferences() = %v, want owner ref to test-owner", refs) + } +} + +// Test that workloads implement the interface +func TestWorkloadInterface(t *testing.T) { + var _ Workload = (*DeploymentWorkload)(nil) + var _ Workload = (*DaemonSetWorkload)(nil) + var _ Workload = (*StatefulSetWorkload)(nil) + var _ Workload = (*RolloutWorkload)(nil) +} + +// RolloutWorkload tests +func TestRolloutWorkload_BasicGetters(t *testing.T) { + rollout := &argorolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rollout", + Namespace: "test-ns", + Annotations: map[string]string{ + "key": "value", + }, + }, + } + + w := NewRolloutWorkload(rollout, testRolloutStrategyAnnotation) + + if w.Kind() != KindArgoRollout { + t.Errorf("Kind() = %v, want %v", w.Kind(), KindArgoRollout) + } + if w.GetName() != "test-rollout" { + t.Errorf("GetName() = %v, want test-rollout", w.GetName()) + } + if w.GetNamespace() != "test-ns" { + t.Errorf("GetNamespace() = %v, want test-ns", w.GetNamespace()) + } + if w.GetAnnotations()["key"] != "value" { + t.Errorf("GetAnnotations()[key] = %v, want value", w.GetAnnotations()["key"]) + } + if w.GetObject() != rollout { + t.Error("GetObject() should return the underlying rollout") + } +} + +func TestRolloutWorkload_PodTemplateAnnotations(t *testing.T) { + rollout := &argorolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: argorolloutv1alpha1.RolloutSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "existing": "annotation", + }, + }, + }, + }, + } + + w := NewRolloutWorkload(rollout, testRolloutStrategyAnnotation) + + // Test get + annotations := w.GetPodTemplateAnnotations() + if annotations["existing"] != "annotation" { + t.Errorf("GetPodTemplateAnnotations()[existing] = %v, want annotation", annotations["existing"]) + } + + // Test set + w.SetPodTemplateAnnotation("new-key", "new-value") + if w.GetPodTemplateAnnotations()["new-key"] != "new-value" { + t.Error("SetPodTemplateAnnotation should add new annotation") + } +} + +func TestRolloutWorkload_GetStrategy_Default(t *testing.T) { + rollout := &argorolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + } + + w := NewRolloutWorkload(rollout, testRolloutStrategyAnnotation) + + if w.GetStrategy() != RolloutStrategyRollout { + t.Errorf("GetStrategy() = %v, want %v (default)", w.GetStrategy(), RolloutStrategyRollout) + } +} + +func TestRolloutWorkload_GetStrategy_Restart(t *testing.T) { + rollout := &argorolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Annotations: map[string]string{ + testRolloutStrategyAnnotation: "restart", + }, + }, + } + + w := NewRolloutWorkload(rollout, testRolloutStrategyAnnotation) + + if w.GetStrategy() != RolloutStrategyRestart { + t.Errorf("GetStrategy() = %v, want %v", w.GetStrategy(), RolloutStrategyRestart) + } +} + +func TestRolloutWorkload_UsesConfigMap_Volume(t *testing.T) { + rollout := &argorolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: argorolloutv1alpha1.RolloutSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "rollout-config", + }, + }, + }, + }, + }, + }, + }, + }, + } + + w := NewRolloutWorkload(rollout, testRolloutStrategyAnnotation) + + if !w.UsesConfigMap("rollout-config") { + t.Error("Rollout UsesConfigMap should return true for ConfigMap volume") + } + if w.UsesConfigMap("other-config") { + t.Error("Rollout UsesConfigMap should return false for non-existent ConfigMap") + } +} + +func TestRolloutWorkload_UsesSecret_EnvFrom(t *testing.T) { + rollout := &argorolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: argorolloutv1alpha1.RolloutSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + EnvFrom: []corev1.EnvFromSource{ + { + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "rollout-secret", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + w := NewRolloutWorkload(rollout, testRolloutStrategyAnnotation) + + if !w.UsesSecret("rollout-secret") { + t.Error("Rollout UsesSecret should return true for Secret envFrom") + } + if w.UsesSecret("other-secret") { + t.Error("Rollout UsesSecret should return false for non-existent Secret") + } +} + +func TestRolloutWorkload_DeepCopy(t *testing.T) { + rollout := &argorolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: argorolloutv1alpha1.RolloutSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "original": "value", + }, + }, + }, + }, + } + + w := NewRolloutWorkload(rollout, testRolloutStrategyAnnotation) + copy := w.DeepCopy() + + // Verify copy is independent + w.SetPodTemplateAnnotation("modified", "true") + + copyAnnotations := copy.(*RolloutWorkload).GetPodTemplateAnnotations() + if copyAnnotations["modified"] == "true" { + t.Error("DeepCopy should create independent copy") + } +} + +func TestRolloutStrategy_Validate(t *testing.T) { + tests := []struct { + strategy RolloutStrategy + wantErr bool + }{ + {RolloutStrategyRollout, false}, + {RolloutStrategyRestart, false}, + {RolloutStrategy("invalid"), true}, + {RolloutStrategy(""), true}, + } + + for _, tt := range tests { + err := tt.strategy.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate(%s) error = %v, wantErr %v", tt.strategy, err, tt.wantErr) + } + } +} + +func TestToRolloutStrategy(t *testing.T) { + tests := []struct { + input string + expected RolloutStrategy + }{ + {"rollout", RolloutStrategyRollout}, + {"restart", RolloutStrategyRestart}, + {"invalid", RolloutStrategyRollout}, // defaults to rollout + {"", RolloutStrategyRollout}, // defaults to rollout + } + + for _, tt := range tests { + result := ToRolloutStrategy(tt.input) + if result != tt.expected { + t.Errorf("ToRolloutStrategy(%s) = %v, want %v", tt.input, result, tt.expected) + } + } +} + +// Job tests +func TestJobWorkload_BasicGetters(t *testing.T) { + job := testutil.NewJobWithAnnotations("test-job", "test-ns", map[string]string{"key": "value"}) + + w := NewJobWorkload(job) + + if w.Kind() != KindJob { + t.Errorf("Kind() = %v, want %v", w.Kind(), KindJob) + } + if w.GetName() != "test-job" { + t.Errorf("GetName() = %v, want test-job", w.GetName()) + } + if w.GetNamespace() != "test-ns" { + t.Errorf("GetNamespace() = %v, want test-ns", w.GetNamespace()) + } + if w.GetAnnotations()["key"] != "value" { + t.Errorf("GetAnnotations()[key] = %v, want value", w.GetAnnotations()["key"]) + } + if w.GetObject() != job { + t.Error("GetObject() should return the underlying job") + } +} + +func TestJobWorkload_PodTemplateAnnotations(t *testing.T) { + job := testutil.NewJob("test", "default") + job.Spec.Template.Annotations["existing"] = "annotation" + + w := NewJobWorkload(job) + + annotations := w.GetPodTemplateAnnotations() + if annotations["existing"] != "annotation" { + t.Errorf("GetPodTemplateAnnotations()[existing] = %v, want annotation", annotations["existing"]) + } + + w.SetPodTemplateAnnotation("new-key", "new-value") + if w.GetPodTemplateAnnotations()["new-key"] != "new-value" { + t.Error("SetPodTemplateAnnotation should add new annotation") + } +} + +func TestJobWorkload_UsesConfigMap(t *testing.T) { + job := testutil.NewJob("test", "default") + job.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "job-config"}, + }, + }, + }, + } + + w := NewJobWorkload(job) + + if !w.UsesConfigMap("job-config") { + t.Error("Job UsesConfigMap should return true for ConfigMap volume") + } + if w.UsesConfigMap("other-config") { + t.Error("Job UsesConfigMap should return false for non-existent ConfigMap") + } +} + +func TestJobWorkload_UsesSecret(t *testing.T) { + job := testutil.NewJob("test", "default") + job.Spec.Template.Spec.Containers[0].EnvFrom = []corev1.EnvFromSource{ + { + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "job-secret"}, + }, + }, + } + + w := NewJobWorkload(job) + + if !w.UsesSecret("job-secret") { + t.Error("Job UsesSecret should return true for Secret envFrom") + } +} + +func TestJobWorkload_DeepCopy(t *testing.T) { + job := testutil.NewJob("test", "default") + job.Spec.Template.Annotations["original"] = "value" + + w := NewJobWorkload(job) + copy := w.DeepCopy() + + w.SetPodTemplateAnnotation("modified", "true") + + copyAnnotations := copy.GetPodTemplateAnnotations() + if copyAnnotations["modified"] == "true" { + t.Error("DeepCopy should create independent copy") + } +} + +// CronJob tests +func TestCronJobWorkload_BasicGetters(t *testing.T) { + cj := testutil.NewCronJobWithAnnotations("test-cronjob", "test-ns", map[string]string{"key": "value"}) + + w := NewCronJobWorkload(cj) + + if w.Kind() != KindCronJob { + t.Errorf("Kind() = %v, want %v", w.Kind(), KindCronJob) + } + if w.GetName() != "test-cronjob" { + t.Errorf("GetName() = %v, want test-cronjob", w.GetName()) + } + if w.GetNamespace() != "test-ns" { + t.Errorf("GetNamespace() = %v, want test-ns", w.GetNamespace()) + } + if w.GetAnnotations()["key"] != "value" { + t.Errorf("GetAnnotations()[key] = %v, want value", w.GetAnnotations()["key"]) + } + if w.GetObject() != cj { + t.Error("GetObject() should return the underlying cronjob") + } +} + +func TestCronJobWorkload_PodTemplateAnnotations(t *testing.T) { + cj := testutil.NewCronJob("test", "default") + cj.Spec.JobTemplate.Spec.Template.Annotations["existing"] = "annotation" + + w := NewCronJobWorkload(cj) + + annotations := w.GetPodTemplateAnnotations() + if annotations["existing"] != "annotation" { + t.Errorf("GetPodTemplateAnnotations()[existing] = %v, want annotation", annotations["existing"]) + } + + w.SetPodTemplateAnnotation("new-key", "new-value") + if w.GetPodTemplateAnnotations()["new-key"] != "new-value" { + t.Error("SetPodTemplateAnnotation should add new annotation") + } +} + +func TestCronJobWorkload_UsesConfigMap(t *testing.T) { + cj := testutil.NewCronJob("test", "default") + cj.Spec.JobTemplate.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "cronjob-config"}, + }, + }, + }, + } + + w := NewCronJobWorkload(cj) + + if !w.UsesConfigMap("cronjob-config") { + t.Error("CronJob UsesConfigMap should return true for ConfigMap volume") + } + if w.UsesConfigMap("other-config") { + t.Error("CronJob UsesConfigMap should return false for non-existent ConfigMap") + } +} + +func TestCronJobWorkload_UsesSecret(t *testing.T) { + cj := testutil.NewCronJob("test", "default") + addEnvVarSecretRef(cj.Spec.JobTemplate.Spec.Template.Spec.Containers, "SECRET_VALUE", "cronjob-secret", "key") + + w := NewCronJobWorkload(cj) + + if !w.UsesSecret("cronjob-secret") { + t.Error("CronJob UsesSecret should return true for Secret envVar") + } +} + +func TestCronJobWorkload_DeepCopy(t *testing.T) { + cj := testutil.NewCronJob("test", "default") + cj.Spec.JobTemplate.Spec.Template.Annotations["original"] = "value" + + w := NewCronJobWorkload(cj) + copy := w.DeepCopy() + + w.SetPodTemplateAnnotation("modified", "true") + + copyAnnotations := copy.GetPodTemplateAnnotations() + if copyAnnotations["modified"] == "true" { + t.Error("DeepCopy should create independent copy") + } +} + +// Test that Job and CronJob implement the interface +func TestJobCronJobWorkloadInterface(t *testing.T) { + var _ Workload = (*JobWorkload)(nil) + var _ Workload = (*CronJobWorkload)(nil) +} + +// DeploymentConfig tests +func TestDeploymentConfigWorkload_BasicGetters(t *testing.T) { + dc := testutil.NewDeploymentConfig("test-dc", "test-ns", map[string]string{"key": "value"}) + + w := NewDeploymentConfigWorkload(dc) + + if w.Kind() != KindDeploymentConfig { + t.Errorf("Kind() = %v, want %v", w.Kind(), KindDeploymentConfig) + } + if w.GetName() != "test-dc" { + t.Errorf("GetName() = %v, want test-dc", w.GetName()) + } + if w.GetNamespace() != "test-ns" { + t.Errorf("GetNamespace() = %v, want test-ns", w.GetNamespace()) + } + if w.GetAnnotations()["key"] != "value" { + t.Errorf("GetAnnotations()[key] = %v, want value", w.GetAnnotations()["key"]) + } + if w.GetObject() != dc { + t.Error("GetObject() should return the underlying deploymentconfig") + } +} + +func TestDeploymentConfigWorkload_PodTemplateAnnotations(t *testing.T) { + dc := testutil.NewDeploymentConfig("test", "default", nil) + dc.Spec.Template.Annotations = map[string]string{"existing": "annotation"} + + w := NewDeploymentConfigWorkload(dc) + + annotations := w.GetPodTemplateAnnotations() + if annotations["existing"] != "annotation" { + t.Errorf("GetPodTemplateAnnotations()[existing] = %v, want annotation", annotations["existing"]) + } + + w.SetPodTemplateAnnotation("new-key", "new-value") + if w.GetPodTemplateAnnotations()["new-key"] != "new-value" { + t.Error("SetPodTemplateAnnotation should add new annotation") + } +} + +func TestDeploymentConfigWorkload_PodTemplateAnnotations_NilTemplate(t *testing.T) { + dc := testutil.NewDeploymentConfig("test", "default", nil) + dc.Spec.Template = nil + + w := NewDeploymentConfigWorkload(dc) + + // Should handle nil template gracefully + annotations := w.GetPodTemplateAnnotations() + if annotations != nil { + t.Error("GetPodTemplateAnnotations should return nil for nil template") + } + + // SetPodTemplateAnnotation should initialize template + w.SetPodTemplateAnnotation("key", "value") + if w.GetPodTemplateAnnotations()["key"] != "value" { + t.Error("SetPodTemplateAnnotation should work with nil template") + } +} + +func TestDeploymentConfigWorkload_PodTemplateAnnotations_NilInit(t *testing.T) { + dc := testutil.NewDeploymentConfig("test", "default", nil) + dc.Spec.Template.Annotations = nil + + w := NewDeploymentConfigWorkload(dc) + + // Should initialize nil map + annotations := w.GetPodTemplateAnnotations() + if annotations == nil { + t.Error("GetPodTemplateAnnotations should initialize nil map") + } + + w.SetPodTemplateAnnotation("key", "value") + if w.GetPodTemplateAnnotations()["key"] != "value" { + t.Error("SetPodTemplateAnnotation should work with nil initial map") + } +} + +func TestDeploymentConfigWorkload_Containers(t *testing.T) { + dc := testutil.NewDeploymentConfig("test", "default", nil) + dc.Spec.Template.Spec.Containers = []corev1.Container{ + {Name: "main", Image: "nginx"}, + } + dc.Spec.Template.Spec.InitContainers = []corev1.Container{ + {Name: "init", Image: "busybox"}, + } + + w := NewDeploymentConfigWorkload(dc) + + containers := w.GetContainers() + if len(containers) != 1 || containers[0].Name != "main" { + t.Errorf("GetContainers() = %v, want [main]", containers) + } + + initContainers := w.GetInitContainers() + if len(initContainers) != 1 || initContainers[0].Name != "init" { + t.Errorf("GetInitContainers() = %v, want [init]", initContainers) + } + + newContainers := []corev1.Container{{Name: "new-main", Image: "alpine"}} + w.SetContainers(newContainers) + if w.GetContainers()[0].Name != "new-main" { + t.Error("SetContainers should update containers") + } + + newInitContainers := []corev1.Container{{Name: "new-init", Image: "alpine"}} + w.SetInitContainers(newInitContainers) + if w.GetInitContainers()[0].Name != "new-init" { + t.Error("SetInitContainers should update init containers") + } +} + +func TestDeploymentConfigWorkload_Containers_NilTemplate(t *testing.T) { + dc := testutil.NewDeploymentConfig("test", "default", nil) + dc.Spec.Template = nil + + w := NewDeploymentConfigWorkload(dc) + + if w.GetContainers() != nil { + t.Error("GetContainers should return nil for nil template") + } + if w.GetInitContainers() != nil { + t.Error("GetInitContainers should return nil for nil template") + } + + // SetContainers should initialize template + w.SetContainers([]corev1.Container{{Name: "main"}}) + if len(w.GetContainers()) != 1 { + t.Error("SetContainers should work with nil template") + } +} + +func TestDeploymentConfigWorkload_Volumes(t *testing.T) { + dc := testutil.NewDeploymentConfig("test", "default", nil) + dc.Spec.Template.Spec.Volumes = []corev1.Volume{ + {Name: "config-vol"}, + {Name: "secret-vol"}, + } + + w := NewDeploymentConfigWorkload(dc) + + volumes := w.GetVolumes() + if len(volumes) != 2 { + t.Errorf("GetVolumes() length = %d, want 2", len(volumes)) + } +} + +func TestDeploymentConfigWorkload_Volumes_NilTemplate(t *testing.T) { + dc := testutil.NewDeploymentConfig("test", "default", nil) + dc.Spec.Template = nil + + w := NewDeploymentConfigWorkload(dc) + + if w.GetVolumes() != nil { + t.Error("GetVolumes should return nil for nil template") + } +} + +func TestDeploymentConfigWorkload_UsesConfigMap_Volume(t *testing.T) { + dc := testutil.NewDeploymentConfig("test", "default", nil) + dc.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "dc-config", + }, + }, + }, + }, + } + + w := NewDeploymentConfigWorkload(dc) + + if !w.UsesConfigMap("dc-config") { + t.Error("DeploymentConfig UsesConfigMap should return true for ConfigMap volume") + } + if w.UsesConfigMap("other-config") { + t.Error("UsesConfigMap should return false for non-existent ConfigMap") + } +} + +func TestDeploymentConfigWorkload_UsesConfigMap_EnvFrom(t *testing.T) { + dc := testutil.NewDeploymentConfig("test", "default", nil) + dc.Spec.Template.Spec.Containers = []corev1.Container{ + { + Name: "main", + EnvFrom: []corev1.EnvFromSource{ + { + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "dc-env-config", + }, + }, + }, + }, + }, + } + + w := NewDeploymentConfigWorkload(dc) + + if !w.UsesConfigMap("dc-env-config") { + t.Error("DeploymentConfig UsesConfigMap should return true for envFrom ConfigMap") + } +} + +func TestDeploymentConfigWorkload_UsesConfigMap_NilTemplate(t *testing.T) { + dc := testutil.NewDeploymentConfig("test", "default", nil) + dc.Spec.Template = nil + + w := NewDeploymentConfigWorkload(dc) + + if w.UsesConfigMap("any-config") { + t.Error("UsesConfigMap should return false for nil template") + } +} + +func TestDeploymentConfigWorkload_UsesSecret_Volume(t *testing.T) { + dc := testutil.NewDeploymentConfig("test", "default", nil) + dc.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "secret-vol", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: "dc-secret", + }, + }, + }, + } + + w := NewDeploymentConfigWorkload(dc) + + if !w.UsesSecret("dc-secret") { + t.Error("DeploymentConfig UsesSecret should return true for Secret volume") + } + if w.UsesSecret("other-secret") { + t.Error("UsesSecret should return false for non-existent Secret") + } +} + +func TestDeploymentConfigWorkload_UsesSecret_EnvFrom(t *testing.T) { + dc := testutil.NewDeploymentConfig("test", "default", nil) + dc.Spec.Template.Spec.Containers = []corev1.Container{ + { + Name: "main", + EnvFrom: []corev1.EnvFromSource{ + { + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "dc-env-secret", + }, + }, + }, + }, + }, + } + + w := NewDeploymentConfigWorkload(dc) + + if !w.UsesSecret("dc-env-secret") { + t.Error("DeploymentConfig UsesSecret should return true for envFrom Secret") + } +} + +func TestDeploymentConfigWorkload_UsesSecret_NilTemplate(t *testing.T) { + dc := testutil.NewDeploymentConfig("test", "default", nil) + dc.Spec.Template = nil + + w := NewDeploymentConfigWorkload(dc) + + if w.UsesSecret("any-secret") { + t.Error("UsesSecret should return false for nil template") + } +} + +func TestDeploymentConfigWorkload_GetEnvFromSources(t *testing.T) { + dc := testutil.NewDeploymentConfig("test", "default", nil) + dc.Spec.Template.Spec.Containers = []corev1.Container{ + { + Name: "main", + EnvFrom: []corev1.EnvFromSource{ + {ConfigMapRef: &corev1.ConfigMapEnvSource{LocalObjectReference: corev1.LocalObjectReference{Name: "cm1"}}}, + }, + }, + } + dc.Spec.Template.Spec.InitContainers = []corev1.Container{ + { + Name: "init", + EnvFrom: []corev1.EnvFromSource{ + {SecretRef: &corev1.SecretEnvSource{LocalObjectReference: corev1.LocalObjectReference{Name: "secret1"}}}, + }, + }, + } + + w := NewDeploymentConfigWorkload(dc) + + sources := w.GetEnvFromSources() + if len(sources) != 2 { + t.Errorf("GetEnvFromSources() returned %d sources, want 2", len(sources)) + } +} + +func TestDeploymentConfigWorkload_GetEnvFromSources_NilTemplate(t *testing.T) { + dc := testutil.NewDeploymentConfig("test", "default", nil) + dc.Spec.Template = nil + + w := NewDeploymentConfigWorkload(dc) + + if w.GetEnvFromSources() != nil { + t.Error("GetEnvFromSources should return nil for nil template") + } +} + +func TestDeploymentConfigWorkload_DeepCopy(t *testing.T) { + dc := testutil.NewDeploymentConfig("test", "default", nil) + dc.Spec.Template.Annotations = map[string]string{"original": "value"} + + w := NewDeploymentConfigWorkload(dc) + copy := w.DeepCopy() + + w.SetPodTemplateAnnotation("modified", "true") + + copyAnnotations := copy.GetPodTemplateAnnotations() + if copyAnnotations["modified"] == "true" { + t.Error("DeepCopy should create independent copy") + } +} + +func TestDeploymentConfigWorkload_GetOwnerReferences(t *testing.T) { + dc := testutil.NewDeploymentConfig("test", "default", nil) + dc.OwnerReferences = []metav1.OwnerReference{ + { + APIVersion: "apps.openshift.io/v1", + Kind: "DeploymentConfig", + Name: "test-owner", + }, + } + + w := NewDeploymentConfigWorkload(dc) + + refs := w.GetOwnerReferences() + if len(refs) != 1 || refs[0].Name != "test-owner" { + t.Errorf("GetOwnerReferences() = %v, want owner ref to test-owner", refs) + } +} + +func TestDeploymentConfigWorkload_GetDeploymentConfig(t *testing.T) { + dc := testutil.NewDeploymentConfig("test", "default", nil) + + w := NewDeploymentConfigWorkload(dc) + + if w.GetDeploymentConfig() != dc { + t.Error("GetDeploymentConfig should return the underlying DeploymentConfig") + } +} + +// Test that DeploymentConfig implements the interface +func TestDeploymentConfigWorkloadInterface(t *testing.T) { + var _ Workload = (*DeploymentConfigWorkload)(nil) +} + +// Tests for UpdateStrategy +func TestWorkload_UpdateStrategy(t *testing.T) { + tests := []struct { + name string + workload Workload + expected UpdateStrategy + }{ + { + name: "Deployment uses Patch strategy", + workload: NewDeploymentWorkload(testutil.NewDeployment("test", "default", nil)), + expected: UpdateStrategyPatch, + }, + { + name: "DaemonSet uses Patch strategy", + workload: NewDaemonSetWorkload(testutil.NewDaemonSet("test", "default", nil)), + expected: UpdateStrategyPatch, + }, + { + name: "StatefulSet uses Patch strategy", + workload: NewStatefulSetWorkload(testutil.NewStatefulSet("test", "default", nil)), + expected: UpdateStrategyPatch, + }, + { + name: "Job uses Recreate strategy", + workload: NewJobWorkload(testutil.NewJob("test", "default")), + expected: UpdateStrategyRecreate, + }, + { + name: "CronJob uses CreateNew strategy", + workload: NewCronJobWorkload(testutil.NewCronJob("test", "default")), + expected: UpdateStrategyCreateNew, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.workload.UpdateStrategy(); got != tt.expected { + t.Errorf("UpdateStrategy() = %v, want %v", got, tt.expected) + } + }) + } +} + +// Tests for ResetOriginal +func TestDeploymentWorkload_ResetOriginal(t *testing.T) { + deploy := testutil.NewDeployment("test", "default", nil) + w := NewDeploymentWorkload(deploy) + + // Modify the workload + w.SetPodTemplateAnnotation("modified", "true") + + // Original should still not have the annotation + originalAnnotations := w.Original().Spec.Template.Annotations + if originalAnnotations != nil && originalAnnotations["modified"] == "true" { + t.Error("Original should not be modified yet") + } + + // Reset original + w.ResetOriginal() + + // Now original should have the annotation + if w.Original().Spec.Template.Annotations["modified"] != "true" { + t.Error("ResetOriginal should update original to match current state") + } +} + +func TestJobWorkload_ResetOriginal(t *testing.T) { + job := testutil.NewJob("test", "default") + w := NewJobWorkload(job) + + // ResetOriginal should be a no-op for Jobs (they don't use strategic merge patch) + w.SetPodTemplateAnnotation("modified", "true") + w.ResetOriginal() // Should not panic or error +} + +func TestCronJobWorkload_ResetOriginal(t *testing.T) { + cj := testutil.NewCronJob("test", "default") + w := NewCronJobWorkload(cj) + + // ResetOriginal should be a no-op for CronJobs + w.SetPodTemplateAnnotation("modified", "true") + w.ResetOriginal() // Should not panic or error +} + +// Tests for BaseWorkload.Original() +func TestDeploymentWorkload_Original(t *testing.T) { + deploy := testutil.NewDeployment("test", "default", nil) + deploy.Spec.Template.Annotations = map[string]string{"initial": "value"} + + w := NewDeploymentWorkload(deploy) + + // Modify the current object + w.SetPodTemplateAnnotation("new", "annotation") + + // Original should still have only the initial annotation + original := w.Original() + if original.Spec.Template.Annotations["new"] == "annotation" { + t.Error("Original should not reflect changes to current object") + } + if original.Spec.Template.Annotations["initial"] != "value" { + t.Error("Original should retain initial state") + } +} + +// Tests for PerformSpecialUpdate returning false for standard workloads +func TestDeploymentWorkload_PerformSpecialUpdate(t *testing.T) { + deploy := testutil.NewDeployment("test", "default", nil) + w := NewDeploymentWorkload(deploy) + + updated, err := w.PerformSpecialUpdate(t.Context(), nil) + if err != nil { + t.Errorf("PerformSpecialUpdate() error = %v", err) + } + if updated { + t.Error("PerformSpecialUpdate() should return false for Deployment") + } +} + +func TestDaemonSetWorkload_PerformSpecialUpdate(t *testing.T) { + ds := testutil.NewDaemonSet("test", "default", nil) + w := NewDaemonSetWorkload(ds) + + updated, err := w.PerformSpecialUpdate(t.Context(), nil) + if err != nil { + t.Errorf("PerformSpecialUpdate() error = %v", err) + } + if updated { + t.Error("PerformSpecialUpdate() should return false for DaemonSet") + } +} + +func TestStatefulSetWorkload_PerformSpecialUpdate(t *testing.T) { + ss := testutil.NewStatefulSet("test", "default", nil) + w := NewStatefulSetWorkload(ss) + + updated, err := w.PerformSpecialUpdate(t.Context(), nil) + if err != nil { + t.Errorf("PerformSpecialUpdate() error = %v", err) + } + if updated { + t.Error("PerformSpecialUpdate() should return false for StatefulSet") + } +} + +// Test Update returns nil for Job (no-op, uses PerformSpecialUpdate instead) +func TestJobWorkload_Update(t *testing.T) { + job := testutil.NewJob("test", "default") + w := NewJobWorkload(job) + + err := w.Update(t.Context(), nil) + if err != nil { + t.Errorf("Update() should return nil for Job, got %v", err) + } +} + +// Test Update returns nil for CronJob (no-op, uses PerformSpecialUpdate instead) +func TestCronJobWorkload_Update(t *testing.T) { + cj := testutil.NewCronJob("test", "default") + w := NewCronJobWorkload(cj) + + err := w.Update(t.Context(), nil) + if err != nil { + t.Errorf("Update() should return nil for CronJob, got %v", err) + } +} + +// Test GetJob and GetCronJob accessors +func TestJobWorkload_GetJob(t *testing.T) { + job := testutil.NewJob("test", "default") + w := NewJobWorkload(job) + + if w.GetJob() != job { + t.Error("GetJob should return the underlying Job") + } +} + +func TestCronJobWorkload_GetCronJob(t *testing.T) { + cj := testutil.NewCronJob("test", "default") + w := NewCronJobWorkload(cj) + + if w.GetCronJob() != cj { + t.Error("GetCronJob should return the underlying CronJob") + } +} + +func TestDeploymentWorkload_GetDeployment(t *testing.T) { + deploy := testutil.NewDeployment("test", "default", nil) + w := NewDeploymentWorkload(deploy) + + if w.GetDeployment() != deploy { + t.Error("GetDeployment should return the underlying Deployment") + } +} + +func TestDaemonSetWorkload_GetDaemonSet(t *testing.T) { + ds := testutil.NewDaemonSet("test", "default", nil) + w := NewDaemonSetWorkload(ds) + + if w.GetDaemonSet() != ds { + t.Error("GetDaemonSet should return the underlying DaemonSet") + } +} + +func TestStatefulSetWorkload_GetStatefulSet(t *testing.T) { + ss := testutil.NewStatefulSet("test", "default", nil) + w := NewStatefulSetWorkload(ss) + + if w.GetStatefulSet() != ss { + t.Error("GetStatefulSet should return the underlying StatefulSet") + } +} + +func TestRolloutWorkload_GetRollout(t *testing.T) { + rollout := &argorolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + w := NewRolloutWorkload(rollout, testRolloutStrategyAnnotation) + + if w.GetRollout() != rollout { + t.Error("GetRollout should return the underlying Rollout") + } +} diff --git a/main.go b/main.go deleted file mode 100644 index 1c429710c..000000000 --- a/main.go +++ /dev/null @@ -1,14 +0,0 @@ -package main - -import ( - "os" - - "github.com/stakater/Reloader/internal/pkg/app" -) - -func main() { - if err := app.Run(); err != nil { - os.Exit(1) - } - os.Exit(0) -} diff --git a/pkg/common/common.go b/pkg/common/common.go deleted file mode 100644 index de37ad867..000000000 --- a/pkg/common/common.go +++ /dev/null @@ -1,383 +0,0 @@ -package common - -import ( - "context" - "os" - "regexp" - "strconv" - "strings" - - "github.com/sirupsen/logrus" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/labels" - "k8s.io/client-go/kubernetes" - - "github.com/stakater/Reloader/internal/pkg/constants" - "github.com/stakater/Reloader/internal/pkg/options" - "github.com/stakater/Reloader/internal/pkg/util" -) - -type Map map[string]string - -type ReloadCheckResult struct { - ShouldReload bool - AutoReload bool -} - -// ReloaderOptions contains all configurable options for the Reloader controller. -// These options control how Reloader behaves when watching for changes in ConfigMaps and Secrets. -type ReloaderOptions struct { - // AutoReloadAll enables automatic reloading of all resources when their corresponding ConfigMaps/Secrets are updated - AutoReloadAll bool `json:"autoReloadAll"` - // ConfigmapUpdateOnChangeAnnotation is the annotation key used to detect changes in ConfigMaps specified by name - ConfigmapUpdateOnChangeAnnotation string `json:"configmapUpdateOnChangeAnnotation"` - // SecretUpdateOnChangeAnnotation is the annotation key used to detect changes in Secrets specified by name - SecretUpdateOnChangeAnnotation string `json:"secretUpdateOnChangeAnnotation"` - // SecretProviderClassUpdateOnChangeAnnotation is the annotation key used to detect changes in SecretProviderClasses specified by name - SecretProviderClassUpdateOnChangeAnnotation string `json:"secretProviderClassUpdateOnChangeAnnotation"` - // ReloaderAutoAnnotation is the annotation key used to detect changes in any referenced ConfigMaps or Secrets - ReloaderAutoAnnotation string `json:"reloaderAutoAnnotation"` - // IgnoreResourceAnnotation is the annotation key used to ignore resources from being watched - IgnoreResourceAnnotation string `json:"ignoreResourceAnnotation"` - // ConfigmapReloaderAutoAnnotation is the annotation key used to detect changes in ConfigMaps only - ConfigmapReloaderAutoAnnotation string `json:"configmapReloaderAutoAnnotation"` - // SecretReloaderAutoAnnotation is the annotation key used to detect changes in Secrets only - SecretReloaderAutoAnnotation string `json:"secretReloaderAutoAnnotation"` - // SecretProviderClassReloaderAutoAnnotation is the annotation key used to detect changes in SecretProviderClasses only - SecretProviderClassReloaderAutoAnnotation string `json:"secretProviderClassReloaderAutoAnnotation"` - // ConfigmapExcludeReloaderAnnotation is the annotation key containing comma-separated list of ConfigMaps to exclude from watching - ConfigmapExcludeReloaderAnnotation string `json:"configmapExcludeReloaderAnnotation"` - // SecretExcludeReloaderAnnotation is the annotation key containing comma-separated list of Secrets to exclude from watching - SecretExcludeReloaderAnnotation string `json:"secretExcludeReloaderAnnotation"` - // SecretProviderClassExcludeReloaderAnnotation is the annotation key containing comma-separated list of SecretProviderClasses to exclude from watching - SecretProviderClassExcludeReloaderAnnotation string `json:"secretProviderClassExcludeReloaderAnnotation"` - // AutoSearchAnnotation is the annotation key used to detect changes in ConfigMaps/Secrets tagged with SearchMatchAnnotation - AutoSearchAnnotation string `json:"autoSearchAnnotation"` - // SearchMatchAnnotation is the annotation key used to tag ConfigMaps/Secrets to be found by AutoSearchAnnotation - SearchMatchAnnotation string `json:"searchMatchAnnotation"` - // RolloutStrategyAnnotation is the annotation key used to define the rollout update strategy for workloads - RolloutStrategyAnnotation string `json:"rolloutStrategyAnnotation"` - // PauseDeploymentAnnotation is the annotation key used to define the time period to pause a deployment after - PauseDeploymentAnnotation string `json:"pauseDeploymentAnnotation"` - // PauseDeploymentTimeAnnotation is the annotation key used to indicate when a deployment was paused by Reloader - PauseDeploymentTimeAnnotation string `json:"pauseDeploymentTimeAnnotation"` - - // LogFormat specifies the log format to use (json, or empty string for default text format) - LogFormat string `json:"logFormat"` - // LogLevel specifies the log level to use (trace, debug, info, warning, error, fatal, panic) - LogLevel string `json:"logLevel"` - // IsArgoRollouts indicates whether support for Argo Rollouts is enabled - IsArgoRollouts bool `json:"isArgoRollouts"` - // ReloadStrategy specifies the strategy used to trigger resource reloads (env-vars or annotations) - ReloadStrategy string `json:"reloadStrategy"` - // ReloadOnCreate indicates whether to trigger reloads when ConfigMaps/Secrets are created - ReloadOnCreate bool `json:"reloadOnCreate"` - // ReloadOnDelete indicates whether to trigger reloads when ConfigMaps/Secrets are deleted - ReloadOnDelete bool `json:"reloadOnDelete"` - // SyncAfterRestart indicates whether to sync add events after Reloader restarts (only works when ReloadOnCreate is true) - SyncAfterRestart bool `json:"syncAfterRestart"` - // EnableHA indicates whether High Availability mode is enabled with leader election - EnableHA bool `json:"enableHA"` - // EnableCSIIntegration indicates whether CSI integration is enabled to watch SecretProviderClassPodStatus - EnableCSIIntegration bool `json:"enableCSIIntegration"` - // WebhookUrl is the URL to send webhook notifications to instead of performing reloads - WebhookUrl string `json:"webhookUrl"` - // ResourcesToIgnore is a list of resource types to ignore (e.g., "configmaps" or "secrets") - ResourcesToIgnore []string `json:"resourcesToIgnore"` - // WorkloadTypesToIgnore is a list of workload types to ignore (e.g., "jobs" or "cronjobs") - WorkloadTypesToIgnore []string `json:"workloadTypesToIgnore"` - // NamespaceSelectors is a list of label selectors to filter namespaces to watch - NamespaceSelectors []string `json:"namespaceSelectors"` - // ResourceSelectors is a list of label selectors to filter ConfigMaps and Secrets to watch - ResourceSelectors []string `json:"resourceSelectors"` - // NamespacesToIgnore is a list of namespace names to ignore when watching for changes - NamespacesToIgnore []string `json:"namespacesToIgnore"` - // EnablePProf enables pprof for profiling - EnablePProf bool `json:"enablePProf"` - // PProfAddr is the address to start pprof server on - PProfAddr string `json:"pprofAddr"` -} - -var CommandLineOptions *ReloaderOptions - -func PublishMetaInfoConfigmap(clientset kubernetes.Interface) { - namespace := os.Getenv("RELOADER_NAMESPACE") - if namespace == "" { - logrus.Warn("RELOADER_NAMESPACE is not set, skipping meta info configmap creation") - return - } - - metaInfo := &MetaInfo{ - BuildInfo: *NewBuildInfo(), - ReloaderOptions: *GetCommandLineOptions(), - DeploymentInfo: metav1.ObjectMeta{ - Name: os.Getenv("RELOADER_DEPLOYMENT_NAME"), - Namespace: namespace, - }, - } - - configMap := metaInfo.ToConfigMap() - - if _, err := clientset.CoreV1().ConfigMaps(namespace).Get(context.Background(), configMap.Name, metav1.GetOptions{}); err == nil { - logrus.Info("Meta info configmap already exists, updating it") - _, err = clientset.CoreV1().ConfigMaps(namespace).Update(context.Background(), configMap, metav1.UpdateOptions{}) - if err != nil { - logrus.Warn("Failed to update existing meta info configmap: ", err) - } - return - } - - _, err := clientset.CoreV1().ConfigMaps(namespace).Create(context.Background(), configMap, metav1.CreateOptions{}) - if err != nil { - logrus.Warn("Failed to create meta info configmap: ", err) - } -} - -func GetNamespaceLabelSelector(slice []string) (string, error) { - for i, kv := range slice { - // Legacy support for ":" as a delimiter and "*" for wildcard. - if strings.Contains(kv, ":") { - split := strings.Split(kv, ":") - if split[1] == "*" { - slice[i] = split[0] - } else { - slice[i] = split[0] + "=" + split[1] - } - } - // Convert wildcard to valid apimachinery operator - if strings.Contains(kv, "=") { - split := strings.Split(kv, "=") - if split[1] == "*" { - slice[i] = split[0] - } - } - } - - namespaceLabelSelector := strings.Join(slice[:], ",") - _, err := labels.Parse(namespaceLabelSelector) - if err != nil { - logrus.Fatal(err) - } - - return namespaceLabelSelector, nil -} - -func GetResourceLabelSelector(slice []string) (string, error) { - for i, kv := range slice { - // Legacy support for ":" as a delimiter and "*" for wildcard. - if strings.Contains(kv, ":") { - split := strings.Split(kv, ":") - if split[1] == "*" { - slice[i] = split[0] - } else { - slice[i] = split[0] + "=" + split[1] - } - } - // Convert wildcard to valid apimachinery operator - if strings.Contains(kv, "=") { - split := strings.Split(kv, "=") - if split[1] == "*" { - slice[i] = split[0] - } - } - } - - resourceLabelSelector := strings.Join(slice[:], ",") - _, err := labels.Parse(resourceLabelSelector) - if err != nil { - logrus.Fatal(err) - } - - return resourceLabelSelector, nil -} - -// ShouldReload checks if a resource should be reloaded based on its annotations and the provided options. -func ShouldReload(config Config, resourceType string, annotations Map, podAnnotations Map, reloaderOpts *ReloaderOptions) ReloadCheckResult { - - // Check if this workload type should be ignored. - // Use reloaderOpts.WorkloadTypesToIgnore directly instead of re-reading the - // global via util.GetIgnoredWorkloadTypesList(), so that invalid entries simply - // skip the ignore check (allowing reload) rather than silently blocking it. - if len(reloaderOpts.WorkloadTypesToIgnore) > 0 { - validIgnored := util.List{} - valid := true - for _, v := range reloaderOpts.WorkloadTypesToIgnore { - if v != "jobs" && v != "cronjobs" { - logrus.Errorf("Failed to parse ignored workload types: 'ignored-workload-types' accepts 'jobs', 'cronjobs', or both, not '%s'", v) - valid = false - break - } - validIgnored = append(validIgnored, v) - } - if valid { - // Map Kubernetes resource types to CLI-friendly names for comparison - var resourceToCheck string - switch resourceType { - case "Job": - resourceToCheck = "jobs" - case "CronJob": - resourceToCheck = "cronjobs" - default: - resourceToCheck = resourceType - } - if validIgnored.Contains(resourceToCheck) { - return ReloadCheckResult{ShouldReload: false} - } - } - } - - ignoreResourceAnnotatonValue := config.ResourceAnnotations[reloaderOpts.IgnoreResourceAnnotation] - if ignoreResourceAnnotatonValue == "true" { - return ReloadCheckResult{ - ShouldReload: false, - } - } - - annotationValue, found := annotations[config.Annotation] - searchAnnotationValue, foundSearchAnn := annotations[reloaderOpts.AutoSearchAnnotation] - reloaderEnabledValue, foundAuto := annotations[reloaderOpts.ReloaderAutoAnnotation] - typedAutoAnnotationEnabledValue, foundTypedAuto := annotations[config.TypedAutoAnnotation] - excludeConfigmapAnnotationValue, foundExcludeConfigmap := annotations[reloaderOpts.ConfigmapExcludeReloaderAnnotation] - excludeSecretAnnotationValue, foundExcludeSecret := annotations[reloaderOpts.SecretExcludeReloaderAnnotation] - excludeSecretProviderClassProviderAnnotationValue, foundExcludeSecretProviderClass := annotations[reloaderOpts.SecretProviderClassExcludeReloaderAnnotation] - - if !found && !foundAuto && !foundTypedAuto && !foundSearchAnn { - annotations = podAnnotations - annotationValue = annotations[config.Annotation] - searchAnnotationValue = annotations[reloaderOpts.AutoSearchAnnotation] - reloaderEnabledValue = annotations[reloaderOpts.ReloaderAutoAnnotation] - typedAutoAnnotationEnabledValue = annotations[config.TypedAutoAnnotation] - } - - isResourceExcluded := false - - switch config.Type { - case constants.ConfigmapEnvVarPostfix: - if foundExcludeConfigmap { - isResourceExcluded = checkIfResourceIsExcluded(config.ResourceName, excludeConfigmapAnnotationValue) - } - case constants.SecretEnvVarPostfix: - if foundExcludeSecret { - isResourceExcluded = checkIfResourceIsExcluded(config.ResourceName, excludeSecretAnnotationValue) - } - - case constants.SecretProviderClassEnvVarPostfix: - if foundExcludeSecretProviderClass { - isResourceExcluded = checkIfResourceIsExcluded(config.ResourceName, excludeSecretProviderClassProviderAnnotationValue) - } - } - - if isResourceExcluded { - return ReloadCheckResult{ - ShouldReload: false, - } - } - - values := strings.Split(annotationValue, ",") - for _, value := range values { - value = strings.TrimSpace(value) - re := regexp.MustCompile("^" + value + "$") - if re.Match([]byte(config.ResourceName)) { - return ReloadCheckResult{ - ShouldReload: true, - AutoReload: false, - } - } - } - - if searchAnnotationValue == "true" { - matchAnnotationValue := config.ResourceAnnotations[reloaderOpts.SearchMatchAnnotation] - if matchAnnotationValue == "true" { - return ReloadCheckResult{ - ShouldReload: true, - AutoReload: true, - } - } - } - - reloaderEnabled, _ := strconv.ParseBool(reloaderEnabledValue) - typedAutoAnnotationEnabled, _ := strconv.ParseBool(typedAutoAnnotationEnabledValue) - if reloaderEnabled || typedAutoAnnotationEnabled || reloaderEnabledValue == "" && typedAutoAnnotationEnabledValue == "" && reloaderOpts.AutoReloadAll { - return ReloadCheckResult{ - ShouldReload: true, - AutoReload: true, - } - } - - return ReloadCheckResult{ - ShouldReload: false, - } -} - -func checkIfResourceIsExcluded(resourceName, excludedResources string) bool { - if excludedResources == "" { - return false - } - - excludedResourcesList := strings.Split(excludedResources, ",") - for _, excludedResource := range excludedResourcesList { - if strings.TrimSpace(excludedResource) == resourceName { - return true - } - } - - return false -} - -func init() { - GetCommandLineOptions() -} - -func GetCommandLineOptions() *ReloaderOptions { - if CommandLineOptions == nil { - CommandLineOptions = &ReloaderOptions{} - } - - CommandLineOptions.AutoReloadAll = options.AutoReloadAll - CommandLineOptions.ConfigmapUpdateOnChangeAnnotation = options.ConfigmapUpdateOnChangeAnnotation - CommandLineOptions.SecretUpdateOnChangeAnnotation = options.SecretUpdateOnChangeAnnotation - CommandLineOptions.SecretProviderClassUpdateOnChangeAnnotation = options.SecretProviderClassUpdateOnChangeAnnotation - CommandLineOptions.ReloaderAutoAnnotation = options.ReloaderAutoAnnotation - CommandLineOptions.IgnoreResourceAnnotation = options.IgnoreResourceAnnotation - CommandLineOptions.ConfigmapReloaderAutoAnnotation = options.ConfigmapReloaderAutoAnnotation - CommandLineOptions.SecretReloaderAutoAnnotation = options.SecretReloaderAutoAnnotation - CommandLineOptions.SecretProviderClassReloaderAutoAnnotation = options.SecretProviderClassReloaderAutoAnnotation - CommandLineOptions.ConfigmapExcludeReloaderAnnotation = options.ConfigmapExcludeReloaderAnnotation - CommandLineOptions.SecretExcludeReloaderAnnotation = options.SecretExcludeReloaderAnnotation - CommandLineOptions.SecretProviderClassExcludeReloaderAnnotation = options.SecretProviderClassExcludeReloaderAnnotation - CommandLineOptions.AutoSearchAnnotation = options.AutoSearchAnnotation - CommandLineOptions.SearchMatchAnnotation = options.SearchMatchAnnotation - CommandLineOptions.RolloutStrategyAnnotation = options.RolloutStrategyAnnotation - CommandLineOptions.PauseDeploymentAnnotation = options.PauseDeploymentAnnotation - CommandLineOptions.PauseDeploymentTimeAnnotation = options.PauseDeploymentTimeAnnotation - CommandLineOptions.LogFormat = options.LogFormat - CommandLineOptions.LogLevel = options.LogLevel - CommandLineOptions.ReloadStrategy = options.ReloadStrategy - CommandLineOptions.SyncAfterRestart = options.SyncAfterRestart - CommandLineOptions.EnableHA = options.EnableHA - CommandLineOptions.EnableCSIIntegration = options.EnableCSIIntegration - CommandLineOptions.WebhookUrl = options.WebhookUrl - CommandLineOptions.ResourcesToIgnore = options.ResourcesToIgnore - CommandLineOptions.WorkloadTypesToIgnore = options.WorkloadTypesToIgnore - CommandLineOptions.NamespaceSelectors = options.NamespaceSelectors - CommandLineOptions.ResourceSelectors = options.ResourceSelectors - CommandLineOptions.NamespacesToIgnore = options.NamespacesToIgnore - CommandLineOptions.IsArgoRollouts = parseBool(options.IsArgoRollouts) - CommandLineOptions.ReloadOnCreate = parseBool(options.ReloadOnCreate) - CommandLineOptions.ReloadOnDelete = parseBool(options.ReloadOnDelete) - CommandLineOptions.EnablePProf = options.EnablePProf - CommandLineOptions.PProfAddr = options.PProfAddr - - return CommandLineOptions -} - -func parseBool(value string) bool { - if value == "" { - return false - } - result, err := strconv.ParseBool(value) - if err != nil { - return false // Default to false if parsing fails - } - return result -} diff --git a/pkg/common/common_test.go b/pkg/common/common_test.go deleted file mode 100644 index 532d3adfa..000000000 --- a/pkg/common/common_test.go +++ /dev/null @@ -1,224 +0,0 @@ -package common - -import ( - "testing" - - "github.com/stakater/Reloader/internal/pkg/options" -) - -func TestShouldReload_IgnoredWorkloadTypes(t *testing.T) { - // Save original state - originalWorkloadTypes := options.WorkloadTypesToIgnore - defer func() { - options.WorkloadTypesToIgnore = originalWorkloadTypes - }() - - tests := []struct { - name string - ignoredWorkloadTypes []string - resourceType string - shouldReload bool - description string - }{ - { - name: "Jobs ignored - Job should not reload", - ignoredWorkloadTypes: []string{"jobs"}, - resourceType: "Job", - shouldReload: false, - description: "When jobs are ignored, Job resources should not be reloaded", - }, - { - name: "Jobs ignored - CronJob should reload", - ignoredWorkloadTypes: []string{"jobs"}, - resourceType: "CronJob", - shouldReload: true, - description: "When jobs are ignored, CronJob resources should still be processed", - }, - { - name: "CronJobs ignored - CronJob should not reload", - ignoredWorkloadTypes: []string{"cronjobs"}, - resourceType: "CronJob", - shouldReload: false, - description: "When cronjobs are ignored, CronJob resources should not be reloaded", - }, - { - name: "CronJobs ignored - Job should reload", - ignoredWorkloadTypes: []string{"cronjobs"}, - resourceType: "Job", - shouldReload: true, - description: "When cronjobs are ignored, Job resources should still be processed", - }, - { - name: "Both ignored - Job should not reload", - ignoredWorkloadTypes: []string{"jobs", "cronjobs"}, - resourceType: "Job", - shouldReload: false, - description: "When both are ignored, Job resources should not be reloaded", - }, - { - name: "Both ignored - CronJob should not reload", - ignoredWorkloadTypes: []string{"jobs", "cronjobs"}, - resourceType: "CronJob", - shouldReload: false, - description: "When both are ignored, CronJob resources should not be reloaded", - }, - { - name: "Both ignored - Deployment should reload", - ignoredWorkloadTypes: []string{"jobs", "cronjobs"}, - resourceType: "Deployment", - shouldReload: true, - description: "When both are ignored, other workload types should still be processed", - }, - { - name: "None ignored - Job should reload", - ignoredWorkloadTypes: []string{}, - resourceType: "Job", - shouldReload: true, - description: "When nothing is ignored, all workload types should be processed", - }, - { - name: "None ignored - CronJob should reload", - ignoredWorkloadTypes: []string{}, - resourceType: "CronJob", - shouldReload: true, - description: "When nothing is ignored, all workload types should be processed", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Set the ignored workload types - options.WorkloadTypesToIgnore = tt.ignoredWorkloadTypes - - // Create minimal test config and options - config := Config{ - ResourceName: "test-resource", - Annotation: "configmap.reloader.stakater.com/reload", - } - - annotations := Map{ - "configmap.reloader.stakater.com/reload": "test-config", - } - - // Create ReloaderOptions with the ignored workload types - opts := &ReloaderOptions{ - WorkloadTypesToIgnore: tt.ignoredWorkloadTypes, - AutoReloadAll: true, // Enable auto-reload to simplify test - ReloaderAutoAnnotation: "reloader.stakater.com/auto", - } - - // Call ShouldReload - result := ShouldReload(config, tt.resourceType, annotations, Map{}, opts) - - // Check the result - if result.ShouldReload != tt.shouldReload { - t.Errorf("For resource type %s with ignored types %v, expected ShouldReload=%v, got=%v", - tt.resourceType, tt.ignoredWorkloadTypes, tt.shouldReload, result.ShouldReload) - } - - t.Logf("✓ %s", tt.description) - }) - } -} - -func TestShouldReload_IgnoredWorkloadTypes_ValidationError(t *testing.T) { - // Save original state - originalWorkloadTypes := options.WorkloadTypesToIgnore - defer func() { - options.WorkloadTypesToIgnore = originalWorkloadTypes - }() - - // Test with invalid workload type - should still continue processing - options.WorkloadTypesToIgnore = []string{"invalid"} - - config := Config{ - ResourceName: "test-resource", - Annotation: "configmap.reloader.stakater.com/reload", - } - - annotations := Map{ - "configmap.reloader.stakater.com/reload": "test-config", - } - - opts := &ReloaderOptions{ - WorkloadTypesToIgnore: []string{"invalid"}, - AutoReloadAll: true, // Enable auto-reload to simplify test - ReloaderAutoAnnotation: "reloader.stakater.com/auto", - } - - // Should not panic and should continue with normal processing - result := ShouldReload(config, "Job", annotations, Map{}, opts) - - // Since validation failed, it should continue with normal processing (should reload) - if !result.ShouldReload { - t.Errorf("Expected ShouldReload=true when validation fails, got=%v", result.ShouldReload) - } -} - -// Test that validates the fix for issue #996 -func TestShouldReload_IssueRBACPermissionFixed(t *testing.T) { - // Save original state - originalWorkloadTypes := options.WorkloadTypesToIgnore - defer func() { - options.WorkloadTypesToIgnore = originalWorkloadTypes - }() - - tests := []struct { - name string - ignoredWorkloadTypes []string - resourceType string - description string - }{ - { - name: "Issue #996 - ignoreJobs prevents Job processing", - ignoredWorkloadTypes: []string{"jobs"}, - resourceType: "Job", - description: "Job resources are skipped entirely, preventing RBAC permission errors", - }, - { - name: "Issue #996 - ignoreCronJobs prevents CronJob processing", - ignoredWorkloadTypes: []string{"cronjobs"}, - resourceType: "CronJob", - description: "CronJob resources are skipped entirely, preventing RBAC permission errors", - }, - { - name: "Issue #996 - both ignored prevent both types", - ignoredWorkloadTypes: []string{"jobs", "cronjobs"}, - resourceType: "Job", - description: "Job resources are skipped entirely when both types are ignored", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Set the ignored workload types - options.WorkloadTypesToIgnore = tt.ignoredWorkloadTypes - - config := Config{ - ResourceName: "test-resource", - Annotation: "configmap.reloader.stakater.com/reload", - } - - annotations := Map{ - "configmap.reloader.stakater.com/reload": "test-config", - } - - opts := &ReloaderOptions{ - WorkloadTypesToIgnore: tt.ignoredWorkloadTypes, - AutoReloadAll: true, // Enable auto-reload to simplify test - ReloaderAutoAnnotation: "reloader.stakater.com/auto", - } - - // Call ShouldReload - result := ShouldReload(config, tt.resourceType, annotations, Map{}, opts) - - // Should not reload when workload type is ignored - if result.ShouldReload { - t.Errorf("Expected ShouldReload=false for ignored workload type %s, got=%v", - tt.resourceType, result.ShouldReload) - } - - t.Logf("✓ %s", tt.description) - }) - } -} diff --git a/pkg/common/config.go b/pkg/common/config.go deleted file mode 100644 index 6c90d08b9..000000000 --- a/pkg/common/config.go +++ /dev/null @@ -1,63 +0,0 @@ -package common - -import ( - v1 "k8s.io/api/core/v1" - csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1" - - "github.com/stakater/Reloader/internal/pkg/constants" - "github.com/stakater/Reloader/internal/pkg/options" - "github.com/stakater/Reloader/internal/pkg/util" -) - -// Config contains rolling upgrade configuration parameters -type Config struct { - Namespace string - ResourceName string - ResourceAnnotations map[string]string - Annotation string - TypedAutoAnnotation string - SHAValue string - Type string - Labels map[string]string -} - -// GetConfigmapConfig provides utility config for configmap -func GetConfigmapConfig(configmap *v1.ConfigMap) Config { - return Config{ - Namespace: configmap.Namespace, - ResourceName: configmap.Name, - ResourceAnnotations: configmap.Annotations, - Annotation: options.ConfigmapUpdateOnChangeAnnotation, - TypedAutoAnnotation: options.ConfigmapReloaderAutoAnnotation, - SHAValue: util.GetSHAfromConfigmap(configmap), - Type: constants.ConfigmapEnvVarPostfix, - Labels: configmap.Labels, - } -} - -// GetSecretConfig provides utility config for secret -func GetSecretConfig(secret *v1.Secret) Config { - return Config{ - Namespace: secret.Namespace, - ResourceName: secret.Name, - ResourceAnnotations: secret.Annotations, - Annotation: options.SecretUpdateOnChangeAnnotation, - TypedAutoAnnotation: options.SecretReloaderAutoAnnotation, - SHAValue: util.GetSHAfromSecret(secret.Data), - Type: constants.SecretEnvVarPostfix, - Labels: secret.Labels, - } -} - -func GetSecretProviderClassPodStatusConfig(podStatus *csiv1.SecretProviderClassPodStatus) Config { - // As csi injects SecretProviderClass, we will create config for it instead of SecretProviderClassPodStatus - // ResourceAnnotations will be retrieved during PerformAction call - return Config{ - Namespace: podStatus.Namespace, - ResourceName: podStatus.Status.SecretProviderClassName, - Annotation: options.SecretProviderClassUpdateOnChangeAnnotation, - TypedAutoAnnotation: options.SecretProviderClassReloaderAutoAnnotation, - SHAValue: util.GetSHAfromSecretProviderClassPodStatus(podStatus.Status), - Type: constants.SecretProviderClassEnvVarPostfix, - } -} diff --git a/pkg/common/metainfo.go b/pkg/common/metainfo.go deleted file mode 100644 index b11c34463..000000000 --- a/pkg/common/metainfo.go +++ /dev/null @@ -1,134 +0,0 @@ -package common - -import ( - "encoding/json" - "fmt" - "runtime" - "time" - - v1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// Version, Commit, and BuildDate are set during the build process -// using the -X linker flag to inject these values into the binary. -// They provide metadata about the build version, commit hash, build date, and whether there are -// uncommitted changes in the source code at the time of build. -// This information is useful for debugging and tracking the specific build of the Reloader binary. -var Version = "dev" -var Commit = "unknown" -var BuildDate = "unknown" -var Edition = "oss" - -const ( - MetaInfoConfigmapName = "reloader-meta-info" - MetaInfoConfigmapLabelKey = "reloader.stakater.com/meta-info" - MetaInfoConfigmapLabelValue = "reloader" -) - -// MetaInfo contains comprehensive metadata about the Reloader instance. -// This includes build information, configuration options, and deployment details. -type MetaInfo struct { - // BuildInfo contains information about the build version, commit, and compilation details - BuildInfo BuildInfo `json:"buildInfo"` - // ReloaderOptions contains all the configuration options and flags used by this Reloader instance - ReloaderOptions ReloaderOptions `json:"reloaderOptions"` - // DeploymentInfo contains metadata about the Kubernetes deployment of this Reloader instance - DeploymentInfo metav1.ObjectMeta `json:"deploymentInfo"` -} - -// BuildInfo contains information about the build and version of the Reloader binary. -// This includes Go version, release version, commit details, and build timestamp. -type BuildInfo struct { - // GoVersion is the version of Go used to compile the binary - GoVersion string `json:"goVersion"` - // ReleaseVersion is the version tag or branch of the Reloader release - ReleaseVersion string `json:"releaseVersion"` - // CommitHash is the Git commit hash of the source code used to build this binary - CommitHash string `json:"commitHash"` - // CommitTime is the timestamp of the Git commit used to build this binary - CommitTime time.Time `json:"commitTime"` - - // Edition indicates the edition of Reloader (e.g., OSS, Enterprise) - Edition string `json:"edition"` -} - -func NewBuildInfo() *BuildInfo { - metaInfo := &BuildInfo{ - GoVersion: runtime.Version(), - ReleaseVersion: Version, - CommitHash: Commit, - CommitTime: ParseUTCTime(BuildDate), - Edition: Edition, - } - - return metaInfo -} - -func (m *MetaInfo) ToConfigMap() *v1.ConfigMap { - return &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: MetaInfoConfigmapName, - Namespace: m.DeploymentInfo.Namespace, - Labels: map[string]string{ - MetaInfoConfigmapLabelKey: MetaInfoConfigmapLabelValue, - }, - }, - Data: map[string]string{ - "buildInfo": toJson(m.BuildInfo), - "reloaderOptions": toJson(m.ReloaderOptions), - "deploymentInfo": toJson(m.DeploymentInfo), - }, - } -} - -func NewMetaInfo(configmap *v1.ConfigMap) (*MetaInfo, error) { - var buildInfo BuildInfo - if val, ok := configmap.Data["buildInfo"]; ok { - err := json.Unmarshal([]byte(val), &buildInfo) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal buildInfo: %w", err) - } - } - - var reloaderOptions ReloaderOptions - if val, ok := configmap.Data["reloaderOptions"]; ok { - err := json.Unmarshal([]byte(val), &reloaderOptions) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal reloaderOptions: %w", err) - } - } - - var deploymentInfo metav1.ObjectMeta - if val, ok := configmap.Data["deploymentInfo"]; ok { - err := json.Unmarshal([]byte(val), &deploymentInfo) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal deploymentInfo: %w", err) - } - } - - return &MetaInfo{ - BuildInfo: buildInfo, - ReloaderOptions: reloaderOptions, - DeploymentInfo: deploymentInfo, - }, nil -} - -func toJson(data interface{}) string { - jsonData, err := json.Marshal(data) - if err != nil { - return "" - } - return string(jsonData) -} - -func ParseUTCTime(value string) time.Time { - if value == "" { - return time.Time{} // Return zero time if value is empty - } - t, err := time.Parse(time.RFC3339, value) - if err != nil { - return time.Time{} // Return zero time if parsing fails - } - return t -} diff --git a/pkg/common/reload_source.go b/pkg/common/reload_source.go deleted file mode 100644 index 093826132..000000000 --- a/pkg/common/reload_source.go +++ /dev/null @@ -1,39 +0,0 @@ -package common - -import "time" - -type ReloadSource struct { - Type string `json:"type"` - Name string `json:"name"` - Namespace string `json:"namespace"` - Hash string `json:"hash"` - ContainerRefs []string `json:"containerRefs"` - ObservedAt int64 `json:"observedAt"` -} - -func NewReloadSource( - resourceName string, - resourceNamespace string, - resourceType string, - resourceHash string, - containerRefs []string, -) ReloadSource { - return ReloadSource{ - ObservedAt: time.Now().Unix(), - Name: resourceName, - Namespace: resourceNamespace, - Type: resourceType, - Hash: resourceHash, - ContainerRefs: containerRefs, - } -} - -func NewReloadSourceFromConfig(config Config, containerRefs []string) ReloadSource { - return NewReloadSource( - config.ResourceName, - config.Namespace, - config.Type, - config.SHAValue, - containerRefs, - ) -} diff --git a/pkg/kube/client.go b/pkg/kube/client.go deleted file mode 100644 index 1cfe21619..000000000 --- a/pkg/kube/client.go +++ /dev/null @@ -1,154 +0,0 @@ -package kube - -import ( - "context" - "os" - - "k8s.io/client-go/tools/clientcmd" - - argorollout "github.com/argoproj/argo-rollouts/pkg/client/clientset/versioned" - appsclient "github.com/openshift/client-go/apps/clientset/versioned" - "github.com/sirupsen/logrus" - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/rest" - csiclient "sigs.k8s.io/secrets-store-csi-driver/pkg/client/clientset/versioned" -) - -// Clients struct exposes interfaces for kubernetes as well as openshift if available -type Clients struct { - KubernetesClient kubernetes.Interface - OpenshiftAppsClient appsclient.Interface - ArgoRolloutClient argorollout.Interface - CSIClient csiclient.Interface -} - -var ( - // IsOpenshift is true if environment is Openshift, it is false if environment is Kubernetes - IsOpenshift = isOpenshift() - // IsCSIEnabled is true if environment has CSI provider installed, otherwise false - IsCSIInstalled = isCSIInstalled() -) - -// GetClients returns a `Clients` object containing both openshift and kubernetes clients with an openshift identifier -func GetClients() Clients { - client, err := GetKubernetesClient() - if err != nil { - logrus.Fatalf("Unable to create Kubernetes client error = %v", err) - } - - var appsClient *appsclient.Clientset - - if IsOpenshift { - appsClient, err = GetOpenshiftAppsClient() - if err != nil { - logrus.Warnf("Unable to create Openshift Apps client error = %v", err) - } - } - - var rolloutClient *argorollout.Clientset - - rolloutClient, err = GetArgoRolloutClient() - if err != nil { - logrus.Warnf("Unable to create ArgoRollout client error = %v", err) - } - - var csiClient *csiclient.Clientset - - if IsCSIInstalled { - csiClient, err = GetCSIClient() - if err != nil { - logrus.Warnf("Unable to create CSI client error = %v", err) - } - } - - return Clients{ - KubernetesClient: client, - OpenshiftAppsClient: appsClient, - ArgoRolloutClient: rolloutClient, - CSIClient: csiClient, - } -} - -func GetArgoRolloutClient() (*argorollout.Clientset, error) { - config, err := getConfig() - if err != nil { - return nil, err - } - return argorollout.NewForConfig(config) -} - -func isCSIInstalled() bool { - client, err := GetKubernetesClient() - if err != nil { - logrus.Fatalf("Unable to create Kubernetes client error = %v", err) - } - _, err = client.RESTClient().Get().AbsPath("/apis/secrets-store.csi.x-k8s.io/v1").Do(context.TODO()).Raw() - if err == nil { - logrus.Info("CSI provider is installed") - return true - } - logrus.Info("CSI provider is not installed") - return false -} - -func GetCSIClient() (*csiclient.Clientset, error) { - config, err := getConfig() - if err != nil { - return nil, err - } - return csiclient.NewForConfig(config) -} - -func isOpenshift() bool { - client, err := GetKubernetesClient() - if err != nil { - logrus.Fatalf("Unable to create Kubernetes client error = %v", err) - } - _, err = client.RESTClient().Get().AbsPath("/apis/project.openshift.io").Do(context.TODO()).Raw() - if err == nil { - logrus.Info("Environment: Openshift") - return true - } - logrus.Info("Environment: Kubernetes") - return false -} - -// GetOpenshiftAppsClient returns an Openshift Client that can query on Apps -func GetOpenshiftAppsClient() (*appsclient.Clientset, error) { - config, err := getConfig() - if err != nil { - return nil, err - } - return appsclient.NewForConfig(config) -} - -// GetKubernetesClient gets the client for k8s, if ~/.kube/config exists so get that config else incluster config -func GetKubernetesClient() (*kubernetes.Clientset, error) { - config, err := getConfig() - if err != nil { - return nil, err - } - return kubernetes.NewForConfig(config) -} - -func getConfig() (*rest.Config, error) { - var config *rest.Config - kubeconfigPath := os.Getenv("KUBECONFIG") - if kubeconfigPath == "" { - kubeconfigPath = os.Getenv("HOME") + "/.kube/config" - } - // If file exists so use that config settings - if _, err := os.Stat(kubeconfigPath); err == nil { - config, err = clientcmd.BuildConfigFromFlags("", kubeconfigPath) - if err != nil { - return nil, err - } - } else { // Use Incluster Configuration - config, err = rest.InClusterConfig() - if err != nil { - return nil, err - } - } - - return config, nil -} diff --git a/pkg/kube/resourcemapper.go b/pkg/kube/resourcemapper.go deleted file mode 100644 index bdb7858ba..000000000 --- a/pkg/kube/resourcemapper.go +++ /dev/null @@ -1,15 +0,0 @@ -package kube - -import ( - v1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/runtime" - csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1" -) - -// ResourceMap are resources from where changes are going to be detected -var ResourceMap = map[string]runtime.Object{ - "configmaps": &v1.ConfigMap{}, - "secrets": &v1.Secret{}, - "namespaces": &v1.Namespace{}, - "secretproviderclasspodstatuses": &csiv1.SecretProviderClassPodStatus{}, -} diff --git a/test/e2e/advanced/advanced_suite_test.go b/test/e2e/advanced/advanced_suite_test.go index bac2aaa27..d2eca1106 100644 --- a/test/e2e/advanced/advanced_suite_test.go +++ b/test/e2e/advanced/advanced_suite_test.go @@ -9,14 +9,12 @@ import ( . "github.com/onsi/gomega" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" - csiclient "sigs.k8s.io/secrets-store-csi-driver/pkg/client/clientset/versioned" "github.com/stakater/Reloader/test/e2e/utils" ) var ( kubeClient kubernetes.Interface - csiClient csiclient.Interface restConfig *rest.Config testNamespace string ctx context.Context @@ -44,10 +42,6 @@ var _ = SynchronizedBeforeSuite( "reloader.reloadStrategy": "annotations", "reloader.watchGlobally": "false", } - if utils.IsCSIDriverInstalled(context.Background(), setupEnv.CSIClient) { - deployValues["reloader.enableCSIIntegration"] = "true" - GinkgoWriter.Println("Deploying Reloader with CSI integration support") - } Expect(setupEnv.DeployAndWait(deployValues)).To(Succeed(), "Failed to deploy Reloader") @@ -68,7 +62,6 @@ var _ = SynchronizedBeforeSuite( Expect(err).NotTo(HaveOccurred(), "Failed to setup shared test environment") kubeClient = testEnv.KubeClient - csiClient = testEnv.CSIClient restConfig = testEnv.RestConfig testNamespace = testEnv.Namespace ctx = testEnv.Ctx diff --git a/test/e2e/advanced/job_reload_test.go b/test/e2e/advanced/job_reload_test.go index a54136ab6..87b5c7241 100644 --- a/test/e2e/advanced/job_reload_test.go +++ b/test/e2e/advanced/job_reload_test.go @@ -1,9 +1,6 @@ package advanced import ( - "fmt" - "time" - . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -12,20 +9,16 @@ import ( var _ = Describe("Job Workload Recreation Tests", func() { var ( - jobName string - configMapName string - secretName string - spcName string - vaultSecretPath string - jobAdapter *utils.JobAdapter + jobName string + configMapName string + secretName string + jobAdapter *utils.JobAdapter ) BeforeEach(func() { jobName = utils.RandName("job") configMapName = utils.RandName("cm") secretName = utils.RandName("secret") - spcName = utils.RandName("spc") - vaultSecretPath = fmt.Sprintf("secret/%s", utils.RandName("vault")) jobAdapter = utils.NewJobAdapter(kubeClient) }) @@ -33,8 +26,6 @@ var _ = Describe("Job Workload Recreation Tests", func() { _ = utils.DeleteJob(ctx, kubeClient, testNamespace, jobName) _ = utils.DeleteConfigMap(ctx, kubeClient, testNamespace, configMapName) _ = utils.DeleteSecret(ctx, kubeClient, testNamespace, secretName) - _ = utils.DeleteSecretProviderClass(ctx, csiClient, testNamespace, spcName) - _ = utils.DeleteVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath) }) Context("Job with ConfigMap reference", func() { @@ -181,68 +172,4 @@ var _ = Describe("Job Workload Recreation Tests", func() { Expect(recreated).To(BeTrue(), "Job with valueFrom.secretKeyRef should be recreated when Secret changes") }) }) - - Context("Job with SecretProviderClass reference", Label("csi"), func() { - BeforeEach(func() { - if !utils.IsCSIDriverInstalled(ctx, csiClient) { - Skip("CSI secrets store driver not installed - skipping CSI test") - } - if !utils.IsVaultProviderInstalled(ctx, kubeClient) { - Skip("Vault CSI provider not installed - skipping CSI test") - } - }) - - It("should recreate Job when Vault secret changes", func() { - By("Creating a secret in Vault") - err := utils.CreateVaultSecret( - ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "initial-value-v1"}) - Expect(err).NotTo(HaveOccurred()) - - By("Creating a SecretProviderClass pointing to Vault secret") - _, err = utils.CreateSecretProviderClassWithSecret( - ctx, csiClient, testNamespace, spcName, - vaultSecretPath, "api_key", - ) - Expect(err).NotTo(HaveOccurred()) - - By("Creating a Job with CSI volume and SPC reload annotation") - job, err := utils.CreateJob(ctx, kubeClient, testNamespace, jobName, - utils.WithJobCommand("sleep 300"), - utils.WithJobCSIVolume(spcName), - utils.WithJobAnnotations(utils.BuildSecretProviderClassReloadAnnotation(spcName))) - Expect(err).NotTo(HaveOccurred()) - originalUID := string(job.UID) - - By("Waiting for Job to be ready") - err = jobAdapter.WaitReady(ctx, testNamespace, jobName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Finding the SPCPS created by CSI driver") - spcpsName, err := utils.FindSPCPSForSPC( - ctx, csiClient, testNamespace, spcName, utils.WorkloadReadyTimeout, - ) - Expect(err).NotTo(HaveOccurred()) - GinkgoWriter.Printf("Found SPCPS: %s\n", spcpsName) - - By("Getting initial SPCPS version") - initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) - Expect(err).NotTo(HaveOccurred()) - GinkgoWriter.Printf("Initial SPCPS version: %s\n", initialVersion) - - By("Updating the Vault secret") - err = utils.UpdateVaultSecret( - ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "updated-value-v2"}) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for CSI driver to sync the new secret version") - err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, 10*time.Second) - Expect(err).NotTo(HaveOccurred()) - GinkgoWriter.Println("CSI driver synced new secret version") - - By("Waiting for Job to be recreated (new UID)") - _, recreated, err := jobAdapter.WaitRecreated(ctx, testNamespace, jobName, originalUID, utils.ReloadTimeout) - Expect(err).NotTo(HaveOccurred()) - Expect(recreated).To(BeTrue(), "Job should be recreated with new UID when Vault secret changes") - }) - }) }) diff --git a/test/e2e/advanced/multi_container_test.go b/test/e2e/advanced/multi_container_test.go index 98ed63910..cef051846 100644 --- a/test/e2e/advanced/multi_container_test.go +++ b/test/e2e/advanced/multi_container_test.go @@ -1,9 +1,6 @@ package advanced import ( - "fmt" - "time" - . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -95,125 +92,4 @@ var _ = Describe("Multi-Container Tests", Serial, func() { Expect(reloaded).To(BeTrue(), "Deployment should be reloaded when first container's ConfigMap changes") }) }) - - Context("Init container with CSI volume", Label("csi"), func() { - var ( - spcName string - vaultSecretPath string - ) - - BeforeEach(func() { - if !utils.IsCSIDriverInstalled(ctx, csiClient) { - Skip("CSI secrets store driver not installed") - } - if !utils.IsVaultProviderInstalled(ctx, kubeClient) { - Skip("Vault CSI provider not installed") - } - spcName = utils.RandName("spc") - vaultSecretPath = fmt.Sprintf("secret/%s", utils.RandName("test")) - }) - - AfterEach(func() { - if spcName != "" { - _ = utils.DeleteSecretProviderClass(ctx, csiClient, testNamespace, spcName) - } - if vaultSecretPath != "" { - _ = utils.DeleteVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath) - } - }) - - It("should reload when SecretProviderClassPodStatus used by init container changes", func() { - By("Creating a Vault secret") - err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{ - "api_key": "initial-init-value", - }) - Expect(err).NotTo(HaveOccurred()) - - By("Creating a SecretProviderClass pointing to Vault") - _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, vaultSecretPath, "api_key") - Expect(err).NotTo(HaveOccurred()) - - By("Creating a Deployment with init container using CSI volume") - _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, - utils.WithInitContainerCSIVolume(spcName), - utils.WithAnnotations(utils.BuildSecretProviderClassReloadAnnotation(spcName)), - ) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for Deployment to be ready") - err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Finding the SPCPS created by CSI driver") - spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, deploymentName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Getting initial SPCPS version") - initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) - Expect(err).NotTo(HaveOccurred()) - - By("Updating the Vault secret") - err = utils.UpdateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{ - "api_key": "updated-init-value", - }) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for CSI driver to sync (SPCPS version change)") - err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, 10*time.Second) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for Deployment to be reloaded") - reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, - utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) - Expect(err).NotTo(HaveOccurred()) - Expect(reloaded).To(BeTrue(), "Deployment with init container using CSI volume should be reloaded") - }) - - It("should reload with auto annotation when init container CSI volume changes", func() { - By("Creating a Vault secret") - err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{ - "api_key": "initial-init-auto-value", - }) - Expect(err).NotTo(HaveOccurred()) - - By("Creating a SecretProviderClass pointing to Vault") - _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, vaultSecretPath, "api_key") - Expect(err).NotTo(HaveOccurred()) - - By("Creating a Deployment with init container using CSI volume and auto annotation") - _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, - utils.WithInitContainerCSIVolume(spcName), - utils.WithAnnotations(utils.BuildAutoTrueAnnotation()), - ) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for Deployment to be ready") - err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Finding the SPCPS created by CSI driver") - spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, deploymentName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Getting initial SPCPS version") - initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) - Expect(err).NotTo(HaveOccurred()) - - By("Updating the Vault secret") - err = utils.UpdateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{ - "api_key": "updated-init-auto-value", - }) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for CSI driver to sync (SPCPS version change)") - err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, 10*time.Second) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for Deployment to be reloaded") - reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, - utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) - Expect(err).NotTo(HaveOccurred()) - Expect(reloaded).To(BeTrue(), "Deployment with init container CSI volume and auto=true should be reloaded") - }) - }) }) diff --git a/test/e2e/annotations/annotations_suite_test.go b/test/e2e/annotations/annotations_suite_test.go index 8c9bc33eb..49c7f3c0d 100644 --- a/test/e2e/annotations/annotations_suite_test.go +++ b/test/e2e/annotations/annotations_suite_test.go @@ -9,14 +9,12 @@ import ( . "github.com/onsi/gomega" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" - csiclient "sigs.k8s.io/secrets-store-csi-driver/pkg/client/clientset/versioned" "github.com/stakater/Reloader/test/e2e/utils" ) var ( kubeClient kubernetes.Interface - csiClient csiclient.Interface restConfig *rest.Config testNamespace string ctx context.Context @@ -45,10 +43,6 @@ var _ = SynchronizedBeforeSuite( "reloader.reloadStrategy": "annotations", "reloader.watchGlobally": "false", } - if utils.IsCSIDriverInstalled(context.Background(), setupEnv.CSIClient) { - deployValues["reloader.enableCSIIntegration"] = "true" - GinkgoWriter.Println("Deploying Reloader with CSI integration support") - } Expect(setupEnv.DeployAndWait(deployValues)).To(Succeed(), "Failed to deploy Reloader") @@ -69,7 +63,6 @@ var _ = SynchronizedBeforeSuite( Expect(err).NotTo(HaveOccurred(), "Failed to setup shared test environment") kubeClient = testEnv.KubeClient - csiClient = testEnv.CSIClient restConfig = testEnv.RestConfig testNamespace = testEnv.Namespace ctx = testEnv.Ctx diff --git a/test/e2e/annotations/auto_reload_test.go b/test/e2e/annotations/auto_reload_test.go index c407fa393..af4b7ac3f 100644 --- a/test/e2e/annotations/auto_reload_test.go +++ b/test/e2e/annotations/auto_reload_test.go @@ -1,9 +1,6 @@ package annotations import ( - "fmt" - "time" - . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -12,20 +9,16 @@ import ( var _ = Describe("Auto Reload Annotation Tests", func() { var ( - deploymentName string - configMapName string - secretName string - spcName string - vaultSecretPath string - adapter *utils.DeploymentAdapter + deploymentName string + configMapName string + secretName string + adapter *utils.DeploymentAdapter ) BeforeEach(func() { deploymentName = utils.RandName("deploy") configMapName = utils.RandName("cm") secretName = utils.RandName("secret") - spcName = utils.RandName("spc") - vaultSecretPath = fmt.Sprintf("secret/%s", utils.RandName("test")) adapter = utils.NewDeploymentAdapter(kubeClient) }) @@ -33,10 +26,6 @@ var _ = Describe("Auto Reload Annotation Tests", func() { _ = utils.DeleteDeployment(ctx, kubeClient, testNamespace, deploymentName) _ = utils.DeleteConfigMap(ctx, kubeClient, testNamespace, configMapName) _ = utils.DeleteSecret(ctx, kubeClient, testNamespace, secretName) - if csiClient != nil { - _ = utils.DeleteSecretProviderClass(ctx, csiClient, testNamespace, spcName) - } - _ = utils.DeleteVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath) }) Context("with reloader.stakater.com/auto=true annotation", func() { @@ -202,169 +191,6 @@ var _ = Describe("Auto Reload Annotation Tests", func() { }) }) - Context("with secretproviderclass.reloader.stakater.com/auto=true annotation", Label("csi"), func() { - BeforeEach(func() { - if !utils.IsCSIDriverInstalled(ctx, csiClient) { - Skip("CSI secrets store driver not installed") - } - if !utils.IsVaultProviderInstalled(ctx, kubeClient) { - Skip("Vault CSI provider not installed") - } - }) - - It("should reload Deployment when SecretProviderClassPodStatus changes", func() { - By("Creating a secret in Vault") - err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "initial-value-v1"}) - Expect(err).NotTo(HaveOccurred()) - - By("Creating a SecretProviderClass pointing to Vault secret") - _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, - vaultSecretPath, "api_key") - Expect(err).NotTo(HaveOccurred()) - - By("Creating a Deployment with secretproviderclass auto=true annotation") - _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, - utils.WithCSIVolume(spcName), - utils.WithAnnotations(utils.BuildSecretProviderClassAutoAnnotation()), - ) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for Deployment to be ready") - err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Finding the SPCPS created by CSI driver") - spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, deploymentName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - GinkgoWriter.Printf("Found SPCPS: %s\n", spcpsName) - - By("Getting initial SPCPS version") - initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) - Expect(err).NotTo(HaveOccurred()) - GinkgoWriter.Printf("Initial SPCPS version: %s\n", initialVersion) - - By("Updating the Vault secret") - err = utils.UpdateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "updated-value-v2"}) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for CSI driver to sync the new secret version") - err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, 10*time.Second) - Expect(err).NotTo(HaveOccurred()) - GinkgoWriter.Println("CSI driver synced new secret version") - - By("Waiting for Deployment to be reloaded") - reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, - utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) - Expect(err).NotTo(HaveOccurred()) - Expect(reloaded).To(BeTrue(), "Deployment should have been reloaded for Vault secret change") - }) - - It("should NOT reload Deployment when ConfigMap changes (only SPC auto enabled)", func() { - By("Creating a secret in Vault") - err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "initial-value-v1"}) - Expect(err).NotTo(HaveOccurred()) - - By("Creating a SecretProviderClass pointing to Vault secret") - _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, - vaultSecretPath, "api_key") - Expect(err).NotTo(HaveOccurred()) - - By("Creating a ConfigMap") - _, err = utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, - map[string]string{"key": "initial"}, nil) - Expect(err).NotTo(HaveOccurred()) - - By("Creating a Deployment with CSI volume AND ConfigMap, but only SPC auto annotation") - _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, - utils.WithCSIVolume(spcName), - utils.WithConfigMapEnvFrom(configMapName), - utils.WithAnnotations(utils.BuildSecretProviderClassAutoAnnotation()), - ) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for Deployment to be ready") - err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Finding the SPCPS created by CSI driver") - spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, deploymentName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Updating the ConfigMap (should NOT trigger reload with SPC auto only)") - err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) - Expect(err).NotTo(HaveOccurred()) - - By("Verifying Deployment was NOT reloaded for ConfigMap change") - time.Sleep(utils.NegativeTestWait) - reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, - utils.AnnotationLastReloadedFrom, utils.ShortTimeout) - Expect(err).NotTo(HaveOccurred()) - Expect(reloaded).To(BeFalse(), "Deployment with SPC auto only should NOT have been reloaded for ConfigMap change") - - By("Getting initial SPCPS version") - initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) - Expect(err).NotTo(HaveOccurred()) - - By("Updating the Vault secret (should trigger reload)") - err = utils.UpdateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "updated-value-v2"}) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for CSI driver to sync the new secret version") - err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, 10*time.Second) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for Deployment to be reloaded for SPC change") - reloaded, err = adapter.WaitReloaded(ctx, testNamespace, deploymentName, - utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) - Expect(err).NotTo(HaveOccurred()) - Expect(reloaded).To(BeTrue(), "Deployment should have been reloaded for Vault secret change") - }) - - It("should reload when using combined auto=true annotation for SPC", func() { - By("Creating a secret in Vault") - err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "initial-value-v1"}) - Expect(err).NotTo(HaveOccurred()) - - By("Creating a SecretProviderClass pointing to Vault secret") - _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, - vaultSecretPath, "api_key") - Expect(err).NotTo(HaveOccurred()) - - By("Creating a Deployment with CSI volume and general auto=true annotation") - _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, - utils.WithCSIVolume(spcName), - utils.WithAnnotations(utils.BuildAutoTrueAnnotation()), - ) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for Deployment to be ready") - err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Finding the SPCPS created by CSI driver") - spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, deploymentName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Getting initial SPCPS version") - initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) - Expect(err).NotTo(HaveOccurred()) - - By("Updating the Vault secret") - err = utils.UpdateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "updated-value-v2"}) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for CSI driver to sync the new secret version") - err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, 10*time.Second) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for Deployment to be reloaded") - reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, - utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) - Expect(err).NotTo(HaveOccurred()) - Expect(reloaded).To(BeTrue(), "Deployment with auto=true should have been reloaded for Vault secret change") - }) - }) - Context("with auto annotation and explicit reload annotation together", func() { It("should reload when auto-detected resource changes", func() { configMapName2 := utils.RandName("cm2") diff --git a/test/e2e/annotations/exclude_test.go b/test/e2e/annotations/exclude_test.go index 73e0e8f0c..5a336514a 100644 --- a/test/e2e/annotations/exclude_test.go +++ b/test/e2e/annotations/exclude_test.go @@ -242,144 +242,4 @@ var _ = Describe("Exclude Annotation Tests", func() { Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), ) }) - - Context("SecretProviderClass exclude annotation", Label("csi"), func() { - var ( - spcName string - spcName2 string - vaultSecretPath string - vaultSecretPath2 string - ) - - BeforeEach(func() { - if !utils.IsCSIDriverInstalled(ctx, csiClient) { - Skip("CSI secrets store driver not installed") - } - if !utils.IsVaultProviderInstalled(ctx, kubeClient) { - Skip("Vault CSI provider not installed") - } - spcName = utils.RandName("spc") - spcName2 = utils.RandName("spc2") - vaultSecretPath = fmt.Sprintf("secret/%s", utils.RandName("test")) - vaultSecretPath2 = fmt.Sprintf("secret/%s", utils.RandName("test2")) - }) - - AfterEach(func() { - _ = utils.DeleteSecretProviderClass(ctx, csiClient, testNamespace, spcName) - _ = utils.DeleteSecretProviderClass(ctx, csiClient, testNamespace, spcName2) - _ = utils.DeleteVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath) - _ = utils.DeleteVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath2) - }) - - It("should NOT reload when excluded SecretProviderClassPodStatus changes", func() { - By("Creating Vault secret for the excluded SPC") - err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{ - "api_key": "initial-excluded-value", - }) - Expect(err).NotTo(HaveOccurred()) - - By("Creating SecretProviderClass pointing to Vault secret") - _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, vaultSecretPath, "api_key") - Expect(err).NotTo(HaveOccurred()) - - By("Creating a Deployment with auto=true and secretproviderclasses.exclude annotation") - _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, - utils.WithCSIVolume(spcName), - utils.WithAnnotations(utils.MergeAnnotations( - utils.BuildAutoTrueAnnotation(), - utils.BuildSecretProviderClassExcludeAnnotation(spcName), - )), - ) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for Deployment to be ready") - err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Finding the SPCPS created by CSI driver") - spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, deploymentName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Getting initial SPCPS version") - initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) - Expect(err).NotTo(HaveOccurred()) - - By("Updating the Vault secret for excluded SPC") - err = utils.UpdateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{ - "api_key": "updated-excluded-value", - }) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for CSI driver to sync (SPCPS version change)") - err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, 10*time.Second) - Expect(err).NotTo(HaveOccurred()) - - By("Verifying Deployment was NOT reloaded (excluded SPC)") - time.Sleep(utils.NegativeTestWait) - reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, - utils.AnnotationLastReloadedFrom, utils.ShortTimeout) - Expect(err).NotTo(HaveOccurred()) - Expect(reloaded).To(BeFalse(), "Deployment should NOT reload when excluded SecretProviderClassPodStatus changes") - }) - - It("should reload when non-excluded SecretProviderClassPodStatus changes", func() { - By("Creating two Vault secrets") - err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{ - "api_key": "initial-excluded-value", - }) - Expect(err).NotTo(HaveOccurred()) - - err = utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath2, map[string]string{ - "api_key": "initial-nonexcluded-value", - }) - Expect(err).NotTo(HaveOccurred()) - - By("Creating two SecretProviderClasses") - _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, vaultSecretPath, "api_key") - Expect(err).NotTo(HaveOccurred()) - - _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName2, vaultSecretPath2, "api_key") - Expect(err).NotTo(HaveOccurred()) - - By("Creating a Deployment with auto=true and secretproviderclasses.exclude for first SPC only") - _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, - utils.WithCSIVolume(spcName), - utils.WithCSIVolume(spcName2), - utils.WithAnnotations(utils.MergeAnnotations( - utils.BuildAutoTrueAnnotation(), - utils.BuildSecretProviderClassExcludeAnnotation(spcName), - )), - ) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for Deployment to be ready") - err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Finding the SPCPS for non-excluded SPC") - - spcpsName2, err := utils.FindSPCPSForSPC(ctx, csiClient, testNamespace, spcName2, 30*time.Second) - Expect(err).NotTo(HaveOccurred()) - - By("Getting initial SPCPS version for non-excluded SPC") - initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName2) - Expect(err).NotTo(HaveOccurred()) - - By("Updating the Vault secret for non-excluded SPC") - err = utils.UpdateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath2, map[string]string{ - "api_key": "updated-nonexcluded-value", - }) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for CSI driver to sync (SPCPS version change)") - err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName2, initialVersion, 10*time.Second) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for Deployment to be reloaded") - reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, - utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) - Expect(err).NotTo(HaveOccurred()) - Expect(reloaded).To(BeTrue(), "Deployment should reload when non-excluded SecretProviderClassPodStatus changes") - }) - }) }) diff --git a/test/e2e/core/core_suite_test.go b/test/e2e/core/core_suite_test.go index acf7bf6e7..d4bac17f4 100644 --- a/test/e2e/core/core_suite_test.go +++ b/test/e2e/core/core_suite_test.go @@ -9,14 +9,12 @@ import ( . "github.com/onsi/gomega" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" - csiclient "sigs.k8s.io/secrets-store-csi-driver/pkg/client/clientset/versioned" "github.com/stakater/Reloader/test/e2e/utils" ) var ( kubeClient kubernetes.Interface - csiClient csiclient.Interface restConfig *rest.Config testNamespace string ctx context.Context @@ -49,10 +47,6 @@ var _ = SynchronizedBeforeSuite( deployValues["reloader.isArgoRollouts"] = "true" GinkgoWriter.Println("Deploying Reloader with Argo Rollouts support") } - if utils.IsCSIDriverInstalled(context.Background(), setupEnv.CSIClient) { - deployValues["reloader.enableCSIIntegration"] = "true" - GinkgoWriter.Println("Deploying Reloader with CSI integration support") - } Expect(setupEnv.DeployAndWait(deployValues)).To(Succeed(), "Failed to deploy Reloader") @@ -73,7 +67,6 @@ var _ = SynchronizedBeforeSuite( Expect(err).NotTo(HaveOccurred(), "Failed to setup shared test environment") kubeClient = testEnv.KubeClient - csiClient = testEnv.CSIClient restConfig = testEnv.RestConfig testNamespace = testEnv.Namespace ctx = testEnv.Ctx diff --git a/test/e2e/core/workloads_test.go b/test/e2e/core/workloads_test.go index 1a7f7b37b..fdbe98ec4 100644 --- a/test/e2e/core/workloads_test.go +++ b/test/e2e/core/workloads_test.go @@ -12,28 +12,20 @@ import ( var _ = Describe("Workload Reload Tests", Serial, func() { var ( - configMapName string - secretName string - workloadName string - spcName string - vaultSecretPath string + configMapName string + secretName string + workloadName string ) BeforeEach(func() { configMapName = utils.RandName("cm") secretName = utils.RandName("secret") workloadName = utils.RandName("workload") - spcName = utils.RandName("spc") - vaultSecretPath = fmt.Sprintf("secret/%s", utils.RandName("test")) }) AfterEach(func() { _ = utils.DeleteConfigMap(ctx, kubeClient, testNamespace, configMapName) _ = utils.DeleteSecret(ctx, kubeClient, testNamespace, secretName) - if csiClient != nil { - _ = utils.DeleteSecretProviderClass(ctx, csiClient, testNamespace, spcName) - } - _ = utils.DeleteVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath) }) // ============================================================ @@ -131,75 +123,6 @@ var _ = Describe("Workload Reload Tests", Serial, func() { Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), ) - // SecretProviderClassPodStatus (CSI) reload tests with real Vault - DescribeTable("should reload when SecretProviderClassPodStatus changes", func(workloadType utils.WorkloadType) { - if !utils.IsCSIDriverInstalled(ctx, csiClient) { - Skip("CSI secrets store driver not installed") - } - if !utils.IsVaultProviderInstalled(ctx, kubeClient) { - Skip("Vault CSI provider not installed") - } - - adapter := registry.Get(workloadType) - if adapter == nil { - Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) - } - - By("Creating a secret in Vault") - err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "initial-value-v1"}) - Expect(err).NotTo(HaveOccurred()) - - By("Creating a SecretProviderClass pointing to Vault secret") - _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, vaultSecretPath, - "api_key") - Expect(err).NotTo(HaveOccurred()) - - By("Creating workload with CSI volume and SPC reload annotation") - err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ - SPCName: spcName, - UseCSIVolume: true, - Annotations: utils.BuildSecretProviderClassReloadAnnotation(spcName), - }) - Expect(err).NotTo(HaveOccurred()) - DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) - - By("Waiting for workload to be ready") - err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Finding the SPCPS created by CSI driver") - spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, workloadName, - utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - GinkgoWriter.Printf("Found SPCPS: %s\n", spcpsName) - - By("Getting initial SPCPS version") - initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) - Expect(err).NotTo(HaveOccurred()) - GinkgoWriter.Printf("Initial SPCPS version: %s\n", initialVersion) - - By("Updating the Vault secret") - err = utils.UpdateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "updated-value-v2"}) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for CSI driver to sync the new secret version") - err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, - 10*time.Second) - Expect(err).NotTo(HaveOccurred()) - GinkgoWriter.Println("CSI driver synced new secret version") - - By("Waiting for workload to be reloaded") - reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, utils.AnnotationLastReloadedFrom, - utils.ReloadTimeout) - Expect(err).NotTo(HaveOccurred()) - Expect(reloaded).To(BeTrue(), "%s should have been reloaded when Vault secret changed", workloadType) - }, Entry("Deployment", Label("csi"), utils.WorkloadDeployment), - Entry("DaemonSet", Label("csi"), utils.WorkloadDaemonSet), - Entry("StatefulSet", Label("csi"), utils.WorkloadStatefulSet), - Entry("ArgoRollout", Label("csi", "argo"), utils.WorkloadArgoRollout), - Entry("DeploymentConfig", Label("csi", "openshift"), utils.WorkloadDeploymentConfig), - ) - // Auto=true annotation tests DescribeTable("should reload with auto=true annotation when ConfigMap changes", func(workloadType utils.WorkloadType) { @@ -330,65 +253,6 @@ var _ = Describe("Workload Reload Tests", Serial, func() { Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), ) - // Negative test: SPCPS label-only changes should NOT trigger reload - DescribeTable("should NOT reload when only SecretProviderClassPodStatus labels change", - func(workloadType utils.WorkloadType) { - if !utils.IsCSIDriverInstalled(ctx, csiClient) { - Skip("CSI secrets store driver not installed") - } - if !utils.IsVaultProviderInstalled(ctx, kubeClient) { - Skip("Vault CSI provider not installed") - } - - adapter := registry.Get(workloadType) - if adapter == nil { - Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) - } - - By("Creating a secret in Vault") - err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "initial-value"}) - Expect(err).NotTo(HaveOccurred()) - - By("Creating a SecretProviderClass pointing to Vault secret") - _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, - vaultSecretPath, "api_key") - Expect(err).NotTo(HaveOccurred()) - - By("Creating workload with CSI volume and SPC reload annotation") - err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ - SPCName: spcName, - UseCSIVolume: true, - Annotations: utils.BuildSecretProviderClassReloadAnnotation(spcName), - }) - Expect(err).NotTo(HaveOccurred()) - DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) - - By("Waiting for workload to be ready") - err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Finding the SPCPS created by CSI driver") - spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, workloadName, - utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Updating only the SPCPS labels (no objects change)") - err = utils.UpdateSecretProviderClassPodStatusLabels(ctx, csiClient, testNamespace, spcpsName, map[string]string{"new-label": "new-value"}) - Expect(err).NotTo(HaveOccurred()) - - By("Verifying workload was NOT reloaded (negative test)") - time.Sleep(utils.NegativeTestWait) - reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, - utils.AnnotationLastReloadedFrom, utils.ShortTimeout) - Expect(err).NotTo(HaveOccurred()) - Expect(reloaded).To(BeFalse(), "%s should NOT reload when only SPCPS labels change", workloadType) - }, Entry("Deployment", Label("csi"), utils.WorkloadDeployment), - Entry("DaemonSet", Label("csi"), utils.WorkloadDaemonSet), - Entry("StatefulSet", Label("csi"), utils.WorkloadStatefulSet), - Entry("ArgoRollout", Label("csi", "argo"), utils.WorkloadArgoRollout), - Entry("DeploymentConfig", Label("csi", "openshift"), utils.WorkloadDeploymentConfig), - ) - // CronJob special handling - triggers a Job instead of annotation Context("CronJob (special handling)", func() { var cronJobAdapter *utils.CronJobAdapter @@ -996,140 +860,6 @@ var _ = Describe("Workload Reload Tests", Serial, func() { Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), ) - DescribeTable("should reload when SecretProviderClass annotation is on pod template only", - func(workloadType utils.WorkloadType) { - if !utils.IsCSIDriverInstalled(ctx, csiClient) { - Skip("CSI secrets store driver not installed") - } - if !utils.IsVaultProviderInstalled(ctx, kubeClient) { - Skip("Vault CSI provider not installed") - } - - adapter := registry.Get(workloadType) - if adapter == nil { - Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) - } - - By("Creating a secret in Vault") - err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "initial-value-v1"}) - Expect(err).NotTo(HaveOccurred()) - - By("Creating a SecretProviderClass pointing to Vault secret") - _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, - vaultSecretPath, "api_key") - Expect(err).NotTo(HaveOccurred()) - - By("Creating workload with SPC annotation on pod template ONLY") - err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ - SPCName: spcName, - UseCSIVolume: true, - PodTemplateAnnotations: utils.BuildSecretProviderClassReloadAnnotation(spcName), - }) - Expect(err).NotTo(HaveOccurred()) - DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) - - By("Waiting for workload to be ready") - err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Finding the SPCPS created by CSI driver") - spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, - workloadName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Getting initial SPCPS version") - initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) - Expect(err).NotTo(HaveOccurred()) - - By("Updating the Vault secret") - err = utils.UpdateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "updated-value-v2"}) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for CSI driver to sync the new secret version") - err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, - initialVersion, 10*time.Second) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for workload to be reloaded") - reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, - utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) - Expect(err).NotTo(HaveOccurred()) - Expect(reloaded).To(BeTrue(), "%s should reload with SPC annotation on pod template", workloadType) - }, - Entry("Deployment", Label("csi"), utils.WorkloadDeployment), - Entry("DaemonSet", Label("csi"), utils.WorkloadDaemonSet), - Entry("StatefulSet", Label("csi"), utils.WorkloadStatefulSet), - Entry("ArgoRollout", Label("csi", "argo"), utils.WorkloadArgoRollout), - Entry("DeploymentConfig", Label("csi", "openshift"), utils.WorkloadDeploymentConfig), - ) - - DescribeTable("should reload when secretproviderclass auto annotation is on pod template only", - func(workloadType utils.WorkloadType) { - if !utils.IsCSIDriverInstalled(ctx, csiClient) { - Skip("CSI secrets store driver not installed") - } - if !utils.IsVaultProviderInstalled(ctx, kubeClient) { - Skip("Vault CSI provider not installed") - } - - adapter := registry.Get(workloadType) - if adapter == nil { - Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) - } - - By("Creating a secret in Vault") - err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "initial-value-v1"}) - Expect(err).NotTo(HaveOccurred()) - - By("Creating a SecretProviderClass pointing to Vault secret") - _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, - vaultSecretPath, "api_key") - Expect(err).NotTo(HaveOccurred()) - - By("Creating workload with SPC auto annotation on pod template ONLY") - err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ - SPCName: spcName, - UseCSIVolume: true, - PodTemplateAnnotations: utils.BuildSecretProviderClassAutoAnnotation(), - }) - Expect(err).NotTo(HaveOccurred()) - DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) - - By("Waiting for workload to be ready") - err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Finding the SPCPS created by CSI driver") - spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, - workloadName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Getting initial SPCPS version") - initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) - Expect(err).NotTo(HaveOccurred()) - - By("Updating the Vault secret") - err = utils.UpdateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "updated-value-v2"}) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for CSI driver to sync the new secret version") - err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, - initialVersion, 10*time.Second) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for workload to be reloaded") - reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, - utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) - Expect(err).NotTo(HaveOccurred()) - Expect(reloaded).To(BeTrue(), "%s should reload with SPC auto on pod template", workloadType) - }, - Entry("Deployment", Label("csi"), utils.WorkloadDeployment), - Entry("DaemonSet", Label("csi"), utils.WorkloadDaemonSet), - Entry("StatefulSet", Label("csi"), utils.WorkloadStatefulSet), - Entry("ArgoRollout", Label("csi", "argo"), utils.WorkloadArgoRollout), - Entry("DeploymentConfig", Label("csi", "openshift"), utils.WorkloadDeploymentConfig), - ) - DescribeTable("should reload when annotations are on both workload and pod template", func(workloadType utils.WorkloadType) { adapter := registry.Get(workloadType) @@ -1236,10 +966,6 @@ var _ = Describe("Workload Reload Tests", Serial, func() { if utils.IsArgoRolloutsInstalled(ctx, testEnv.RolloutsClient) { deployValues["reloader.isArgoRollouts"] = "true" } - // Enable CSI integration if CSI driver is installed - if utils.IsCSIDriverInstalled(ctx, csiClient) { - deployValues["reloader.enableCSIIntegration"] = "true" - } err := testEnv.DeployAndWait(deployValues) Expect(err).NotTo(HaveOccurred(), "Failed to redeploy Reloader with envvars strategy") }) @@ -1253,10 +979,6 @@ var _ = Describe("Workload Reload Tests", Serial, func() { if utils.IsArgoRolloutsInstalled(ctx, testEnv.RolloutsClient) { deployValues["reloader.isArgoRollouts"] = "true" } - // Preserve CSI integration if CSI driver is installed - if utils.IsCSIDriverInstalled(ctx, csiClient) { - deployValues["reloader.enableCSIIntegration"] = "true" - } err := testEnv.DeployAndWait(deployValues) Expect(err).NotTo(HaveOccurred(), "Failed to restore Reloader to annotations strategy") }) @@ -1351,77 +1073,6 @@ var _ = Describe("Workload Reload Tests", Serial, func() { Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), ) - // CSI SecretProviderClassPodStatus env var tests with real Vault - DescribeTable("should add STAKATER_ env var when SecretProviderClassPodStatus changes", - func(workloadType utils.WorkloadType) { - if !utils.IsCSIDriverInstalled(ctx, csiClient) { - Skip("CSI secrets store driver not installed") - } - if !utils.IsVaultProviderInstalled(ctx, kubeClient) { - Skip("Vault CSI provider not installed") - } - - adapter := registry.Get(workloadType) - if adapter == nil { - Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) - } - - if !adapter.SupportsEnvVarStrategy() { - Skip("Workload type does not support env var strategy") - } - - By("Creating a secret in Vault") - err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "initial-value-v1"}) - Expect(err).NotTo(HaveOccurred()) - - By("Creating a SecretProviderClass pointing to Vault secret") - _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, - vaultSecretPath, "api_key") - Expect(err).NotTo(HaveOccurred()) - - By("Creating workload with CSI volume and SPC reload annotation") - err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ - SPCName: spcName, - UseCSIVolume: true, - Annotations: utils.BuildSecretProviderClassReloadAnnotation(spcName), - }) - Expect(err).NotTo(HaveOccurred()) - DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) - - By("Waiting for workload to be ready") - err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Finding the SPCPS created by CSI driver") - spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, workloadName, - utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Getting initial SPCPS version") - initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) - Expect(err).NotTo(HaveOccurred()) - - By("Updating the Vault secret") - err = utils.UpdateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "updated-value-v2"}) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for CSI driver to sync the new secret version") - err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, - 10*time.Second) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for workload to have STAKATER_ env var") - found, err := adapter.WaitEnvVar(ctx, testNamespace, workloadName, utils.StakaterEnvVarPrefix, - utils.ReloadTimeout) - Expect(err).NotTo(HaveOccurred()) - Expect(found).To(BeTrue(), "%s should have STAKATER_ env var after Vault secret change", workloadType) - }, Entry("Deployment", Label("csi"), utils.WorkloadDeployment), - Entry("DaemonSet", Label("csi"), utils.WorkloadDaemonSet), - Entry("StatefulSet", Label("csi"), utils.WorkloadStatefulSet), - Entry("ArgoRollout", Label("csi", "argo"), utils.WorkloadArgoRollout), - Entry("DeploymentConfig", Label("csi", "openshift"), utils.WorkloadDeploymentConfig), - ) - // Negative tests for env var strategy DescribeTable("should NOT add STAKATER_ env var when only ConfigMap labels change", func(workloadType utils.WorkloadType) { @@ -1512,245 +1163,5 @@ var _ = Describe("Workload Reload Tests", Serial, func() { Entry("DaemonSet", utils.WorkloadDaemonSet), Entry("StatefulSet", utils.WorkloadStatefulSet), ) - - // CSI SPCPS label-only change negative test with real Vault - DescribeTable("should NOT add STAKATER_ env var when only SecretProviderClassPodStatus labels change", - func(workloadType utils.WorkloadType) { - if !utils.IsCSIDriverInstalled(ctx, csiClient) { - Skip("CSI secrets store driver not installed") - } - if !utils.IsVaultProviderInstalled(ctx, kubeClient) { - Skip("Vault CSI provider not installed") - } - - adapter := registry.Get(workloadType) - if adapter == nil { - Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) - } - - if !adapter.SupportsEnvVarStrategy() { - Skip("Workload type does not support env var strategy") - } - - By("Creating a secret in Vault") - err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "initial-value"}) - Expect(err).NotTo(HaveOccurred()) - - By("Creating a SecretProviderClass pointing to Vault secret") - _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, - vaultSecretPath, "api_key") - Expect(err).NotTo(HaveOccurred()) - - By("Creating workload with CSI volume and SPC reload annotation") - err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ - SPCName: spcName, - UseCSIVolume: true, - Annotations: utils.BuildSecretProviderClassReloadAnnotation(spcName), - }) - Expect(err).NotTo(HaveOccurred()) - DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) - - By("Waiting for workload to be ready") - err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Finding the SPCPS created by CSI driver") - spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, workloadName, - utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Updating only the SPCPS labels (should NOT trigger reload)") - err = utils.UpdateSecretProviderClassPodStatusLabels(ctx, csiClient, testNamespace, spcpsName, map[string]string{"new-label": "new-value"}) - Expect(err).NotTo(HaveOccurred()) - - By("Verifying workload does NOT have STAKATER_ env var") - time.Sleep(utils.NegativeTestWait) - found, err := adapter.WaitEnvVar(ctx, testNamespace, workloadName, utils.StakaterEnvVarPrefix, - utils.ShortTimeout) - Expect(err).NotTo(HaveOccurred()) - Expect(found).To(BeFalse(), "%s should NOT have STAKATER_ env var for SPCPS label-only change", - workloadType) - }, Entry("Deployment", Label("csi"), utils.WorkloadDeployment), - Entry("DaemonSet", Label("csi"), utils.WorkloadDaemonSet), - Entry("StatefulSet", Label("csi"), utils.WorkloadStatefulSet), - Entry("ArgoRollout", Label("csi", "argo"), utils.WorkloadArgoRollout), - Entry("DeploymentConfig", Label("csi", "openshift"), utils.WorkloadDeploymentConfig), - ) - - // CSI auto annotation with EnvVar strategy and real Vault - It("should add STAKATER_ env var with secretproviderclass auto annotation", Label("csi"), func() { - if !utils.IsCSIDriverInstalled(ctx, csiClient) { - Skip("CSI secrets store driver not installed") - } - if !utils.IsVaultProviderInstalled(ctx, kubeClient) { - Skip("Vault CSI provider not installed") - } - - adapter := registry.Get(utils.WorkloadDeployment) - Expect(adapter).NotTo(BeNil()) - - By("Creating a secret in Vault") - err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "initial-value-v1"}) - Expect(err).NotTo(HaveOccurred()) - - By("Creating a SecretProviderClass pointing to Vault secret") - _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, vaultSecretPath, - "api_key") - Expect(err).NotTo(HaveOccurred()) - - By("Creating Deployment with CSI volume and SPC auto annotation") - err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ - SPCName: spcName, - UseCSIVolume: true, - Annotations: utils.BuildSecretProviderClassAutoAnnotation(), - }) - Expect(err).NotTo(HaveOccurred()) - DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) - - By("Waiting for Deployment to be ready") - err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Finding the SPCPS created by CSI driver") - spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, workloadName, - utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Getting initial SPCPS version") - initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) - Expect(err).NotTo(HaveOccurred()) - - By("Updating the Vault secret") - err = utils.UpdateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "updated-value-v2"}) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for CSI driver to sync the new secret version") - err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, - 10*time.Second) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for Deployment to have STAKATER_ env var") - found, err := adapter.WaitEnvVar(ctx, testNamespace, workloadName, utils.StakaterEnvVarPrefix, - utils.ReloadTimeout) - Expect(err).NotTo(HaveOccurred()) - Expect(found).To(BeTrue(), "Deployment with SPC auto annotation should have STAKATER_ env var") - }) - - // CSI exclude annotation with EnvVar strategy and real Vault - It("should NOT add STAKATER_ env var when excluded SecretProviderClassPodStatus changes", Label("csi"), func() { - if !utils.IsCSIDriverInstalled(ctx, csiClient) { - Skip("CSI secrets store driver not installed") - } - if !utils.IsVaultProviderInstalled(ctx, kubeClient) { - Skip("Vault CSI provider not installed") - } - - adapter := registry.Get(utils.WorkloadDeployment) - Expect(adapter).NotTo(BeNil()) - - By("Creating a secret in Vault") - err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "initial-value-v1"}) - Expect(err).NotTo(HaveOccurred()) - - By("Creating a SecretProviderClass pointing to Vault secret") - _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, vaultSecretPath, - "api_key") - Expect(err).NotTo(HaveOccurred()) - - By("Creating Deployment with auto=true and SPC exclude annotation") - err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ - SPCName: spcName, - UseCSIVolume: true, - Annotations: utils.MergeAnnotations(utils.BuildAutoTrueAnnotation(), - utils.BuildSecretProviderClassExcludeAnnotation(spcName)), - }) - Expect(err).NotTo(HaveOccurred()) - DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) - - By("Waiting for Deployment to be ready") - err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Finding the SPCPS created by CSI driver") - spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, workloadName, - utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Getting initial SPCPS version") - initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) - Expect(err).NotTo(HaveOccurred()) - - By("Updating the Vault secret (excluded SPC - should NOT trigger reload)") - err = utils.UpdateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "updated-value-v2"}) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for CSI driver to sync the new secret version") - err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, - 10*time.Second) - Expect(err).NotTo(HaveOccurred()) - - By("Verifying Deployment does NOT have STAKATER_ env var") - time.Sleep(utils.NegativeTestWait) - found, err := adapter.WaitEnvVar(ctx, testNamespace, workloadName, utils.StakaterEnvVarPrefix, - utils.ShortTimeout) - Expect(err).NotTo(HaveOccurred()) - Expect(found).To(BeFalse(), "Deployment should NOT have STAKATER_ env var for excluded SPCPS change") - }) - - // CSI init container with EnvVar strategy and real Vault - It("should add STAKATER_ env var when SecretProviderClassPodStatus used by init container changes", Label("csi"), func() { - if !utils.IsCSIDriverInstalled(ctx, csiClient) { - Skip("CSI secrets store driver not installed") - } - if !utils.IsVaultProviderInstalled(ctx, kubeClient) { - Skip("Vault CSI provider not installed") - } - - By("Creating a secret in Vault") - err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "initial-value-v1"}) - Expect(err).NotTo(HaveOccurred()) - - By("Creating a SecretProviderClass pointing to Vault secret") - _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, - vaultSecretPath, "api_key") - Expect(err).NotTo(HaveOccurred()) - - By("Creating Deployment with init container using CSI volume") - _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, workloadName, - utils.WithInitContainerCSIVolume(spcName), - utils.WithAnnotations(utils.BuildSecretProviderClassReloadAnnotation(spcName))) - Expect(err).NotTo(HaveOccurred()) - DeferCleanup(func() { _ = utils.DeleteDeployment(ctx, kubeClient, testNamespace, workloadName) }) - - adapter := utils.NewDeploymentAdapter(kubeClient) - - By("Waiting for Deployment to be ready") - err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Finding the SPCPS created by CSI driver") - spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, workloadName, - utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Getting initial SPCPS version") - initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) - Expect(err).NotTo(HaveOccurred()) - - By("Updating the Vault secret") - err = utils.UpdateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "updated-value-v2"}) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for CSI driver to sync the new secret version") - err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, - 10*time.Second) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for Deployment to have STAKATER_ env var") - found, err := adapter.WaitEnvVar(ctx, testNamespace, workloadName, - utils.StakaterEnvVarPrefix, utils.ReloadTimeout) - Expect(err).NotTo(HaveOccurred()) - Expect(found).To(BeTrue(), "Deployment with init container CSI should have STAKATER_ env var") - }) }) }) diff --git a/test/e2e/csi/csi_suite_test.go b/test/e2e/csi/csi_suite_test.go deleted file mode 100644 index f2e809cf7..000000000 --- a/test/e2e/csi/csi_suite_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package csi - -import ( - "context" - "encoding/json" - "testing" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/rest" - csiclient "sigs.k8s.io/secrets-store-csi-driver/pkg/client/clientset/versioned" - - "github.com/stakater/Reloader/test/e2e/utils" -) - -var ( - kubeClient kubernetes.Interface - csiClient csiclient.Interface - restConfig *rest.Config - testNamespace string - ctx context.Context - testEnv *utils.TestEnvironment -) - -func TestCSI(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "CSI SecretProviderClass E2E Suite") -} - -// SynchronizedBeforeSuite ensures only process 1 deploys Reloader. -// Process 1 also checks prerequisites (CSI driver, Vault) and calls Skip if -// they are not installed — Ginkgo propagates the skip to all processes. -var _ = SynchronizedBeforeSuite( - // Process 1 only: check prerequisites, create namespace, deploy Reloader. - func() []byte { - setupEnv, err := utils.SetupTestEnvironment(context.Background(), "reloader-csi-test") - Expect(err).NotTo(HaveOccurred(), "Failed to setup test environment") - // Ensure the namespace is deleted even if DeployAndWait fails, so - // orphaned namespaces don't accumulate on long-lived clusters. - DeferCleanup(setupEnv.CleanupOnFailure) - - if !utils.IsCSIDriverInstalled(context.Background(), setupEnv.CSIClient) { - Skip("CSI secrets store driver not installed - skipping CSI suite") - } - if !utils.IsVaultProviderInstalled(context.Background(), setupEnv.KubeClient) { - Skip("Vault CSI provider not installed - skipping CSI suite") - } - - Expect(setupEnv.DeployAndWait(map[string]string{ - "reloader.reloadStrategy": "annotations", - "reloader.watchGlobally": "false", - "reloader.enableCSIIntegration": "true", - })).To(Succeed(), "Failed to deploy Reloader") - - data, err := json.Marshal(utils.SharedEnvData{ - Namespace: setupEnv.Namespace, - ReleaseName: setupEnv.ReleaseName, - }) - Expect(err).NotTo(HaveOccurred()) - return data - }, - // All processes (including #1): connect to the shared environment. - func(data []byte) { - var shared utils.SharedEnvData - Expect(json.Unmarshal(data, &shared)).To(Succeed()) - - var err error - testEnv, err = utils.SetupSharedTestEnvironment(context.Background(), shared.Namespace, shared.ReleaseName) - Expect(err).NotTo(HaveOccurred(), "Failed to setup shared test environment") - - kubeClient = testEnv.KubeClient - csiClient = testEnv.CSIClient - restConfig = testEnv.RestConfig - testNamespace = testEnv.Namespace - ctx = testEnv.Ctx - }, -) - -var _ = SynchronizedAfterSuite( - // All processes: cancel the per-process context. - func() { - if testEnv != nil { - testEnv.Cancel() - } - }, - // Process 1 only (runs last): undeploy Reloader and delete namespace. - func() { - if testEnv != nil { - err := testEnv.Cleanup() - Expect(err).NotTo(HaveOccurred(), "Failed to cleanup test environment") - } - GinkgoWriter.Println("CSI E2E Suite cleanup complete") - }, -) diff --git a/test/e2e/csi/csi_test.go b/test/e2e/csi/csi_test.go deleted file mode 100644 index ef55f2bdf..000000000 --- a/test/e2e/csi/csi_test.go +++ /dev/null @@ -1,330 +0,0 @@ -package csi - -import ( - "fmt" - "time" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - "github.com/stakater/Reloader/test/e2e/utils" -) - -var _ = Describe("CSI SecretProviderClass Tests", Label("csi"), Serial, func() { - var ( - deploymentName string - configMapName string - spcName string - vaultSecretPath string - adapter *utils.DeploymentAdapter - ) - - BeforeEach(func() { - deploymentName = utils.RandName("deploy") - configMapName = utils.RandName("cm") - spcName = utils.RandName("spc") - vaultSecretPath = fmt.Sprintf("secret/%s", utils.RandName("test")) - adapter = utils.NewDeploymentAdapter(kubeClient) - }) - - AfterEach(func() { - _ = utils.DeleteDeployment(ctx, kubeClient, testNamespace, deploymentName) - _ = utils.DeleteConfigMap(ctx, kubeClient, testNamespace, configMapName) - _ = utils.DeleteSecretProviderClass(ctx, csiClient, testNamespace, spcName) - _ = utils.DeleteVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath) - }) - - Context("Real Vault Integration Tests", func() { - It("should reload when Vault secret changes", func() { - By("Creating a secret in Vault") - err := utils.CreateVaultSecret( - ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "initial-value-v1"}) - Expect(err).NotTo(HaveOccurred()) - - By("Creating a SecretProviderClass pointing to Vault secret") - _, err = utils.CreateSecretProviderClassWithSecret( - ctx, csiClient, testNamespace, spcName, - vaultSecretPath, "api_key", - ) - Expect(err).NotTo(HaveOccurred()) - - By("Creating Deployment with CSI volume and SPC reload annotation") - _, err = utils.CreateDeployment( - ctx, kubeClient, testNamespace, deploymentName, - utils.WithCSIVolume(spcName), - utils.WithAnnotations(utils.BuildSecretProviderClassReloadAnnotation(spcName)), - ) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for Deployment to be ready") - err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Finding the SPCPS created by CSI driver") - spcpsName, err := utils.FindSPCPSForDeployment( - ctx, csiClient, kubeClient, testNamespace, deploymentName, utils.WorkloadReadyTimeout, - ) - Expect(err).NotTo(HaveOccurred()) - GinkgoWriter.Printf("Found SPCPS: %s\n", spcpsName) - - By("Getting initial SPCPS version") - initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) - Expect(err).NotTo(HaveOccurred()) - GinkgoWriter.Printf("Initial SPCPS version: %s\n", initialVersion) - - By("Updating the Vault secret") - err = utils.UpdateVaultSecret( - ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "updated-value-v2"}) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for CSI driver to sync the new secret version") - err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, 10*time.Second) - Expect(err).NotTo(HaveOccurred()) - GinkgoWriter.Println("CSI driver synced new secret version") - - By("Waiting for Deployment to be reloaded by Reloader") - reloaded, err := adapter.WaitReloaded( - ctx, testNamespace, deploymentName, - utils.AnnotationLastReloadedFrom, utils.ReloadTimeout, - ) - Expect(err).NotTo(HaveOccurred()) - Expect(reloaded).To(BeTrue(), "Deployment should have been reloaded after Vault secret change") - }) - - It("should handle multiple Vault secret updates", func() { - By("Creating a secret in Vault") - err := utils.CreateVaultSecret( - ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"password": "pass-v1"}) - Expect(err).NotTo(HaveOccurred()) - - By("Creating a SecretProviderClass pointing to Vault secret") - _, err = utils.CreateSecretProviderClassWithSecret( - ctx, csiClient, testNamespace, spcName, - vaultSecretPath, "password", - ) - Expect(err).NotTo(HaveOccurred()) - - By("Creating Deployment with CSI volume") - _, err = utils.CreateDeployment( - ctx, kubeClient, testNamespace, deploymentName, - utils.WithCSIVolume(spcName), - utils.WithAnnotations(utils.BuildSecretProviderClassReloadAnnotation(spcName)), - ) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for Deployment to be ready") - err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Finding the SPCPS") - spcpsName, err := utils.FindSPCPSForDeployment( - ctx, csiClient, kubeClient, testNamespace, deploymentName, utils.WorkloadReadyTimeout, - ) - Expect(err).NotTo(HaveOccurred()) - - By("First update to Vault secret") - initialVersion, _ := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) - err = utils.UpdateVaultSecret( - ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"password": "pass-v2"}) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for first CSI sync") - err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, 10*time.Second) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for first reload") - reloaded, err := adapter.WaitReloaded( - ctx, testNamespace, deploymentName, - utils.AnnotationLastReloadedFrom, utils.ReloadTimeout, - ) - Expect(err).NotTo(HaveOccurred()) - Expect(reloaded).To(BeTrue()) - - By("Getting annotation value after first reload") - deploy, err := utils.GetDeployment(ctx, kubeClient, testNamespace, deploymentName) - Expect(err).NotTo(HaveOccurred()) - firstReloadValue := deploy.Spec.Template.Annotations[utils.AnnotationLastReloadedFrom] - Expect(firstReloadValue).NotTo(BeEmpty()) - - By("Waiting for Deployment to stabilize") - err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Finding the NEW SPCPS after first reload (new pod = new SPCPS)") - newSpcpsName, err := utils.FindSPCPSForDeployment( - ctx, csiClient, kubeClient, testNamespace, deploymentName, utils.WorkloadReadyTimeout, - ) - Expect(err).NotTo(HaveOccurred()) - GinkgoWriter.Printf("New SPCPS after first reload: %s\n", newSpcpsName) - - By("Second update to Vault secret") - err = utils.UpdateVaultSecret( - ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"password": "pass-v3"}) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for second reload with different annotation value") - Eventually(func() string { - deploy, err := utils.GetDeployment(ctx, kubeClient, testNamespace, deploymentName) - if err != nil { - return "" - } - return deploy.Spec.Template.Annotations[utils.AnnotationLastReloadedFrom] - }, utils.ReloadTimeout).ShouldNot(Equal(firstReloadValue), "Annotation should change after second Vault secret update") - }) - }) - - Context("Typed Auto Annotation Tests", func() { - It("should reload only SPC changes with secretproviderclass auto annotation, not ConfigMap", func() { - By("Creating a ConfigMap") - _, err := utils.CreateConfigMap( - ctx, kubeClient, testNamespace, configMapName, - map[string]string{"key": "initial"}, nil, - ) - Expect(err).NotTo(HaveOccurred()) - - By("Creating a secret in Vault") - err = utils.CreateVaultSecret( - ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"token": "token-v1"}) - Expect(err).NotTo(HaveOccurred()) - - By("Creating a SecretProviderClass pointing to Vault secret") - _, err = utils.CreateSecretProviderClassWithSecret( - ctx, csiClient, testNamespace, spcName, - vaultSecretPath, "token", - ) - Expect(err).NotTo(HaveOccurred()) - - By("Creating Deployment with ConfigMap envFrom AND CSI volume, but only SPC auto annotation") - _, err = utils.CreateDeployment( - ctx, kubeClient, testNamespace, deploymentName, - utils.WithConfigMapEnvFrom(configMapName), - utils.WithCSIVolume(spcName), - utils.WithAnnotations(utils.BuildSecretProviderClassAutoAnnotation()), - ) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for Deployment to be ready") - err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Updating the ConfigMap (should NOT trigger reload)") - err = utils.UpdateConfigMap( - ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) - Expect(err).NotTo(HaveOccurred()) - - By("Verifying Deployment was NOT reloaded for ConfigMap change") - time.Sleep(utils.NegativeTestWait) - reloaded, err := adapter.WaitReloaded( - ctx, testNamespace, deploymentName, - utils.AnnotationLastReloadedFrom, utils.ShortTimeout, - ) - Expect(err).NotTo(HaveOccurred()) - Expect(reloaded).To(BeFalse(), "SPC auto annotation should not trigger reload for ConfigMap changes") - - By("Finding the SPCPS") - spcpsName, err := utils.FindSPCPSForDeployment( - ctx, csiClient, kubeClient, testNamespace, deploymentName, utils.WorkloadReadyTimeout, - ) - Expect(err).NotTo(HaveOccurred()) - - By("Getting SPCPS version before Vault update") - initialVersion, _ := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) - - By("Updating the Vault secret (should trigger reload)") - err = utils.UpdateVaultSecret( - ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"token": "token-v2"}) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for CSI driver to sync") - err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, 10*time.Second) - Expect(err).NotTo(HaveOccurred()) - - By("Verifying Deployment WAS reloaded for Vault secret change") - reloaded, err = adapter.WaitReloaded( - ctx, testNamespace, deploymentName, - utils.AnnotationLastReloadedFrom, utils.ReloadTimeout, - ) - Expect(err).NotTo(HaveOccurred()) - Expect(reloaded).To(BeTrue(), "SPC auto annotation should trigger reload for Vault secret changes") - }) - - It("should reload for both ConfigMap and SPC when using combined auto=true", func() { - By("Creating a ConfigMap") - _, err := utils.CreateConfigMap( - ctx, kubeClient, testNamespace, configMapName, - map[string]string{"key": "initial"}, nil, - ) - Expect(err).NotTo(HaveOccurred()) - - By("Creating a secret in Vault") - err = utils.CreateVaultSecret( - ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"secret": "secret-v1"}) - Expect(err).NotTo(HaveOccurred()) - - By("Creating a SecretProviderClass pointing to Vault secret") - _, err = utils.CreateSecretProviderClassWithSecret( - ctx, csiClient, testNamespace, spcName, - vaultSecretPath, "secret", - ) - Expect(err).NotTo(HaveOccurred()) - - By("Creating Deployment with ConfigMap envFrom AND CSI volume with combined auto=true") - _, err = utils.CreateDeployment( - ctx, kubeClient, testNamespace, deploymentName, - utils.WithConfigMapEnvFrom(configMapName), - utils.WithCSIVolume(spcName), - utils.WithAnnotations(utils.BuildAutoTrueAnnotation()), - ) - Expect(err).NotTo(HaveOccurred()) - - By("Waiting for Deployment to be ready") - err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Updating the ConfigMap (should trigger reload with auto=true)") - err = utils.UpdateConfigMap( - ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) - Expect(err).NotTo(HaveOccurred()) - - By("Verifying Deployment WAS reloaded for ConfigMap change") - reloaded, err := adapter.WaitReloaded( - ctx, testNamespace, deploymentName, - utils.AnnotationLastReloadedFrom, utils.ReloadTimeout, - ) - Expect(err).NotTo(HaveOccurred()) - Expect(reloaded).To(BeTrue(), "Combined auto=true should trigger reload for ConfigMap changes") - - By("Waiting for Deployment to stabilize") - err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) - Expect(err).NotTo(HaveOccurred()) - - By("Getting current annotation value") - deploy, err := utils.GetDeployment(ctx, kubeClient, testNamespace, deploymentName) - Expect(err).NotTo(HaveOccurred()) - firstReloadValue := deploy.Spec.Template.Annotations[utils.AnnotationLastReloadedFrom] - - By("Finding the NEW SPCPS after ConfigMap reload (new pod = new SPCPS)") - newSpcpsName, err := utils.FindSPCPSForDeployment( - ctx, csiClient, kubeClient, testNamespace, deploymentName, utils.WorkloadReadyTimeout, - ) - Expect(err).NotTo(HaveOccurred()) - GinkgoWriter.Printf("New SPCPS after ConfigMap reload: %s\n", newSpcpsName) - - By("Updating the Vault secret (should also trigger reload with auto=true)") - err = utils.UpdateVaultSecret( - ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"secret": "secret-v2"}) - Expect(err).NotTo(HaveOccurred()) - - By("Verifying Deployment WAS reloaded for Vault secret change") - Eventually(func() string { - deploy, err := utils.GetDeployment(ctx, kubeClient, testNamespace, deploymentName) - if err != nil { - return "" - } - return deploy.Spec.Template.Annotations[utils.AnnotationLastReloadedFrom] - }, utils.ReloadTimeout).ShouldNot(Equal(firstReloadValue), - "Combined auto=true should trigger reload for Vault secret changes", - ) - }) - }) -}) diff --git a/test/e2e/utils/accessors.go b/test/e2e/utils/accessors.go index 445f86a9b..9852aff77 100644 --- a/test/e2e/utils/accessors.go +++ b/test/e2e/utils/accessors.go @@ -1,13 +1,10 @@ package utils import ( - "strings" - appsv1 "k8s.io/api/apps/v1" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" - csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1" rolloutsv1alpha1 "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1" openshiftappsv1 "github.com/openshift/api/apps/v1" @@ -150,27 +147,3 @@ var ( return d.Status.ReadyReplicas == d.Spec.Replicas } ) - -// SecretProviderClassPodStatus accessors -var ( - SPCPSIsMounted StatusAccessor[*csiv1.SecretProviderClassPodStatus] = func(s *csiv1.SecretProviderClassPodStatus) bool { - return s.Status.Mounted - } - SPCPSClassName ValueAccessor[*csiv1.SecretProviderClassPodStatus, string] = func(s *csiv1.SecretProviderClassPodStatus) string { - return s.Status.SecretProviderClassName - } - SPCPSPodName ValueAccessor[*csiv1.SecretProviderClassPodStatus, string] = func(s *csiv1.SecretProviderClassPodStatus) string { - return s.Status.PodName - } - // SPCPSVersions returns concatenated versions of all objects for change detection. - SPCPSVersions ValueAccessor[*csiv1.SecretProviderClassPodStatus, string] = func(s *csiv1.SecretProviderClassPodStatus) string { - if len(s.Status.Objects) == 0 { - return "" - } - var versions []string - for _, obj := range s.Status.Objects { - versions = append(versions, obj.Version) - } - return strings.Join(versions, ",") - } -) diff --git a/test/e2e/utils/annotations.go b/test/e2e/utils/annotations.go index 60c0132b3..1be041574 100644 --- a/test/e2e/utils/annotations.go +++ b/test/e2e/utils/annotations.go @@ -20,11 +20,6 @@ const ( // Value: comma-separated list of Secret names, e.g., "secret1,secret2" AnnotationSecretReload = "secret.reloader.stakater.com/reload" - // AnnotationSecretProviderClassReload triggers reload when specified SecretProviderClass(es) change. - // Value: comma-separated list of SecretProviderClass names, e.g., "spc1,spc2" - // Note: Reloader actually watches SecretProviderClassPodStatus resources, not SecretProviderClass. - AnnotationSecretProviderClassReload = "secretproviderclass.reloader.stakater.com/reload" - // ============================================================ // Auto-reload annotations // ============================================================ @@ -41,10 +36,6 @@ const ( // Value: "true" or "false" AnnotationSecretAuto = "secret.reloader.stakater.com/auto" - // AnnotationSecretProviderClassAuto enables auto-reload for all referenced SecretProviderClasses only. - // Value: "true" or "false" - AnnotationSecretProviderClassAuto = "secretproviderclass.reloader.stakater.com/auto" - // ============================================================ // Exclude annotations (used with auto=true to exclude specific resources) // ============================================================ @@ -57,10 +48,6 @@ const ( // Value: comma-separated list of Secret names AnnotationSecretExclude = "secrets.exclude.reloader.stakater.com/reload" - // AnnotationSecretProviderClassExclude excludes specified SecretProviderClasses from auto-reload. - // Value: comma-separated list of SecretProviderClass names - AnnotationSecretProviderClassExclude = "secretproviderclasses.exclude.reloader.stakater.com/reload" - // ============================================================ // Search annotations (for regex matching) // ============================================================ @@ -130,13 +117,6 @@ func BuildSecretReloadAnnotation(secretNames ...string) map[string]string { } } -// BuildSecretProviderClassReloadAnnotation creates an annotation map for SecretProviderClass reload. -func BuildSecretProviderClassReloadAnnotation(spcNames ...string) map[string]string { - return map[string]string{ - AnnotationSecretProviderClassReload: joinNames(spcNames), - } -} - // BuildAutoTrueAnnotation creates an annotation map with auto=true. func BuildAutoTrueAnnotation() map[string]string { return map[string]string{ @@ -165,13 +145,6 @@ func BuildSecretAutoAnnotation() map[string]string { } } -// BuildSecretProviderClassAutoAnnotation creates an annotation map with secretproviderclass auto=true. -func BuildSecretProviderClassAutoAnnotation() map[string]string { - return map[string]string{ - AnnotationSecretProviderClassAuto: AnnotationValueTrue, - } -} - // BuildSearchAnnotation creates an annotation map to enable search mode. func BuildSearchAnnotation() map[string]string { return map[string]string{ @@ -214,13 +187,6 @@ func BuildSecretExcludeAnnotation(secretNames ...string) map[string]string { } } -// BuildSecretProviderClassExcludeAnnotation creates an annotation to exclude SecretProviderClasses from auto-reload. -func BuildSecretProviderClassExcludeAnnotation(spcNames ...string) map[string]string { - return map[string]string{ - AnnotationSecretProviderClassExclude: joinNames(spcNames), - } -} - // BuildPausePeriodAnnotation creates an annotation for deployment pause period. func BuildPausePeriodAnnotation(duration string) map[string]string { return map[string]string{ diff --git a/test/e2e/utils/conditions.go b/test/e2e/utils/conditions.go index 5736b0228..7afd146fc 100644 --- a/test/e2e/utils/conditions.go +++ b/test/e2e/utils/conditions.go @@ -6,7 +6,6 @@ import ( batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" - csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1" ) // PodTemplateAccessor extracts PodTemplateSpec from a workload. @@ -216,43 +215,3 @@ func IsTriggeredJobForCronJob(cronJobName string) Condition[*batchv1.Job] { return false } } - -// SPCPSVersionChanged returns a condition that checks if the SPCPS version has changed -// from the initial version and the SPCPS is mounted. -func SPCPSVersionChanged(initialVersion string) Condition[*csiv1.SecretProviderClassPodStatus] { - return func(spcps *csiv1.SecretProviderClassPodStatus) bool { - if !spcps.Status.Mounted || len(spcps.Status.Objects) == 0 { - return false - } - for _, obj := range spcps.Status.Objects { - if obj.Version != initialVersion { - return true - } - } - return false - } -} - -// SPCPSForSPC returns a condition that checks if the SPCPS references a specific -// SecretProviderClass and is mounted. -func SPCPSForSPC(spcName string) Condition[*csiv1.SecretProviderClassPodStatus] { - return func(spcps *csiv1.SecretProviderClassPodStatus) bool { - return spcps.Status.SecretProviderClassName == spcName && spcps.Status.Mounted - } -} - -// SPCPSForPod returns a condition that checks if the SPCPS references a specific -// pod and is mounted. -func SPCPSForPod(podName string) Condition[*csiv1.SecretProviderClassPodStatus] { - return func(spcps *csiv1.SecretProviderClassPodStatus) bool { - return spcps.Status.PodName == podName && spcps.Status.Mounted - } -} - -// SPCPSForPods returns a condition that checks if the SPCPS references any of the -// specified pods and is mounted. -func SPCPSForPods(podNames map[string]bool) Condition[*csiv1.SecretProviderClassPodStatus] { - return func(spcps *csiv1.SecretProviderClassPodStatus) bool { - return podNames[spcps.Status.PodName] && spcps.Status.Mounted - } -} diff --git a/test/e2e/utils/csi.go b/test/e2e/utils/csi.go deleted file mode 100644 index 3a34ff2a4..000000000 --- a/test/e2e/utils/csi.go +++ /dev/null @@ -1,338 +0,0 @@ -package utils - -import ( - "bytes" - "context" - "errors" - "fmt" - "strings" - "time" - - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/watch" - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/kubernetes/scheme" - "k8s.io/client-go/rest" - "k8s.io/client-go/tools/clientcmd" - "k8s.io/client-go/tools/remotecommand" - csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1" - csiclient "sigs.k8s.io/secrets-store-csi-driver/pkg/client/clientset/versioned" -) - -// CSI Driver constants -const ( - // CSIDriverName is the name of the secrets-store CSI driver - CSIDriverName = "secrets-store.csi.k8s.io" - - // DefaultCSIProvider is the default provider name for testing (Vault) - DefaultCSIProvider = "vault" - - // VaultAddress is the default Vault address in the cluster - VaultAddress = "http://vault.vault:8200" - - // VaultRole is the Kubernetes auth role configured in Vault for testing - VaultRole = "test-role" - - // VaultNamespace is the namespace where Vault is deployed - VaultNamespace = "vault" - - // VaultPodName is the name of the Vault pod (dev mode) - VaultPodName = "vault-0" - - // CSIVolumeName is the default volume name for CSI volumes in tests - CSIVolumeName = "csi-secrets-store" - - // CSIMountPath is the default mount path for CSI volumes in tests - CSIMountPath = "/mnt/secrets-store" - - // CSIRotationPollInterval is how often CSI driver checks for secret changes - CSIRotationPollInterval = 2 * time.Second -) - -// NewCSIClient creates a new CSI client using the default kubeconfig. -func NewCSIClient() (csiclient.Interface, error) { - kubeconfig := GetKubeconfig() - config, err := clientcmd.BuildConfigFromFlags("", kubeconfig) - if err != nil { - return nil, fmt.Errorf("building config from kubeconfig: %w", err) - } - return NewCSIClientFromConfig(config) -} - -// NewCSIClientFromConfig creates a new CSI client from a rest.Config. -func NewCSIClientFromConfig(config *rest.Config) (csiclient.Interface, error) { - client, err := csiclient.NewForConfig(config) - if err != nil { - return nil, fmt.Errorf("creating CSI client: %w", err) - } - return client, nil -} - -// IsCSIDriverInstalled checks if the CSI secrets store driver CRDs are available in the cluster. -// This checks for the SecretProviderClass CRD which is required for CSI tests. -func IsCSIDriverInstalled(ctx context.Context, client csiclient.Interface) bool { - if client == nil { - return false - } - - // Try to list SecretProviderClasses - if CRD doesn't exist, this will fail - _, err := client.SecretsstoreV1().SecretProviderClasses("default").List(ctx, metav1.ListOptions{Limit: 1}) - return err == nil -} - -// IsVaultProviderInstalled checks if Vault CSI provider is installed by checking for the vault-csi-provider DaemonSet. -// This is used to determine if CSI tests with actual volume mounting can run. -func IsVaultProviderInstalled(ctx context.Context, kubeClient kubernetes.Interface) bool { - if kubeClient == nil { - return false - } - - // Check if vault-csi-provider DaemonSet exists in vault namespace - _, err := kubeClient.AppsV1().DaemonSets("vault").Get(ctx, "vault-csi-provider", metav1.GetOptions{}) - return err == nil -} - -// CreateSecretProviderClass creates a SecretProviderClass in the given namespace. -// If params is nil, it creates a Vault-compatible SecretProviderClass with default test settings. -func CreateSecretProviderClass(ctx context.Context, client csiclient.Interface, namespace, name string, params map[string]string) ( - *csiv1.SecretProviderClass, error, -) { - if params == nil { - params = map[string]string{ - "vaultAddress": VaultAddress, - "roleName": VaultRole, - "objects": `- objectName: "test-secret" - secretPath: "secret/data/test" - secretKey: "username"`, - } - } - - spc := &csiv1.SecretProviderClass{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: namespace, - }, - Spec: csiv1.SecretProviderClassSpec{ - Provider: DefaultCSIProvider, - Parameters: params, - }, - } - - created, err := client.SecretsstoreV1().SecretProviderClasses(namespace).Create(ctx, spc, metav1.CreateOptions{}) - if err != nil { - return nil, fmt.Errorf("creating SecretProviderClass %s/%s: %w", namespace, name, err) - } - return created, nil -} - -// CreateSecretProviderClassWithSecret creates a SecretProviderClass that fetches a specific secret from Vault. -// secretPath should be like "secret/mysecret" (the function converts it to KV v2 format "secret/data/mysecret"). -// secretKey is the key within that secret to fetch. -func CreateSecretProviderClassWithSecret(ctx context.Context, client csiclient.Interface, namespace, name, secretPath, secretKey string) ( - *csiv1.SecretProviderClass, error, -) { - kvV2Path := secretPath - if strings.HasPrefix(secretPath, "secret/") && !strings.HasPrefix(secretPath, "secret/data/") { - kvV2Path = strings.Replace(secretPath, "secret/", "secret/data/", 1) - } - - params := map[string]string{ - "vaultAddress": VaultAddress, - "roleName": VaultRole, - "objects": fmt.Sprintf( - `- objectName: "%s" - secretPath: "%s" - secretKey: "%s"`, secretKey, kvV2Path, secretKey, - ), - } - return CreateSecretProviderClass(ctx, client, namespace, name, params) -} - -// DeleteSecretProviderClass deletes a SecretProviderClass by name. -func DeleteSecretProviderClass(ctx context.Context, client csiclient.Interface, namespace, name string) error { - err := client.SecretsstoreV1().SecretProviderClasses(namespace).Delete(ctx, name, metav1.DeleteOptions{}) - if err != nil { - return fmt.Errorf("deleting SecretProviderClass %s/%s: %w", namespace, name, err) - } - return nil -} - -// UpdateSecretProviderClassPodStatusLabels updates only the labels on a SecretProviderClassPodStatus. -// This should NOT trigger a reload (used for negative testing to verify Reloader ignores label-only changes). -func UpdateSecretProviderClassPodStatusLabels(ctx context.Context, client csiclient.Interface, namespace, name string, labels map[string]string) error { - spcps, err := client.SecretsstoreV1().SecretProviderClassPodStatuses(namespace).Get(ctx, name, metav1.GetOptions{}) - if err != nil { - return fmt.Errorf("getting SecretProviderClassPodStatus %s/%s: %w", namespace, name, err) - } - - if spcps.Labels == nil { - spcps.Labels = make(map[string]string) - } - for k, v := range labels { - spcps.Labels[k] = v - } - - _, err = client.SecretsstoreV1().SecretProviderClassPodStatuses(namespace).Update(ctx, spcps, metav1.UpdateOptions{}) - if err != nil { - return fmt.Errorf("updating SecretProviderClassPodStatus labels %s/%s: %w", namespace, name, err) - } - return nil -} - -// ============================================================================= -// Vault Integration Helpers -// ============================================================================= - -// CreateVaultSecret creates a new secret in Vault. -// secretPath should be like "secret/test" (without "data" prefix - it's added automatically). -// data is a map of key-value pairs to store in the secret. -func CreateVaultSecret(ctx context.Context, kubeClient kubernetes.Interface, restConfig *rest.Config, secretPath string, data map[string]string) error { - return UpdateVaultSecret(ctx, kubeClient, restConfig, secretPath, data) -} - -// UpdateVaultSecret updates a secret in Vault. This triggers the CSI driver to -// sync the new secret version, which creates/updates the SecretProviderClassPodStatus. -// secretPath should be like "secret/test" (without "data" prefix - it's added automatically). -// data is a map of key-value pairs to store in the secret. -func UpdateVaultSecret(ctx context.Context, kubeClient kubernetes.Interface, restConfig *rest.Config, secretPath string, data map[string]string) error { - args := []string{"kv", "put", secretPath} - for k, v := range data { - args = append(args, fmt.Sprintf("%s=%s", k, v)) - } - - if err := execInVaultPod(ctx, kubeClient, restConfig, args); err != nil { - return fmt.Errorf("updating Vault secret %s: %w", secretPath, err) - } - return nil -} - -// DeleteVaultSecret deletes a secret from Vault. -// secretPath should be like "secret/test". -func DeleteVaultSecret(ctx context.Context, kubeClient kubernetes.Interface, restConfig *rest.Config, secretPath string) error { - args := []string{"kv", "metadata", "delete", secretPath} - if err := execInVaultPod(ctx, kubeClient, restConfig, args); err != nil { - if strings.Contains(err.Error(), "No value found") { - return nil - } - return fmt.Errorf("deleting Vault secret %s: %w", secretPath, err) - } - return nil -} - -// execInVaultPod executes a vault command in the Vault pod. -func execInVaultPod(ctx context.Context, kubeClient kubernetes.Interface, restConfig *rest.Config, args []string) error { - req := kubeClient.CoreV1().RESTClient().Post(). - Resource("pods"). - Name(VaultPodName). - Namespace(VaultNamespace). - SubResource("exec"). - VersionedParams( - &corev1.PodExecOptions{ - Container: "vault", - Command: append([]string{"vault"}, args...), - Stdout: true, - Stderr: true, - }, scheme.ParameterCodec, - ) - - exec, err := remotecommand.NewSPDYExecutor(restConfig, "POST", req.URL()) - if err != nil { - return fmt.Errorf("creating executor: %w", err) - } - - var stdout, stderr bytes.Buffer - err = exec.StreamWithContext( - ctx, remotecommand.StreamOptions{ - Stdout: &stdout, - Stderr: &stderr, - }, - ) - if err != nil { - return fmt.Errorf("executing command: %w (stderr: %s)", err, stderr.String()) - } - - return nil -} - -// WaitForSPCPSVersionChange waits for the SecretProviderClassPodStatus version to change -// from the initial version using watches. This is used after updating a Vault secret to -// wait for CSI driver to sync the new version. -func WaitForSPCPSVersionChange(ctx context.Context, client csiclient.Interface, namespace, spcpsName, initialVersion string, timeout time.Duration) error { - watchFunc := func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { - return client.SecretsstoreV1().SecretProviderClassPodStatuses(namespace).Watch(ctx, opts) - } - - _, err := WatchUntil(ctx, watchFunc, spcpsName, SPCPSVersionChanged(initialVersion), timeout) - if errors.Is(err, ErrWatchTimeout) { - return fmt.Errorf("timeout waiting for SecretProviderClassPodStatus %s/%s version to change from %s", namespace, spcpsName, initialVersion) - } - return err -} - -// FindSPCPSForDeployment finds the SecretProviderClassPodStatus created by CSI driver -// for pods of a given deployment using watches. Returns the first matching SPCPS name. -func FindSPCPSForDeployment(ctx context.Context, csiClient csiclient.Interface, kubeClient kubernetes.Interface, namespace, deploymentName string, timeout time.Duration) ( - string, error, -) { - pods, err := kubeClient.CoreV1().Pods(namespace).List( - ctx, metav1.ListOptions{ - LabelSelector: fmt.Sprintf("app=%s", deploymentName), - }, - ) - if err != nil { - return "", fmt.Errorf("listing pods for deployment %s: %w", deploymentName, err) - } - - podNames := make(map[string]bool) - for _, pod := range pods.Items { - podNames[pod.Name] = true - } - - watchFunc := func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { - return csiClient.SecretsstoreV1().SecretProviderClassPodStatuses(namespace).Watch(ctx, opts) - } - - spcps, err := WatchUntil(ctx, watchFunc, "", SPCPSForPods(podNames), timeout) - if errors.Is(err, ErrWatchTimeout) { - return "", fmt.Errorf("timeout finding SecretProviderClassPodStatus for deployment %s/%s", namespace, deploymentName) - } - if err != nil { - return "", err - } - return spcps.Name, nil -} - -// FindSPCPSForSPC finds the SecretProviderClassPodStatus created by CSI driver -// that references a specific SecretProviderClass using watches. Returns the first matching SPCPS name. -func FindSPCPSForSPC(ctx context.Context, csiClient csiclient.Interface, namespace, spcName string, timeout time.Duration) (string, error) { - watchFunc := func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { - return csiClient.SecretsstoreV1().SecretProviderClassPodStatuses(namespace).Watch(ctx, opts) - } - - spcps, err := WatchUntil(ctx, watchFunc, "", SPCPSForSPC(spcName), timeout) - if errors.Is(err, ErrWatchTimeout) { - return "", fmt.Errorf("timeout finding SecretProviderClassPodStatus for SPC %s/%s", namespace, spcName) - } - if err != nil { - return "", err - } - return spcps.Name, nil -} - -// GetSPCPSVersion gets the current version string from a SecretProviderClassPodStatus. -// Returns the version of the first object, or empty string if not found. -func GetSPCPSVersion(ctx context.Context, client csiclient.Interface, namespace, name string) (string, error) { - spcps, err := client.SecretsstoreV1().SecretProviderClassPodStatuses(namespace).Get(ctx, name, metav1.GetOptions{}) - if err != nil { - return "", fmt.Errorf("getting SecretProviderClassPodStatus %s/%s: %w", namespace, name, err) - } - if len(spcps.Status.Objects) == 0 { - return "", nil - } - var versions []string - for _, obj := range spcps.Status.Objects { - versions = append(versions, obj.Version) - } - return strings.Join(versions, ","), nil -} diff --git a/test/e2e/utils/podspec.go b/test/e2e/utils/podspec.go index 263bed9cf..91ca6f6d1 100644 --- a/test/e2e/utils/podspec.go +++ b/test/e2e/utils/podspec.go @@ -4,7 +4,6 @@ import ( "fmt" corev1 "k8s.io/api/core/v1" - "k8s.io/utils/ptr" ) // AddEnvFromSource adds ConfigMap or Secret envFrom to a container. @@ -107,69 +106,6 @@ func AddKeyRef(spec *corev1.PodSpec, containerIdx int, resourceName, key, envVar spec.Containers[containerIdx].Env = append(spec.Containers[containerIdx].Env, envVar) } -// AddCSIVolume adds CSI volume referencing SecretProviderClass. -func AddCSIVolume(spec *corev1.PodSpec, containerIdx int, spcName string) { - volumeName := "csi-" + spcName - mountPath := "/mnt/secrets-store/" + spcName - spec.Volumes = append(spec.Volumes, corev1.Volume{ - Name: volumeName, - VolumeSource: corev1.VolumeSource{ - CSI: &corev1.CSIVolumeSource{ - Driver: CSIDriverName, - ReadOnly: ptr.To(true), - VolumeAttributes: map[string]string{ - "secretProviderClass": spcName, - }, - }, - }, - }) - if containerIdx < len(spec.Containers) { - spec.Containers[containerIdx].VolumeMounts = append( - spec.Containers[containerIdx].VolumeMounts, - corev1.VolumeMount{Name: volumeName, MountPath: mountPath, ReadOnly: true}, - ) - } -} - -// AddCSIInitContainer adds an init container that mounts a CSI SecretProviderClass volume. -// The init container is named "init-csi-{spcName}" to avoid collisions when multiple CSI -// volumes are mounted. The volume is only added if not already present (idempotent). -// This is distinct from AddCSIVolume which mounts into a regular container. -func AddCSIInitContainer(spec *corev1.PodSpec, spcName string) { - volumeName := "csi-" + spcName - mountPath := "/mnt/secrets-store/" + spcName - - hasVolume := false - for _, v := range spec.Volumes { - if v.Name == volumeName { - hasVolume = true - break - } - } - if !hasVolume { - spec.Volumes = append(spec.Volumes, corev1.Volume{ - Name: volumeName, - VolumeSource: corev1.VolumeSource{ - CSI: &corev1.CSIVolumeSource{ - Driver: CSIDriverName, - ReadOnly: ptr.To(true), - VolumeAttributes: map[string]string{ - "secretProviderClass": spcName, - }, - }, - }, - }) - } - spec.InitContainers = append(spec.InitContainers, corev1.Container{ - Name: "init-csi-" + spcName, - Image: DefaultImage, - Command: []string{"sh", "-c", "echo init done"}, - VolumeMounts: []corev1.VolumeMount{ - {Name: volumeName, MountPath: mountPath, ReadOnly: true}, - }, - }) -} - // AddInitContainer adds init container with optional envFrom references. func AddInitContainer(spec *corev1.PodSpec, cmName, secretName string) { init := corev1.Container{ @@ -282,18 +218,12 @@ func ApplyWorkloadConfig(template *corev1.PodTemplateSpec, cfg WorkloadConfig) { } AddKeyRef(spec, 0, cfg.SecretName, key, envVar, true) } - if cfg.UseCSIVolume && cfg.SPCName != "" { - AddCSIVolume(spec, 0, cfg.SPCName) - } if cfg.UseInitContainer { AddInitContainer(spec, cfg.ConfigMapName, cfg.SecretName) } if cfg.UseInitContainerVolume { AddInitContainerWithVolumes(spec, cfg.ConfigMapName, cfg.SecretName) } - if cfg.UseInitContainerCSI && cfg.SPCName != "" { - AddCSIInitContainer(spec, cfg.SPCName) - } if cfg.MultipleContainers > 1 { for i := 1; i < cfg.MultipleContainers; i++ { spec.Containers = append(spec.Containers, corev1.Container{ diff --git a/test/e2e/utils/resources.go b/test/e2e/utils/resources.go index 7f0fa9463..36ae3be77 100644 --- a/test/e2e/utils/resources.go +++ b/test/e2e/utils/resources.go @@ -516,42 +516,6 @@ func WithInitContainerProjectedVolume(cmName, secretName string) DeploymentOptio } } -// WithCSIVolume adds a CSI volume referencing a SecretProviderClass to a Deployment. -func WithCSIVolume(spcName string) DeploymentOption { - return func(d *appsv1.Deployment) { - volumeName := csiVolumeName(spcName) - mountPath := csiMountPath(spcName) - - d.Spec.Template.Spec.Volumes = append(d.Spec.Template.Spec.Volumes, corev1.Volume{ - Name: volumeName, - VolumeSource: corev1.VolumeSource{ - CSI: &corev1.CSIVolumeSource{ - Driver: CSIDriverName, - ReadOnly: ptr.To(true), - VolumeAttributes: map[string]string{ - "secretProviderClass": spcName, - }, - }, - }, - }) - d.Spec.Template.Spec.Containers[0].VolumeMounts = append( - d.Spec.Template.Spec.Containers[0].VolumeMounts, - corev1.VolumeMount{ - Name: volumeName, - MountPath: mountPath, - ReadOnly: true, - }, - ) - } -} - -// WithInitContainerCSIVolume adds an init container with a CSI volume mount. -func WithInitContainerCSIVolume(spcName string) DeploymentOption { - return func(d *appsv1.Deployment) { - AddCSIInitContainer(&d.Spec.Template.Spec, spcName) - } -} - func baseDeploymentResource(namespace, name string) *appsv1.Deployment { labels := map[string]string{"app": name} return &appsv1.Deployment{ @@ -868,35 +832,6 @@ func WithJobCommand(command string) JobOption { } } -// WithJobCSIVolume adds a CSI volume referencing a SecretProviderClass to a Job. -func WithJobCSIVolume(spcName string) JobOption { - return func(j *batchv1.Job) { - volumeName := csiVolumeName(spcName) - mountPath := csiMountPath(spcName) - - j.Spec.Template.Spec.Volumes = append(j.Spec.Template.Spec.Volumes, corev1.Volume{ - Name: volumeName, - VolumeSource: corev1.VolumeSource{ - CSI: &corev1.CSIVolumeSource{ - Driver: CSIDriverName, - ReadOnly: ptr.To(true), - VolumeAttributes: map[string]string{ - "secretProviderClass": spcName, - }, - }, - }, - }) - j.Spec.Template.Spec.Containers[0].VolumeMounts = append( - j.Spec.Template.Spec.Containers[0].VolumeMounts, - corev1.VolumeMount{ - Name: volumeName, - MountPath: mountPath, - ReadOnly: true, - }, - ) - } -} - // baseJobResource creates a base Job template. func baseJobResource(namespace, name string) *batchv1.Job { labels := map[string]string{"app": name} @@ -933,14 +868,6 @@ func DeleteJob(ctx context.Context, client kubernetes.Interface, namespace, name }) } -func csiVolumeName(spcName string) string { - return fmt.Sprintf("csi-%s", spcName) -} - -func csiMountPath(spcName string) string { - return fmt.Sprintf("/mnt/secrets-store/%s", spcName) -} - // GetDeployment retrieves a deployment by name. func GetDeployment(ctx context.Context, client kubernetes.Interface, namespace, name string) (*appsv1.Deployment, error) { return client.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{}) diff --git a/test/e2e/utils/testenv.go b/test/e2e/utils/testenv.go index a8be4551e..6f73bd068 100644 --- a/test/e2e/utils/testenv.go +++ b/test/e2e/utils/testenv.go @@ -13,7 +13,6 @@ import ( "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" - csiclient "sigs.k8s.io/secrets-store-csi-driver/pkg/client/clientset/versioned" ) // TestEnvironment holds the common test environment state. @@ -22,7 +21,6 @@ type TestEnvironment struct { Cancel context.CancelFunc KubeClient kubernetes.Interface DiscoveryClient discovery.DiscoveryInterface - CSIClient csiclient.Interface RolloutsClient rolloutsclient.Interface OpenShiftClient openshiftclient.Interface RestConfig *rest.Config @@ -84,9 +82,6 @@ func SetupSharedTestEnvironment(ctx context.Context, namespace, releaseName stri } // Optional clients — failures are non-fatal. - if env.CSIClient, err = csiclient.NewForConfig(config); err != nil { - env.CSIClient = nil - } if env.RolloutsClient, err = rolloutsclient.NewForConfig(config); err != nil { env.RolloutsClient = nil } @@ -135,12 +130,6 @@ func SetupTestEnvironment(ctx context.Context, namespacePrefix string) (*TestEnv return nil, fmt.Errorf("creating discovery client: %w", err) } - env.CSIClient, err = csiclient.NewForConfig(config) - if err != nil { - ginkgo.GinkgoWriter.Printf("Warning: Could not create CSI client: %v (CSI tests will be skipped)\n", err) - env.CSIClient = nil - } - // Try to create Argo Rollouts client (optional - may not be installed) env.RolloutsClient, err = rolloutsclient.NewForConfig(config) if err != nil { diff --git a/test/e2e/utils/workload_adapter.go b/test/e2e/utils/workload_adapter.go index bc7f80ce4..2610337fe 100644 --- a/test/e2e/utils/workload_adapter.go +++ b/test/e2e/utils/workload_adapter.go @@ -32,7 +32,6 @@ const ( type WorkloadConfig struct { ConfigMapName string SecretName string - SPCName string Annotations map[string]string // Annotations for workload metadata (e.g., Deployment.metadata.annotations) PodTemplateAnnotations map[string]string // Annotations for pod template metadata (e.g., Deployment.spec.template.metadata.annotations) UseConfigMapEnvFrom bool @@ -44,8 +43,6 @@ type WorkloadConfig struct { UseSecretKeyRef bool UseInitContainer bool UseInitContainerVolume bool - UseCSIVolume bool - UseInitContainerCSI bool ConfigMapKey string SecretKey string EnvVarName string