diff --git a/internal/cnpgi/instance/backup.go b/internal/cnpgi/instance/backup.go index ebf166c4..cc4bf27c 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 7353c1e9..68246fff 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 00000000..5f2acb76 --- /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 a29197c6..983ddaee 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