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
36 changes: 34 additions & 2 deletions internal/cnpgi/instance/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ package instance
import (
"context"
"fmt"
"path/filepath"
"time"

"github.com/cloudnative-pg/cloudnative-pg/pkg/postgres"
Expand Down Expand Up @@ -118,11 +119,32 @@ func (b BackupServiceImplementation) Backup(
return nil, err
}

err = backupCmd.CreatePgbackrestStanza(ctx, configuration.Stanza, env)
// pgbackrest stanza-create requires a primary PostgreSQL connection: it reads
// the live cluster's control data to initialize the stanza metadata in the
// repository. On a standby it fails with pgbackrest exit code 56
// ("unable to find primary cluster"), which aborts the whole backup. That
// makes target: prefer-standby / standby backups unusable (see issue #83).
//
// Skip stanza-create when this instance is a standby. This is safe: the
// stanza is created by the primary (its own backups + WAL archiving's
// CheckWalArchiveDestination run against the same repo), so by the time a
// standby backup runs the stanza already exists. We detect the role the same
// way PostgreSQL and CNPG itself do — the presence of standby.signal in
// PGDATA — so we need neither a database connection nor credentials here.
isStandby, err := instanceIsStandby(b.PGDataPath)
if err != nil {
contextLogger.Error(err, "while initializing pgbackrest stanza")
contextLogger.Error(err, "while determining if instance is a standby")
return nil, err
}
if isStandby {
contextLogger.Info("Instance is a standby; skipping pgbackrest stanza-create " +
"(the stanza is initialized by the primary)")
} else {
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 Expand Up @@ -166,3 +188,13 @@ func (b BackupServiceImplementation) Backup(
},
}, nil
}

// instanceIsStandby reports whether the PostgreSQL instance rooted at pgDataPath
// is a standby (replica). It checks for the standby.signal file in PGDATA, which
// is the same signal PostgreSQL itself uses to start in standby mode and the same
// mechanism CloudNativePG uses to distinguish a replica from a primary. Using the
// signal file means we do not need a database connection or credentials at backup
// time to determine the role.
func instanceIsStandby(pgDataPath string) (bool, error) {
return fileutils.FileExists(filepath.Join(pgDataPath, "standby.signal"))
}
Comment on lines +198 to +200
58 changes: 58 additions & 0 deletions internal/cnpgi/instance/backup_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
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 (
"os"
"path/filepath"

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

var _ = Describe("instanceIsStandby", func() {
var pgData string

BeforeEach(func() {
pgData = GinkgoT().TempDir()
})

It("reports false for a primary (no standby.signal)", func() {
isStandby, err := instanceIsStandby(pgData)
Expect(err).NotTo(HaveOccurred())
Expect(isStandby).To(BeFalse())
})

It("reports true when standby.signal is present", func() {
Expect(
os.WriteFile(filepath.Join(pgData, "standby.signal"), []byte{}, 0o600),
).To(Succeed())

isStandby, err := instanceIsStandby(pgData)
Expect(err).NotTo(HaveOccurred())
Expect(isStandby).To(BeTrue())
})

It("treats a non-existent PGDATA as a primary rather than erroring", func() {
// FileExists returns (false, nil) when the directory does not exist, so a
// missing/odd PGDATA path falls back to the primary path (stanza-create
// runs) instead of aborting the backup.
isStandby, err := instanceIsStandby(filepath.Join(pgData, "does-not-exist"))
Expect(err).NotTo(HaveOccurred())
Expect(isStandby).To(BeFalse())
})
})
Comment on lines +50 to +58
29 changes: 29 additions & 0 deletions internal/cnpgi/instance/suite_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
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 plugin test suite")
}