From 6359d958c277a2aee57f9c3e7023a76f52639ca8 Mon Sep 17 00:00:00 2001 From: Leonardo Cecchi Date: Tue, 6 Oct 2026 14:03:33 +0200 Subject: [PATCH 1/2] feat: allow customizing the sidecar image per cluster Add a `sidecarImage` plugin parameter that overrides the sidecar image injected into the instance pods and the recovery jobs of a cluster. The image is resolved with the following precedence: the parameter in `spec.plugins[]`, the parameter in the recovery source plugin configuration (`externalClusters[]`, so recovery-only clusters can set it without a `spec.plugins[]` entry) and, finally, the operator-level `SIDECAR_IMAGE` default. Assisted-by: Claude Signed-off-by: Leonardo Cecchi --- internal/cnpgi/operator/config/config.go | 20 +++++++ internal/cnpgi/operator/config/config_test.go | 52 +++++++++++++++++++ internal/cnpgi/operator/lifecycle.go | 5 +- internal/cnpgi/operator/lifecycle_test.go | 34 ++++++++++++ web/docs/parameters.md | 2 + 5 files changed, 112 insertions(+), 1 deletion(-) diff --git a/internal/cnpgi/operator/config/config.go b/internal/cnpgi/operator/config/config.go index db9ca41d4..9461e6b4c 100644 --- a/internal/cnpgi/operator/config/config.go +++ b/internal/cnpgi/operator/config/config.go @@ -70,9 +70,11 @@ type PluginConfiguration struct { BarmanObjectName string ServerName string + SidecarImage string RecoveryBarmanObjectName string RecoveryServerName string + RecoverySidecarImage string ReplicaSourceBarmanObjectName string ReplicaSourceServerName string @@ -163,9 +165,11 @@ func NewFromCluster(cluster *cnpgv1.Cluster) *PluginConfiguration { recoveryServerName := "" recoveryBarmanObjectName := "" + recoverySidecarImage := "" if recoveryParameters := getRecoveryParameters(cluster); recoveryParameters != nil { recoveryBarmanObjectName = recoveryParameters["barmanObjectName"] recoveryServerName = recoveryParameters["serverName"] + recoverySidecarImage = recoveryParameters["sidecarImage"] if len(recoveryServerName) == 0 { recoveryServerName = cluster.Name } @@ -186,9 +190,11 @@ func NewFromCluster(cluster *cnpgv1.Cluster) *PluginConfiguration { // used for the backup/archive BarmanObjectName: helper.Parameters["barmanObjectName"], ServerName: serverName, + SidecarImage: helper.Parameters["sidecarImage"], // used for restore and wal_restore during backup recovery RecoveryServerName: recoveryServerName, RecoveryBarmanObjectName: recoveryBarmanObjectName, + RecoverySidecarImage: recoverySidecarImage, // used for wal_restore in the designed primary of a replica cluster ReplicaSourceServerName: replicaSourceServerName, ReplicaSourceBarmanObjectName: replicaSourceBarmanObjectName, @@ -266,6 +272,20 @@ func getReplicaSourcePlugin(cluster *cnpgv1.Cluster) *cnpgv1.PluginConfiguration return recoveryExternalCluster.PluginConfiguration } +// ResolveSidecarImage returns the image to be used for the sidecar. +// The precedence is: the plugin parameter in spec.plugins[], the plugin +// parameter of the recovery source and, finally, the passed default. +func (config *PluginConfiguration) ResolveSidecarImage(defaultImage string) string { + switch { + case len(config.SidecarImage) > 0: + return config.SidecarImage + case len(config.RecoverySidecarImage) > 0: + return config.RecoverySidecarImage + default: + return defaultImage + } +} + // Validate checks if the barmanObjectName is set func (config *PluginConfiguration) Validate() error { err := NewConfigurationError() diff --git a/internal/cnpgi/operator/config/config_test.go b/internal/cnpgi/operator/config/config_test.go index aaec9fa0b..8bdc3a975 100644 --- a/internal/cnpgi/operator/config/config_test.go +++ b/internal/cnpgi/operator/config/config_test.go @@ -124,3 +124,55 @@ var _ = Describe("NewFromCluster", func() { Expect(cfg.Validate()).NotTo(Succeed()) }) }) + +var _ = Describe("sidecar image resolution", func() { + const defaultImage = "default:latest" + + newCluster := func(pluginImage, recoveryImage string) *cnpgv1.Cluster { + cluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{Name: "cluster", Namespace: "test-ns"}, + Spec: cnpgv1.ClusterSpec{ + Bootstrap: &cnpgv1.BootstrapConfiguration{ + Recovery: &cnpgv1.BootstrapRecovery{Source: "source"}, + }, + ExternalClusters: []cnpgv1.ExternalCluster{{ + Name: "source", + PluginConfiguration: &cnpgv1.PluginConfiguration{ + Name: metadata.PluginName, + Parameters: map[string]string{ + "barmanObjectName": "object-store", + "sidecarImage": recoveryImage, + }, + }, + }}, + }, + } + if pluginImage != "" { + cluster.Spec.Plugins = []cnpgv1.PluginConfiguration{{ + Name: metadata.PluginName, + Parameters: map[string]string{"sidecarImage": pluginImage}, + }} + } + return cluster + } + + It("uses the default when no image is requested", func() { + cfg := NewFromCluster(newCluster("", "")) + Expect(cfg.ResolveSidecarImage(defaultImage)).To(Equal(defaultImage)) + }) + + It("uses the spec.plugins[] image", func() { + cfg := NewFromCluster(newCluster("plugin:1", "")) + Expect(cfg.ResolveSidecarImage(defaultImage)).To(Equal("plugin:1")) + }) + + It("falls back to the recovery source image", func() { + cfg := NewFromCluster(newCluster("", "recovery:1")) + Expect(cfg.ResolveSidecarImage(defaultImage)).To(Equal("recovery:1")) + }) + + It("prefers the spec.plugins[] image over the recovery source image", func() { + cfg := NewFromCluster(newCluster("plugin:1", "recovery:1")) + Expect(cfg.ResolveSidecarImage(defaultImage)).To(Equal("plugin:1")) + }) +}) diff --git a/internal/cnpgi/operator/lifecycle.go b/internal/cnpgi/operator/lifecycle.go index 18730fd1d..7209b3bb7 100644 --- a/internal/cnpgi/operator/lifecycle.go +++ b/internal/cnpgi/operator/lifecycle.go @@ -151,6 +151,7 @@ func (impl LifecycleImplementation) reconcileJob( } return reconcileJob(ctx, cluster, request, sidecarConfiguration{ + image: pluginConfiguration.ResolveSidecarImage(viper.GetString("sidecar-image")), env: env, certificates: certificates, resources: resources, @@ -158,6 +159,7 @@ func (impl LifecycleImplementation) reconcileJob( } type sidecarConfiguration struct { + image string env []corev1.EnvVar certificates []corev1.VolumeProjection resources corev1.ResourceRequirements @@ -249,6 +251,7 @@ func (impl LifecycleImplementation) reconcilePod( } return reconcileInstancePod(ctx, cluster, request, pluginConfiguration, sidecarConfiguration{ + image: pluginConfiguration.ResolveSidecarImage(viper.GetString("sidecar-image")), env: env, certificates: certificates, resources: resources, @@ -450,7 +453,7 @@ func reconcilePodSpec( // fixed values sidecarTemplate.Name = "plugin-barman-cloud" - sidecarTemplate.Image = viper.GetString("sidecar-image") + sidecarTemplate.Image = config.image sidecarTemplate.ImagePullPolicy = cluster.Spec.ImagePullPolicy sidecarTemplate.StartupProbe = baseProbe.DeepCopy() sidecarTemplate.SecurityContext = &corev1.SecurityContext{ diff --git a/internal/cnpgi/operator/lifecycle_test.go b/internal/cnpgi/operator/lifecycle_test.go index 76d658ad5..02bf8146a 100644 --- a/internal/cnpgi/operator/lifecycle_test.go +++ b/internal/cnpgi/operator/lifecycle_test.go @@ -157,6 +157,25 @@ var _ = Describe("LifecycleImplementation", func() { Expect(response.JsonPatch).NotTo(BeEmpty()) }) + It("uses the requested sidecar image", func(ctx SpecContext) { + job := &batchv1.Job{ + TypeMeta: jobTypeMeta, + ObjectMeta: metav1.ObjectMeta{Name: "test-job", Labels: map[string]string{}}, + Spec: batchv1.JobSpec{Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{utils.JobRoleLabelName: fullRecoveryJobName}, + }, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: fullRecoveryJobName}}}, + }}, + } + jobJSON, _ := json.Marshal(job) + request := &lifecycle.OperatorLifecycleRequest{ObjectDefinition: jobJSON} + + response, err := reconcileJob(ctx, cluster, request, sidecarConfiguration{image: "custom:1"}) + Expect(err).NotTo(HaveOccurred()) + Expect(string(response.JsonPatch)).To(ContainSubstring(`"image":"custom:1"`)) + }) + It("skips non-recovery jobs", func(ctx SpecContext) { job := &batchv1.Job{ TypeMeta: jobTypeMeta, @@ -242,6 +261,21 @@ var _ = Describe("LifecycleImplementation", func() { HaveKey("value"))) }) + It("uses the requested sidecar image", func(ctx SpecContext) { + pod := &corev1.Pod{ + TypeMeta: podTypeMeta, + ObjectMeta: metav1.ObjectMeta{Name: "test-pod"}, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "postgres"}}}, + } + podJSON, _ := json.Marshal(pod) + request := &lifecycle.OperatorLifecycleRequest{ObjectDefinition: podJSON} + + response, err := reconcileInstancePod( + ctx, cluster, request, pluginConfiguration, sidecarConfiguration{image: "custom:1"}) + Expect(err).NotTo(HaveOccurred()) + Expect(string(response.JsonPatch)).To(ContainSubstring(`"image":"custom:1"`)) + }) + It("injects the sidecar for a recovery-only cluster", func(ctx SpecContext) { recoveryOnlyConfig := &config.PluginConfiguration{ RecoveryBarmanObjectName: "object-store-recovery", diff --git a/web/docs/parameters.md b/web/docs/parameters.md index ca0cd2bab..00672d31d 100644 --- a/web/docs/parameters.md +++ b/web/docs/parameters.md @@ -11,6 +11,8 @@ The following parameters are available for the Barman Cloud Plugin: - `barmanObjectName`: references the `ObjectStore` resource to be used by the plugin. - `serverName`: Specifies the server name in the object store. +- `sidecarImage`: overrides the [sidecar container image](images.md#sidecar-container-image) + injected into the instance pods of the cluster. :::important The `serverName` parameter in the `ObjectStore` resource is retained solely for From d2c656a6a1163a76ea5eafe87a7c9552a4ca335c Mon Sep 17 00:00:00 2001 From: Marco Nenciarini Date: Tue, 6 Oct 2026 15:05:47 +0200 Subject: [PATCH 2/2] refactor: resolve the sidecar image in one field Resolve the image once in NewFromCluster instead of keeping one field per source. Also read it from the replica source, and warn in the docs that the parameter is meant for debugging. Signed-off-by: Marco Nenciarini --- internal/cnpgi/operator/config/config.go | 30 ++++++----------- internal/cnpgi/operator/config/config_test.go | 32 +++++++++++++++---- internal/cnpgi/operator/lifecycle.go | 5 +-- web/docs/images.md | 9 ++++++ web/docs/parameters.md | 9 +++++- 5 files changed, 55 insertions(+), 30 deletions(-) diff --git a/internal/cnpgi/operator/config/config.go b/internal/cnpgi/operator/config/config.go index 9461e6b4c..1d3c70375 100644 --- a/internal/cnpgi/operator/config/config.go +++ b/internal/cnpgi/operator/config/config.go @@ -20,6 +20,7 @@ SPDX-License-Identifier: Apache-2.0 package config import ( + "cmp" "strings" cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" @@ -70,11 +71,13 @@ type PluginConfiguration struct { BarmanObjectName string ServerName string - SidecarImage string + + // SidecarImage overrides the default sidecar image. It is the first set + // of: spec.plugins[], the recovery source, the replica source. + SidecarImage string RecoveryBarmanObjectName string RecoveryServerName string - RecoverySidecarImage string ReplicaSourceBarmanObjectName string ReplicaSourceServerName string @@ -165,11 +168,9 @@ func NewFromCluster(cluster *cnpgv1.Cluster) *PluginConfiguration { recoveryServerName := "" recoveryBarmanObjectName := "" - recoverySidecarImage := "" if recoveryParameters := getRecoveryParameters(cluster); recoveryParameters != nil { recoveryBarmanObjectName = recoveryParameters["barmanObjectName"] recoveryServerName = recoveryParameters["serverName"] - recoverySidecarImage = recoveryParameters["sidecarImage"] if len(recoveryServerName) == 0 { recoveryServerName = cluster.Name } @@ -190,11 +191,14 @@ func NewFromCluster(cluster *cnpgv1.Cluster) *PluginConfiguration { // used for the backup/archive BarmanObjectName: helper.Parameters["barmanObjectName"], ServerName: serverName, - SidecarImage: helper.Parameters["sidecarImage"], + SidecarImage: cmp.Or( + helper.Parameters["sidecarImage"], + getRecoveryParameters(cluster)["sidecarImage"], + getReplicaSourceParameters(cluster)["sidecarImage"], + ), // used for restore and wal_restore during backup recovery RecoveryServerName: recoveryServerName, RecoveryBarmanObjectName: recoveryBarmanObjectName, - RecoverySidecarImage: recoverySidecarImage, // used for wal_restore in the designed primary of a replica cluster ReplicaSourceServerName: replicaSourceServerName, ReplicaSourceBarmanObjectName: replicaSourceBarmanObjectName, @@ -272,20 +276,6 @@ func getReplicaSourcePlugin(cluster *cnpgv1.Cluster) *cnpgv1.PluginConfiguration return recoveryExternalCluster.PluginConfiguration } -// ResolveSidecarImage returns the image to be used for the sidecar. -// The precedence is: the plugin parameter in spec.plugins[], the plugin -// parameter of the recovery source and, finally, the passed default. -func (config *PluginConfiguration) ResolveSidecarImage(defaultImage string) string { - switch { - case len(config.SidecarImage) > 0: - return config.SidecarImage - case len(config.RecoverySidecarImage) > 0: - return config.RecoverySidecarImage - default: - return defaultImage - } -} - // Validate checks if the barmanObjectName is set func (config *PluginConfiguration) Validate() error { err := NewConfigurationError() diff --git a/internal/cnpgi/operator/config/config_test.go b/internal/cnpgi/operator/config/config_test.go index 8bdc3a975..f0be2b2e8 100644 --- a/internal/cnpgi/operator/config/config_test.go +++ b/internal/cnpgi/operator/config/config_test.go @@ -126,8 +126,6 @@ var _ = Describe("NewFromCluster", func() { }) var _ = Describe("sidecar image resolution", func() { - const defaultImage = "default:latest" - newCluster := func(pluginImage, recoveryImage string) *cnpgv1.Cluster { cluster := &cnpgv1.Cluster{ ObjectMeta: metav1.ObjectMeta{Name: "cluster", Namespace: "test-ns"}, @@ -156,23 +154,43 @@ var _ = Describe("sidecar image resolution", func() { return cluster } - It("uses the default when no image is requested", func() { + It("is empty when no image is requested", func() { cfg := NewFromCluster(newCluster("", "")) - Expect(cfg.ResolveSidecarImage(defaultImage)).To(Equal(defaultImage)) + Expect(cfg.SidecarImage).To(BeEmpty()) }) It("uses the spec.plugins[] image", func() { cfg := NewFromCluster(newCluster("plugin:1", "")) - Expect(cfg.ResolveSidecarImage(defaultImage)).To(Equal("plugin:1")) + Expect(cfg.SidecarImage).To(Equal("plugin:1")) }) It("falls back to the recovery source image", func() { cfg := NewFromCluster(newCluster("", "recovery:1")) - Expect(cfg.ResolveSidecarImage(defaultImage)).To(Equal("recovery:1")) + Expect(cfg.SidecarImage).To(Equal("recovery:1")) }) It("prefers the spec.plugins[] image over the recovery source image", func() { cfg := NewFromCluster(newCluster("plugin:1", "recovery:1")) - Expect(cfg.ResolveSidecarImage(defaultImage)).To(Equal("plugin:1")) + Expect(cfg.SidecarImage).To(Equal("plugin:1")) + }) + + It("falls back to the replica source image", func() { + cluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{Name: "cluster", Namespace: "test-ns"}, + Spec: cnpgv1.ClusterSpec{ + ReplicaCluster: &cnpgv1.ReplicaClusterConfiguration{Source: "source"}, + ExternalClusters: []cnpgv1.ExternalCluster{{ + Name: "source", + PluginConfiguration: &cnpgv1.PluginConfiguration{ + Name: metadata.PluginName, + Parameters: map[string]string{ + "barmanObjectName": "object-store", + "sidecarImage": "replica:1", + }, + }, + }}, + }, + } + Expect(NewFromCluster(cluster).SidecarImage).To(Equal("replica:1")) }) }) diff --git a/internal/cnpgi/operator/lifecycle.go b/internal/cnpgi/operator/lifecycle.go index 7209b3bb7..3b01fcc24 100644 --- a/internal/cnpgi/operator/lifecycle.go +++ b/internal/cnpgi/operator/lifecycle.go @@ -20,6 +20,7 @@ SPDX-License-Identifier: Apache-2.0 package operator import ( + "cmp" "context" "errors" "fmt" @@ -151,7 +152,7 @@ func (impl LifecycleImplementation) reconcileJob( } return reconcileJob(ctx, cluster, request, sidecarConfiguration{ - image: pluginConfiguration.ResolveSidecarImage(viper.GetString("sidecar-image")), + image: cmp.Or(pluginConfiguration.SidecarImage, viper.GetString("sidecar-image")), env: env, certificates: certificates, resources: resources, @@ -251,7 +252,7 @@ func (impl LifecycleImplementation) reconcilePod( } return reconcileInstancePod(ctx, cluster, request, pluginConfiguration, sidecarConfiguration{ - image: pluginConfiguration.ResolveSidecarImage(viper.GetString("sidecar-image")), + image: cmp.Or(pluginConfiguration.SidecarImage, viper.GetString("sidecar-image")), env: env, certificates: certificates, resources: resources, diff --git a/web/docs/images.md b/web/docs/images.md index f6c32d348..652b51bab 100644 --- a/web/docs/images.md +++ b/web/docs/images.md @@ -35,3 +35,12 @@ built from the These sidecar images are designed to work seamlessly with the [`minimal` PostgreSQL container images](https://github.com/cloudnative-pg/postgres-containers?tab=readme-ov-file#minimal-images) maintained by the CloudNativePG Community. + +The image can be overridden per cluster with the +[`sidecarImage` parameter](parameters.md). + +:::warning +This override is meant for debugging and testing. Running a sidecar whose +version differs from the operator's is not recommended. Leave it unset in +production. +::: diff --git a/web/docs/parameters.md b/web/docs/parameters.md index 00672d31d..759e121c4 100644 --- a/web/docs/parameters.md +++ b/web/docs/parameters.md @@ -12,7 +12,14 @@ The following parameters are available for the Barman Cloud Plugin: plugin. - `serverName`: Specifies the server name in the object store. - `sidecarImage`: overrides the [sidecar container image](images.md#sidecar-container-image) - injected into the instance pods of the cluster. + injected into the instance pods and the recovery job pods of the cluster. + It can also be set on a recovery or replica source in `externalClusters[]`. + +:::warning +`sidecarImage` is meant for debugging and testing. Running a sidecar whose +version differs from the operator's is not recommended. Leave it unset in +production. +::: :::important The `serverName` parameter in the `ObjectStore` resource is retained solely for