From f38b931b12c7b2a73e546f007b1c7beda85de628 Mon Sep 17 00:00:00 2001 From: David Young Date: Wed, 6 May 2026 10:41:34 +1200 Subject: [PATCH] Support configurable Helm storage driver Adds a `--helm-storage-driver` flag and an `HELM_DRIVER` environment variable fallback that select the Helm release storage backend, mirroring the Helm CLI's HELM_DRIVER behaviour. Supported values are `secret`/`secrets`, `configmap`/`configmaps`, `memory`, and `sql`, matched case-insensitively. Unset preserves the current behaviour (Secret), so the change is backwards compatible. The SQL driver reads its connection string from `HELM_DRIVER_SQL_CONNECTION_STRING` (matching the Helm CLI). It is useful when: - Helm release information exceeds the 1MiB Secret size limit; - the cumulative Secret count is causing cluster-wide pressure; - compliance requires storing release data outside the cluster. A `Memory` driver is accepted for parity with Helm itself, but the flag help marks it as test/dev only, since the storage is re-initialised on every reconcile. Implementation notes: - The driver name is normalised once at startup (with validation), so an unsupported value fails the controller process rather than every HelmRelease reconcile. - SQL drivers are managed by an SQLDriverPool keyed by storage namespace, sharing one helmdriver.SQL instance per namespace. Helm v4 does not expose Close on storage.Driver, so connection pools are released only when the controller exits; the per-namespace cache bounds the live pool count to the set of storage namespaces actually in use rather than once per reconcile. - At startup, when the SQL driver is selected, the controller probes the database with a transient database/sql.Open + PingContext + Close to surface invalid DSNs or unreachable backends before the manager starts. The probe connection is closed cleanly and does not contribute to the long-lived pool. - SQL connect/migration errors can echo the connection string; they are caught at the WithStorage call site and a generic message is returned, so DSN material cannot leak into HelmRelease status conditions. Closes fluxcd/helm-controller#272. Supersedes fluxcd/helm-controller#760. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: David Young --- go.mod | 2 +- internal/action/config.go | 156 +++++++++++--- internal/action/config_test.go | 190 +++++++++++++++++- internal/controller/helmrelease_controller.go | 11 +- main.go | 72 ++++++- 5 files changed, 402 insertions(+), 29 deletions(-) diff --git a/go.mod b/go.mod index 36f63a023..87f1f255d 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/google/cel-go v0.26.1 github.com/google/go-cmp v0.7.0 github.com/hashicorp/go-retryablehttp v0.7.8 + github.com/lib/pq v1.10.9 github.com/mitchellh/copystructure v1.2.0 github.com/onsi/gomega v1.39.1 github.com/opencontainers/go-digest v1.0.1-0.20231025023718-d50d2fec9c98 @@ -157,7 +158,6 @@ require ( github.com/kylelemons/godebug v1.1.0 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect - github.com/lib/pq v1.10.9 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect diff --git a/internal/action/config.go b/internal/action/config.go index 61a42e201..b5a07a9c6 100644 --- a/internal/action/config.go +++ b/internal/action/config.go @@ -20,6 +20,8 @@ import ( "context" "fmt" "log/slog" + "strings" + "sync" helmaction "helm.sh/helm/v4/pkg/action" helmstorage "helm.sh/helm/v4/pkg/storage" @@ -31,10 +33,88 @@ import ( "github.com/fluxcd/helm-controller/internal/storage" ) -const ( - // DefaultStorageDriver is the default Helm storage driver. - DefaultStorageDriver = helmdriver.SecretsDriverName -) +// DefaultStorageDriver is the default Helm storage driver. +const DefaultStorageDriver = helmdriver.SecretsDriverName + +// NormalizeStorageDriverName maps a user-supplied storage driver name to its +// canonical Helm constant. The accepted forms mirror the Helm CLI / SDK +// behaviour (see helm.sh/helm/v4/pkg/action.(*Configuration).Init), allowing +// both "secret"/"secrets" and "configmap"/"configmaps" interchangeably and +// matching case-insensitively. An empty name maps to DefaultStorageDriver. +// The boolean return is false when name is not a recognised driver. +func NormalizeStorageDriverName(name string) (string, bool) { + switch strings.ToLower(name) { + case "": + return DefaultStorageDriver, true + case strings.ToLower(helmdriver.SecretsDriverName), "secrets": + return helmdriver.SecretsDriverName, true + case strings.ToLower(helmdriver.ConfigMapsDriverName), "configmaps": + return helmdriver.ConfigMapsDriverName, true + case strings.ToLower(helmdriver.MemoryDriverName): + return helmdriver.MemoryDriverName, true + case strings.ToLower(helmdriver.SQLDriverName): + return helmdriver.SQLDriverName, true + } + return "", false +} + +// IsSupportedStorageDriver reports whether name is a recognised storage driver +// name. The accepted forms match those of the Helm CLI's HELM_DRIVER variable, +// matched case-insensitively. An empty name is treated as the default driver. +func IsSupportedStorageDriver(name string) bool { + _, ok := NormalizeStorageDriverName(name) + return ok +} + +// SQLDriverPool returns Helm SQL storage drivers keyed by storage namespace, +// reusing the underlying database handle across reconciliations to avoid +// opening a fresh connection pool on every action. +// +// Helm v4 does not expose a Close method on storage.Driver, so the connection +// pools opened by helmdriver.NewSQL are released only when the controller +// process exits. The pool therefore caches drivers per storage namespace, +// bounding the resource usage to the set of namespaces actually in use. +type SQLDriverPool struct { + connectionString string + factory func(connection, namespace string) (helmdriver.Driver, error) + + mu sync.Mutex + drivers map[string]helmdriver.Driver +} + +// NewSQLDriverPool returns an SQLDriverPool that lazily opens a Helm SQL driver +// per namespace using the given connection string. +func NewSQLDriverPool(connectionString string) *SQLDriverPool { + return &SQLDriverPool{ + connectionString: connectionString, + factory: func(connection, namespace string) (helmdriver.Driver, error) { + return helmdriver.NewSQL(connection, namespace) + }, + drivers: make(map[string]helmdriver.Driver), + } +} + +// SetFactory replaces the driver constructor; intended for tests. It is not +// safe to call concurrently with DriverFor. +func (p *SQLDriverPool) SetFactory(f func(connection, namespace string) (helmdriver.Driver, error)) { + p.factory = f +} + +// DriverFor returns the cached SQL driver for the given storage namespace, +// constructing a new one on first use. +func (p *SQLDriverPool) DriverFor(namespace string) (helmdriver.Driver, error) { + p.mu.Lock() + defer p.mu.Unlock() + if d, ok := p.drivers[namespace]; ok { + return d, nil + } + d, err := p.factory(p.connectionString, namespace) + if err != nil { + return nil, err + } + p.drivers[namespace] = d + return d, nil +} // ConfigFactory is a factory for the Helm action configuration of a (series // of) Helm action(s). It allows for sharing Kubernetes client(s) and the @@ -51,6 +131,9 @@ type ConfigFactory struct { KubeClient *Client // Driver to use for the Helm action. Driver helmdriver.Driver + // SQLDriverPool, when set, supplies SQL storage drivers per namespace and + // is consulted by WithStorage when the SQL driver is requested. + SQLDriverPool *SQLDriverPool // StorageLog is the logger to use for the Helm storage driver. StorageLog slog.Handler // NewResourceManager is the resource manager used to evaluate custom health checks. @@ -84,38 +167,56 @@ func NewConfigFactory(getter genericclioptions.RESTClientGetter, opts ...ConfigF // WithStorage configures the ConfigFactory.Driver by constructing a new Helm // driver.Driver using the provided driver name and namespace. -// It supports driver.ConfigMapsDriverName, driver.SecretsDriverName and -// driver.MemoryDriverName. -// It returns an error when the driver name is not supported, or the client -// configuration for the storage fails. +// +// Driver names are matched case-insensitively and accept the same aliases as +// the Helm CLI: "secret"/"secrets", "configmap"/"configmaps", "memory", "sql". +// An empty driver falls back to DefaultStorageDriver. +// +// The SQL driver requires a SQLDriverPool to have been set on the +// ConfigFactory via WithSQLDriverPool. The Memory driver creates a fresh +// store on every call and is therefore only suitable for tests or +// short-lived processes. +// +// It returns an error when the driver name is not supported, the namespace +// is empty, or the underlying client/storage configuration fails. func WithStorage(driver, namespace string) ConfigFactoryOption { - if driver == "" { - driver = DefaultStorageDriver - } + canonical, ok := NormalizeStorageDriverName(driver) return func(f *ConfigFactory) error { + if !ok { + return fmt.Errorf("unsupported Helm storage driver '%s'", driver) + } if namespace == "" { - return fmt.Errorf("no namespace provided for '%s' storage driver", driver) + return fmt.Errorf("no namespace provided for Helm storage driver '%s'", canonical) } - switch driver { - case helmdriver.SecretsDriverName, helmdriver.ConfigMapsDriverName, "": + switch canonical { + case helmdriver.SecretsDriverName, helmdriver.ConfigMapsDriverName: clientSet, err := f.KubeClient.Factory.KubernetesClientSet() if err != nil { - return fmt.Errorf("could not get client set for '%s' storage driver: %w", driver, err) + return fmt.Errorf("could not get client set for '%s' storage driver: %w", canonical, err) } - if driver == helmdriver.ConfigMapsDriverName { + if canonical == helmdriver.ConfigMapsDriverName { f.Driver = helmdriver.NewConfigMaps(clientSet.CoreV1().ConfigMaps(namespace)) - } - if driver == helmdriver.SecretsDriverName { + } else { f.Driver = helmdriver.NewSecrets(clientSet.CoreV1().Secrets(namespace)) } case helmdriver.MemoryDriverName: - driver := helmdriver.NewMemory() - driver.SetNamespace(namespace) - f.Driver = driver - default: - return fmt.Errorf("unsupported Helm storage driver '%s'", driver) + d := helmdriver.NewMemory() + d.SetNamespace(namespace) + f.Driver = d + case helmdriver.SQLDriverName: + if f.SQLDriverPool == nil { + return fmt.Errorf("Helm storage driver '%s' requires a SQL driver pool", canonical) + } + sqlDriver, err := f.SQLDriverPool.DriverFor(namespace) + if err != nil { + // Underlying errors from helmdriver.NewSQL / sqlx.Connect can + // echo the connection string; surface a generic message and + // keep the detail off the HelmRelease status condition. + return fmt.Errorf("could not initialize Helm SQL storage driver: connection failed") + } + f.Driver = sqlDriver } return nil } @@ -129,6 +230,15 @@ func WithDriver(driver helmdriver.Driver) ConfigFactoryOption { } } +// WithSQLDriverPool sets the ConfigFactory.SQLDriverPool. The pool is consulted +// by WithStorage when the SQL driver is requested. +func WithSQLDriverPool(pool *SQLDriverPool) ConfigFactoryOption { + return func(f *ConfigFactory) error { + f.SQLDriverPool = pool + return nil + } +} + // WithStorageLog sets the ConfigFactory.StorageLog. func WithStorageLog(log slog.Handler) ConfigFactoryOption { return func(f *ConfigFactory) error { diff --git a/internal/action/config_test.go b/internal/action/config_test.go index 1c82b6cbf..d54af9628 100644 --- a/internal/action/config_test.go +++ b/internal/action/config_test.go @@ -19,6 +19,7 @@ package action import ( "errors" "log/slog" + "strings" "testing" . "github.com/onsi/gomega" @@ -33,6 +34,26 @@ import ( "github.com/fluxcd/helm-controller/internal/testutil" ) +// fakeSQLDriverPool returns a pool whose factory yields a memory-backed +// driver that reports its Name as SQL. +func fakeSQLDriverPool() *SQLDriverPool { + pool := NewSQLDriverPool("postgres://user@example.com:5432/helm?sslmode=disable") + pool.SetFactory(func(_ string, namespace string) (helmdriver.Driver, error) { + d := helmdriver.NewMemory() + d.SetNamespace(namespace) + return fakeSQLDriver{Driver: d}, nil + }) + return pool +} + +type fakeSQLDriver struct { + helmdriver.Driver +} + +func (f fakeSQLDriver) Name() string { + return helmdriver.SQLDriverName +} + func TestNewConfigFactory(t *testing.T) { tests := []struct { name string @@ -80,6 +101,68 @@ func TestNewConfigFactory(t *testing.T) { } } +func TestIsSupportedStorageDriver(t *testing.T) { + g := NewWithT(t) + + g.Expect(IsSupportedStorageDriver("")).To(BeTrue()) + g.Expect(IsSupportedStorageDriver("secret")).To(BeTrue()) + g.Expect(IsSupportedStorageDriver("Secret")).To(BeTrue()) + g.Expect(IsSupportedStorageDriver("SECRET")).To(BeTrue()) + // Plural aliases match Helm CLI's HELM_DRIVER behaviour. + g.Expect(IsSupportedStorageDriver("secrets")).To(BeTrue()) + g.Expect(IsSupportedStorageDriver("Secrets")).To(BeTrue()) + g.Expect(IsSupportedStorageDriver("configmap")).To(BeTrue()) + g.Expect(IsSupportedStorageDriver("ConfigMap")).To(BeTrue()) + g.Expect(IsSupportedStorageDriver("configmaps")).To(BeTrue()) + g.Expect(IsSupportedStorageDriver("memory")).To(BeTrue()) + g.Expect(IsSupportedStorageDriver("Memory")).To(BeTrue()) + g.Expect(IsSupportedStorageDriver("sql")).To(BeTrue()) + g.Expect(IsSupportedStorageDriver("SQL")).To(BeTrue()) + + g.Expect(IsSupportedStorageDriver("postgres")).To(BeFalse()) + g.Expect(IsSupportedStorageDriver(" secret ")).To(BeFalse()) +} + +func TestSQLDriverPool(t *testing.T) { + t.Run("caches one driver per namespace", func(t *testing.T) { + g := NewWithT(t) + + var calls int + pool := NewSQLDriverPool("ignored") + pool.SetFactory(func(_ string, ns string) (helmdriver.Driver, error) { + calls++ + d := helmdriver.NewMemory() + d.SetNamespace(ns) + return fakeSQLDriver{Driver: d}, nil + }) + + first, err := pool.DriverFor("foo") + g.Expect(err).ToNot(HaveOccurred()) + again, err := pool.DriverFor("foo") + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(again).To(BeIdenticalTo(first)) + g.Expect(calls).To(Equal(1)) + + other, err := pool.DriverFor("bar") + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(other).ToNot(BeIdenticalTo(first)) + g.Expect(calls).To(Equal(2)) + }) + + t.Run("propagates factory errors", func(t *testing.T) { + g := NewWithT(t) + + pool := NewSQLDriverPool("ignored") + pool.SetFactory(func(_ string, _ string) (helmdriver.Driver, error) { + return nil, errors.New("boom") + }) + + d, err := pool.DriverFor("foo") + g.Expect(err).To(MatchError("boom")) + g.Expect(d).To(BeNil()) + }) +} + func TestWithStorage(t *testing.T) { tests := []struct { name string @@ -106,6 +189,24 @@ func TestWithStorage(t *testing.T) { }, wantDriver: helmdriver.SecretsDriverName, }, + { + name: "lowercase secret", + driverName: strings.ToLower(helmdriver.SecretsDriverName), + namespace: "default", + factory: ConfigFactory{ + KubeClient: &Client{Client: helmkube.New(cmdtest.NewTestFactory())}, + }, + wantDriver: helmdriver.SecretsDriverName, + }, + { + name: "secrets alias", + driverName: "secrets", + namespace: "default", + factory: ConfigFactory{ + KubeClient: &Client{Client: helmkube.New(cmdtest.NewTestFactory())}, + }, + wantDriver: helmdriver.SecretsDriverName, + }, { name: helmdriver.ConfigMapsDriverName, driverName: helmdriver.ConfigMapsDriverName, @@ -115,6 +216,24 @@ func TestWithStorage(t *testing.T) { }, wantDriver: helmdriver.ConfigMapsDriverName, }, + { + name: "lowercase configmap", + driverName: strings.ToLower(helmdriver.ConfigMapsDriverName), + namespace: "default", + factory: ConfigFactory{ + KubeClient: &Client{Client: helmkube.New(cmdtest.NewTestFactory())}, + }, + wantDriver: helmdriver.ConfigMapsDriverName, + }, + { + name: "configmaps alias", + driverName: "configmaps", + namespace: "default", + factory: ConfigFactory{ + KubeClient: &Client{Client: helmkube.New(cmdtest.NewTestFactory())}, + }, + wantDriver: helmdriver.ConfigMapsDriverName, + }, { name: helmdriver.MemoryDriverName, driverName: helmdriver.MemoryDriverName, @@ -122,12 +241,47 @@ func TestWithStorage(t *testing.T) { factory: ConfigFactory{}, wantDriver: helmdriver.MemoryDriverName, }, + { + name: "uppercase sql", + driverName: helmdriver.SQLDriverName, + namespace: "default", + factory: ConfigFactory{ + SQLDriverPool: fakeSQLDriverPool(), + }, + wantDriver: helmdriver.SQLDriverName, + }, + { + name: "lowercase sql", + driverName: strings.ToLower(helmdriver.SQLDriverName), + namespace: "default", + factory: ConfigFactory{ + SQLDriverPool: fakeSQLDriverPool(), + }, + wantDriver: helmdriver.SQLDriverName, + }, { name: "invalid namespace", driverName: helmdriver.SecretsDriverName, namespace: "", factory: ConfigFactory{}, - wantErr: errors.New("no namespace provided for Helm storage driver 'secrets'"), + wantErr: errors.New("no namespace provided for Helm storage driver '" + helmdriver.SecretsDriverName + "'"), + }, + { + name: "invalid namespace via alias", + driverName: "secrets", + namespace: "", + factory: ConfigFactory{}, + // Error message is normalised to the canonical Helm constant. + wantErr: errors.New("no namespace provided for Helm storage driver '" + helmdriver.SecretsDriverName + "'"), + }, + { + name: "sql with empty namespace", + driverName: helmdriver.SQLDriverName, + namespace: "", + factory: ConfigFactory{ + SQLDriverPool: fakeSQLDriverPool(), + }, + wantErr: errors.New("no namespace provided for Helm storage driver 'SQL'"), }, { name: "invalid driver", @@ -136,6 +290,29 @@ func TestWithStorage(t *testing.T) { factory: ConfigFactory{}, wantErr: errors.New("unsupported Helm storage driver 'invalid'"), }, + { + name: "sql without pool", + driverName: helmdriver.SQLDriverName, + namespace: "default", + factory: ConfigFactory{}, + wantErr: errors.New("Helm storage driver 'SQL' requires a SQL driver pool"), + }, + { + name: "sql with failing pool", + driverName: helmdriver.SQLDriverName, + namespace: "default", + factory: ConfigFactory{ + SQLDriverPool: func() *SQLDriverPool { + p := NewSQLDriverPool("postgres://user:secret@host:5432/db") + p.SetFactory(func(_ string, _ string) (helmdriver.Driver, error) { + return nil, errors.New("dial error: postgres://user:secret@host:5432/db: refused") + }) + return p + }(), + }, + // Underlying error must NOT be surfaced (it can echo the DSN). + wantErr: errors.New("could not initialize Helm SQL storage driver: connection failed"), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -144,7 +321,7 @@ func TestWithStorage(t *testing.T) { factory := tt.factory err := WithStorage(tt.driverName, tt.namespace)(&factory) if tt.wantErr != nil { - g.Expect(err).To(HaveOccurred()) + g.Expect(err).To(MatchError(tt.wantErr.Error())) g.Expect(factory.Driver).To(BeNil()) return } @@ -165,6 +342,15 @@ func TestWithDriver(t *testing.T) { g.Expect(factory.Driver).To(Equal(driver)) } +func TestWithSQLDriverPool(t *testing.T) { + g := NewWithT(t) + + factory := &ConfigFactory{} + pool := NewSQLDriverPool("ignored") + g.Expect(WithSQLDriverPool(pool)(factory)).NotTo(HaveOccurred()) + g.Expect(factory.SQLDriverPool).To(BeIdenticalTo(pool)) +} + func TestStorageLog(t *testing.T) { g := NewWithT(t) diff --git a/internal/controller/helmrelease_controller.go b/internal/controller/helmrelease_controller.go index e0c3315da..96788eaed 100644 --- a/internal/controller/helmrelease_controller.go +++ b/internal/controller/helmrelease_controller.go @@ -100,6 +100,11 @@ type HelmReleaseReconciler struct { APIReader client.Reader TokenCache *cache.TokenCache + // Helm storage configuration + + HelmStorageDriver string + HelmStorageSQLPool *action.SQLDriverPool + // Retry and requeue configuration DependencyRequeueInterval time.Duration @@ -402,7 +407,8 @@ func (r *HelmReleaseReconciler) reconcileRelease(ctx context.Context, // Construct config factory for any further Helm actions. cfg, err := action.NewConfigFactory(getter, - action.WithStorage(action.DefaultStorageDriver, obj.Status.StorageNamespace), + action.WithSQLDriverPool(r.HelmStorageSQLPool), + action.WithStorage(r.HelmStorageDriver, obj.Status.StorageNamespace), action.WithStorageLog(action.NewTraceLogger(ctx)), action.WithResourceManager(resourceManager), action.WithWaitContext(ctx), @@ -581,7 +587,8 @@ func (r *HelmReleaseReconciler) reconcileUninstall(ctx context.Context, getter g // Construct config factory for current release first to validate // storage configuration before building the resource manager. cfg, err := action.NewConfigFactory(getter, - action.WithStorage(action.DefaultStorageDriver, obj.Status.StorageNamespace), + action.WithSQLDriverPool(r.HelmStorageSQLPool), + action.WithStorage(r.HelmStorageDriver, obj.Status.StorageNamespace), action.WithStorageLog(action.NewTraceLogger(ctx)), action.WithWaitContext(ctx), ) diff --git a/main.go b/main.go index a2153982e..6da0a3d9b 100644 --- a/main.go +++ b/main.go @@ -17,12 +17,16 @@ limitations under the License. package main import ( + "context" + "database/sql" "fmt" "os" "time" + _ "github.com/lib/pq" flag "github.com/spf13/pflag" "helm.sh/helm/v4/pkg/kube" + helmdriver "helm.sh/helm/v4/pkg/storage/driver" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" @@ -63,7 +67,13 @@ import ( "github.com/fluxcd/helm-controller/internal/oomwatch" ) -const controllerName = "helm-controller" +const ( + controllerName = "helm-controller" + + // helmDriverSQLConnectionStringEnv names the environment variable from which + // the connection string for the Helm SQL storage driver is read. + helmDriverSQLConnectionStringEnv = "HELM_DRIVER_SQL_CONNECTION_STRING" +) var ( scheme = runtime.NewScheme() @@ -109,6 +119,7 @@ func main() { disallowedFieldManagers []string tokenCacheOptions cache.TokenFlags defaultKubeConfigServiceAccount string + helmStorageDriver string ) flag.StringVar(&metricsAddr, "metrics-addr", ":8080", @@ -143,6 +154,11 @@ func main() { "The algorithm to use to calculate the digest of Helm release storage snapshots.") flag.StringArrayVar(&disallowedFieldManagers, "override-manager", []string{}, "List of field managers to override during drift detection.") + flag.StringVar(&helmStorageDriver, "helm-storage-driver", "", + "The Helm storage driver used to store release information. One of: secret, configmap, memory, sql "+ + "(case-insensitive). If unset, the HELM_DRIVER environment variable is consulted, and if also unset "+ + "the controller defaults to secret. The 'memory' driver is not persistent across reconciles and is "+ + "intended for tests only. The 'sql' driver requires HELM_DRIVER_SQL_CONNECTION_STRING.") clientOptions.BindFlags(flag.CommandLine) logOptions.BindFlags(flag.CommandLine) @@ -213,6 +229,58 @@ func main() { watchNamespace = os.Getenv("RUNTIME_NAMESPACE") } + // Resolve --helm-storage-driver, falling back to HELM_DRIVER and finally the + // built-in default. Validation is intentionally up-front so an invalid value + // fails the controller process rather than every HelmRelease reconcile. + if helmStorageDriver == "" { + if driverEnv, ok := os.LookupEnv("HELM_DRIVER"); ok && driverEnv != "" { + helmStorageDriver = driverEnv + } else { + helmStorageDriver = action.DefaultStorageDriver + } + } + canonicalDriver, ok := action.NormalizeStorageDriverName(helmStorageDriver) + if !ok { + setupLog.Error(fmt.Errorf("unsupported Helm storage driver %q", helmStorageDriver), + "valid values are secret, secrets, configmap, configmaps, memory, sql") + os.Exit(1) + } + helmStorageDriver = canonicalDriver + + var helmStorageSQLPool *action.SQLDriverPool + if helmStorageDriver == helmdriver.SQLDriverName { + connectionString := os.Getenv(helmDriverSQLConnectionStringEnv) + if connectionString == "" { + setupLog.Error(fmt.Errorf("%s must be set when --helm-storage-driver=sql", helmDriverSQLConnectionStringEnv), + "missing Helm SQL connection string") + os.Exit(1) + } + // Eagerly verify the DSN and database reachability at startup, rather + // than letting every HelmRelease reconcile fail. Use database/sql + // directly so we can Close() the probe connection — Helm v4 does not + // expose Close on storage.Driver, so going through the pool would leak + // a permanent connection. + probe, err := sql.Open("postgres", connectionString) + if err != nil { + setupLog.Error(fmt.Errorf("invalid Helm SQL connection string"), + "check HELM_DRIVER_SQL_CONNECTION_STRING") + os.Exit(1) + } + probeCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + if pingErr := probe.PingContext(probeCtx); pingErr != nil { + cancel() + _ = probe.Close() + setupLog.Error(fmt.Errorf("failed to reach Helm SQL storage backend"), + "check HELM_DRIVER_SQL_CONNECTION_STRING and database availability") + os.Exit(1) + } + cancel() + _ = probe.Close() + + helmStorageSQLPool = action.NewSQLDriverPool(connectionString) + } + setupLog.Info("Helm storage driver configured", "driver", helmStorageDriver) + watchSelector, err := helper.GetWatchSelector(watchOptions) if err != nil { setupLog.Error(err, "unable to configure watch label selector for manager") @@ -398,6 +466,8 @@ func main() { ArtifactFetchTimeout: httpTimeout, AllowExternalArtifact: allowExternalArtifact, DisallowedFieldManagers: disallowedFieldManagers, + HelmStorageDriver: helmStorageDriver, + HelmStorageSQLPool: helmStorageSQLPool, }).SetupWithManager(ctx, mgr, controller.HelmReleaseReconcilerOptions{ RateLimiter: helper.GetRateLimiter(rateLimiterOptions), WatchConfigs: watchConfigs,