Skip to content
Merged
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
12 changes: 12 additions & 0 deletions config/crd/bases/pgbackrest.cnpg.opera.com_archives.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ spec:
- lz4
- zst
type: string
createStanza:
default: OnFirstArchive
description: |-
CreateStanza controls when the pgBackRest stanza is created. `OnFirstArchive`
(default) creates it on the first WAL archive if it does not exist yet, so
archiving works without a prior backup. `OnBackup` creates it only when a backup
runs. `Disabled` never creates it automatically.
enum:
- OnFirstArchive
- OnBackup
- Disabled
type: string
data:
description: |-
The configuration to be used to backup the data files
Expand Down
26 changes: 24 additions & 2 deletions internal/cnpgi/common/wal.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import (
"github.com/operasoftware/cnpg-plugin-pgbackrest/internal/cnpgi/metadata"
"github.com/operasoftware/cnpg-plugin-pgbackrest/internal/cnpgi/operator/config"
"github.com/operasoftware/cnpg-plugin-pgbackrest/internal/pgbackrest/archiver"
pgbackrestBackup "github.com/operasoftware/cnpg-plugin-pgbackrest/internal/pgbackrest/backup"
pgbackrestCommand "github.com/operasoftware/cnpg-plugin-pgbackrest/internal/pgbackrest/command"
pgbackrestCredentials "github.com/operasoftware/cnpg-plugin-pgbackrest/internal/pgbackrest/credentials"
pgbackrestRestorer "github.com/operasoftware/cnpg-plugin-pgbackrest/internal/pgbackrest/restorer"
Expand Down Expand Up @@ -136,9 +137,30 @@ func (w WALServiceImplementation) Archive(
return &wal.WALArchiveResult{}, nil
}

// Check if we're ok to archive in the desired destination
// Check that the destination repository is reachable and its stanza exists.
err = arch.CheckWalArchiveDestination(ctx, &archive.Spec.Configuration, configuration.Stanza, envArchive)
if err != nil {
switch {
case errors.Is(err, archiver.ErrStanzaMissing):
// On a fresh cluster, or after a major upgrade changes the repository path, the
// stanza does not exist yet and archive-push cannot succeed until it is created.
// When the Archive opts into it (createStanza=OnFirstArchive, the default), create
// it here instead of waiting for the first backup: this runs on the primary as soon
// as its sidecar is up, and PostgreSQL retries archiving on its own. stanza-create
// is idempotent, and we only reach it when the stanza is genuinely missing, so it
// does not contend with a running backup for the stanza lock.
if archive.Spec.Configuration.ShouldCreateStanzaOnArchive() {
backupCmd := pgbackrestBackup.NewBackupCommand(&archive.Spec.Configuration, nil, w.PGDataPath)
if stanzaErr := backupCmd.CreatePgbackrestStanza(ctx, configuration.Stanza, envArchive); stanzaErr != nil {
// Best-effort: log and continue. archive-push below reports the real
// outcome, and PostgreSQL retries the WAL if the stanza is still missing.
contextLogger.Warning("could not auto-create pgbackrest stanza; WAL archiving will retry",
"stanza", configuration.Stanza, "err", stanzaErr.Error())
} else {
contextLogger.Info("created pgbackrest stanza so WAL archiving can start",
"stanza", configuration.Stanza)
}
}
case err != nil:
log.Error(err, "while checking if pgbackrest repo can be used for archival")
return nil, err
}
Expand Down
11 changes: 7 additions & 4 deletions internal/cnpgi/instance/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,13 @@ func (b BackupServiceImplementation) Backup(
return nil, err
}

err = backupCmd.CreatePgbackrestStanza(ctx, configuration.Stanza, env)
if err != nil {
contextLogger.Error(err, "while initializing pgbackrest stanza")
return nil, err
// Create the stanza unless the Archive disables it (createStanza=Disabled), in which
// case it is expected to be managed out of band.
if archive.Spec.Configuration.ShouldCreateStanzaOnBackup() {
if err = backupCmd.CreatePgbackrestStanza(ctx, configuration.Stanza, env); err != nil {
contextLogger.Error(err, "while initializing pgbackrest stanza")
return nil, err
}
}

backupName := fmt.Sprintf("backup-%v", pgTime.ToCompactISO8601(time.Now()))
Expand Down
9 changes: 8 additions & 1 deletion internal/cnpgi/restore/restore.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package restore

import (
"context"
"errors"
"fmt"
"os"
"path"
Expand Down Expand Up @@ -247,7 +248,13 @@ func (impl *JobHookImpl) checkBackupDestination(
}

if utils.IsEmptyWalArchiveCheckEnabled(&cluster.ObjectMeta) {
return walArchiver.CheckWalArchiveDestination(ctx, pgbackrestConfiguration, stanza, env)
err := walArchiver.CheckWalArchiveDestination(ctx, pgbackrestConfiguration, stanza, env)
if errors.Is(err, pgbackrestArchiver.ErrStanzaMissing) {
// A reachable but not-yet-created stanza is a legitimate empty destination for
// a cluster about to start archiving; treat it as OK.
return nil
}
return err
}

return nil
Expand Down
46 changes: 46 additions & 0 deletions internal/pgbackrest/api/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,23 @@ type PgbackrestRepository struct {
Retention *PgbackrestRetention `json:"retention,omitempty"`
}

// StanzaCreatePolicy controls when the pgBackRest stanza is created.
// +kubebuilder:validation:Enum=OnFirstArchive;OnBackup;Disabled
type StanzaCreatePolicy string

const (
// StanzaCreateOnFirstArchive creates the stanza the first time a WAL is archived,
// if it does not exist yet, so archiving works without waiting for a backup.
StanzaCreateOnFirstArchive StanzaCreatePolicy = "OnFirstArchive"

// StanzaCreateOnBackup creates the stanza only when a backup runs (the legacy behavior).
StanzaCreateOnBackup StanzaCreatePolicy = "OnBackup"

// StanzaCreateDisabled never creates the stanza automatically; it must be created
// out of band.
StanzaCreateDisabled StanzaCreatePolicy = "Disabled"
)

// PgbackrestConfiguration is the configuration of all pgBackRest operations
type PgbackrestConfiguration struct {
Repositories []PgbackrestRepository `json:"repositories"`
Expand Down Expand Up @@ -351,6 +368,35 @@ type PgbackrestConfiguration struct {
// this parameter is omitted
// +optional
Stanza string `json:"stanza,omitempty"`

// CreateStanza controls when the pgBackRest stanza is created. `OnFirstArchive`
// (default) creates it on the first WAL archive if it does not exist yet, so
// archiving works without a prior backup. `OnBackup` creates it only when a backup
// runs. `Disabled` never creates it automatically.
// +kubebuilder:validation:Enum=OnFirstArchive;OnBackup;Disabled
// +kubebuilder:default=OnFirstArchive
// +optional
CreateStanza StanzaCreatePolicy `json:"createStanza,omitempty"`
}

// GetCreateStanzaPolicy returns the configured stanza creation policy, defaulting to
// OnFirstArchive when unset.
func (c *PgbackrestConfiguration) GetCreateStanzaPolicy() StanzaCreatePolicy {
if c.CreateStanza == "" {
return StanzaCreateOnFirstArchive
Comment thread
Agalin marked this conversation as resolved.
}
return c.CreateStanza
}

// ShouldCreateStanzaOnArchive reports whether the WAL archive path should create the
// stanza when it is missing.
func (c *PgbackrestConfiguration) ShouldCreateStanzaOnArchive() bool {
return c.GetCreateStanzaPolicy() == StanzaCreateOnFirstArchive
}

// ShouldCreateStanzaOnBackup reports whether the backup path should create the stanza.
func (c *PgbackrestConfiguration) ShouldCreateStanzaOnBackup() bool {
return c.GetCreateStanzaPolicy() != StanzaCreateDisabled
}

// ArePopulated checks if the passed set of credentials contains
Expand Down
26 changes: 19 additions & 7 deletions internal/pgbackrest/archiver/archiver.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ package archiver

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

Expand Down Expand Up @@ -128,19 +129,30 @@ func (archiver *WALArchiver) ArchiveList(
return result
}

// CheckWalArchiveDestination checks if the destination archive is ready to perform
// archiving, i.e. if proper stanzas exist.
// ErrStanzaMissing is returned by CheckWalArchiveDestination when the destination
// repository is reachable but the pgBackRest stanza has not been created yet. Callers
// can react to it (create the stanza, or treat the destination as empty) instead of
// inspecting the "pgbackrest info" output themselves.
var ErrStanzaMissing = errors.New("pgbackrest stanza has not been created")

// CheckWalArchiveDestination checks that the destination archive is reachable and its
// stanza exists. It runs "pgbackrest info" (which, unlike stanza-create, does not take
// the stanza lock) and returns ErrStanzaMissing when the repository is reachable but
// the stanza has not been created yet.
func (archiver *WALArchiver) CheckWalArchiveDestination(
ctx context.Context,
configuration *pgbackrestApi.PgbackrestConfiguration,
stanza string,
env []string,
) error {
// Probably the easiest way to check if stanza exists is to run "pgbackrest info".
// It's possible to use stanza-create instead but it requires the lock file
// which makes it unusable during backups.
_, err := pgbackrestCommand.GetBackupList(ctx, configuration, stanza, env)
return err
destinationCatalog, err := pgbackrestCommand.GetBackupList(ctx, configuration, stanza, env)
if err != nil {
return err
}
if destinationCatalog.StanzaMissing() {
return ErrStanzaMissing
}
return nil
}

// PgbackrestCheckWalArchiveOptions create the options needed for the `pgbackrest check`
Expand Down
20 changes: 20 additions & 0 deletions internal/pgbackrest/catalog/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,26 @@ type Catalog struct {
Stanza string `json:"name"`
Databases []PgbackrestBackupDatabase `json:"db"`
Encryption string `json:"cipher"`
Status PgbackrestStanzaStatus `json:"status"`
}

// StanzaStatusCodeMissing is the "pgbackrest info" stanza status code returned when
// the stanza has not been created in the repository yet (its archive.info is absent).
// While the stanza is in this state WAL archiving cannot succeed until
// "pgbackrest stanza-create" has been run.
const StanzaStatusCodeMissing = 1

// PgbackrestStanzaStatus is the "status" object that "pgbackrest info" reports for a
// stanza. Only the fields we act on are parsed; the rest of the object is ignored.
type PgbackrestStanzaStatus struct {
Code int `json:"code"`
Message string `json:"message"`
}

// StanzaMissing reports whether "pgbackrest info" says the stanza has not been created
// in the repository yet, i.e. WAL archiving cannot work until stanza-create is run.
func (catalog *Catalog) StanzaMissing() bool {
return catalog.Status.Code == StanzaStatusCodeMissing
}

// NewSingleBackupCatalogFromPgbackrestInfo parses the output of pgbackrest info
Expand Down
23 changes: 23 additions & 0 deletions internal/pgbackrest/catalog/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,29 @@ var _ = Describe("pgbackrest info parsing", func() {
Equal(time.Date(2025, 3, 31, 14, 20, 41, 0, time.UTC)))
})

It("reports the stanza as present when the status code is zero", func() {
result, err := NewCatalogFromPgbackrestInfo(pgbackrestInfoOutput)
Expect(err).ToNot(HaveOccurred())
Expect(result.StanzaMissing()).To(BeFalse())
})

It("reports the stanza as missing when the status code is 1", func() {
const missingStanzaOutput = `[
{
"archive": [],
"backup": [],
"cipher": "none",
"db": [],
"name": "cluster-example-pgbackrest",
"status": { "code": 1, "message": "missing stanza path" }
}
]`
result, err := NewCatalogFromPgbackrestInfo(missingStanzaOutput)
Expect(err).ToNot(HaveOccurred())
Expect(result.StanzaMissing()).To(BeTrue())
Expect(result.Status.Message).To(Equal("missing stanza path"))
})

// It("can find the closest backup info when there is one", func() {
// recoveryTarget := &v1.RecoveryTarget{TargetTime: time.Now().Format("2006-01-02 15:04:04")}
// closestBackupInfo, err := catalog.FindBackupInfo(recoveryTarget)
Expand Down
12 changes: 12 additions & 0 deletions manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,18 @@ spec:
- lz4
- zst
type: string
createStanza:
default: OnFirstArchive
description: |-
CreateStanza controls when the pgBackRest stanza is created. `OnFirstArchive`
(default) creates it on the first WAL archive if it does not exist yet, so
archiving works without a prior backup. `OnBackup` creates it only when a backup
runs. `Disabled` never creates it automatically.
enum:
- OnFirstArchive
- OnBackup
- Disabled
type: string
data:
description: |-
The configuration to be used to backup the data files
Expand Down
1 change: 1 addition & 0 deletions test/e2e/e2e_suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import (
_ "github.com/operasoftware/cnpg-plugin-pgbackrest/test/e2e/internal/tests/backup"
_ "github.com/operasoftware/cnpg-plugin-pgbackrest/test/e2e/internal/tests/parallelarchive"
_ "github.com/operasoftware/cnpg-plugin-pgbackrest/test/e2e/internal/tests/replicacluster"
_ "github.com/operasoftware/cnpg-plugin-pgbackrest/test/e2e/internal/tests/walarchive"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
Expand Down
25 changes: 10 additions & 15 deletions test/e2e/internal/logs/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,29 +84,24 @@ func GetPodContainerLogs(
// FindArchiveBatches finds "WAL archive batch prepared" log entries and returns the parsed data.
// Each returned map contains the structured log fields.
func FindArchiveBatches(logEntries []map[string]any) []map[string]any {
var batches []map[string]any

for _, logEntry := range logEntries {
// Check if this is a "WAL archive batch prepared" message
if msg, ok := logEntry["msg"].(string); ok && msg == "WAL archive batch prepared" {
batches = append(batches, logEntry)
}
}

return batches
return FindLogEntriesByMessage(logEntries, "WAL archive batch prepared")
}

// FindArchiveBatchCompletions finds "WAL archive batch completed" log entries and returns the parsed data.
// Each returned map contains the structured log fields.
func FindArchiveBatchCompletions(logEntries []map[string]any) []map[string]any {
var batches []map[string]any
return FindLogEntriesByMessage(logEntries, "WAL archive batch completed")
}

// FindLogEntriesByMessage returns the log entries whose "msg" field equals the given message.
func FindLogEntriesByMessage(logEntries []map[string]any, message string) []map[string]any {
var matches []map[string]any

for _, logEntry := range logEntries {
// Check if this is a "WAL archive batch completed" message
if msg, ok := logEntry["msg"].(string); ok && msg == "WAL archive batch completed" {
batches = append(batches, logEntry)
if msg, ok := logEntry["msg"].(string); ok && msg == message {
matches = append(matches, logEntry)
}
}

return batches
return matches
}
20 changes: 20 additions & 0 deletions test/e2e/internal/tests/walarchive/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
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 walarchive contains e2e tests verifying that WAL archiving works
// without taking a pgbackrest backup first, by creating the stanza lazily on
// the first WAL archive.
package walarchive
Loading