From bd5c39a313edae5c8f37596b8a85ee9f0c1e22b6 Mon Sep 17 00:00:00 2001 From: Marco Nenciarini Date: Thu, 16 Jul 2026 16:41:17 +0200 Subject: [PATCH] feat: add check_empty_wal_archive to WAL and RestoreJobHooks requests Verifying a WAL archive destination before writing to it is a decision about the Cluster's state, and that is the operator's domain, not the plugin's. Today, though, a plugin that performs this check re-derives the decision on its own (e.g. by reading a Cluster annotation directly), duplicating policy that belongs upstream instead of relying on a single source of truth. Add an explicit, optional boolean field to WALArchiveRequest and to RestoreJobHooks' RestoreRequest so the operator can compute the decision once and hand it to the plugin explicitly. The field is optional: an unset value means the request comes from an operator that predates this field, and the plugin is free to handle that case as it sees fit. Signed-off-by: Marco Nenciarini --- docs/protocol.md | 2 ++ pkg/restore/job/restore_job.pb.go | 25 +++++++++++++++++++++---- pkg/wal/wal.pb.go | 28 ++++++++++++++++++++++------ proto/restore_job.proto | 8 ++++++++ proto/wal.proto | 7 +++++++ 5 files changed, 60 insertions(+), 10 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index 3bc28f2..ed0a122 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -1506,6 +1506,7 @@ through the kube-api server. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | | cluster_definition | [bytes](#bytes) | | This field is REQUIRED. Value of this field is the JSON serialization of the Cluster. | +| check_empty_wal_archive | [bool](#bool) | optional | This field is OPTIONAL. The operator always sets it, to convey its decision on whether the plugin should verify that the backup destination is not already in use by a different cluster or timeline before restoring (protection against WAL/timeline divergence). If unset, the request comes from an operator that predates this field, and the plugin is free to handle that case as it sees fit. | @@ -1605,6 +1606,7 @@ Intentionally empty. | cluster_definition | [bytes](#bytes) | | This field is REQUIRED. Value of this field is the JSON serialization of the Cluster corresponding to the Pod being applied | | source_file_name | [string](#string) | | This field is REQUIRED. Value of this field is the full path of the WAL file that should be archived | | parameters | [WALArchiveRequest.ParametersEntry](#cnpgi-wal-v1-WALArchiveRequest-ParametersEntry) | repeated | This field is OPTIONAL. Values are opaque. | +| check_empty_wal_archive | [bool](#bool) | optional | This field is OPTIONAL. The operator always sets it, to convey its decision on whether the plugin should verify the WAL archive destination is empty before this upload. If unset, the request comes from an operator that predates this field, and the plugin is free to handle that case as it sees fit. | diff --git a/pkg/restore/job/restore_job.pb.go b/pkg/restore/job/restore_job.pb.go index 9e0401f..c60367a 100644 --- a/pkg/restore/job/restore_job.pb.go +++ b/pkg/restore/job/restore_job.pb.go @@ -218,8 +218,15 @@ type RestoreRequest struct { // This field is REQUIRED. Value of this field is the JSON // serialization of the Cluster. ClusterDefinition []byte `protobuf:"bytes,1,opt,name=cluster_definition,json=clusterDefinition,proto3" json:"cluster_definition,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // This field is OPTIONAL. The operator always sets it, to convey its + // decision on whether the plugin should verify that the backup + // destination is not already in use by a different cluster or timeline + // before restoring (protection against WAL/timeline divergence). If + // unset, the request comes from an operator that predates this field, + // and the plugin is free to handle that case as it sees fit. + CheckEmptyWalArchive *bool `protobuf:"varint,2,opt,name=check_empty_wal_archive,json=checkEmptyWalArchive,proto3,oneof" json:"check_empty_wal_archive,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RestoreRequest) Reset() { @@ -259,6 +266,13 @@ func (x *RestoreRequest) GetClusterDefinition() []byte { return nil } +func (x *RestoreRequest) GetCheckEmptyWalArchive() bool { + if x != nil && x.CheckEmptyWalArchive != nil { + return *x.CheckEmptyWalArchive + } + return false +} + type RestoreResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // This field is REQUIRED. Value of this field is the string @@ -328,9 +342,11 @@ const file_proto_restore_job_proto_rawDesc = "" + "\x04kind\x18\x01 \x01(\x0e2,.cnpgi.wal.v1.RestoreJobHooksCapability.KindR\x04kind\".\n" + "\x04Kind\x12\x14\n" + "\x10KIND_UNSPECIFIED\x10\x00\x12\x10\n" + - "\fKIND_RESTORE\x10\x01\"?\n" + + "\fKIND_RESTORE\x10\x01\"\x97\x01\n" + "\x0eRestoreRequest\x12-\n" + - "\x12cluster_definition\x18\x01 \x01(\fR\x11clusterDefinition\"L\n" + + "\x12cluster_definition\x18\x01 \x01(\fR\x11clusterDefinition\x12:\n" + + "\x17check_empty_wal_archive\x18\x02 \x01(\bH\x00R\x14checkEmptyWalArchive\x88\x01\x01B\x1a\n" + + "\x18_check_empty_wal_archive\"L\n" + "\x0fRestoreResponse\x12%\n" + "\x0erestore_config\x18\x01 \x01(\tR\rrestoreConfig\x12\x12\n" + "\x04envs\x18\x02 \x03(\tR\x04envs2\xd3\x01\n" + @@ -379,6 +395,7 @@ func file_proto_restore_job_proto_init() { if File_proto_restore_job_proto != nil { return } + file_proto_restore_job_proto_msgTypes[3].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ diff --git a/pkg/wal/wal.pb.go b/pkg/wal/wal.pb.go index 05ebcee..8c6c0a7 100644 --- a/pkg/wal/wal.pb.go +++ b/pkg/wal/wal.pb.go @@ -171,9 +171,15 @@ type WALArchiveRequest struct { // of the WAL file that should be archived SourceFileName string `protobuf:"bytes,2,opt,name=source_file_name,json=sourceFileName,proto3" json:"source_file_name,omitempty"` // This field is OPTIONAL. Values are opaque. - Parameters map[string]string `protobuf:"bytes,3,rep,name=parameters,proto3" json:"parameters,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Parameters map[string]string `protobuf:"bytes,3,rep,name=parameters,proto3" json:"parameters,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // This field is OPTIONAL. The operator always sets it, to convey its + // decision on whether the plugin should verify the WAL archive + // destination is empty before this upload. If unset, the request comes + // from an operator that predates this field, and the plugin is free to + // handle that case as it sees fit. + CheckEmptyWalArchive *bool `protobuf:"varint,4,opt,name=check_empty_wal_archive,json=checkEmptyWalArchive,proto3,oneof" json:"check_empty_wal_archive,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *WALArchiveRequest) Reset() { @@ -227,6 +233,13 @@ func (x *WALArchiveRequest) GetParameters() map[string]string { return nil } +func (x *WALArchiveRequest) GetCheckEmptyWalArchive() bool { + if x != nil && x.CheckEmptyWalArchive != nil { + return *x.CheckEmptyWalArchive + } + return false +} + type WALArchiveResult struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -787,16 +800,18 @@ var File_proto_wal_proto protoreflect.FileDescriptor const file_proto_wal_proto_rawDesc = "" + "\n" + - "\x0fproto/wal.proto\x12\fcnpgi.wal.v1\"\xfc\x01\n" + + "\x0fproto/wal.proto\x12\fcnpgi.wal.v1\"\xd4\x02\n" + "\x11WALArchiveRequest\x12-\n" + "\x12cluster_definition\x18\x01 \x01(\fR\x11clusterDefinition\x12(\n" + "\x10source_file_name\x18\x02 \x01(\tR\x0esourceFileName\x12O\n" + "\n" + "parameters\x18\x03 \x03(\v2/.cnpgi.wal.v1.WALArchiveRequest.ParametersEntryR\n" + - "parameters\x1a=\n" + + "parameters\x12:\n" + + "\x17check_empty_wal_archive\x18\x04 \x01(\bH\x00R\x14checkEmptyWalArchive\x88\x01\x01\x1a=\n" + "\x0fParametersEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x12\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x1a\n" + + "\x18_check_empty_wal_archive\"\x12\n" + "\x10WALArchiveResult\"\xaa\x03\n" + "\x11WALRestoreRequest\x12-\n" + "\x12cluster_definition\x18\x01 \x01(\fR\x11clusterDefinition\x12&\n" + @@ -911,6 +926,7 @@ func file_proto_wal_proto_init() { if File_proto_wal_proto != nil { return } + file_proto_wal_proto_msgTypes[0].OneofWrappers = []any{} file_proto_wal_proto_msgTypes[10].OneofWrappers = []any{ (*WALCapability_Rpc)(nil), } diff --git a/proto/restore_job.proto b/proto/restore_job.proto index 01f9b12..37e7492 100644 --- a/proto/restore_job.proto +++ b/proto/restore_job.proto @@ -54,6 +54,14 @@ message RestoreRequest { // This field is REQUIRED. Value of this field is the JSON // serialization of the Cluster. bytes cluster_definition = 1; + + // This field is OPTIONAL. The operator always sets it, to convey its + // decision on whether the plugin should verify that the backup + // destination is not already in use by a different cluster or timeline + // before restoring (protection against WAL/timeline divergence). If + // unset, the request comes from an operator that predates this field, + // and the plugin is free to handle that case as it sees fit. + optional bool check_empty_wal_archive = 2; } message RestoreResponse { diff --git a/proto/wal.proto b/proto/wal.proto index 2a21986..0d5baa8 100644 --- a/proto/wal.proto +++ b/proto/wal.proto @@ -48,6 +48,13 @@ message WALArchiveRequest { // This field is OPTIONAL. Values are opaque. map parameters = 3; + + // This field is OPTIONAL. The operator always sets it, to convey its + // decision on whether the plugin should verify the WAL archive + // destination is empty before this upload. If unset, the request comes + // from an operator that predates this field, and the plugin is free to + // handle that case as it sees fit. + optional bool check_empty_wal_archive = 4; } message WALArchiveResult {