feat: honor the operator's check_empty_wal_archive decision (#1009)

Archive() and the restore job hook each re-derived, on their own,
whether to verify the WAL archive destination is empty, by reading a
Cluster annotation and, for Archive, an on-disk marker file. That
decision belongs to the operator, which already tracks both the
annotation and the marker file's lifecycle.

Honor cnpg-i's new WALArchiveRequest/RestoreRequest field
CheckEmptyWalArchive when the operator sets it: obey it directly,
without re-inspecting the marker file. Only fall back to the previous
annotation-and-marker-file logic when talking to an operator that
predates this field.

Related: cloudnative-pg/cnpg-i#353 adds the field this depends on;
cloudnative-pg/cloudnative-pg#11216 is the operator-side counterpart.

Signed-off-by: Marco Nenciarini <marco.nenciarini@enterprisedb.com>
Signed-off-by: Armando Ruocco <armando.ruocco@enterprisedb.com>
Co-authored-by: Armando Ruocco <armando.ruocco@enterprisedb.com>
This commit is contained in:
Marco Nenciarini 2026-07-21 17:29:10 +02:00 committed by GitHub
parent 1d777435d0
commit 7bcb74b987
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 217 additions and 9 deletions

View File

@ -156,13 +156,17 @@ func (w WALServiceImplementation) Archive(
return nil, err return nil, err
} }
// Step 2: Check if the archive location is safe to perform archiving // Step 2: Check if the archive location is safe to perform archiving.
checkFileExisting, err := fileutils.FileExists(emptyWalArchiveFile) checkEmptyWalArchive, err := resolveArchiveEmptyWalArchiveCheck(
request.CheckEmptyWalArchive,
configuration.Cluster,
emptyWalArchiveFile,
)
if err != nil { if err != nil {
return nil, fmt.Errorf("while checking for empty wal archive check file %q: %w", emptyWalArchiveFile, err) return nil, err
} }
if utils.IsEmptyWalArchiveCheckEnabled(&configuration.Cluster.ObjectMeta) && checkFileExisting { if checkEmptyWalArchive {
if err := CheckBackupDestination( if err := CheckBackupDestination(
ctx, ctx,
&objectStore.Spec.Configuration, &objectStore.Spec.Configuration,
@ -224,6 +228,31 @@ func (w WALServiceImplementation) Archive(
return &wal.WALArchiveResult{}, nil return &wal.WALArchiveResult{}, nil
} }
// resolveArchiveEmptyWalArchiveCheck reports whether the WAL archive
// destination must be verified before archiving this segment.
//
// The operator owns the marker file's lifecycle, so when it sets the decision
// (non-nil) that value already accounts for the marker and is obeyed as-is. A
// nil value comes from an operator that predates this field, so we fall back to
// the previous logic: the Cluster annotation combined with the on-disk marker
// file.
func resolveArchiveEmptyWalArchiveCheck(
operatorDecision *bool,
cluster *cnpgv1.Cluster,
markerFilePath string,
) (bool, error) {
if operatorDecision != nil {
return *operatorDecision, nil
}
markerFilePresent, err := fileutils.FileExists(markerFilePath)
if err != nil {
return false, fmt.Errorf("while checking for empty wal archive check file %q: %w", markerFilePath, err)
}
return utils.IsEmptyWalArchiveCheckEnabled(&cluster.ObjectMeta) && markerFilePresent, nil
}
// Restore implements the WALService interface // Restore implements the WALService interface
func (w WALServiceImplementation) Restore( func (w WALServiceImplementation) Restore(
ctx context.Context, ctx context.Context,

View File

@ -21,15 +21,20 @@ package common
import ( import (
"context" "context"
"os"
"path/filepath"
barmanapi "github.com/cloudnative-pg/barman-cloud/pkg/api" barmanapi "github.com/cloudnative-pg/barman-cloud/pkg/api"
barmanRestorer "github.com/cloudnative-pg/barman-cloud/pkg/restorer" barmanRestorer "github.com/cloudnative-pg/barman-cloud/pkg/restorer"
cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/utils/ptr"
"github.com/cloudnative-pg/plugin-barman-cloud/internal/cnpgi/metadata"
"github.com/cloudnative-pg/plugin-barman-cloud/internal/cnpgi/operator/config"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/cloudnative-pg/plugin-barman-cloud/internal/cnpgi/operator/config"
) )
var _ = Describe("resolveRestoreObjectStore", func() { var _ = Describe("resolveRestoreObjectStore", func() {
@ -177,3 +182,65 @@ var _ = Describe("clearEndOfWALStreamFlag", func() {
Expect(isEOS).To(BeFalse()) Expect(isEOS).To(BeFalse())
}) })
}) })
var _ = Describe("resolveArchiveEmptyWalArchiveCheck", func() {
// skipAnnotation mirrors the unexported constant in cloudnative-pg's
// pkg/utils; hard-coding the literal makes a divergence surface as a
// failing test rather than silently disabling the check.
const skipAnnotation = "cnpg.io/skipEmptyWalArchiveCheck"
clusterWith := func(annotationValue *string) *cnpgv1.Cluster {
cluster := &cnpgv1.Cluster{}
if annotationValue != nil {
cluster.Annotations = map[string]string{skipAnnotation: *annotationValue}
}
return cluster
}
// markerPath returns the marker file path inside a fresh temp dir,
// creating the file there when present is true.
markerPath := func(present bool) string {
filePath := filepath.Join(GinkgoT().TempDir(), metadata.CheckEmptyWalArchiveFile)
if present {
Expect(os.WriteFile(filePath, []byte{}, 0o600)).To(Succeed())
}
return filePath
}
When("the operator sets the decision", func() {
It("obeys true, ignoring the annotation and the marker file", func() {
// annotation would skip the check and the marker is absent, yet the
// operator's explicit true must still win.
got, err := resolveArchiveEmptyWalArchiveCheck(
ptr.To(true), clusterWith(ptr.To("enabled")), markerPath(false))
Expect(err).NotTo(HaveOccurred())
Expect(got).To(BeTrue())
})
It("obeys false, ignoring the annotation and the marker file", func() {
// annotation would keep the check on and the marker is present, yet the
// operator's explicit false must still win.
got, err := resolveArchiveEmptyWalArchiveCheck(
ptr.To(false), clusterWith(nil), markerPath(true))
Expect(err).NotTo(HaveOccurred())
Expect(got).To(BeFalse())
})
})
When("the operator predates the field (nil decision)", func() {
DescribeTable(
"falls back to the annotation combined with the marker file",
func(annotationValue *string, markerPresent bool, expected bool) {
got, err := resolveArchiveEmptyWalArchiveCheck(
nil, clusterWith(annotationValue), markerPath(markerPresent))
Expect(err).NotTo(HaveOccurred())
Expect(got).To(Equal(expected))
},
Entry("no annotation and marker present: check runs", nil, true, true),
Entry("no annotation and marker absent: check skipped", nil, false, false),
Entry("opt-out annotation and marker present: check skipped", ptr.To("enabled"), true, false),
Entry("unrelated annotation value and marker present: check runs", ptr.To("something-else"), true, true),
Entry("empty annotation value and marker present: check runs", ptr.To(""), true, true),
)
})
})

View File

@ -113,6 +113,7 @@ func (impl JobHookImpl) Restore(
configuration.Cluster, configuration.Cluster,
&targetObjectStore.Spec.Configuration, &targetObjectStore.Spec.Configuration,
targetObjectStore.Name, targetObjectStore.Name,
req.CheckEmptyWalArchive,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -250,6 +251,7 @@ func (impl *JobHookImpl) checkBackupDestination(
cluster *cnpgv1.Cluster, cluster *cnpgv1.Cluster,
barmanConfiguration *cnpgv1.BarmanObjectStoreConfiguration, barmanConfiguration *cnpgv1.BarmanObjectStoreConfiguration,
objectStoreName string, objectStoreName string,
operatorCheckEmptyWalArchive *bool,
) error { ) error {
// Get environment from cache // Get environment from cache
env, err := barmanCredentials.EnvSetCloudCredentialsAndCertificates(ctx, env, err := barmanCredentials.EnvSetCloudCredentialsAndCertificates(ctx,
@ -288,14 +290,26 @@ func (impl *JobHookImpl) checkBackupDestination(
} }
} }
// Check if we're ok to archive in the desired destination if resolveRestoreEmptyWalArchiveCheck(operatorCheckEmptyWalArchive, cluster) {
if utils.IsEmptyWalArchiveCheckEnabled(&cluster.ObjectMeta) {
return common.CheckBackupDestination(ctx, barmanConfiguration, walArchiver, serverName) return common.CheckBackupDestination(ctx, barmanConfiguration, walArchiver, serverName)
} }
return nil return nil
} }
// resolveRestoreEmptyWalArchiveCheck reports whether the destination must be
// verified before restoring. When the operator sets the decision (non-nil) it
// is obeyed as-is; a nil value comes from an operator that predates this field,
// so we fall back to the Cluster annotation. Unlike archiving, restore is a
// one-shot operation that has never been gated on the first-archive marker
// file, so the annotation is the only fallback needed.
func resolveRestoreEmptyWalArchiveCheck(operatorDecision *bool, cluster *cnpgv1.Cluster) bool {
if operatorDecision != nil {
return *operatorDecision
}
return utils.IsEmptyWalArchiveCheckEnabled(&cluster.ObjectMeta)
}
// restoreCustomWalDir moves the current pg_wal data to the specified custom wal dir and applies the symlink // restoreCustomWalDir moves the current pg_wal data to the specified custom wal dir and applies the symlink
// returns indicating if any changes were made and any error encountered in the process // returns indicating if any changes were made and any error encountered in the process
func (impl JobHookImpl) restoreCustomWalDir(ctx context.Context) (bool, error) { func (impl JobHookImpl) restoreCustomWalDir(ctx context.Context) (bool, error) {

View File

@ -0,0 +1,66 @@
/*
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 restore
import (
cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
"k8s.io/utils/ptr"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("resolveRestoreEmptyWalArchiveCheck", func() {
// skipAnnotation mirrors the unexported constant in cloudnative-pg's
// pkg/utils; hard-coding the literal makes a divergence surface as a
// failing test rather than silently disabling the check.
const skipAnnotation = "cnpg.io/skipEmptyWalArchiveCheck"
clusterWith := func(annotationValue *string) *cnpgv1.Cluster {
cluster := &cnpgv1.Cluster{}
if annotationValue != nil {
cluster.Annotations = map[string]string{skipAnnotation: *annotationValue}
}
return cluster
}
When("the operator sets the decision", func() {
It("obeys true even when the annotation would skip the check", func() {
Expect(resolveRestoreEmptyWalArchiveCheck(ptr.To(true), clusterWith(ptr.To("enabled")))).To(BeTrue())
})
It("obeys false even when the annotation would keep the check on", func() {
Expect(resolveRestoreEmptyWalArchiveCheck(ptr.To(false), clusterWith(nil))).To(BeFalse())
})
})
When("the operator predates the field (nil decision)", func() {
DescribeTable(
"falls back to the Cluster annotation, never to a marker file",
func(annotationValue *string, expected bool) {
Expect(resolveRestoreEmptyWalArchiveCheck(nil, clusterWith(annotationValue))).To(Equal(expected))
},
Entry("no annotation: check runs", nil, true),
Entry("opt-out annotation: check skipped", ptr.To("enabled"), false),
Entry("unrelated annotation value: check runs", ptr.To("something-else"), true),
Entry("empty annotation value: check runs", ptr.To(""), true),
)
})
})

View File

@ -0,0 +1,32 @@
/*
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 restore
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestRestore(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Restore job hook test suite")
}