Compare commits

...

7 Commits

Author SHA1 Message Date
Vaibhav Bhembre
f41c034877 fix: pass --timeline to barman-cloud-check-wal-archive after failover
After a failover/promotion, WAL archiving permanently breaks because
barman-cloud-check-wal-archive rejects the archive with "Expected
empty archive" -- it finds WAL from the previous timeline and treats
it as an error.

Detect the server's current timeline from pg_controldata (reliable
because PostgreSQL's end-of-recovery checkpoint updates the control
file before the archiver starts) and pass it via --timeline to the
check command. WAL from earlier timelines is then accepted as
expected, while WAL from the current timeline in the archive still
correctly triggers the safety check.

Timeline detection is scoped strictly inside the one-time
empty-archive check gate (.check-empty-wal-archive flag file).
Steady-state WAL archiving is completely unaffected.

During restore/bootstrap, timeline 0 is passed so the check remains
strict (archive must be empty).

Fixes: #842
Related: #828

Assisted-by: Claude Opus 4.6
Assisted-by: GPT-5.4 in Cursor
Signed-off-by: Vaibhav Bhembre <vbhembre@coreweave.com>
2026-04-13 16:46:20 +02:00
renovate[bot]
441f43be24
fix(deps): update module github.com/cert-manager/cert-manager to v1.20.2 (#844)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-13 16:43:18 +02:00
renovate[bot]
e72c779a29
chore(deps): refresh pip-compile outputs (#846)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-13 16:42:16 +02:00
renovate[bot]
ddb222a30f
chore(deps): lock file maintenance (#845)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-13 16:41:45 +02:00
Armando Ruocco
8971a397f4
fix(rbac): reconcile Role when ObjectStore spec changes (#823)
When an ObjectStore's credentials change (e.g., secret rename), the
RBAC Role granting the Cluster's ServiceAccount access to those
secrets was not updated because nothing triggered a Cluster
reconciliation.

Implement the ObjectStore controller's Reconcile to detect affected
Roles and update their rules directly, without needing access to
Cluster objects. The controller lists Roles by a label set by the
Pre hook, inspects their rules to find which ObjectStores they
reference, fetches those ObjectStores, and patches the rules to
match the current specs.

Replace the custom setOwnerReference helper with
controllerutil.SetControllerReference. Add dynamic CNPG scheme
registration (internal/scheme) to the operator, instance, and
restore managers.

Signed-off-by: Armando Ruocco <armando.ruocco@enterprisedb.com>
Signed-off-by: Marco Nenciarini <marco.nenciarini@enterprisedb.com>
Signed-off-by: Gabriele Quaresima <gabriele.quaresima@enterprisedb.com>
Co-authored-by: Marco Nenciarini <marco.nenciarini@enterprisedb.com>
Co-authored-by: Gabriele Quaresima <gabriele.quaresima@enterprisedb.com>
2026-04-13 15:48:35 +02:00
Armando Ruocco
b1f373d84b
fix(restore): use custom CNPG group and version for scheme registration (#847)
The restore manager used a hardcoded scheme registration that ignored
CUSTOM_CNPG_GROUP and CUSTOM_CNPG_VERSION, causing restore jobs to fail
under non-standard CNPG API groups.

Extract `generateScheme()` into `common.GenerateScheme()` and use it
from both instance and restore managers.

Signed-off-by: Armando Ruocco <armando.ruocco@enterprisedb.com>
Signed-off-by: Marco Nenciarini <marco.nenciarini@enterprisedb.com>
Co-authored-by: Marco Nenciarini <marco.nenciarini@enterprisedb.com>
2026-04-13 13:20:38 +02:00
Marco Nenciarini
7f92182884
test(e2e): use correct image name in kustomize overlay (#841)
Some checks failed
release-please / release-please (push) Failing after 3s
The e2e kustomize overlay tried to match
`docker.io/library/plugin-barman-cloud` but the base kustomization at
`kubernetes/kustomization.yaml` already transforms the bare
`plugin-barman-cloud` image to the GHCR name. The overlay must match
the base's output (`ghcr.io/cloudnative-pg/plugin-barman-cloud-testing`)
to override it.

Broken since b7daaac (#89) changed the base kustomization's newName
from `docker.io/library/plugin-barman-cloud` to the GHCR image without
updating the e2e overlay.

Closes #840

Signed-off-by: Marco Nenciarini <marco.nenciarini@enterprisedb.com>
2026-04-11 13:31:31 +02:00
34 changed files with 2160 additions and 319 deletions

View File

@ -22,13 +22,13 @@ barman==3.18.0 \
--hash=sha256:8e752ac93d2f3a61e86b8374185209cae477a638ece7e6f540070f36d28d6997 \
--hash=sha256:ff90c44dafa4107b7574142771cdc2611c4cf1af818d93d3e67440a0c81164b9
# via -r sidecar-requirements.in
boto3==1.42.86 \
--hash=sha256:492c3c7cbbe9842882680064902f50cf711b5ab770d26525549872339ed95d5b \
--hash=sha256:c87d2a750b1a8cad0384d1a83d3bad6aedf924ae9a14aaba814bcb3297b39c01
boto3==1.42.88 \
--hash=sha256:2d0f52c971503377e4370d2a83edee6f077ddb8e684366ff38df4f13581d9cfc \
--hash=sha256:2d22c70de5726918676a06f1a03acfb4d5d9ea92fc759354800b67b22aaeef19
# via barman
botocore==1.42.86 \
--hash=sha256:443387337864e069f7e4e885ccdc81592725b5598ca966514af3e9776bce0bfe \
--hash=sha256:baa49e93b4c92d63e0c8288026ee1ef8de83f182743127cc9175504440a48e49
botocore==1.42.88 \
--hash=sha256:032375b213305b6b81eedb269eaeefdf96f674620799bbf96117dca86052cc1a \
--hash=sha256:cbb59ee464662039b0c2c95a520cdf85b1e8ce00b72375ab9cd9f842cc001301
# via
# boto3
# s3transfer
@ -446,15 +446,15 @@ cryptography==46.0.7 \
# google-auth
# msal
# pyjwt
google-api-core==2.30.2 \
--hash=sha256:9a8113e1a88bdc09a7ff629707f2214d98d61c7f6ceb0ea38c42a095d02dc0f9 \
--hash=sha256:a4c226766d6af2580577db1f1a51bf53cd262f722b49731ce7414c43068a9594
google-api-core==2.30.3 \
--hash=sha256:a85761ba72c444dad5d611c2220633480b2b6be2521eca69cca2dbb3ffd6bfe8 \
--hash=sha256:e601a37f148585319b26db36e219df68c5d07b6382cff2d580e83404e44d641b
# via
# google-cloud-core
# google-cloud-storage
google-auth==2.49.1 \
--hash=sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64 \
--hash=sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7
google-auth==2.49.2 \
--hash=sha256:c1ae38500e73065dcae57355adb6278cf8b5c8e391994ae9cbadbcb9631ab409 \
--hash=sha256:c2720924dfc82dedb962c9f52cabb2ab16714fd0a6a707e40561d217574ed6d5
# via
# google-api-core
# google-cloud-core

10
go.mod
View File

@ -5,7 +5,7 @@ go 1.25.0
toolchain go1.26.2
require (
github.com/cert-manager/cert-manager v1.20.1
github.com/cert-manager/cert-manager v1.20.2
github.com/cloudnative-pg/api v1.29.0
github.com/cloudnative-pg/barman-cloud v0.5.0
github.com/cloudnative-pg/cloudnative-pg v1.29.0
@ -102,12 +102,12 @@ require (
github.com/xlab/treeprint v1.2.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect

24
go.sum
View File

@ -14,8 +14,8 @@ 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/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cert-manager/cert-manager v1.20.1 h1:99ExHJu5TPp1V92AvvE4oY6BkOSyJiWLxxMkbqbdGaY=
github.com/cert-manager/cert-manager v1.20.1/go.mod h1:ut67FnggYJJqAdDWLhSPnj10P06QwbNU88RYNh9MvMc=
github.com/cert-manager/cert-manager v1.20.2 h1:CimnY00nLqB2lmxhoSuEC4GDMFDK7JCXqyjwMM9ndIQ=
github.com/cert-manager/cert-manager v1.20.2/go.mod h1:1g/+a/WK5zWH/dXPZa3dMD3aJQJNRXQu+PN17C6WrOw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudnative-pg/api v1.29.0 h1:mNx6yJ5qi+Xrjs0NYrUy6V4MlXBkVJxGKwvTJZIuTX4=
@ -243,20 +243,20 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 h1:EtFWSnwW9hGObjkIdmlnWSydO+Qs8OwzfzXLUPg4xOc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h1:QjUEoiGCPkvFZ/MjK6ZZfNOS6mfVEVKYE99dFhuN2LI=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os=
go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=

View File

@ -57,6 +57,8 @@ func NewCmd() *cobra.Command {
_ = viper.BindEnv("pod-name", "POD_NAME")
_ = viper.BindEnv("pgdata", "PGDATA")
_ = viper.BindEnv("spool-directory", "SPOOL_DIRECTORY")
_ = viper.BindEnv("custom-cnpg-group", "CUSTOM_CNPG_GROUP")
_ = viper.BindEnv("custom-cnpg-version", "CUSTOM_CNPG_VERSION")
return cmd
}

View File

@ -28,21 +28,33 @@ import (
)
// CheckBackupDestination checks if the backup destination is suitable
// to archive WALs
// to archive WALs.
//
// The timeline parameter, when > 0, is passed to
// barman-cloud-check-wal-archive via --timeline so that WAL from earlier
// timelines (expected after a failover) does not cause the check to fail.
func CheckBackupDestination(
ctx context.Context,
barmanConfiguration *cnpgv1.BarmanObjectStoreConfiguration,
barmanArchiver *archiver.WALArchiver,
serverName string,
timeline int,
) error {
contextLogger := log.FromContext(ctx)
contextLogger.Info(
"Checking backup destination with barman-cloud-wal-archive",
"serverName", serverName)
"Checking backup destination with barman-cloud-check-wal-archive",
"serverName", serverName,
"timeline", timeline)
// Build options, passing --timeline when available so that WAL from
// earlier timelines in the archive is accepted after a failover.
var opts []archiver.CheckWalArchiveOption
if timeline > 0 {
opts = append(opts, archiver.WithTimeline(timeline))
}
// Get WAL archive options
checkWalOptions, err := barmanArchiver.BarmanCloudCheckWalArchiveOptions(
ctx, barmanConfiguration, serverName)
ctx, barmanConfiguration, serverName, opts...)
if err != nil {
log.Error(err, "while getting barman-cloud-wal-archive options")
return err

View File

@ -0,0 +1,67 @@
/*
Copyright © contributors to CloudNativePG, established as
CloudNativePG a Series of LF Projects, LLC.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
SPDX-License-Identifier: Apache-2.0
*/
package common
import (
"context"
cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
"github.com/cloudnative-pg/machinery/pkg/log"
"github.com/spf13/viper"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"sigs.k8s.io/controller-runtime/pkg/scheme"
barmancloudv1 "github.com/cloudnative-pg/plugin-barman-cloud/api/v1"
)
// GenerateScheme creates a runtime.Scheme object with all the
// definition needed to support the sidecar. This allows
// the plugin to be used in every CNPG-based operator.
func GenerateScheme(ctx context.Context) *runtime.Scheme {
result := runtime.NewScheme()
utilruntime.Must(barmancloudv1.AddToScheme(result))
utilruntime.Must(clientgoscheme.AddToScheme(result))
cnpgGroup := viper.GetString("custom-cnpg-group")
cnpgVersion := viper.GetString("custom-cnpg-version")
if len(cnpgGroup) == 0 {
cnpgGroup = cnpgv1.SchemeGroupVersion.Group
}
if len(cnpgVersion) == 0 {
cnpgVersion = cnpgv1.SchemeGroupVersion.Version
}
// Proceed with custom registration of the CNPG scheme
schemeGroupVersion := schema.GroupVersion{Group: cnpgGroup, Version: cnpgVersion}
schemeBuilder := &scheme.Builder{GroupVersion: schemeGroupVersion}
schemeBuilder.Register(&cnpgv1.Cluster{}, &cnpgv1.ClusterList{})
schemeBuilder.Register(&cnpgv1.Backup{}, &cnpgv1.BackupList{})
schemeBuilder.Register(&cnpgv1.ScheduledBackup{}, &cnpgv1.ScheduledBackupList{})
utilruntime.Must(schemeBuilder.AddToScheme(result))
schemeLog := log.FromContext(ctx)
schemeLog.Info("CNPG types registration", "schemeGroupVersion", schemeGroupVersion)
return result
}

View File

@ -0,0 +1,92 @@
/*
Copyright © contributors to CloudNativePG, established as
CloudNativePG a Series of LF Projects, LLC.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
SPDX-License-Identifier: Apache-2.0
*/
package common
import (
"context"
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
"github.com/cloudnative-pg/machinery/pkg/log"
)
var timelineRe = regexp.MustCompile(`Latest checkpoint's TimeLineID:\s+(\d+)`)
// currentTimeline returns the server's current PostgreSQL timeline by
// parsing pg_controldata output.
//
// This is reliable for the promotion case because PostgreSQL performs a
// synchronous end-of-recovery checkpoint (which updates the control file)
// before the server starts accepting connections and before the WAL
// archiver is signaled. By the time this function is called during the
// first WAL archive attempt, the control file reflects the promoted
// timeline.
//
// Returns an error if the timeline cannot be determined. Callers must NOT
// silently fall back to omitting --timeline, as that reintroduces the
// original "Expected empty archive" bug after failover.
func currentTimeline(ctx context.Context, pgDataPath string) (int, error) {
contextLogger := log.FromContext(ctx)
cmd := exec.CommandContext(ctx, "pg_controldata", pgDataPath) // #nosec G204
cmd.Env = append(cmd.Environ(), "LC_ALL=C")
out, err := cmd.Output()
if err != nil {
return 0, fmt.Errorf(
"pg_controldata exec failed (PGDATA=%s): %w; "+
"WAL archive check cannot run safely without a timeline — "+
"set annotation cnpg.io/skipEmptyWalArchiveCheck=enabled "+
"as a manual workaround",
pgDataPath, err)
}
tl, err := parseTimelineIDFromPgControldataOutput(string(out), pgDataPath)
if err != nil {
return 0, err
}
contextLogger.Info("Detected PostgreSQL timeline from pg_controldata",
"timeline", tl)
return tl, nil
}
// parseTimelineIDFromPgControldataOutput extracts Latest checkpoint's TimeLineID
// from pg_controldata stdout. pgDataPath is used only in error messages.
func parseTimelineIDFromPgControldataOutput(out string, pgDataPath string) (int, error) {
matches := timelineRe.FindStringSubmatch(out)
if len(matches) < 2 {
return 0, fmt.Errorf(
"could not parse TimeLineID from pg_controldata output "+
"(PGDATA=%s); set annotation "+
"cnpg.io/skipEmptyWalArchiveCheck=enabled as a manual "+
"workaround",
pgDataPath)
}
tl, err := strconv.Atoi(strings.TrimSpace(matches[1]))
if err != nil {
return 0, fmt.Errorf("parse timeline %q: %w", matches[1], err)
}
return tl, nil
}

View File

@ -0,0 +1,99 @@
/*
Copyright © contributors to CloudNativePG, established as
CloudNativePG a Series of LF Projects, LLC.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
SPDX-License-Identifier: Apache-2.0
*/
package common
import (
"strings"
"testing"
)
func TestParseTimelineIDFromPgControldataOutput(t *testing.T) {
pgData := "/var/lib/postgresql/data/pgdata"
tests := []struct {
name string
out string
want int
wantErr bool
errHasPGData bool // if true, error must mention pgData path (parse-not-found cases)
}{
{
name: "typical_pg_controldata_snippet",
out: `
Database cluster state: in production
Latest checkpoint location: 0/3000028
Latest checkpoint's REDO location: 0/3000028
Latest checkpoint's TimeLineID: 2
Latest checkpoint's REDO WAL file: 000000010000000000000003
`,
want: 2,
wantErr: false,
},
{
name: "timeline_one",
out: `Latest checkpoint's TimeLineID: 1
`,
want: 1,
wantErr: false,
},
{
name: "missing_timeline_line",
out: "Database cluster state: in production\n",
want: 0,
wantErr: true,
errHasPGData: true,
},
{
name: "empty",
out: "",
want: 0,
wantErr: true,
errHasPGData: true,
},
{
name: "overflow_timeline",
out: `Latest checkpoint's TimeLineID: 999999999999999999999999999999999999
`,
want: 0,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseTimelineIDFromPgControldataOutput(tt.out, pgData)
if tt.wantErr {
if err == nil {
t.Fatal("expected error")
}
if tt.errHasPGData && !strings.Contains(err.Error(), pgData) {
t.Errorf("error should mention PGDATA path: %v", err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("got %d, want %d", got, tt.want)
}
})
}
}

View File

@ -155,18 +155,34 @@ func (w WALServiceImplementation) Archive(
return nil, err
}
// Step 2: Check if the archive location is safe to perform archiving
// Step 2: Check if the archive location is safe to perform archiving.
// This is a one-time check gated by the .check-empty-wal-archive flag
// file, which is deleted after the first successful WAL archive.
// Timeline detection only runs here — steady-state archiving is
// completely unaffected.
checkFileExisting, err := fileutils.FileExists(emptyWalArchiveFile)
if err != nil {
return nil, fmt.Errorf("while checking for empty wal archive check file %q: %w", emptyWalArchiveFile, err)
}
if utils.IsEmptyWalArchiveCheckEnabled(&configuration.Cluster.ObjectMeta) && checkFileExisting {
// Detect the server's current timeline from pg_controldata so
// barman-cloud-check-wal-archive can tolerate WAL from earlier
// timelines in the archive (expected after a failover).
timeline, err := currentTimeline(ctx, w.PGDataPath)
if err != nil {
contextLogger.Error(err,
"Cannot determine PostgreSQL timeline for WAL archive check; "+
"archive attempt will be retried by PostgreSQL")
return nil, err
}
if err := CheckBackupDestination(
ctx,
&objectStore.Spec.Configuration,
arch,
configuration.ServerName,
timeline,
); err != nil {
return nil, err
}

View File

@ -27,22 +27,18 @@ import (
"github.com/cloudnative-pg/machinery/pkg/log"
"github.com/spf13/viper"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/scheme"
barmancloudv1 "github.com/cloudnative-pg/plugin-barman-cloud/api/v1"
"github.com/cloudnative-pg/plugin-barman-cloud/internal/cnpgi/common"
extendedclient "github.com/cloudnative-pg/plugin-barman-cloud/internal/cnpgi/instance/internal/client"
)
// Start starts the sidecar informers and CNPG-i server
func Start(ctx context.Context) error {
scheme := generateScheme(ctx)
scheme := common.GenerateScheme(ctx)
setupLog := log.FromContext(ctx)
setupLog.Info("Starting barman cloud instance plugin")
@ -118,35 +114,3 @@ func Start(ctx context.Context) error {
return nil
}
// generateScheme creates a runtime.Scheme object with all the
// definition needed to support the sidecar. This allows
// the plugin to be used in every CNPG-based operator.
func generateScheme(ctx context.Context) *runtime.Scheme {
result := runtime.NewScheme()
utilruntime.Must(barmancloudv1.AddToScheme(result))
utilruntime.Must(clientgoscheme.AddToScheme(result))
cnpgGroup := viper.GetString("custom-cnpg-group")
cnpgVersion := viper.GetString("custom-cnpg-version")
if len(cnpgGroup) == 0 {
cnpgGroup = cnpgv1.SchemeGroupVersion.Group
}
if len(cnpgVersion) == 0 {
cnpgVersion = cnpgv1.SchemeGroupVersion.Version
}
// Proceed with custom registration of the CNPG scheme
schemeGroupVersion := schema.GroupVersion{Group: cnpgGroup, Version: cnpgVersion}
schemeBuilder := &scheme.Builder{GroupVersion: schemeGroupVersion}
schemeBuilder.Register(&cnpgv1.Cluster{}, &cnpgv1.ClusterList{})
schemeBuilder.Register(&cnpgv1.Backup{}, &cnpgv1.BackupList{})
schemeBuilder.Register(&cnpgv1.ScheduledBackup{}, &cnpgv1.ScheduledBackupList{})
utilruntime.Must(schemeBuilder.AddToScheme(result))
schemeLog := log.FromContext(ctx)
schemeLog.Info("CNPG types registration", "schemeGroupVersion", schemeGroupVersion)
return result
}

View File

@ -26,6 +26,10 @@ import "github.com/cloudnative-pg/cnpg-i/pkg/identity"
const PluginName = "barman-cloud.cloudnative-pg.io"
const (
// ClusterLabelName is the label applied to RBAC resources created
// by this plugin. Its value is the name of the owning Cluster.
ClusterLabelName = "barmancloud.cnpg.io/cluster"
// CheckEmptyWalArchiveFile is the name of the file in the PGDATA that,
// if present, requires the WAL archiver to check that the backup object
// store is empty.

View File

@ -24,7 +24,6 @@ import (
"crypto/tls"
// +kubebuilder:scaffold:imports
cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
"github.com/cloudnative-pg/machinery/pkg/log"
"github.com/spf13/viper"
"k8s.io/apimachinery/pkg/runtime"
@ -49,7 +48,6 @@ var scheme = runtime.NewScheme()
func init() {
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
utilruntime.Must(barmancloudv1.AddToScheme(scheme))
utilruntime.Must(cnpgv1.AddToScheme(scheme))
// +kubebuilder:scaffold:scheme
}

View File

@ -0,0 +1,22 @@
/*
Copyright © contributors to CloudNativePG, established as
CloudNativePG a Series of LF Projects, LLC.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
SPDX-License-Identifier: Apache-2.0
*/
// Package rbac contains utilities to reconcile RBAC resources
// for the barman-cloud plugin.
package rbac

View File

@ -0,0 +1,181 @@
/*
Copyright © contributors to CloudNativePG, established as
CloudNativePG a Series of LF Projects, LLC.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
SPDX-License-Identifier: Apache-2.0
*/
package rbac
import (
"context"
cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
"github.com/cloudnative-pg/machinery/pkg/log"
rbacv1 "k8s.io/api/rbac/v1"
"k8s.io/apimachinery/pkg/api/equality"
apierrs "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/client-go/util/retry"
"sigs.k8s.io/controller-runtime/pkg/client"
barmancloudv1 "github.com/cloudnative-pg/plugin-barman-cloud/api/v1"
"github.com/cloudnative-pg/plugin-barman-cloud/internal/cnpgi/metadata"
"github.com/cloudnative-pg/plugin-barman-cloud/internal/cnpgi/operator/specs"
)
// EnsureRole ensures the RBAC Role for the given Cluster matches
// the desired state derived from the given ObjectStores. On creation,
// the Cluster is set as the owner of the Role for garbage collection.
//
// This function is called from the Pre hook (gRPC). It creates the
// Role if it does not exist, then patches rules and labels to match
// the desired state.
//
// Note: the ObjectStore controller (EnsureRoleRules) can patch the
// same Role concurrently. Both paths use RetryOnConflict but compute
// desired rules from their own view of ObjectStores. If the Pre hook
// reads stale ObjectStore data from the informer cache, it may
// briefly revert a fresher update. This is self-healing: the next
// ObjectStore reconcile restores the correct state.
func EnsureRole(
ctx context.Context,
c client.Client,
cluster *cnpgv1.Cluster,
barmanObjects []barmancloudv1.ObjectStore,
) error {
newRole := specs.BuildRole(cluster, barmanObjects)
roleKey := client.ObjectKeyFromObject(newRole)
if err := ensureRoleExists(ctx, c, cluster, newRole); err != nil {
return err
}
return patchRole(ctx, c, roleKey, newRole.Rules, map[string]string{
metadata.ClusterLabelName: cluster.Name,
})
}
// EnsureRoleRules updates the rules of an existing Role to match
// the desired state derived from the given ObjectStores. Unlike
// EnsureRole, this function does not create Roles or set owner
// references — it only patches rules on Roles that already exist.
// It is intended for the ObjectStore controller path where no
// Cluster object is available. Returns nil if the Role does not
// exist (the Pre hook has not created it yet).
func EnsureRoleRules(
ctx context.Context,
c client.Client,
roleKey client.ObjectKey,
barmanObjects []barmancloudv1.ObjectStore,
) error {
err := patchRole(ctx, c, roleKey, specs.BuildRoleRules(barmanObjects), nil)
if apierrs.IsNotFound(err) {
log.FromContext(ctx).Debug("Role not found, skipping rule update",
"name", roleKey.Name, "namespace", roleKey.Namespace)
return nil
}
return err
}
// ensureRoleExists creates the Role if it does not exist. Returns
// nil on success and nil on AlreadyExists (another writer created
// it concurrently). The caller always follows up with patchRole.
func ensureRoleExists(
ctx context.Context,
c client.Client,
cluster *cnpgv1.Cluster,
newRole *rbacv1.Role,
) error {
contextLogger := log.FromContext(ctx)
var existing rbacv1.Role
err := c.Get(ctx, client.ObjectKeyFromObject(newRole), &existing)
if err == nil {
return nil
}
if !apierrs.IsNotFound(err) {
return err
}
if err := specs.SetControllerReference(cluster, newRole); err != nil {
return err
}
contextLogger.Info("Creating role",
"name", newRole.Name, "namespace", newRole.Namespace)
createErr := c.Create(ctx, newRole)
if createErr == nil || apierrs.IsAlreadyExists(createErr) {
return nil
}
return createErr
}
// patchRole patches the Role's rules and optionally its labels to
// match the desired state. When desiredLabels is nil, labels are
// not modified. Uses retry.RetryOnConflict for concurrent
// modification handling.
func patchRole(
ctx context.Context,
c client.Client,
roleKey client.ObjectKey,
desiredRules []rbacv1.PolicyRule,
desiredLabels map[string]string,
) error {
return retry.RetryOnConflict(retry.DefaultBackoff, func() error {
var role rbacv1.Role
if err := c.Get(ctx, roleKey, &role); err != nil {
return err
}
rulesMatch := equality.Semantic.DeepEqual(desiredRules, role.Rules)
labelsMatch := desiredLabels == nil || !labelsNeedUpdate(role.Labels, desiredLabels)
if rulesMatch && labelsMatch {
return nil
}
contextLogger := log.FromContext(ctx)
contextLogger.Info("Patching role",
"name", role.Name, "namespace", role.Namespace)
oldRole := role.DeepCopy()
role.Rules = desiredRules
if desiredLabels != nil {
if role.Labels == nil {
role.Labels = make(map[string]string, len(desiredLabels))
}
for k, v := range desiredLabels {
role.Labels[k] = v
}
}
return c.Patch(ctx, &role, client.MergeFrom(oldRole))
})
}
// labelsNeedUpdate returns true if any key in desired is missing
// or has a different value in existing.
func labelsNeedUpdate(existing, desired map[string]string) bool {
for k, v := range desired {
if existing[k] != v {
return true
}
}
return false
}

View File

@ -0,0 +1,359 @@
/*
Copyright © contributors to CloudNativePG, established as
CloudNativePG a Series of LF Projects, LLC.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
SPDX-License-Identifier: Apache-2.0
*/
package rbac_test
import (
"context"
"fmt"
barmanapi "github.com/cloudnative-pg/barman-cloud/pkg/api"
cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
machineryapi "github.com/cloudnative-pg/machinery/pkg/api"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
rbacv1 "k8s.io/api/rbac/v1"
apierrs "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
barmancloudv1 "github.com/cloudnative-pg/plugin-barman-cloud/api/v1"
"github.com/cloudnative-pg/plugin-barman-cloud/internal/cnpgi/metadata"
"github.com/cloudnative-pg/plugin-barman-cloud/internal/cnpgi/operator/rbac"
)
func newScheme() *runtime.Scheme {
s := runtime.NewScheme()
utilruntime.Must(rbacv1.AddToScheme(s))
utilruntime.Must(cnpgv1.AddToScheme(s))
utilruntime.Must(barmancloudv1.AddToScheme(s))
return s
}
func newCluster(name, namespace string) *cnpgv1.Cluster {
return &cnpgv1.Cluster{
TypeMeta: metav1.TypeMeta{
APIVersion: cnpgv1.SchemeGroupVersion.String(),
Kind: cnpgv1.ClusterKind,
},
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
},
}
}
func newObjectStore(name, namespace, secretName string) barmancloudv1.ObjectStore {
return barmancloudv1.ObjectStore{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
},
Spec: barmancloudv1.ObjectStoreSpec{
Configuration: barmanapi.BarmanObjectStoreConfiguration{
DestinationPath: "s3://bucket/path",
BarmanCredentials: barmanapi.BarmanCredentials{
AWS: &barmanapi.S3Credentials{
AccessKeyIDReference: &machineryapi.SecretKeySelector{
LocalObjectReference: machineryapi.LocalObjectReference{
Name: secretName,
},
Key: "ACCESS_KEY_ID",
},
},
},
},
},
}
}
var _ = Describe("EnsureRole", func() {
var (
ctx context.Context
cluster *cnpgv1.Cluster
objects []barmancloudv1.ObjectStore
fakeClient client.Client
)
BeforeEach(func() {
ctx = context.Background()
cluster = newCluster("test-cluster", "default")
objects = []barmancloudv1.ObjectStore{
newObjectStore("my-store", "default", "aws-creds"),
}
})
Context("when the Role does not exist", func() {
BeforeEach(func() {
fakeClient = fake.NewClientBuilder().WithScheme(newScheme()).Build()
})
It("should create the Role with owner reference and label", func() {
err := rbac.EnsureRole(ctx, fakeClient, cluster, objects)
Expect(err).NotTo(HaveOccurred())
var role rbacv1.Role
err = fakeClient.Get(ctx, client.ObjectKey{
Namespace: "default",
Name: "test-cluster-barman-cloud",
}, &role)
Expect(err).NotTo(HaveOccurred())
Expect(role.Rules).To(HaveLen(3))
Expect(role.OwnerReferences).To(HaveLen(1))
Expect(role.OwnerReferences[0].Name).To(Equal("test-cluster"))
Expect(role.OwnerReferences[0].Kind).To(Equal("Cluster"))
Expect(role.Labels).To(HaveKeyWithValue(metadata.ClusterLabelName, "test-cluster"))
})
})
Context("when the Role exists with matching rules", func() {
BeforeEach(func() {
fakeClient = fake.NewClientBuilder().WithScheme(newScheme()).Build()
Expect(rbac.EnsureRole(ctx, fakeClient, cluster, objects)).To(Succeed())
})
It("should not patch the Role", func() {
var before rbacv1.Role
Expect(fakeClient.Get(ctx, client.ObjectKey{
Namespace: "default",
Name: "test-cluster-barman-cloud",
}, &before)).To(Succeed())
err := rbac.EnsureRole(ctx, fakeClient, cluster, objects)
Expect(err).NotTo(HaveOccurred())
var after rbacv1.Role
Expect(fakeClient.Get(ctx, client.ObjectKey{
Namespace: "default",
Name: "test-cluster-barman-cloud",
}, &after)).To(Succeed())
Expect(after.ResourceVersion).To(Equal(before.ResourceVersion))
})
})
Context("when the Role exists with different rules", func() {
BeforeEach(func() {
fakeClient = fake.NewClientBuilder().WithScheme(newScheme()).Build()
oldObjects := []barmancloudv1.ObjectStore{
newObjectStore("my-store", "default", "old-secret"),
}
Expect(rbac.EnsureRole(ctx, fakeClient, cluster, oldObjects)).To(Succeed())
})
It("should patch the Role with new rules and preserve owner reference", func() {
err := rbac.EnsureRole(ctx, fakeClient, cluster, objects)
Expect(err).NotTo(HaveOccurred())
var role rbacv1.Role
Expect(fakeClient.Get(ctx, client.ObjectKey{
Namespace: "default",
Name: "test-cluster-barman-cloud",
}, &role)).To(Succeed())
secretsRule := role.Rules[2]
Expect(secretsRule.ResourceNames).To(ContainElement("aws-creds"))
Expect(secretsRule.ResourceNames).NotTo(ContainElement("old-secret"))
Expect(role.OwnerReferences).To(HaveLen(1))
Expect(role.OwnerReferences[0].Name).To(Equal("test-cluster"))
})
})
Context("when Role creation fails with a transient error", func() {
BeforeEach(func() {
internalErr := apierrs.NewInternalError(fmt.Errorf("etcd timeout"))
fakeClient = fake.NewClientBuilder().
WithScheme(newScheme()).
WithInterceptorFuncs(interceptor.Funcs{
Create: func(ctx context.Context, c client.WithWatch, obj client.Object, opts ...client.CreateOption) error {
return internalErr
},
}).
Build()
})
It("should propagate the error", func() {
err := rbac.EnsureRole(ctx, fakeClient, cluster, objects)
Expect(err).To(HaveOccurred())
Expect(apierrs.IsInternalError(err)).To(BeTrue())
})
})
Context("when the Role has pre-existing unrelated labels", func() {
BeforeEach(func() {
fakeClient = fake.NewClientBuilder().WithScheme(newScheme()).Build()
existing := &rbacv1.Role{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cluster-barman-cloud",
Namespace: "default",
Labels: map[string]string{
"app.kubernetes.io/managed-by": "helm",
},
},
}
Expect(fakeClient.Create(ctx, existing)).To(Succeed())
})
It("should preserve unrelated labels while adding the cluster label", func() {
err := rbac.EnsureRole(ctx, fakeClient, cluster, objects)
Expect(err).NotTo(HaveOccurred())
var role rbacv1.Role
Expect(fakeClient.Get(ctx, client.ObjectKey{
Namespace: "default",
Name: "test-cluster-barman-cloud",
}, &role)).To(Succeed())
Expect(role.Labels).To(HaveKeyWithValue("app.kubernetes.io/managed-by", "helm"))
Expect(role.Labels).To(HaveKeyWithValue(metadata.ClusterLabelName, "test-cluster"))
})
})
Context("when the Role exists without the cluster label (upgrade scenario)", func() {
BeforeEach(func() {
fakeClient = fake.NewClientBuilder().WithScheme(newScheme()).Build()
// Create a Role without the label (simulates pre-upgrade state)
unlabeledRole := &rbacv1.Role{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cluster-barman-cloud",
Namespace: "default",
},
Rules: []rbacv1.PolicyRule{},
}
Expect(fakeClient.Create(ctx, unlabeledRole)).To(Succeed())
})
It("should add the label and update rules", func() {
err := rbac.EnsureRole(ctx, fakeClient, cluster, objects)
Expect(err).NotTo(HaveOccurred())
var role rbacv1.Role
Expect(fakeClient.Get(ctx, client.ObjectKey{
Namespace: "default",
Name: "test-cluster-barman-cloud",
}, &role)).To(Succeed())
Expect(role.Labels).To(HaveKeyWithValue(metadata.ClusterLabelName, "test-cluster"))
Expect(role.Rules).To(HaveLen(3))
})
})
})
var _ = Describe("EnsureRoleRules", func() {
var (
ctx context.Context
fakeClient client.Client
objects []barmancloudv1.ObjectStore
)
BeforeEach(func() {
ctx = context.Background()
objects = []barmancloudv1.ObjectStore{
newObjectStore("my-store", "default", "aws-creds"),
}
})
Context("when the Role exists", func() {
BeforeEach(func() {
fakeClient = fake.NewClientBuilder().WithScheme(newScheme()).Build()
// Seed a labeled Role with old rules
cluster := newCluster("test-cluster", "default")
oldObjects := []barmancloudv1.ObjectStore{
newObjectStore("my-store", "default", "old-secret"),
}
Expect(rbac.EnsureRole(ctx, fakeClient, cluster, oldObjects)).To(Succeed())
})
It("should patch the rules", func() {
roleKey := client.ObjectKey{
Namespace: "default",
Name: "test-cluster-barman-cloud",
}
err := rbac.EnsureRoleRules(ctx, fakeClient, roleKey, objects)
Expect(err).NotTo(HaveOccurred())
var role rbacv1.Role
Expect(fakeClient.Get(ctx, roleKey, &role)).To(Succeed())
secretsRule := role.Rules[2]
Expect(secretsRule.ResourceNames).To(ContainElement("aws-creds"))
Expect(secretsRule.ResourceNames).NotTo(ContainElement("old-secret"))
})
It("should not patch when rules already match", func() {
// Seed with the same objects so rules match
cluster := newCluster("test-cluster", "default")
Expect(rbac.EnsureRole(ctx, fakeClient, cluster, objects)).To(Succeed())
roleKey := client.ObjectKey{
Namespace: "default",
Name: "test-cluster-barman-cloud",
}
var before rbacv1.Role
Expect(fakeClient.Get(ctx, roleKey, &before)).To(Succeed())
Expect(rbac.EnsureRoleRules(ctx, fakeClient, roleKey, objects)).To(Succeed())
var after rbacv1.Role
Expect(fakeClient.Get(ctx, roleKey, &after)).To(Succeed())
Expect(after.ResourceVersion).To(Equal(before.ResourceVersion))
})
It("should not modify labels", func() {
roleKey := client.ObjectKey{
Namespace: "default",
Name: "test-cluster-barman-cloud",
}
var before rbacv1.Role
Expect(fakeClient.Get(ctx, roleKey, &before)).To(Succeed())
Expect(rbac.EnsureRoleRules(ctx, fakeClient, roleKey, objects)).To(Succeed())
var after rbacv1.Role
Expect(fakeClient.Get(ctx, roleKey, &after)).To(Succeed())
Expect(after.Labels).To(Equal(before.Labels))
})
})
Context("when the Role does not exist", func() {
BeforeEach(func() {
fakeClient = fake.NewClientBuilder().WithScheme(newScheme()).Build()
})
It("should return nil", func() {
roleKey := client.ObjectKey{
Namespace: "default",
Name: "nonexistent-barman-cloud",
}
err := rbac.EnsureRoleRules(ctx, fakeClient, roleKey, objects)
Expect(err).NotTo(HaveOccurred())
})
})
})

View File

@ -0,0 +1,32 @@
/*
Copyright © contributors to CloudNativePG, established as
CloudNativePG a Series of LF Projects, LLC.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
SPDX-License-Identifier: Apache-2.0
*/
package rbac_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestRBAC(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "RBAC Suite")
}

View File

@ -28,12 +28,12 @@ import (
"github.com/cloudnative-pg/cnpg-i/pkg/reconciler"
"github.com/cloudnative-pg/machinery/pkg/log"
rbacv1 "k8s.io/api/rbac/v1"
"k8s.io/apimachinery/pkg/api/equality"
apierrs "k8s.io/apimachinery/pkg/api/errors"
"sigs.k8s.io/controller-runtime/pkg/client"
barmancloudv1 "github.com/cloudnative-pg/plugin-barman-cloud/api/v1"
"github.com/cloudnative-pg/plugin-barman-cloud/internal/cnpgi/operator/config"
"github.com/cloudnative-pg/plugin-barman-cloud/internal/cnpgi/operator/rbac"
"github.com/cloudnative-pg/plugin-barman-cloud/internal/cnpgi/operator/specs"
)
@ -113,7 +113,7 @@ func (r ReconcilerImplementation) Pre(
barmanObjects = append(barmanObjects, barmanObject)
}
if err := r.ensureRole(ctx, &cluster, barmanObjects); err != nil {
if err := rbac.EnsureRole(ctx, r.Client, &cluster, barmanObjects); err != nil {
return nil, err
}
@ -137,66 +137,15 @@ func (r ReconcilerImplementation) Post(
}, nil
}
func (r ReconcilerImplementation) ensureRole(
ctx context.Context,
cluster *cnpgv1.Cluster,
barmanObjects []barmancloudv1.ObjectStore,
) error {
contextLogger := log.FromContext(ctx)
newRole := specs.BuildRole(cluster, barmanObjects)
var role rbacv1.Role
if err := r.Client.Get(ctx, client.ObjectKey{
Namespace: newRole.Namespace,
Name: newRole.Name,
}, &role); err != nil {
if !apierrs.IsNotFound(err) {
return err
}
contextLogger.Info(
"Creating role",
"name", newRole.Name,
"namespace", newRole.Namespace,
)
if err := setOwnerReference(cluster, newRole); err != nil {
return err
}
return r.Client.Create(ctx, newRole)
}
if equality.Semantic.DeepEqual(newRole.Rules, role.Rules) {
// There's no need to hit the API server again
return nil
}
contextLogger.Info(
"Patching role",
"name", newRole.Name,
"namespace", newRole.Namespace,
"rules", newRole.Rules,
)
oldRole := role.DeepCopy()
// Apply to the role the new rules
role.Rules = newRole.Rules
// Push it back to the API server
return r.Client.Patch(ctx, &role, client.MergeFrom(oldRole))
}
func (r ReconcilerImplementation) ensureRoleBinding(
ctx context.Context,
cluster *cnpgv1.Cluster,
) error {
var role rbacv1.RoleBinding
var roleBinding rbacv1.RoleBinding
if err := r.Client.Get(ctx, client.ObjectKey{
Namespace: cluster.Namespace,
Name: specs.GetRBACName(cluster.Name),
}, &role); err != nil {
}, &roleBinding); err != nil {
if apierrs.IsNotFound(err) {
return r.createRoleBinding(ctx, cluster)
}
@ -213,7 +162,7 @@ func (r ReconcilerImplementation) createRoleBinding(
cluster *cnpgv1.Cluster,
) error {
roleBinding := specs.BuildRoleBinding(cluster)
if err := setOwnerReference(cluster, roleBinding); err != nil {
if err := specs.SetControllerReference(cluster, roleBinding); err != nil {
return err
}
return r.Client.Create(ctx, roleBinding)

View File

@ -17,7 +17,7 @@ limitations under the License.
SPDX-License-Identifier: Apache-2.0
*/
package operator
package specs
import (
"fmt"
@ -27,26 +27,32 @@ import (
"k8s.io/utils/ptr"
)
// setOwnerReference explicitly set the owner reference between an
// owner object and a controller one.
// SetControllerReference sets an owner reference on controlled
// pointing to owner, reading the GVK from the owner object's
// metadata rather than from a scheme. This is necessary because
// the operator does not know the CNPG API group at compile time
// (it may be customized), while the Cluster object decoded from
// the gRPC request carries the correct GVK in its TypeMeta.
//
// Important: this function won't use any registered scheme and will
// fail unless the metadata has been correctly set into the owner
// object.
func setOwnerReference(owner, controlled metav1.Object) error {
// This function replaces all existing owner references rather than
// merging, so it assumes the controlled object has a single owner.
// This holds for plugin-managed Roles and RoleBindings, which are
// exclusively owned by one Cluster.
func SetControllerReference(owner, controlled metav1.Object) error {
ro, ok := owner.(runtime.Object)
if !ok {
return fmt.Errorf("%T is not a runtime.Object, cannot call setOwnerReference", owner)
return fmt.Errorf("%T is not a runtime.Object, cannot call SetControllerReference", owner)
}
if len(ro.DeepCopyObject().GetObjectKind().GroupVersionKind().Group) == 0 {
return fmt.Errorf("%T metadata have not been set, cannot call setOwnerReference", owner)
gvk := ro.GetObjectKind().GroupVersionKind()
if gvk.Kind == "" {
return fmt.Errorf("%T has no GVK set in its metadata, cannot call SetControllerReference", owner)
}
controlled.SetOwnerReferences([]metav1.OwnerReference{
{
APIVersion: ro.GetObjectKind().GroupVersionKind().GroupVersion().String(),
Kind: ro.GetObjectKind().GroupVersionKind().Kind,
APIVersion: gvk.GroupVersion().String(),
Kind: gvk.Kind,
Name: owner.GetName(),
UID: owner.GetUID(),
BlockOwnerDeletion: ptr.To(true),

View File

@ -0,0 +1,100 @@
/*
Copyright © contributors to CloudNativePG, established as
CloudNativePG a Series of LF Projects, LLC.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
SPDX-License-Identifier: Apache-2.0
*/
package specs
import (
cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
)
var _ = Describe("SetControllerReference", func() {
It("should set the owner reference from the owner's TypeMeta", func() {
owner := &cnpgv1.Cluster{
TypeMeta: metav1.TypeMeta{
APIVersion: "postgresql.cnpg.io/v1",
Kind: "Cluster",
},
ObjectMeta: metav1.ObjectMeta{
Name: "my-cluster",
Namespace: "default",
UID: types.UID("test-uid"),
},
}
controlled := &rbacv1.Role{
ObjectMeta: metav1.ObjectMeta{
Name: "my-role",
Namespace: "default",
},
}
Expect(SetControllerReference(owner, controlled)).To(Succeed())
Expect(controlled.OwnerReferences).To(HaveLen(1))
Expect(controlled.OwnerReferences[0].APIVersion).To(Equal("postgresql.cnpg.io/v1"))
Expect(controlled.OwnerReferences[0].Kind).To(Equal("Cluster"))
Expect(controlled.OwnerReferences[0].Name).To(Equal("my-cluster"))
Expect(controlled.OwnerReferences[0].UID).To(Equal(types.UID("test-uid")))
Expect(*controlled.OwnerReferences[0].Controller).To(BeTrue())
Expect(*controlled.OwnerReferences[0].BlockOwnerDeletion).To(BeTrue())
})
It("should work with a custom CNPG API group", func() {
owner := &cnpgv1.Cluster{
TypeMeta: metav1.TypeMeta{
APIVersion: "mycompany.io/v1",
Kind: "Cluster",
},
ObjectMeta: metav1.ObjectMeta{
Name: "my-cluster",
UID: types.UID("test-uid"),
},
}
controlled := &rbacv1.Role{}
Expect(SetControllerReference(owner, controlled)).To(Succeed())
Expect(controlled.OwnerReferences[0].APIVersion).To(Equal("mycompany.io/v1"))
})
It("should fail when the owner has no GVK set", func() {
owner := &cnpgv1.Cluster{
ObjectMeta: metav1.ObjectMeta{
Name: "my-cluster",
},
}
controlled := &rbacv1.Role{}
err := SetControllerReference(owner, controlled)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("has no GVK set"))
})
It("should fail when the owner does not implement runtime.Object", func() {
// metav1.ObjectMeta satisfies metav1.Object but not runtime.Object.
owner := &metav1.ObjectMeta{Name: "my-cluster"}
controlled := &rbacv1.Role{}
err := SetControllerReference(owner, controlled)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("is not a runtime.Object"))
})
})

View File

@ -21,6 +21,7 @@ package specs
import (
"fmt"
"slices"
cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
"github.com/cloudnative-pg/machinery/pkg/stringset"
@ -28,6 +29,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
barmancloudv1 "github.com/cloudnative-pg/plugin-barman-cloud/api/v1"
"github.com/cloudnative-pg/plugin-barman-cloud/internal/cnpgi/metadata"
)
// BuildRole builds the Role object for this cluster
@ -35,15 +37,20 @@ func BuildRole(
cluster *cnpgv1.Cluster,
barmanObjects []barmancloudv1.ObjectStore,
) *rbacv1.Role {
role := &rbacv1.Role{
return &rbacv1.Role{
ObjectMeta: metav1.ObjectMeta{
Namespace: cluster.Namespace,
Name: GetRBACName(cluster.Name),
Labels: map[string]string{
metadata.ClusterLabelName: cluster.Name,
},
},
Rules: []rbacv1.PolicyRule{},
Rules: BuildRoleRules(barmanObjects),
}
}
// BuildRoleRules builds the RBAC PolicyRules for the given ObjectStores.
func BuildRoleRules(barmanObjects []barmancloudv1.ObjectStore) []rbacv1.PolicyRule {
secretsSet := stringset.New()
barmanObjectsSet := stringset.New()
@ -54,11 +61,10 @@ func BuildRole(
}
}
role.Rules = append(
role.Rules,
rbacv1.PolicyRule{
return []rbacv1.PolicyRule{
{
APIGroups: []string{
"barmancloud.cnpg.io",
barmancloudv1.GroupVersion.Group,
},
Verbs: []string{
"get",
@ -70,9 +76,9 @@ func BuildRole(
},
ResourceNames: barmanObjectsSet.ToSortedList(),
},
rbacv1.PolicyRule{
{
APIGroups: []string{
"barmancloud.cnpg.io",
barmancloudv1.GroupVersion.Group,
},
Verbs: []string{
"update",
@ -82,7 +88,7 @@ func BuildRole(
},
ResourceNames: barmanObjectsSet.ToSortedList(),
},
rbacv1.PolicyRule{
{
APIGroups: []string{
"",
},
@ -96,9 +102,25 @@ func BuildRole(
},
ResourceNames: secretsSet.ToSortedList(),
},
)
}
}
return role
// ObjectStoreNamesFromRole extracts the ObjectStore names referenced
// by a plugin-managed Role. It finds the objectstores rule
// semantically (by APIGroup and Resource, not by index) and returns
// a copy of its ResourceNames. Returns nil if no matching rule is
// found.
func ObjectStoreNamesFromRole(role *rbacv1.Role) []string {
for _, rule := range role.Rules {
if len(rule.APIGroups) == 1 &&
rule.APIGroups[0] == barmancloudv1.GroupVersion.Group &&
len(rule.Resources) == 1 &&
rule.Resources[0] == "objectstores" {
return slices.Clone(rule.ResourceNames)
}
}
return nil
}
// BuildRoleBinding builds the role binding object for this cluster

View File

@ -0,0 +1,210 @@
/*
Copyright © contributors to CloudNativePG, established as
CloudNativePG a Series of LF Projects, LLC.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
SPDX-License-Identifier: Apache-2.0
*/
package specs
import (
barmanapi "github.com/cloudnative-pg/barman-cloud/pkg/api"
cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
machineryapi "github.com/cloudnative-pg/machinery/pkg/api"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
barmancloudv1 "github.com/cloudnative-pg/plugin-barman-cloud/api/v1"
"github.com/cloudnative-pg/plugin-barman-cloud/internal/cnpgi/metadata"
)
func newTestObjectStore(name, secretName string) barmancloudv1.ObjectStore {
return barmancloudv1.ObjectStore{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: "default",
},
Spec: barmancloudv1.ObjectStoreSpec{
Configuration: barmanapi.BarmanObjectStoreConfiguration{
DestinationPath: "s3://bucket/path",
BarmanCredentials: barmanapi.BarmanCredentials{
AWS: &barmanapi.S3Credentials{
AccessKeyIDReference: &machineryapi.SecretKeySelector{
LocalObjectReference: machineryapi.LocalObjectReference{
Name: secretName,
},
Key: "ACCESS_KEY_ID",
},
},
},
},
},
}
}
var _ = Describe("BuildRoleRules", func() {
It("should produce 3 rules with correct ResourceNames", func() {
objects := []barmancloudv1.ObjectStore{
newTestObjectStore("store-a", "secret-a"),
newTestObjectStore("store-b", "secret-b"),
}
rules := BuildRoleRules(objects)
Expect(rules).To(HaveLen(3))
Expect(rules[0].APIGroups).To(Equal([]string{barmancloudv1.GroupVersion.Group}))
Expect(rules[0].Resources).To(Equal([]string{"objectstores"}))
Expect(rules[0].ResourceNames).To(ConsistOf("store-a", "store-b"))
Expect(rules[1].APIGroups).To(Equal([]string{barmancloudv1.GroupVersion.Group}))
Expect(rules[1].Resources).To(Equal([]string{"objectstores/status"}))
Expect(rules[1].ResourceNames).To(ConsistOf("store-a", "store-b"))
Expect(rules[2].APIGroups).To(Equal([]string{""}))
Expect(rules[2].Resources).To(Equal([]string{"secrets"}))
Expect(rules[2].ResourceNames).To(ConsistOf("secret-a", "secret-b"))
})
It("should produce rules with empty ResourceNames for empty input", func() {
rules := BuildRoleRules(nil)
Expect(rules).To(HaveLen(3))
Expect(rules[0].ResourceNames).To(BeEmpty())
Expect(rules[0].ResourceNames).NotTo(BeNil())
Expect(rules[1].ResourceNames).To(BeEmpty())
Expect(rules[2].ResourceNames).To(BeEmpty())
})
It("should deduplicate secret names across ObjectStores", func() {
objects := []barmancloudv1.ObjectStore{
newTestObjectStore("store-a", "shared-secret"),
newTestObjectStore("store-b", "shared-secret"),
}
rules := BuildRoleRules(objects)
Expect(rules[2].ResourceNames).To(Equal([]string{"shared-secret"}))
})
})
var _ = Describe("BuildRole", func() {
It("should set the cluster label", func() {
cluster := &cnpgv1.Cluster{
ObjectMeta: metav1.ObjectMeta{
Name: "my-cluster",
Namespace: "default",
},
}
role := BuildRole(cluster, nil)
Expect(role.Labels).To(HaveKeyWithValue(metadata.ClusterLabelName, "my-cluster"))
Expect(role.Name).To(Equal("my-cluster-barman-cloud"))
Expect(role.Namespace).To(Equal("default"))
})
})
var _ = Describe("BuildRoleRules / ObjectStoreNamesFromRole round-trip", func() {
It("should recover the same ObjectStore names from built rules", func() {
objects := []barmancloudv1.ObjectStore{
newTestObjectStore("store-a", "secret-a"),
newTestObjectStore("store-b", "secret-b"),
}
rules := BuildRoleRules(objects)
role := &rbacv1.Role{Rules: rules}
names := ObjectStoreNamesFromRole(role)
Expect(names).To(ConsistOf("store-a", "store-b"))
})
It("should recover empty names from rules built with no ObjectStores", func() {
rules := BuildRoleRules(nil)
role := &rbacv1.Role{Rules: rules}
names := ObjectStoreNamesFromRole(role)
Expect(names).To(BeEmpty())
})
})
var _ = Describe("ObjectStoreNamesFromRole", func() {
It("should extract ObjectStore names from a well-formed Role", func() {
role := &rbacv1.Role{
Rules: []rbacv1.PolicyRule{
{
APIGroups: []string{barmancloudv1.GroupVersion.Group},
Resources: []string{"objectstores"},
ResourceNames: []string{"store-a", "store-b"},
},
{
APIGroups: []string{""},
Resources: []string{"secrets"},
ResourceNames: []string{"secret-a"},
},
},
}
Expect(ObjectStoreNamesFromRole(role)).To(Equal([]string{"store-a", "store-b"}))
})
It("should return nil for a Role with no matching rule", func() {
role := &rbacv1.Role{
Rules: []rbacv1.PolicyRule{
{
APIGroups: []string{""},
Resources: []string{"secrets"},
ResourceNames: []string{"secret-a"},
},
},
}
Expect(ObjectStoreNamesFromRole(role)).To(BeNil())
})
It("should return nil for a Role with empty rules", func() {
role := &rbacv1.Role{}
Expect(ObjectStoreNamesFromRole(role)).To(BeNil())
})
It("should not match a rule with a different APIGroup", func() {
role := &rbacv1.Role{
Rules: []rbacv1.PolicyRule{
{
APIGroups: []string{"other.io"},
Resources: []string{"objectstores"},
ResourceNames: []string{"store-a"},
},
},
}
Expect(ObjectStoreNamesFromRole(role)).To(BeNil())
})
It("should not match a rule with multiple APIGroups", func() {
role := &rbacv1.Role{
Rules: []rbacv1.PolicyRule{
{
APIGroups: []string{barmancloudv1.GroupVersion.Group, "other.io"},
Resources: []string{"objectstores"},
ResourceNames: []string{"store-a"},
},
},
}
Expect(ObjectStoreNamesFromRole(role)).To(BeNil())
})
It("should not match a rule for objectstores/status", func() {
role := &rbacv1.Role{
Rules: []rbacv1.PolicyRule{
{
APIGroups: []string{barmancloudv1.GroupVersion.Group},
Resources: []string{"objectstores/status"},
ResourceNames: []string{"store-a"},
},
},
}
Expect(ObjectStoreNamesFromRole(role)).To(BeNil())
})
})

View File

@ -22,34 +22,23 @@ package restore
import (
"context"
cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
"github.com/cloudnative-pg/machinery/pkg/log"
"github.com/spf13/viper"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
barmancloudv1 "github.com/cloudnative-pg/plugin-barman-cloud/api/v1"
"github.com/cloudnative-pg/plugin-barman-cloud/internal/cnpgi/common"
)
var scheme = runtime.NewScheme()
func init() {
utilruntime.Must(barmancloudv1.AddToScheme(scheme))
utilruntime.Must(cnpgv1.AddToScheme(scheme))
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
}
// Start starts the sidecar informers and CNPG-i server
func Start(ctx context.Context) error {
setupLog := log.FromContext(ctx)
setupLog.Info("Starting barman cloud instance plugin")
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Scheme: common.GenerateScheme(ctx),
Client: client.Options{
Cache: &client.CacheOptions{
DisableFor: []client.Object{

View File

@ -285,9 +285,11 @@ func (impl *JobHookImpl) checkBackupDestination(
}
}
// Check if we're ok to archive in the desired destination
// Check if we're ok to archive in the desired destination.
// During restore/bootstrap, timeline is 0 (omit --timeline) so the
// check remains strict — the archive must be empty.
if utils.IsEmptyWalArchiveCheckEnabled(&cluster.ObjectMeta) {
return common.CheckBackupDestination(ctx, barmanConfiguration, walArchiver, serverName)
return common.CheckBackupDestination(ctx, barmanConfiguration, walArchiver, serverName, 0)
}
return nil

View File

@ -21,14 +21,23 @@ package controller
import (
"context"
"errors"
"fmt"
"slices"
"github.com/cloudnative-pg/machinery/pkg/log"
rbacv1 "k8s.io/api/rbac/v1"
apierrs "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/predicate"
barmancloudv1 "github.com/cloudnative-pg/plugin-barman-cloud/api/v1"
"github.com/cloudnative-pg/plugin-barman-cloud/internal/cnpgi/metadata"
"github.com/cloudnative-pg/plugin-barman-cloud/internal/cnpgi/operator/rbac"
"github.com/cloudnative-pg/plugin-barman-cloud/internal/cnpgi/operator/specs"
)
// ObjectStoreReconciler reconciles a ObjectStore object.
@ -40,33 +49,93 @@ type ObjectStoreReconciler struct {
// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=rolebindings,verbs=create;patch;update;get;list;watch
// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles,verbs=create;patch;update;get;list;watch
// +kubebuilder:rbac:groups="",resources=secrets,verbs=create;list;get;watch;delete
// +kubebuilder:rbac:groups=postgresql.cnpg.io,resources=clusters/finalizers,verbs=update
// +kubebuilder:rbac:groups=postgresql.cnpg.io,resources=backups,verbs=get;list;watch
// +kubebuilder:rbac:groups=postgresql.cnpg.io,resources=clusters/finalizers,verbs=update
// +kubebuilder:rbac:groups=barmancloud.cnpg.io,resources=objectstores,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=barmancloud.cnpg.io,resources=objectstores/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=barmancloud.cnpg.io,resources=objectstores/finalizers,verbs=update
// Reconcile is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state.
// TODO(user): Modify the Reconcile function to compare the state specified by
// the ObjectStore object against the actual cluster state, and then
// perform operations to make the cluster state reflect the state specified by
// the user.
//
// For more details, check Reconcile and its Result here:
// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.0/pkg/reconcile
func (r *ObjectStoreReconciler) Reconcile(ctx context.Context, _ ctrl.Request) (ctrl.Result, error) {
_ = log.FromContext(ctx)
// Reconcile ensures that the RBAC Role for each Cluster referencing
// this ObjectStore is up to date with the current ObjectStore spec.
// It discovers affected Roles by listing plugin-managed Roles and
// inspecting their rules, without needing access to Cluster objects.
func (r *ObjectStoreReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
contextLogger := log.FromContext(ctx).WithValues(
"objectStoreName", req.Name,
"namespace", req.Namespace,
)
ctx = log.IntoContext(ctx, contextLogger)
// TODO(user): your logic here
contextLogger.Info("ObjectStore reconciliation start")
return ctrl.Result{}, nil
// NOTE: Roles created before the introduction of ClusterLabelName
// are not discovered here. The Pre hook patches the label on every
// Cluster reconciliation, so unlabeled Roles are picked up after
// the next Cluster reconcile cycle.
var roleList rbacv1.RoleList
if err := r.List(ctx, &roleList,
client.InNamespace(req.Namespace),
client.HasLabels{metadata.ClusterLabelName},
); err != nil {
return ctrl.Result{}, fmt.Errorf("while listing roles: %w", err)
}
var errs []error
for i := range roleList.Items {
role := &roleList.Items[i]
objectStoreNames := specs.ObjectStoreNamesFromRole(role)
if !slices.Contains(objectStoreNames, req.Name) {
continue
}
contextLogger.Info("Reconciling RBAC for role",
"roleName", role.Name)
if err := r.reconcileRoleRules(ctx, role, objectStoreNames); err != nil {
contextLogger.Error(err, "Failed to reconcile RBAC for role",
"roleName", role.Name)
errs = append(errs, fmt.Errorf("while reconciling role %s: %w", role.Name, err))
}
}
contextLogger.Info("ObjectStore reconciliation completed")
return ctrl.Result{}, errors.Join(errs...)
}
// reconcileRoleRules fetches the ObjectStores referenced by the
// Role and patches its rules to match the current specs.
func (r *ObjectStoreReconciler) reconcileRoleRules(
ctx context.Context,
role *rbacv1.Role,
objectStoreNames []string,
) error {
contextLogger := log.FromContext(ctx)
barmanObjects := make([]barmancloudv1.ObjectStore, 0, len(objectStoreNames))
for _, name := range objectStoreNames {
var barmanObject barmancloudv1.ObjectStore
if err := r.Get(ctx, client.ObjectKey{
Namespace: role.Namespace,
Name: name,
}, &barmanObject); err != nil {
if apierrs.IsNotFound(err) {
contextLogger.Info("ObjectStore not found, skipping",
"objectStoreName", name)
continue
}
return fmt.Errorf("while getting ObjectStore %s: %w", name, err)
}
barmanObjects = append(barmanObjects, barmanObject)
}
return rbac.EnsureRoleRules(ctx, r.Client, client.ObjectKeyFromObject(role), barmanObjects)
}
// SetupWithManager sets up the controller with the Manager.
func (r *ObjectStoreReconciler) SetupWithManager(mgr ctrl.Manager) error {
err := ctrl.NewControllerManagedBy(mgr).
For(&barmancloudv1.ObjectStore{}).
For(&barmancloudv1.ObjectStore{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
Complete(r)
if err != nil {
return fmt.Errorf("unable to create controller: %w", err)

View File

@ -21,71 +21,382 @@ package controller
import (
"context"
"fmt"
barmanapi "github.com/cloudnative-pg/barman-cloud/pkg/api"
"k8s.io/apimachinery/pkg/api/errors"
machineryapi "github.com/cloudnative-pg/machinery/pkg/api"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
rbacv1 "k8s.io/api/rbac/v1"
apierrs "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
barmancloudv1 "github.com/cloudnative-pg/plugin-barman-cloud/api/v1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/cloudnative-pg/plugin-barman-cloud/internal/cnpgi/metadata"
"github.com/cloudnative-pg/plugin-barman-cloud/internal/cnpgi/operator/specs"
)
var _ = Describe("ObjectStore Controller", func() {
Context("When reconciling a resource", func() {
const resourceName = "test-resource"
func newFakeScheme() *runtime.Scheme {
s := runtime.NewScheme()
utilruntime.Must(rbacv1.AddToScheme(s))
utilruntime.Must(barmancloudv1.AddToScheme(s))
return s
}
ctx := context.Background()
typeNamespacedName := types.NamespacedName{
Name: resourceName,
Namespace: "default", // TODO(user):Modify as needed
}
objectstore := &barmancloudv1.ObjectStore{}
BeforeEach(func() {
By("creating the custom resource for the Kind ObjectStore")
err := k8sClient.Get(ctx, typeNamespacedName, objectstore)
if err != nil && errors.IsNotFound(err) {
resource := &barmancloudv1.ObjectStore{
ObjectMeta: metav1.ObjectMeta{
Name: resourceName,
Namespace: "default",
func newTestObjectStore(name, namespace, secretName string) *barmancloudv1.ObjectStore {
return &barmancloudv1.ObjectStore{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
},
Spec: barmancloudv1.ObjectStoreSpec{
Configuration: barmanapi.BarmanObjectStoreConfiguration{
DestinationPath: "s3://bucket/path",
BarmanCredentials: barmanapi.BarmanCredentials{
AWS: &barmanapi.S3Credentials{
AccessKeyIDReference: &machineryapi.SecretKeySelector{
LocalObjectReference: machineryapi.LocalObjectReference{
Name: secretName,
},
Key: "ACCESS_KEY_ID",
},
},
Spec: barmancloudv1.ObjectStoreSpec{
Configuration: barmanapi.BarmanObjectStoreConfiguration{DestinationPath: "/tmp"},
},
// TODO(user): Specify other spec details if needed.
}
Expect(k8sClient.Create(ctx, resource)).To(Succeed())
}
})
},
},
},
}
}
AfterEach(func() {
// TODO(user): Cleanup logic after each test, like removing the resource instance.
resource := &barmancloudv1.ObjectStore{}
err := k8sClient.Get(ctx, typeNamespacedName, resource)
Expect(err).NotTo(HaveOccurred())
func newLabeledRole(clusterName, namespace string, objectStores []barmancloudv1.ObjectStore) *rbacv1.Role {
return &rbacv1.Role{
ObjectMeta: metav1.ObjectMeta{
Name: specs.GetRBACName(clusterName),
Namespace: namespace,
Labels: map[string]string{
metadata.ClusterLabelName: clusterName,
},
},
Rules: specs.BuildRoleRules(objectStores),
}
}
By("Cleanup the specific resource instance ObjectStore")
Expect(k8sClient.Delete(ctx, resource)).To(Succeed())
})
It("should successfully reconcile the resource", func() {
By("Reconciling the created resource")
controllerReconciler := &ObjectStoreReconciler{
Client: k8sClient,
Scheme: k8sClient.Scheme(),
var _ = Describe("ObjectStoreReconciler", func() {
var (
ctx context.Context
scheme *runtime.Scheme
)
BeforeEach(func() {
ctx = context.Background()
scheme = newFakeScheme()
})
Describe("Reconcile", func() {
It("should update Role rules when ObjectStore credentials change", func() {
oldStore := newTestObjectStore("my-store", "default", "old-secret")
role := newLabeledRole("my-cluster", "default", []barmancloudv1.ObjectStore{*oldStore})
// Update the ObjectStore with new credentials
newStore := newTestObjectStore("my-store", "default", "new-secret")
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(role, newStore).
Build()
reconciler := &ObjectStoreReconciler{
Client: fakeClient,
Scheme: scheme,
}
_, err := controllerReconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: typeNamespacedName,
result, err := reconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "my-store",
Namespace: "default",
},
})
Expect(err).NotTo(HaveOccurred())
// TODO(user): Add more specific assertions depending on your controller's reconciliation logic.
// Example: If you expect a certain status condition after reconciliation, verify it here.
Expect(result).To(Equal(reconcile.Result{}))
var updatedRole rbacv1.Role
Expect(fakeClient.Get(ctx, client.ObjectKey{
Namespace: "default",
Name: "my-cluster-barman-cloud",
}, &updatedRole)).To(Succeed())
secretsRule := updatedRole.Rules[2]
Expect(secretsRule.ResourceNames).To(ContainElement("new-secret"))
Expect(secretsRule.ResourceNames).NotTo(ContainElement("old-secret"))
})
It("should skip Roles that don't reference the ObjectStore", func() {
otherStore := newTestObjectStore("other-store", "default", "other-creds")
role := newLabeledRole("my-cluster", "default", []barmancloudv1.ObjectStore{*otherStore})
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(role).
Build()
reconciler := &ObjectStoreReconciler{
Client: fakeClient,
Scheme: scheme,
}
var before rbacv1.Role
Expect(fakeClient.Get(ctx, client.ObjectKey{
Namespace: "default",
Name: "my-cluster-barman-cloud",
}, &before)).To(Succeed())
result, err := reconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "unrelated-store",
Namespace: "default",
},
})
Expect(err).NotTo(HaveOccurred())
Expect(result).To(Equal(reconcile.Result{}))
var after rbacv1.Role
Expect(fakeClient.Get(ctx, client.ObjectKey{
Namespace: "default",
Name: "my-cluster-barman-cloud",
}, &after)).To(Succeed())
Expect(after.ResourceVersion).To(Equal(before.ResourceVersion))
})
It("should succeed with no labeled Roles in the namespace", func() {
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
Build()
reconciler := &ObjectStoreReconciler{
Client: fakeClient,
Scheme: scheme,
}
result, err := reconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "my-store",
Namespace: "default",
},
})
Expect(err).NotTo(HaveOccurred())
Expect(result).To(Equal(reconcile.Result{}))
})
It("should handle deleted ObjectStores gracefully", func() {
storeA := newTestObjectStore("store-a", "default", "secret-a")
storeB := newTestObjectStore("store-b", "default", "secret-b")
role := newLabeledRole("my-cluster", "default", []barmancloudv1.ObjectStore{*storeA, *storeB})
// Only store-a exists; store-b was deleted
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(role, storeA).
Build()
reconciler := &ObjectStoreReconciler{
Client: fakeClient,
Scheme: scheme,
}
result, err := reconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "store-b",
Namespace: "default",
},
})
Expect(err).NotTo(HaveOccurred())
Expect(result).To(Equal(reconcile.Result{}))
var updatedRole rbacv1.Role
Expect(fakeClient.Get(ctx, client.ObjectKey{
Namespace: "default",
Name: "my-cluster-barman-cloud",
}, &updatedRole)).To(Succeed())
objectStoreRule := updatedRole.Rules[0]
Expect(objectStoreRule.ResourceNames).To(ContainElement("store-a"))
Expect(objectStoreRule.ResourceNames).NotTo(ContainElement("store-b"))
})
It("should not panic on a Role with empty rules", func() {
emptyRole := &rbacv1.Role{
ObjectMeta: metav1.ObjectMeta{
Name: "empty-barman-cloud",
Namespace: "default",
Labels: map[string]string{
metadata.ClusterLabelName: "empty",
},
},
}
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(emptyRole).
Build()
reconciler := &ObjectStoreReconciler{
Client: fakeClient,
Scheme: scheme,
}
result, err := reconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "my-store",
Namespace: "default",
},
})
Expect(err).NotTo(HaveOccurred())
Expect(result).To(Equal(reconcile.Result{}))
})
It("should produce empty ResourceNames when all ObjectStores are deleted", func() {
store := newTestObjectStore("my-store", "default", "aws-creds")
role := newLabeledRole("my-cluster", "default", []barmancloudv1.ObjectStore{*store})
// Don't add the ObjectStore to the fake client (simulates deletion)
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(role).
Build()
reconciler := &ObjectStoreReconciler{
Client: fakeClient,
Scheme: scheme,
}
result, err := reconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "my-store",
Namespace: "default",
},
})
Expect(err).NotTo(HaveOccurred())
Expect(result).To(Equal(reconcile.Result{}))
var updatedRole rbacv1.Role
Expect(fakeClient.Get(ctx, client.ObjectKey{
Namespace: "default",
Name: "my-cluster-barman-cloud",
}, &updatedRole)).To(Succeed())
// All rules should have empty ResourceNames
Expect(updatedRole.Rules[0].ResourceNames).To(BeEmpty())
Expect(updatedRole.Rules[1].ResourceNames).To(BeEmpty())
Expect(updatedRole.Rules[2].ResourceNames).To(BeEmpty())
})
It("should return an error when listing Roles fails", func() {
internalErr := apierrs.NewInternalError(fmt.Errorf("etcd timeout"))
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithInterceptorFuncs(interceptor.Funcs{
List: func(ctx context.Context, c client.WithWatch, list client.ObjectList, opts ...client.ListOption) error {
if _, ok := list.(*rbacv1.RoleList); ok {
return internalErr
}
return c.List(ctx, list, opts...)
},
}).
Build()
reconciler := &ObjectStoreReconciler{Client: fakeClient, Scheme: scheme}
_, err := reconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: types.NamespacedName{Name: "my-store", Namespace: "default"},
})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("while listing roles"))
})
It("should return an error when fetching an ObjectStore fails with a transient error", func() {
store := newTestObjectStore("my-store", "default", "aws-creds")
role := newLabeledRole("my-cluster", "default", []barmancloudv1.ObjectStore{*store})
internalErr := apierrs.NewInternalError(fmt.Errorf("etcd timeout"))
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(role).
WithInterceptorFuncs(interceptor.Funcs{
Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
if _, ok := obj.(*barmancloudv1.ObjectStore); ok {
return internalErr
}
return c.Get(ctx, key, obj, opts...)
},
}).
Build()
reconciler := &ObjectStoreReconciler{Client: fakeClient, Scheme: scheme}
_, err := reconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: types.NamespacedName{Name: "my-store", Namespace: "default"},
})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("while reconciling role"))
})
It("should reconcile multiple Roles referencing the same ObjectStore", func() {
store := newTestObjectStore("shared-store", "default", "new-secret")
oldStore := barmancloudv1.ObjectStore{
ObjectMeta: metav1.ObjectMeta{Name: "shared-store", Namespace: "default"},
Spec: barmancloudv1.ObjectStoreSpec{
Configuration: barmanapi.BarmanObjectStoreConfiguration{
DestinationPath: "s3://bucket/path",
BarmanCredentials: barmanapi.BarmanCredentials{
AWS: &barmanapi.S3Credentials{
AccessKeyIDReference: &machineryapi.SecretKeySelector{
LocalObjectReference: machineryapi.LocalObjectReference{Name: "old-secret"},
Key: "ACCESS_KEY_ID",
},
},
},
},
},
}
role1 := newLabeledRole("cluster-1", "default", []barmancloudv1.ObjectStore{oldStore})
role2 := newLabeledRole("cluster-2", "default", []barmancloudv1.ObjectStore{oldStore})
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(role1, role2, store).
Build()
reconciler := &ObjectStoreReconciler{
Client: fakeClient,
Scheme: scheme,
}
result, err := reconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: types.NamespacedName{
Name: "shared-store",
Namespace: "default",
},
})
Expect(err).NotTo(HaveOccurred())
Expect(result).To(Equal(reconcile.Result{}))
for _, clusterName := range []string{"cluster-1", "cluster-2"} {
var updatedRole rbacv1.Role
Expect(fakeClient.Get(ctx, client.ObjectKey{
Namespace: "default",
Name: specs.GetRBACName(clusterName),
}, &updatedRole)).To(Succeed())
secretsRule := updatedRole.Rules[2]
Expect(secretsRule.ResourceNames).To(ContainElement("new-secret"))
Expect(secretsRule.ResourceNames).NotTo(ContainElement("old-secret"))
}
})
})
})

View File

@ -20,81 +20,14 @@ SPDX-License-Identifier: Apache-2.0
package controller
import (
"context"
"fmt"
"path/filepath"
"runtime"
"testing"
// +kubebuilder:scaffold:imports
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
logf "sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
barmancloudv1 "github.com/cloudnative-pg/plugin-barman-cloud/api/v1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// These tests use Ginkgo (BDD-style Go testing framework). Refer to
// http://onsi.github.io/ginkgo/ to learn more about Ginkgo.
var (
cfg *rest.Config
k8sClient client.Client
testEnv *envtest.Environment
ctx context.Context
cancel context.CancelFunc
)
func TestControllers(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Controller Suite")
}
var _ = BeforeSuite(func() {
logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true)))
ctx, cancel = context.WithCancel(context.TODO())
By("bootstrapping test environment")
testEnv = &envtest.Environment{
CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")},
ErrorIfCRDPathMissing: true,
// The BinaryAssetsDirectory is only required if you want to run the tests directly
// without call the makefile target test. If not informed it will look for the
// default path defined in controller-runtime which is /usr/local/kubebuilder/.
// Note that you must have the required binaries setup under the bin directory to perform
// the tests directly. When we run make test it will be setup and used automatically.
BinaryAssetsDirectory: filepath.Join("..", "..", "bin", "k8s",
fmt.Sprintf("1.31.0-%s-%s", runtime.GOOS, runtime.GOARCH)),
}
var err error
// cfg is defined in this file globally.
cfg, err = testEnv.Start()
Expect(err).NotTo(HaveOccurred())
Expect(cfg).NotTo(BeNil())
err = barmancloudv1.AddToScheme(scheme.Scheme)
Expect(err).NotTo(HaveOccurred())
// +kubebuilder:scaffold:scheme
k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
Expect(err).NotTo(HaveOccurred())
Expect(k8sClient).NotTo(BeNil())
})
var _ = AfterSuite(func() {
By("tearing down the test environment")
cancel()
err := testEnv.Stop()
Expect(err).NotTo(HaveOccurred())
})

56
internal/scheme/cnpg.go Normal file
View File

@ -0,0 +1,56 @@
/*
Copyright © contributors to CloudNativePG, established as
CloudNativePG a Series of LF Projects, LLC.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
SPDX-License-Identifier: Apache-2.0
*/
package scheme
import (
"context"
cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
"github.com/cloudnative-pg/machinery/pkg/log"
"github.com/spf13/viper"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
crscheme "sigs.k8s.io/controller-runtime/pkg/scheme"
)
// AddCNPGToScheme registers CNPG types into the given scheme using
// the API group configured via CUSTOM_CNPG_GROUP/CUSTOM_CNPG_VERSION
// environment variables, defaulting to postgresql.cnpg.io/v1.
// This allows the plugin to work with any CNPG-based operator.
func AddCNPGToScheme(ctx context.Context, s *runtime.Scheme) {
cnpgGroup := viper.GetString("custom-cnpg-group")
cnpgVersion := viper.GetString("custom-cnpg-version")
if len(cnpgGroup) == 0 {
cnpgGroup = cnpgv1.SchemeGroupVersion.Group
}
if len(cnpgVersion) == 0 {
cnpgVersion = cnpgv1.SchemeGroupVersion.Version
}
schemeGroupVersion := schema.GroupVersion{Group: cnpgGroup, Version: cnpgVersion}
schemeBuilder := &crscheme.Builder{GroupVersion: schemeGroupVersion}
schemeBuilder.Register(&cnpgv1.Cluster{}, &cnpgv1.ClusterList{})
schemeBuilder.Register(&cnpgv1.Backup{}, &cnpgv1.BackupList{})
schemeBuilder.Register(&cnpgv1.ScheduledBackup{}, &cnpgv1.ScheduledBackupList{})
utilruntime.Must(schemeBuilder.AddToScheme(s))
log.FromContext(ctx).Info("CNPG types registration", "schemeGroupVersion", schemeGroupVersion)
}

View File

@ -0,0 +1,116 @@
/*
Copyright © contributors to CloudNativePG, established as
CloudNativePG a Series of LF Projects, LLC.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
SPDX-License-Identifier: Apache-2.0
*/
package scheme
import (
"context"
cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/spf13/viper"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
)
var _ = Describe("AddCNPGToScheme", func() {
var s *runtime.Scheme
BeforeEach(func() {
s = runtime.NewScheme()
})
AfterEach(func() {
viper.Reset()
})
It("should register CNPG types under the default group and version", func() {
AddCNPGToScheme(context.Background(), s)
gvks, _, err := s.ObjectKinds(&cnpgv1.Cluster{})
Expect(err).NotTo(HaveOccurred())
Expect(gvks).To(ContainElement(schema.GroupVersionKind{
Group: cnpgv1.SchemeGroupVersion.Group,
Version: cnpgv1.SchemeGroupVersion.Version,
Kind: "Cluster",
}))
})
It("should register Backup and ScheduledBackup under the default group", func() {
AddCNPGToScheme(context.Background(), s)
gvks, _, err := s.ObjectKinds(&cnpgv1.Backup{})
Expect(err).NotTo(HaveOccurred())
Expect(gvks).To(ContainElement(HaveField("Group", cnpgv1.SchemeGroupVersion.Group)))
gvks, _, err = s.ObjectKinds(&cnpgv1.ScheduledBackup{})
Expect(err).NotTo(HaveOccurred())
Expect(gvks).To(ContainElement(HaveField("Group", cnpgv1.SchemeGroupVersion.Group)))
})
It("should register CNPG types under a custom group", func() {
viper.Set("custom-cnpg-group", "mycompany.io")
AddCNPGToScheme(context.Background(), s)
gvks, _, err := s.ObjectKinds(&cnpgv1.Cluster{})
Expect(err).NotTo(HaveOccurred())
Expect(gvks).To(ContainElement(schema.GroupVersionKind{
Group: "mycompany.io",
Version: cnpgv1.SchemeGroupVersion.Version,
Kind: "Cluster",
}))
// The default group must not be registered
Expect(s.Recognizes(schema.GroupVersionKind{
Group: cnpgv1.SchemeGroupVersion.Group,
Version: cnpgv1.SchemeGroupVersion.Version,
Kind: "Cluster",
})).To(BeFalse())
})
It("should register CNPG types under a custom version", func() {
viper.Set("custom-cnpg-version", "v2")
AddCNPGToScheme(context.Background(), s)
gvks, _, err := s.ObjectKinds(&cnpgv1.Cluster{})
Expect(err).NotTo(HaveOccurred())
Expect(gvks).To(ContainElement(schema.GroupVersionKind{
Group: cnpgv1.SchemeGroupVersion.Group,
Version: "v2",
Kind: "Cluster",
}))
})
It("should register CNPG types under both a custom group and custom version", func() {
viper.Set("custom-cnpg-group", "mycompany.io")
viper.Set("custom-cnpg-version", "v2")
AddCNPGToScheme(context.Background(), s)
gvks, _, err := s.ObjectKinds(&cnpgv1.Cluster{})
Expect(err).NotTo(HaveOccurred())
Expect(gvks).To(ContainElement(schema.GroupVersionKind{
Group: "mycompany.io",
Version: "v2",
Kind: "Cluster",
}))
})
})

22
internal/scheme/doc.go Normal file
View File

@ -0,0 +1,22 @@
/*
Copyright © contributors to CloudNativePG, established as
CloudNativePG a Series of LF Projects, LLC.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
SPDX-License-Identifier: Apache-2.0
*/
// Package scheme provides utilities for building runtime schemes
// with support for custom CNPG API groups.
package scheme

View File

@ -0,0 +1,32 @@
/*
Copyright © contributors to CloudNativePG, established as
CloudNativePG a Series of LF Projects, LLC.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
SPDX-License-Identifier: Apache-2.0
*/
package scheme
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestScheme(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Scheme Suite")
}

View File

@ -9,6 +9,7 @@ resources:
- service.yaml
- ../config/crd
- ../config/rbac
# If you change newName, update the e2e overlay in test/e2e/e2e_suite_test.go too.
images:
- name: plugin-barman-cloud
newName: ghcr.io/cloudnative-pg/plugin-barman-cloud-testing

View File

@ -37,6 +37,7 @@ import (
"github.com/cloudnative-pg/plugin-barman-cloud/test/e2e/internal/kustomize"
_ "github.com/cloudnative-pg/plugin-barman-cloud/test/e2e/internal/tests/backup"
_ "github.com/cloudnative-pg/plugin-barman-cloud/test/e2e/internal/tests/credentialrotation"
_ "github.com/cloudnative-pg/plugin-barman-cloud/test/e2e/internal/tests/replicacluster"
. "github.com/onsi/ginkgo/v2"
@ -57,9 +58,11 @@ var _ = SynchronizedBeforeSuite(func(ctx SpecContext) []byte {
const barmanCloudKustomizationPath = "./kustomize/kubernetes/"
barmanCloudKustomization := &kustomizeTypes.Kustomization{
Resources: []string{barmanCloudKustomizationPath},
// Override the image from the base kustomization (kubernetes/kustomization.yaml)
// with the locally-built one. The Name must match the newName in the base.
Images: []kustomizeTypes.Image{
{
Name: "docker.io/library/plugin-barman-cloud",
Name: "ghcr.io/cloudnative-pg/plugin-barman-cloud-testing",
NewName: "registry.barman-cloud-plugin:5000/plugin-barman-cloud",
NewTag: "testing",
},

View File

@ -0,0 +1,171 @@
/*
Copyright © contributors to CloudNativePG, established as
CloudNativePG a Series of LF Projects, LLC.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
SPDX-License-Identifier: Apache-2.0
*/
package credentialrotation
import (
"time"
cloudnativepgv1 "github.com/cloudnative-pg/api/pkg/api/v1"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/utils/ptr"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/cloudnative-pg/plugin-barman-cloud/internal/cnpgi/metadata"
"github.com/cloudnative-pg/plugin-barman-cloud/internal/cnpgi/operator/specs"
internalClient "github.com/cloudnative-pg/plugin-barman-cloud/test/e2e/internal/client"
internalCluster "github.com/cloudnative-pg/plugin-barman-cloud/test/e2e/internal/cluster"
nmsp "github.com/cloudnative-pg/plugin-barman-cloud/test/e2e/internal/namespace"
"github.com/cloudnative-pg/plugin-barman-cloud/test/e2e/internal/objectstore"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
const (
clusterName = "source"
objectStoreName = "source"
oldSecretName = "minio"
newSecretName = "minio-rotated"
)
var _ = Describe("Credential rotation", func() {
var namespace *corev1.Namespace
var cl client.Client
BeforeEach(func(ctx SpecContext) {
var err error
cl, _, err = internalClient.NewClient()
Expect(err).NotTo(HaveOccurred())
namespace, err = nmsp.CreateUniqueNamespace(ctx, cl, "cred-rotation")
Expect(err).NotTo(HaveOccurred())
})
AfterEach(func(ctx SpecContext) {
Expect(cl.Delete(ctx, namespace)).To(Succeed())
})
It("should update the Role when the ObjectStore secret reference changes", func(ctx SpecContext) {
By("starting the ObjectStore deployment")
resources := objectstore.NewMinioObjectStoreResources(namespace.Name, oldSecretName)
Expect(resources.Create(ctx, cl)).To(Succeed())
By("creating the ObjectStore")
store := objectstore.NewMinioObjectStore(namespace.Name, objectStoreName, oldSecretName)
Expect(cl.Create(ctx, store)).To(Succeed())
By("creating the Cluster")
cluster := newCluster(namespace.Name)
Expect(cl.Create(ctx, cluster)).To(Succeed())
By("waiting for the Cluster to be ready")
Eventually(func(g Gomega) {
g.Expect(cl.Get(ctx, types.NamespacedName{
Name: cluster.Name,
Namespace: cluster.Namespace,
}, cluster)).To(Succeed())
g.Expect(internalCluster.IsReady(*cluster)).To(BeTrue())
}).WithTimeout(10 * time.Minute).WithPolling(10 * time.Second).Should(Succeed())
roleKey := types.NamespacedName{
Name: specs.GetRBACName(clusterName),
Namespace: namespace.Name,
}
By("verifying the Role has the cluster label and references the original secret")
var role rbacv1.Role
Expect(cl.Get(ctx, roleKey, &role)).To(Succeed())
Expect(role.Labels).To(HaveKeyWithValue(metadata.ClusterLabelName, clusterName))
Expect(secretNamesFromRole(&role)).To(ContainElement(oldSecretName))
By("creating a new secret with the same credentials")
newSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: newSecretName,
Namespace: namespace.Name,
},
Data: map[string][]byte{
"ACCESS_KEY_ID": []byte("minio"),
"ACCESS_SECRET_KEY": []byte("minio123"),
},
}
Expect(cl.Create(ctx, newSecret)).To(Succeed())
By("updating the ObjectStore to reference the new secret")
Expect(cl.Get(ctx, types.NamespacedName{
Name: objectStoreName,
Namespace: namespace.Name,
}, store)).To(Succeed())
store.Spec.Configuration.BarmanCredentials.AWS.AccessKeyIDReference.Name = newSecretName
store.Spec.Configuration.BarmanCredentials.AWS.SecretAccessKeyReference.Name = newSecretName
Expect(cl.Update(ctx, store)).To(Succeed())
By("waiting for the Role to reference the new secret")
Eventually(func(g Gomega) {
g.Expect(cl.Get(ctx, roleKey, &role)).To(Succeed())
g.Expect(secretNamesFromRole(&role)).To(ContainElement(newSecretName))
g.Expect(secretNamesFromRole(&role)).NotTo(ContainElement(oldSecretName))
}).WithTimeout(3 * time.Minute).WithPolling(5 * time.Second).Should(Succeed())
})
})
func newCluster(namespace string) *cloudnativepgv1.Cluster {
return &cloudnativepgv1.Cluster{
TypeMeta: metav1.TypeMeta{
Kind: "Cluster",
APIVersion: "postgresql.cnpg.io/v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: clusterName,
Namespace: namespace,
},
Spec: cloudnativepgv1.ClusterSpec{
Instances: 1,
ImagePullPolicy: corev1.PullAlways,
Plugins: []cloudnativepgv1.PluginConfiguration{
{
Name: "barman-cloud.cloudnative-pg.io",
Parameters: map[string]string{
"barmanObjectName": objectStoreName,
},
IsWALArchiver: ptr.To(true),
},
},
StorageConfiguration: cloudnativepgv1.StorageConfiguration{
Size: "1Gi",
},
},
}
}
func secretNamesFromRole(role *rbacv1.Role) []string {
for _, rule := range role.Rules {
if len(rule.APIGroups) == 1 &&
rule.APIGroups[0] == "" &&
len(rule.Resources) == 1 &&
rule.Resources[0] == "secrets" {
return rule.ResourceNames
}
}
return nil
}

View File

@ -3471,9 +3471,9 @@ balanced-match@^1.0.0:
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
baseline-browser-mapping@^2.10.12:
version "2.10.17"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz#435c101835c314c2d89d768795e1ea79941fafd3"
integrity sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==
version "2.10.18"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.18.tgz#565745085ba7743af7d4072707ad132db3a5a42f"
integrity sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A==
batch@0.6.1:
version "0.6.1"
@ -3550,9 +3550,9 @@ boxen@^7.0.0:
wrap-ansi "^8.1.0"
brace-expansion@^1.1.7:
version "1.1.13"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.13.tgz#d37875c01dc9eff988dd49d112a57cb67b54efe6"
integrity sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==
version "1.1.14"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.14.tgz#d9de602370d91347cd9ddad1224d4fd701eb348b"
integrity sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==
dependencies:
balanced-match "^1.0.0"
concat-map "0.0.1"
@ -4476,9 +4476,9 @@ ee-first@1.1.1:
integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==
electron-to-chromium@^1.5.328:
version "1.5.334"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz#1e3fdd8d014852104eb8e632e760fb364db7dd0e"
integrity sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==
version "1.5.335"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.335.tgz#0b957cea44ef86795c227c616d16b4803d119daa"
integrity sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q==
emoji-regex@^8.0.0:
version "8.0.0"
@ -8248,10 +8248,11 @@ resolve-pathname@^3.0.0:
integrity sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==
resolve@^1.22.11:
version "1.22.11"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.11.tgz#aad857ce1ffb8bfa9b0b1ac29f1156383f68c262"
integrity sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==
version "1.22.12"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.12.tgz#f5b2a680897c69c238a13cd16b15671f8b73549f"
integrity sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==
dependencies:
es-errors "^1.3.0"
is-core-module "^2.16.1"
path-parse "^1.0.7"
supports-preserve-symlinks-flag "^1.0.0"
@ -8951,9 +8952,9 @@ undici-types@~7.19.0:
integrity sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==
undici@^7.19.0:
version "7.24.7"
resolved "https://registry.yarnpkg.com/undici/-/undici-7.24.7.tgz#af9535341bbe80625ca403a02418477a5c6a8760"
integrity sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==
version "7.24.8"
resolved "https://registry.yarnpkg.com/undici/-/undici-7.24.8.tgz#8207d06a68955d4420e50468f2749e940b3eb4cf"
integrity sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==
unicode-canonical-property-names-ecmascript@^2.0.0:
version "2.0.1"
@ -9269,9 +9270,9 @@ webpack-sources@^3.3.4:
integrity sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==
webpack@^5.88.1, webpack@^5.95.0:
version "5.106.0"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.106.0.tgz#ee374da5573eef1e47b2650d6be8e40fb928d697"
integrity sha512-Pkx5joZ9RrdgO5LBkyX1L2ZAJeK/Taz3vqZ9CbcP0wS5LEMx5QkKsEwLl29QJfihZ+DKRBFldzy1O30pJ1MDpA==
version "5.106.1"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.106.1.tgz#0a3eeb43a50e4f67fbecd206e1e6fc2c89fc2b6f"
integrity sha512-EW8af29ak8Oaf4T8k8YsajjrDBDYgnKZ5er6ljWFJsXABfTNowQfvHLftwcepVgdz+IoLSdEAbBiM9DFXoll9w==
dependencies:
"@types/eslint-scope" "^3.7.7"
"@types/estree" "^1.0.8"