diff --git a/go.mod b/go.mod index 3ef99f1f..39587a78 100644 --- a/go.mod +++ b/go.mod @@ -39,6 +39,7 @@ require ( github.com/go-openapi/swag/stringutils v0.25.4 // indirect github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.1 // indirect @@ -52,9 +53,16 @@ require ( 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/common v0.67.4 // indirect github.com/prometheus/procfs v0.19.2 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/viper v1.21.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect diff --git a/go.sum b/go.sum index 0e81e0d9..f7e22e63 100644 --- a/go.sum +++ b/go.sum @@ -65,6 +65,8 @@ github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6 github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= @@ -114,6 +116,8 @@ github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -132,17 +136,29 @@ github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 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.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 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/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/internal/pkg/config/flags.go b/internal/pkg/config/flags.go index e30be2ff..e37af209 100644 --- a/internal/pkg/config/flags.go +++ b/internal/pkg/config/flags.go @@ -2,215 +2,211 @@ package config import ( "strings" + "time" "github.com/spf13/pflag" + "github.com/spf13/viper" ) -// flagValues holds intermediate string values from CLI flags -// that need further parsing into the Config struct. -type flagValues struct { - namespaceSelectors string - resourceSelectors string - ignoredResources string - ignoredWorkloads string - ignoredNamespaces string - isArgoRollouts string - reloadOnCreate string - reloadOnDelete string -} +// v is the viper instance for configuration. +var v *viper.Viper -var fv flagValues +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.BoolVar( - &cfg.AutoReloadAll, "auto-reload-all", cfg.AutoReloadAll, - "Automatically reload all resources when their configmaps/secrets are updated, without requiring annotations", - ) + fs.Bool("auto-reload-all", cfg.AutoReloadAll, + "Automatically reload all resources when their configmaps/secrets are updated, without requiring annotations") // Reload strategy - fs.StringVar( - (*string)(&cfg.ReloadStrategy), "reload-strategy", string(cfg.ReloadStrategy), - "Strategy for triggering workload restart: 'env-vars' (default, GitOps friendly) or 'annotations'", - ) + fs.String("reload-strategy", string(cfg.ReloadStrategy), + "Strategy for triggering workload restart: 'env-vars' (default, GitOps friendly) or 'annotations'") // Argo Rollouts - fs.StringVar( - &fv.isArgoRollouts, "is-Argo-Rollouts", "false", - "Enable Argo Rollouts support (true/false)", - ) + fs.String("is-Argo-Rollouts", "false", + "Enable Argo Rollouts support (true/false)") // Event watching - fs.StringVar( - &fv.reloadOnCreate, "reload-on-create", "false", - "Reload when configmaps/secrets are created (true/false)", - ) - fs.StringVar( - &fv.reloadOnDelete, "reload-on-delete", "false", - "Reload when configmaps/secrets are deleted (true/false)", - ) + 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.BoolVar( - &cfg.SyncAfterRestart, "sync-after-restart", cfg.SyncAfterRestart, - "Trigger sync operation after restart", - ) + fs.Bool("sync-after-restart", cfg.SyncAfterRestart, + "Trigger sync operation after restart") // High availability / Leader election - fs.BoolVar( - &cfg.EnableHA, "enable-ha", cfg.EnableHA, - "Enable high-availability mode with leader election", - ) - fs.StringVar( - &cfg.LeaderElection.LockName, "leader-election-id", cfg.LeaderElection.LockName, - "Name of the lease resource for leader election", - ) - fs.StringVar( - &cfg.LeaderElection.Namespace, "leader-election-namespace", cfg.LeaderElection.Namespace, - "Namespace for the leader election lease (defaults to pod namespace)", - ) - fs.DurationVar( - &cfg.LeaderElection.LeaseDuration, "leader-election-lease-duration", cfg.LeaderElection.LeaseDuration, - "Duration that non-leader candidates will wait before attempting to acquire leadership", - ) - fs.DurationVar( - &cfg.LeaderElection.RenewDeadline, "leader-election-renew-deadline", cfg.LeaderElection.RenewDeadline, - "Duration that the acting leader will retry refreshing leadership before giving up", - ) - fs.DurationVar( - &cfg.LeaderElection.RetryPeriod, "leader-election-retry-period", cfg.LeaderElection.RetryPeriod, - "Duration between leader election retries", - ) - fs.BoolVar( - &cfg.LeaderElection.ReleaseOnCancel, "leader-election-release-on-cancel", cfg.LeaderElection.ReleaseOnCancel, - "Release the leader lock when the manager is stopped", - ) + 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.StringVar( - &cfg.WebhookURL, "webhook-url", cfg.WebhookURL, - "URL to send notification instead of triggering reload", - ) + fs.String("webhook-url", cfg.WebhookURL, + "URL to send notification instead of triggering reload") - // Filtering - resources (use StringVar not StringSliceVar for simpler parsing) - fs.StringVar( - &fv.ignoredResources, "resources-to-ignore", "", - "Comma-separated list of resources to ignore (valid options: 'configMaps' or 'secrets')", - ) - fs.StringVar( - &fv.ignoredWorkloads, "ignored-workload-types", "", - "Comma-separated list of workload types to ignore (valid options: 'jobs', 'cronjobs', or both)", - ) - fs.StringVar( - &fv.ignoredNamespaces, "namespaces-to-ignore", "", - "Comma-separated list of namespaces to ignore", - ) + // 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.StringVar( - &fv.namespaceSelectors, "namespace-selector", "", - "Comma-separated list of namespace label selectors", - ) - fs.StringVar( - &fv.resourceSelectors, "resource-label-selector", "", - "Comma-separated list of resource label selectors", - ) + fs.String("namespace-selector", "", + "Comma-separated list of namespace label selectors") + fs.String("resource-label-selector", "", + "Comma-separated list of resource label selectors") // Logging - fs.StringVar( - &cfg.LogFormat, "log-format", cfg.LogFormat, - "Log format: 'json' or empty for default", - ) - fs.StringVar( - &cfg.LogLevel, "log-level", cfg.LogLevel, - "Log level: trace, debug, info, warning, error, fatal, panic", - ) + 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.StringVar( - &cfg.MetricsAddr, "metrics-addr", cfg.MetricsAddr, - "Address to serve metrics on", - ) + fs.String("metrics-addr", cfg.MetricsAddr, + "Address to serve metrics on") // Health probes - fs.StringVar( - &cfg.HealthAddr, "health-addr", cfg.HealthAddr, - "Address to serve health probes on", - ) + fs.String("health-addr", cfg.HealthAddr, + "Address to serve health probes on") // Profiling - fs.BoolVar( - &cfg.EnablePProf, "enable-pprof", cfg.EnablePProf, - "Enable pprof profiling server", - ) - fs.StringVar( - &cfg.PProfAddr, "pprof-addr", cfg.PProfAddr, - "Address for pprof server", - ) + 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.StringVar( - &cfg.Annotations.Auto, "auto-annotation", cfg.Annotations.Auto, - "Annotation to detect changes in secrets/configmaps", - ) - fs.StringVar( - &cfg.Annotations.ConfigmapAuto, "configmap-auto-annotation", cfg.Annotations.ConfigmapAuto, - "Annotation to detect changes in configmaps", - ) - fs.StringVar( - &cfg.Annotations.SecretAuto, "secret-auto-annotation", cfg.Annotations.SecretAuto, - "Annotation to detect changes in secrets", - ) - fs.StringVar( - &cfg.Annotations.ConfigmapReload, "configmap-annotation", cfg.Annotations.ConfigmapReload, - "Annotation to detect changes in configmaps, specified by name", - ) - fs.StringVar( - &cfg.Annotations.SecretReload, "secret-annotation", cfg.Annotations.SecretReload, - "Annotation to detect changes in secrets, specified by name", - ) - fs.StringVar( - &cfg.Annotations.Search, "auto-search-annotation", cfg.Annotations.Search, - "Annotation to detect changes in configmaps or secrets tagged with special match annotation", - ) - fs.StringVar( - &cfg.Annotations.Match, "search-match-annotation", cfg.Annotations.Match, - "Annotation to mark secrets or configmaps to match the search", - ) - fs.StringVar( - &cfg.Annotations.PausePeriod, "pause-deployment-annotation", cfg.Annotations.PausePeriod, - "Annotation to define the time period to pause a deployment after a configmap/secret change", - ) - fs.StringVar( - &cfg.Annotations.PausedAt, "pause-deployment-time-annotation", cfg.Annotations.PausedAt, - "Annotation to indicate when a deployment was paused by Reloader", - ) + 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.StringVar( - &cfg.WatchedNamespace, "watch-namespace", cfg.WatchedNamespace, - "Namespace to watch (empty for all namespaces)", - ) + 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 that need post-processing. +// ApplyFlags applies flag values from viper to the config struct. // Call this after parsing flags. func ApplyFlags(cfg *Config) error { - // Parse boolean string flags - cfg.ArgoRolloutsEnabled = parseBoolString(fv.isArgoRollouts) - cfg.ReloadOnCreate = parseBoolString(fv.reloadOnCreate) - cfg.ReloadOnDelete = parseBoolString(fv.reloadOnDelete) + // 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")) + + // 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") + + // 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(fv.ignoredResources) - cfg.IgnoredWorkloads = splitAndTrim(fv.ignoredWorkloads) - cfg.IgnoredNamespaces = splitAndTrim(fv.ignoredNamespaces) + cfg.IgnoredResources = splitAndTrim(v.GetString("resources-to-ignore")) + cfg.IgnoredWorkloads = splitAndTrim(v.GetString("ignored-workload-types")) + cfg.IgnoredNamespaces = splitAndTrim(v.GetString("namespaces-to-ignore")) // Store raw selector strings - cfg.NamespaceSelectorStrings = splitAndTrim(fv.namespaceSelectors) - cfg.ResourceSelectorStrings = splitAndTrim(fv.resourceSelectors) + cfg.NamespaceSelectorStrings = splitAndTrim(v.GetString("namespace-selector")) + cfg.ResourceSelectorStrings = splitAndTrim(v.GetString("resource-label-selector")) // Parse selectors into labels.Selector var err error @@ -223,9 +219,25 @@ func ApplyFlags(cfg *Config) error { return err } + // 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 } +// GetViper returns the viper instance for testing or advanced configuration. +func GetViper() *viper.Viper { + return v +} + // parseBoolString parses a string as a boolean, defaulting to false. func parseBoolString(s string) bool { s = strings.ToLower(strings.TrimSpace(s)) diff --git a/internal/pkg/config/flags_test.go b/internal/pkg/config/flags_test.go index 06638d6b..76b088c5 100644 --- a/internal/pkg/config/flags_test.go +++ b/internal/pkg/config/flags_test.go @@ -1,12 +1,23 @@ package config import ( + "os" + "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) @@ -48,6 +59,12 @@ func TestBindFlags(t *testing.T) { "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 { @@ -58,6 +75,7 @@ func TestBindFlags(t *testing.T) { } func TestBindFlags_DefaultValues(t *testing.T) { + resetViper() cfg := NewDefault() fs := pflag.NewFlagSet("test", pflag.ContinueOnError) @@ -67,6 +85,10 @@ func TestBindFlags_DefaultValues(t *testing.T) { 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) } @@ -77,6 +99,7 @@ func TestBindFlags_DefaultValues(t *testing.T) { } func TestBindFlags_CustomValues(t *testing.T) { + resetViper() cfg := NewDefault() fs := pflag.NewFlagSet("test", pflag.ContinueOnError) @@ -96,6 +119,10 @@ func TestBindFlags_CustomValues(t *testing.T) { 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") } @@ -143,35 +170,31 @@ func TestApplyFlags_BooleanStrings(t *testing.T) { } for _, tt := range tests { - t.Run( - tt.name, func(t *testing.T) { - fv = flagValues{} + t.Run(tt.name, func(t *testing.T) { + resetViper() + cfg := NewDefault() + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + BindFlags(fs, cfg) - 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) + } - 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 + } - 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) - } - }, - ) + if cfg.ArgoRolloutsEnabled != tt.want { + t.Errorf("ArgoRolloutsEnabled = %v, want %v", cfg.ArgoRolloutsEnabled, tt.want) + } + }) } } func TestApplyFlags_CommaSeparatedLists(t *testing.T) { - fv = flagValues{} - + resetViper() cfg := NewDefault() fs := pflag.NewFlagSet("test", pflag.ContinueOnError) BindFlags(fs, cfg) @@ -201,15 +224,13 @@ func TestApplyFlags_CommaSeparatedLists(t *testing.T) { t.Errorf("IgnoredWorkloads length = %d, want 2", len(cfg.IgnoredWorkloads)) } - // Check ignored namespaces if len(cfg.IgnoredNamespaces) != 2 { t.Errorf("IgnoredNamespaces length = %d, want 2", len(cfg.IgnoredNamespaces)) } } func TestApplyFlags_Selectors(t *testing.T) { - fv = flagValues{} - + resetViper() cfg := NewDefault() fs := pflag.NewFlagSet("test", pflag.ContinueOnError) BindFlags(fs, cfg) @@ -241,8 +262,7 @@ func TestApplyFlags_Selectors(t *testing.T) { } func TestApplyFlags_InvalidSelector(t *testing.T) { - fv = flagValues{} - + resetViper() cfg := NewDefault() fs := pflag.NewFlagSet("test", pflag.ContinueOnError) BindFlags(fs, cfg) @@ -261,6 +281,100 @@ func TestApplyFlags_InvalidSelector(t *testing.T) { } } +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) { + // Clear env and reset viper + for k := range tt.envVars { + os.Unsetenv(k) + } + resetViper() + + // Set env vars + for k, val := range tt.envVars { + os.Setenv(k, val) + defer os.Unsetenv(k) + } + + 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() + + // Set legacy env var + os.Setenv("ALERT_WEBHOOK_PROXY", "http://legacy-proxy:8080") + defer os.Unsetenv("ALERT_WEBHOOK_PROXY") + + 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 @@ -282,14 +396,12 @@ func TestParseBoolString(t *testing.T) { } 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) - } - }, - ) + 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) + } + }) } } @@ -308,19 +420,17 @@ func TestSplitAndTrim(t *testing.T) { } 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 + 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]) } - 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]) - } - } - }, - ) + } + }) } }