From e64d1805b60be09915e2b386ef2badfb844e0e11 Mon Sep 17 00:00:00 2001 From: BoxBoxJason Date: Sun, 4 Oct 2026 04:49:58 +0200 Subject: [PATCH] feat: record the backup location in the Backup plugin metadata Add the `ObjectStore` name, the server name, the `destinationPath` and the `endpointURL` a backup was written to in the backup result metadata, so that they end up in `Backup.status.pluginMetadata`. A recovery cluster can then be pointed at the right location without listing the bucket or digging through old manifests, which matters when `serverName` changes across cluster generations. The keys are only added, and left out when empty, so the metadata of backups taken by older versions keeps the same shape. The plugin writes them but doesn't read them back. Credentials embedded in `destinationPath` or `endpointURL` are masked before being recorded. Closes #1140 Assisted-by: Claude Opus 5.5 Signed-off-by: BoxBoxJason --- internal/cnpgi/instance/backup.go | 7 +- internal/cnpgi/instance/types.go | 53 ++++++++++++++- internal/cnpgi/instance/types_test.go | 97 +++++++++++++++++++++++++++ web/docs/usage.md | 31 +++++++++ 4 files changed, 184 insertions(+), 4 deletions(-) create mode 100644 internal/cnpgi/instance/types_test.go diff --git a/internal/cnpgi/instance/backup.go b/internal/cnpgi/instance/backup.go index ebf166c4d..cc4bf27c8 100644 --- a/internal/cnpgi/instance/backup.go +++ b/internal/cnpgi/instance/backup.go @@ -190,7 +190,12 @@ func (b BackupServiceImplementation) Backup( EndLsn: executedBackupInfo.EndLSN, InstanceId: b.InstanceName, Online: true, - Metadata: newBackupResultMetadata(configuration.Cluster.ObjectMeta.UID, executedBackupInfo.TimeLine).toMap(), + Metadata: newBackupResultMetadata( + configuration.Cluster.ObjectMeta.UID, + executedBackupInfo.TimeLine, + &objectStore, + configuration.ServerName, + ).toMap(), }, nil } diff --git a/internal/cnpgi/instance/types.go b/internal/cnpgi/instance/types.go index 7353c1e9e..68246ffff 100644 --- a/internal/cnpgi/instance/types.go +++ b/internal/cnpgi/instance/types.go @@ -20,10 +20,12 @@ SPDX-License-Identifier: Apache-2.0 package instance import ( + "net/url" "strconv" "k8s.io/apimachinery/pkg/types" + barmancloudv1 "github.com/cloudnative-pg/plugin-barman-cloud/api/v1" "github.com/cloudnative-pg/plugin-barman-cloud/internal/cnpgi/metadata" ) @@ -34,10 +36,15 @@ type backupResultMetadata struct { displayName string clusterUID string pluginName string + + barmanObjectName string + serverName string + destinationPath string + endpointURL string } func (b backupResultMetadata) toMap() map[string]string { - return map[string]string{ + result := map[string]string{ "timeline": b.timeline, "version": b.version, "name": b.name, @@ -45,18 +52,47 @@ func (b backupResultMetadata) toMap() map[string]string { "clusterUID": b.clusterUID, "pluginName": b.pluginName, } + + // location keys are omitted when empty (for retrocompatibility) + location := map[string]string{ + "barmanObjectName": b.barmanObjectName, + "serverName": b.serverName, + "destinationPath": b.destinationPath, + "endpointURL": b.endpointURL, + } + for key, value := range location { + if len(value) > 0 { + result[key] = value + } + } + + return result } -func newBackupResultMetadata(clusterUID types.UID, timeline int) backupResultMetadata { - return backupResultMetadata{ +func newBackupResultMetadata( + clusterUID types.UID, + timeline int, + objectStore *barmancloudv1.ObjectStore, + serverName string, +) backupResultMetadata { + result := backupResultMetadata{ timeline: strconv.Itoa(timeline), clusterUID: string(clusterUID), + serverName: serverName, // static values version: metadata.Data.Version, name: metadata.Data.Name, displayName: metadata.Data.DisplayName, pluginName: metadata.PluginName, } + + if objectStore != nil { + result.barmanObjectName = objectStore.Name + result.destinationPath = redactURL(objectStore.Spec.Configuration.DestinationPath) + result.endpointURL = redactURL(objectStore.Spec.Configuration.EndpointURL) + } + + return result } func newBackupResultMetadataFromMap(m map[string]string) backupResultMetadata { @@ -73,3 +109,14 @@ func newBackupResultMetadataFromMap(m map[string]string) backupResultMetadata { pluginName: m["pluginName"], } } + +// redactURL masks any password embedded in the given URL, so that it +// is not exposed in the status of the Backup object +func redactURL(rawURL string) string { + parsed, err := url.Parse(rawURL) + if err != nil || parsed.User == nil { + return rawURL + } + + return parsed.Redacted() +} diff --git a/internal/cnpgi/instance/types_test.go b/internal/cnpgi/instance/types_test.go new file mode 100644 index 000000000..5f2acb766 --- /dev/null +++ b/internal/cnpgi/instance/types_test.go @@ -0,0 +1,97 @@ +/* +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 instance + +import ( + barmanapi "github.com/cloudnative-pg/barman-cloud/pkg/api" + 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" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("backupResultMetadata", func() { + newObjectStore := func(endpointURL string) *barmancloudv1.ObjectStore { + return &barmancloudv1.ObjectStore{ + ObjectMeta: metav1.ObjectMeta{Name: "store", Namespace: "default"}, + Spec: barmancloudv1.ObjectStoreSpec{ + Configuration: barmanapi.BarmanObjectStoreConfiguration{ + DestinationPath: "s3://bucket/path", + EndpointURL: endpointURL, + }, + }, + } + } + + It("records the location the backup was written to", func() { + m := newBackupResultMetadata("uid", 2, newObjectStore("https://s3.example.com"), "mydb-20261003T120000").toMap() + Expect(m).To(Equal(map[string]string{ + "timeline": "2", + "clusterUID": "uid", + "version": metadata.Data.Version, + "name": metadata.Data.Name, + "displayName": metadata.Data.DisplayName, + "pluginName": metadata.PluginName, + "barmanObjectName": "store", + "serverName": "mydb-20261003T120000", + "destinationPath": "s3://bucket/path", + "endpointURL": "https://s3.example.com", + })) + }) + + It("omits the endpointURL when it is not set", func() { + m := newBackupResultMetadata("uid", 1, newObjectStore(""), "mydb").toMap() + Expect(m).NotTo(HaveKey("endpointURL")) + Expect(m).To(HaveKeyWithValue("destinationPath", "s3://bucket/path")) + }) + + It("omits the location keys of a missing object store", func() { + m := newBackupResultMetadata("uid", 1, nil, "mydb").toMap() + Expect(m).To(HaveKeyWithValue("serverName", "mydb")) + Expect(m).NotTo(HaveKey("barmanObjectName")) + Expect(m).NotTo(HaveKey("destinationPath")) + Expect(m).NotTo(HaveKey("endpointURL")) + }) + + It("omits the serverName when it is not set", func() { + m := newBackupResultMetadata("uid", 1, newObjectStore(""), "").toMap() + Expect(m).NotTo(HaveKey("serverName")) + Expect(m).To(HaveKeyWithValue("barmanObjectName", "store")) + }) + + It("redacts credentials embedded in the endpointURL", func() { + m := newBackupResultMetadata("uid", 1, newObjectStore("https://user:secret@s3.example.com"), "mydb").toMap() + Expect(m).To(HaveKeyWithValue("endpointURL", "https://user:xxxxx@s3.example.com")) + }) +}) + +var _ = Describe("redactURL", func() { + It("masks passwords embedded in the URL", func() { + Expect(redactURL("https://user:secret@s3.example.com")).To(Equal("https://user:xxxxx@s3.example.com")) + }) + + It("leaves URLs without credentials untouched", func() { + Expect(redactURL("s3://bucket/path")).To(Equal("s3://bucket/path")) + Expect(redactURL("")).To(BeEmpty()) + }) +}) diff --git a/web/docs/usage.md b/web/docs/usage.md index a29197c6a..983ddaee2 100644 --- a/web/docs/usage.md +++ b/web/docs/usage.md @@ -126,6 +126,37 @@ kubectl cnpg backup -n \ ``` ::: +### Locating a backup + +Once a backup completes, the plugin records where it was written in the +`.status.pluginMetadata` section of the `Backup` object: + +| Key | Value | +|--------------------|-----------------------------------------------------------------------------| +| `barmanObjectName` | Name of the `ObjectStore` the backup was written to | +| `serverName` | Server name used for the backup (the `serverName` parameter, or the cluster name when unset) | +| `destinationPath` | `destinationPath` of the `ObjectStore` at backup time | +| `endpointURL` | `endpointURL` of the `ObjectStore` at backup time, only when set | + +Credentials embedded in `destinationPath` or `endpointURL` are masked. + +Use `barmanObjectName` and `serverName` to fill in the `externalClusters` +entry when [restoring a cluster](#restoring-a-cluster) from this backup. +`destinationPath` and `endpointURL` show whether the `ObjectStore` has +been changed to point somewhere else since the backup was taken. + +:::important +`barmanObjectName` always refers to an `ObjectStore` in the namespace of +the `Cluster` that uses it. To restore into a different namespace, create +an `ObjectStore` there with the same `destinationPath`, `endpointURL` and +credentials, and reference that one instead. +::: + +:::note +Backups taken with plugin versions that predate this feature don't carry +these keys. +::: + ## Restoring a Cluster To restore a cluster from an object store, create a new `Cluster` resource that