Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,31 @@ This configuration enables both WAL archiving and data directory backups.
> Archiving will only start working after at least one backup is created. That's due to
> the stanza creation process which currently is only executed on backups.

### Backups From a Standby (experimental, incomplete)

`backupStandby` offloads backup I/O to a standby using pgBackRest multi-host TLS.
Whether a backup runs on a standby is decided by CloudNativePG through the backup
target; the plugin adds the primary as a second pgBackRest host when it finds
itself on a replica.

```yaml
spec:
configuration:
backupStandby:
enabled: true
injectService: false
injectSAN: false
# serviceName: my-pgbackrest # defaults to <cluster>-pgbackrest
```

> [!WARNING]
> This is incomplete. The plugin does not yet inject the headless service or the
> certificate SAN, and it does not yet run the pgBackRest TLS server on the
> instances. Enabling it with `injectService` or `injectSAN` left at their default
> fails with an explicit error. To try it, provide the service (exposing the
> pgBackRest TLS server port) and the SAN yourself, run the server, and set both
> options to `false`. See [issue #103](https://github.com/operasoftware/cnpg-plugin-pgbackrest/issues/103).

### Performing a Base Backup

Once WAL archiving is enabled, the cluster is ready for backups. To create a
Expand Down
30 changes: 30 additions & 0 deletions config/crd/bases/pgbackrest.cnpg.opera.com_archives.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,36 @@ spec:
description: PgbackrestConfiguration is the configuration of all pgBackRest
operations
properties:
backupStandby:
description: |-
BackupStandby, when enabled, offloads backups to a standby instance using
pgBackRest multi-host TLS. See BackupStandbyConfiguration.
properties:
enabled:
description: Enabled turns on backup-from-standby.
type: boolean
injectSAN:
default: true
description: |-
InjectSAN controls whether the plugin adds the pgBackRest service DNS name to
the cluster server certificate (serverAltDNSNames). Defaults to true; set to
false to manage the SAN yourself.
type: boolean
injectService:
default: true
description: |-
InjectService controls whether the plugin injects the headless service that
exposes the pgBackRest TLS server port on the instances. Defaults to true; set
to false to manage that service yourself.
type: boolean
serviceName:
description: |-
ServiceName is the headless service that resolves to the primary's pgBackRest
TLS server. Defaults to "<cluster>-pgbackrest", the service the plugin manages;
set it when you provide the service yourself. The port is not configurable: a
user-provided service must expose DefaultServerPort.
type: string
type: object
compression:
description: |-
Compress a WAL file before sending it to the object store. Available
Expand Down
70 changes: 70 additions & 0 deletions internal/cnpgi/instance/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ package instance

import (
"context"
"errors"
"fmt"
"time"

cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
"github.com/cloudnative-pg/cloudnative-pg/pkg/postgres"
"github.com/cloudnative-pg/cnpg-i-machinery/pkg/pluginhelper/decoder"
"github.com/cloudnative-pg/cnpg-i/pkg/backup"
Expand All @@ -33,8 +35,10 @@ import (
pgbackrestv1 "github.com/operasoftware/cnpg-plugin-pgbackrest/api/v1"
"github.com/operasoftware/cnpg-plugin-pgbackrest/internal/cnpgi/metadata"
"github.com/operasoftware/cnpg-plugin-pgbackrest/internal/cnpgi/operator/config"
pgbackrestApi "github.com/operasoftware/cnpg-plugin-pgbackrest/internal/pgbackrest/api"
pgbackrestBackup "github.com/operasoftware/cnpg-plugin-pgbackrest/internal/pgbackrest/backup"
"github.com/operasoftware/cnpg-plugin-pgbackrest/internal/pgbackrest/catalog"
pgbackrestCommand "github.com/operasoftware/cnpg-plugin-pgbackrest/internal/pgbackrest/command"
pgbackrestCredentials "github.com/operasoftware/cnpg-plugin-pgbackrest/internal/pgbackrest/credentials"
"github.com/operasoftware/cnpg-plugin-pgbackrest/internal/pgbackrest/utils"
)
Expand Down Expand Up @@ -102,6 +106,20 @@ func (b BackupServiceImplementation) Backup(
b.PGDataPath,
)

// When backup-from-standby is enabled and this instance is a standby, point
// pgBackRest at the current primary so both stanza-create and the backup can
// coordinate control operations there.
standbyTopology, err := b.resolveStandbyTopology(ctx, configuration.Cluster, &archive.Spec.Configuration)
if err != nil {
contextLogger.Error(err, "while resolving backup-from-standby topology")
return nil, err
}
if standbyTopology != nil {
contextLogger.Info("Taking backup from standby",
"primaryHost", standbyTopology.PrimaryHost)
backupCmd = backupCmd.WithStandbyBackup(standbyTopology)
}

// We need to connect to PostgreSQL and to do that we need
// PGHOST (and the like) to be available
osEnvironment := utils.SanitizedEnviron()
Expand Down Expand Up @@ -167,3 +185,55 @@ func (b BackupServiceImplementation) Backup(
},
}, nil
}

