diff --git a/internal/cnpgi/instance/backup.go b/internal/cnpgi/instance/backup.go index b06f34b..98f9ddf 100644 --- a/internal/cnpgi/instance/backup.go +++ b/internal/cnpgi/instance/backup.go @@ -20,6 +20,7 @@ package instance import ( "context" "fmt" + "path/filepath" "time" "github.com/cloudnative-pg/cloudnative-pg/pkg/postgres" @@ -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())) @@ -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")) +} diff --git a/internal/cnpgi/instance/backup_test.go b/internal/cnpgi/instance/backup_test.go new file mode 100644 index 0000000..01c8fdb --- /dev/null +++ b/internal/cnpgi/instance/backup_test.go @@ -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()) + }) +}) diff --git a/internal/cnpgi/instance/suite_test.go b/internal/cnpgi/instance/suite_test.go new file mode 100644 index 0000000..ab33693 --- /dev/null +++ b/internal/cnpgi/instance/suite_test.go @@ -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") +}