// errNoStandbyInjection is returned when backup-from-standby is enabled while the
// plugin is still expected to inject the service and the certificate SAN, which is
// not implemented yet.
var errNoStandbyInjection = errors.New(
"backup-from-standby is experimental and incomplete: the plugin does not inject the pgBackRest " +
"service and certificate SAN yet. Provide both yourself and set injectService and injectSAN to false")

// resolveStandbyTopology returns the topology needed to take this backup from a
// standby, or nil when a normal (local/primary) backup should be taken. It
// returns nil when the feature is disabled, or when this instance is the
// primary (or no primary is known yet). When this instance is a standby with a
// known primary, it resolves the primary pod's IP so pgBackRest can reach the
// primary's TLS server.
func (b BackupServiceImplementation) resolveStandbyTopology(
ctx context.Context,
cluster *cnpgv1.Cluster,
cfg *pgbackrestApi.PgbackrestConfiguration,
) (*pgbackrestCommand.StandbyBackupTopology, error) {
contextLogger := log.FromContext(ctx)

enabled := cfg.IsBackupStandbyEnabled()
currentPrimary := cluster.Status.CurrentPrimary
onStandby, err := pgbackrestCommand.ShouldConfigurePrimaryPeer(enabled, currentPrimary, b.InstanceName)
if err != nil {
return nil, err
}
if !onStandby {
if enabled {
contextLogger.Info(
"backup-from-standby enabled but this instance is not a standby; taking a local backup",
"instance", b.InstanceName, "currentPrimary", currentPrimary)
}
return nil, nil
}

// The service and SAN injection is not implemented yet, so the feature only works
// when both are provided out of band. Fail fast instead of letting pgBackRest fail
// with a connection or certificate error.
if cfg.BackupStandby.ShouldInjectService() || cfg.BackupStandby.ShouldInjectSAN() {
return nil, errNoStandbyInjection
}

return &pgbackrestCommand.StandbyBackupTopology{

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we go with a managed service, as discussed, I believe we don't need to retrieve pod at all. Whole flow would be:

  • Check if standby backups enabled.
  • Check if we're primary.
  • If we're on standby - use the configured service's name (its name is either known because we manage it or configurable by user in the Archive object), same for port.

That also means we don't need permissions to read pods (all data comes directly from the Archive object). The only thing needed is to determine primary name from the Cluster to find if we're primary.

PrimaryHost: cfg.BackupStandby.GetServiceName(cluster.Name),
PrimaryPort: pgbackrestCommand.DefaultServerPort,
PrimaryPGData: b.PGDataPath,
CertFile: pgbackrestCommand.DefaultTLSCertFile,
KeyFile: pgbackrestCommand.DefaultTLSKeyFile,
CAFile: pgbackrestCommand.DefaultTLSCAFile,
}, nil
}
116 changes: 116 additions & 0 deletions internal/cnpgi/instance/backup_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/*
Copyright The CloudNativePG Contributors
Copyright 2025, Opera Norway AS

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.
*/

package instance

import (
cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"

pgbackrestApi "github.com/operasoftware/cnpg-plugin-pgbackrest/internal/pgbackrest/api"
pgbackrestCommand "github.com/operasoftware/cnpg-plugin-pgbackrest/internal/pgbackrest/command"
)

var _ = Describe("resolveStandbyTopology", func() {
const (
ns = "test-ns"
primaryPod = "cluster-1"
standby = "cluster-2"
pgData = "/var/lib/postgresql/data/pgdata"
)

newCluster := func(currentPrimary string) *cnpgv1.Cluster {
c := &cnpgv1.Cluster{ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: "cluster"}}
c.Status.CurrentPrimary = currentPrimary
return c
}

newImpl := func(instanceName string, objs ...client.Object) BackupServiceImplementation {
fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objs...).Build()
return BackupServiceImplementation{Client: fakeClient, InstanceName: instanceName, PGDataPath: pgData}
}

// The injection is not implemented yet, so a usable configuration opts out of it.
cfg := func(enabled bool) *pgbackrestApi.PgbackrestConfiguration {
no := false
return &pgbackrestApi.PgbackrestConfiguration{
BackupStandby: &pgbackrestApi.BackupStandbyConfiguration{
Enabled: enabled,
InjectService: &no,
InjectSAN: &no,
},
}
}

It("returns nil when the feature is disabled", func(ctx SpecContext) {
impl := newImpl(standby)
topo, err := impl.resolveStandbyTopology(ctx, newCluster(primaryPod), cfg(false))
Expect(err).ToNot(HaveOccurred())
Expect(topo).To(BeNil())
})

It("returns nil (local backup) when this instance is the primary", func(ctx SpecContext) {
impl := newImpl(primaryPod)
topo, err := impl.resolveStandbyTopology(ctx, newCluster(primaryPod), cfg(true))
Expect(err).ToNot(HaveOccurred())
Expect(topo).To(BeNil())
})

It("errors when no primary is known", func(ctx SpecContext) {
impl := newImpl(standby)
_, err := impl.resolveStandbyTopology(ctx, newCluster(""), cfg(true))
Expect(err).To(MatchError(pgbackrestCommand.ErrNoCurrentPrimary))
})

It("builds the topology from the configured service when on a standby", func(ctx SpecContext) {
impl := newImpl(standby)
topo, err := impl.resolveStandbyTopology(ctx, newCluster(primaryPod), cfg(true))
Expect(err).ToNot(HaveOccurred())
Expect(topo).ToNot(BeNil())
Expect(*topo).To(Equal(pgbackrestCommand.StandbyBackupTopology{
PrimaryHost: "cluster" + pgbackrestApi.ServiceNameSuffix,
PrimaryPort: pgbackrestCommand.DefaultServerPort,
PrimaryPGData: pgData,
CertFile: pgbackrestCommand.DefaultTLSCertFile,
KeyFile: pgbackrestCommand.DefaultTLSKeyFile,
CAFile: pgbackrestCommand.DefaultTLSCAFile,
}))
})

It("honours an explicit service name", func(ctx SpecContext) {
conf := cfg(true)
conf.BackupStandby.ServiceName = "my-pgbackrest"
impl := newImpl(standby)
topo, err := impl.resolveStandbyTopology(ctx, newCluster(primaryPod), conf)
Expect(err).ToNot(HaveOccurred())
Expect(topo.PrimaryHost).To(Equal("my-pgbackrest"))
})

It("fails fast while the service and SAN injection is not implemented", func(ctx SpecContext) {
conf := &pgbackrestApi.PgbackrestConfiguration{
BackupStandby: &pgbackrestApi.BackupStandbyConfiguration{Enabled: true},
}
impl := newImpl(standby)
_, err := impl.resolveStandbyTopology(ctx, newCluster(primaryPod), conf)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("injectService"))
})
})
30 changes: 30 additions & 0 deletions internal/cnpgi/instance/suite_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
Copyright The CloudNativePG Contributors
Copyright 2025, Opera Norway AS

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.
*/

package instance

import (
"testing"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

func TestInstance(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Instance suite")
}
Loading