From 27a63e9b1be205a9df6f69cda5860f14d2da97fc Mon Sep 17 00:00:00 2001 From: Martin Schuppert Date: Fri, 27 Mar 2026 11:27:46 +0100 Subject: [PATCH] [b/r] Add backup/restore labeling utilities and annotation overrides Add backup/restore label helpers (GetBackupLabels, GetRestoreLabels, EnsureBackupLabels, ApplyAnnotationOverrides) with annotation override support. Add GetCertSecretBackupLabels for cert-manager SecretTemplate integration and BackupAnnotationChangedPredicate for controller watches. Add extraLabels parameter to EnsureCertForService*WithSelector. Jira: OSPRH-22912 Jira: OSPRH-22913 Co-Authored-By: Claude Opus 4.6 Signed-off-by: Martin Schuppert --- modules/certmanager/certificate.go | 25 +- modules/common/backup/cache.go | 77 +++++ modules/common/backup/cache_test.go | 252 +++++++++++++++ modules/common/backup/labels.go | 246 ++++++++++++++ modules/common/backup/labels_test.go | 462 +++++++++++++++++++++++++++ modules/common/backup/restore.go | 43 +++ modules/common/go.mod | 2 +- 7 files changed, 1101 insertions(+), 6 deletions(-) create mode 100644 modules/common/backup/cache.go create mode 100644 modules/common/backup/cache_test.go create mode 100644 modules/common/backup/labels.go create mode 100644 modules/common/backup/labels_test.go create mode 100644 modules/common/backup/restore.go diff --git a/modules/certmanager/certificate.go b/modules/certmanager/certificate.go index 728e4a79..e417bec4 100644 --- a/modules/certmanager/certificate.go +++ b/modules/certmanager/certificate.go @@ -24,6 +24,7 @@ import ( certmgrv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" certmgrmetav1 "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" + "github.com/openstack-k8s-operators/lib-common/modules/common/backup" "github.com/openstack-k8s-operators/lib-common/modules/common/helper" "github.com/openstack-k8s-operators/lib-common/modules/common/net" "github.com/openstack-k8s-operators/lib-common/modules/common/secret" @@ -270,7 +271,7 @@ func EnsureCert( } // EnsureCertForServicesWithSelector - creates certificate for k8s services identified -// by a label selector +// by a label selector. Optional extraLabels are merged into the certificate request labels. func EnsureCertForServicesWithSelector( ctx context.Context, helper *helper.Helper, @@ -278,6 +279,7 @@ func EnsureCertForServicesWithSelector( selector map[string]string, issuer string, owner client.Object, + extraLabels ...map[string]string, ) (map[string]string, ctrl.Result, error) { certs := map[string]string{} svcs, err := service.GetServicesListWithLabel( @@ -292,12 +294,23 @@ func EnsureCertForServicesWithSelector( for _, svc := range svcs.Items { hostname := fmt.Sprintf("%s.%s.svc", svc.Name, namespace) + certName := fmt.Sprintf("%s-svc", svc.Name) + // Merge service labels with extra labels, then apply cert secret + // backup annotation overrides so cert-manager's SecretTemplate + // propagates the correct labels. + labels, err := backup.GetCertSecretBackupLabels( + ctx, helper.GetClient(), certName, namespace, + util.MergeMaps(svc.Labels, extraLabels...), + ) + if err != nil { + return nil, ctrl.Result{}, err + } // create cert for the service certRequest := CertificateRequest{ IssuerName: issuer, - CertName: fmt.Sprintf("%s-svc", svc.Name), + CertName: certName, Hostnames: []string{hostname}, - Labels: svc.Labels, + Labels: labels, } certSecret, ctrlResult, err := EnsureCert( ctx, @@ -317,7 +330,8 @@ func EnsureCertForServicesWithSelector( } // EnsureCertForServiceWithSelector - creates certificate for a k8s service identified -// by a label selector. The label selector must match a single service +// by a label selector. The label selector must match a single service. +// Optional extraLabels are merged into the certificate request labels. // Note: Returns an NotFound error if <1 or >1 service found using the selector func EnsureCertForServiceWithSelector( ctx context.Context, @@ -326,6 +340,7 @@ func EnsureCertForServiceWithSelector( selector map[string]string, issuer string, owner client.Object, + extraLabels ...map[string]string, ) (string, ctrl.Result, error) { var cert string svcs, err := service.GetServicesListWithLabel( @@ -346,7 +361,7 @@ func EnsureCertForServiceWithSelector( } certs, ctrlResult, err := EnsureCertForServicesWithSelector( - ctx, helper, namespace, selector, issuer, owner) + ctx, helper, namespace, selector, issuer, owner, extraLabels...) if err != nil { return cert, ctrlResult, err } else if (ctrlResult != ctrl.Result{}) { diff --git a/modules/common/backup/cache.go b/modules/common/backup/cache.go new file mode 100644 index 00000000..994ca78f --- /dev/null +++ b/modules/common/backup/cache.go @@ -0,0 +1,77 @@ +/* +Copyright 2025 Red Hat + +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 backup + +import ( + "context" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Config holds backup/restore configuration for a CRD +type Config struct { + Enabled bool + RestoreOrder string + Category string +} + +// CRDLabelCache maps CRD names to their backup configuration +type CRDLabelCache map[string]Config + +// BuildCRDLabelCache reads all CRDs and caches their backup labels +func BuildCRDLabelCache(ctx context.Context, c client.Client) (CRDLabelCache, error) { + cache := make(CRDLabelCache) + + crdList := &apiextensionsv1.CustomResourceDefinitionList{} + if err := c.List(ctx, crdList); err != nil { + return nil, err + } + + for _, crd := range crdList.Items { + labels := crd.GetLabels() + if labels == nil { + continue + } + + // Only cache CRDs that opt into backup/restore + if labels[BackupRestoreLabel] != "true" { + continue + } + + config := Config{ + Enabled: true, + RestoreOrder: labels[BackupRestoreOrderLabel], + Category: labels[BackupCategoryLabel], + } + + // Cache by CRD name (e.g., "keystoneapis.keystone.openstack.org") + cache[crd.Name] = config + } + + return cache, nil +} + +// GetConfig looks up backup configuration by CRD name +// (e.g., "keystoneapis.keystone.openstack.org"). +// Returns Config with Enabled=false if not found. +func (c CRDLabelCache) GetConfig(crdName string) Config { + if config, ok := c[crdName]; ok { + return config + } + return Config{Enabled: false} +} diff --git a/modules/common/backup/cache_test.go b/modules/common/backup/cache_test.go new file mode 100644 index 00000000..16e97f1c --- /dev/null +++ b/modules/common/backup/cache_test.go @@ -0,0 +1,252 @@ +/* +Copyright 2025 Red Hat + +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 backup + +import ( + "context" + "testing" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestBuildCRDLabelCache(t *testing.T) { + scheme := runtime.NewScheme() + _ = apiextensionsv1.AddToScheme(scheme) + + tests := []struct { + name string + crds []apiextensionsv1.CustomResourceDefinition + want CRDLabelCache + wantErr bool + }{ + { + name: "CRD with backup labels", + crds: []apiextensionsv1.CustomResourceDefinition{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "keystoneapis.keystone.openstack.org", + Labels: map[string]string{ + BackupRestoreLabel: "true", + BackupRestoreOrderLabel: RestoreOrder30, + BackupCategoryLabel: CategoryControlPlane, + }, + }, + }, + }, + want: CRDLabelCache{ + "keystoneapis.keystone.openstack.org": { + Enabled: true, + RestoreOrder: RestoreOrder30, + Category: CategoryControlPlane, + }, + }, + wantErr: false, + }, + { + name: "CRD without backup-restore label", + crds: []apiextensionsv1.CustomResourceDefinition{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "other.example.com", + Labels: map[string]string{ + "some-other": "label", + }, + }, + }, + }, + want: CRDLabelCache{}, + wantErr: false, + }, + { + name: "CRD with backup-restore=false", + crds: []apiextensionsv1.CustomResourceDefinition{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "disabled.example.com", + Labels: map[string]string{ + BackupRestoreLabel: "false", + }, + }, + }, + }, + want: CRDLabelCache{}, + wantErr: false, + }, + { + name: "Multiple CRDs with different configurations", + crds: []apiextensionsv1.CustomResourceDefinition{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "keystoneapis.keystone.openstack.org", + Labels: map[string]string{ + BackupRestoreLabel: "true", + BackupRestoreOrderLabel: RestoreOrder30, + BackupCategoryLabel: CategoryControlPlane, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "openstackdataplaneservices.dataplane.openstack.org", + Labels: map[string]string{ + BackupRestoreLabel: "true", + BackupRestoreOrderLabel: RestoreOrder60, + BackupCategoryLabel: CategoryDataPlane, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "ignored.example.com", + Labels: map[string]string{ + "other": "label", + }, + }, + }, + }, + want: CRDLabelCache{ + "keystoneapis.keystone.openstack.org": { + Enabled: true, + RestoreOrder: RestoreOrder30, + Category: CategoryControlPlane, + }, + "openstackdataplaneservices.dataplane.openstack.org": { + Enabled: true, + RestoreOrder: RestoreOrder60, + Category: CategoryDataPlane, + }, + }, + wantErr: false, + }, + { + name: "CRD without category label", + crds: []apiextensionsv1.CustomResourceDefinition{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "secrets.core", + Labels: map[string]string{ + BackupRestoreLabel: "true", + BackupRestoreOrderLabel: RestoreOrder10, + }, + }, + }, + }, + want: CRDLabelCache{ + "secrets.core": { + Enabled: true, + RestoreOrder: RestoreOrder10, + Category: "", + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + objs := make([]runtime.Object, len(tt.crds)) + for i := range tt.crds { + objs[i] = &tt.crds[i] + } + + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithRuntimeObjects(objs...). + Build() + + got, err := BuildCRDLabelCache(context.Background(), c) + if (err != nil) != tt.wantErr { + t.Errorf("BuildCRDLabelCache() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if len(got) != len(tt.want) { + t.Errorf("BuildCRDLabelCache() returned %d entries, want %d", len(got), len(tt.want)) + } + + for name, wantConfig := range tt.want { + gotConfig, ok := got[name] + if !ok { + t.Errorf("BuildCRDLabelCache() missing entry for %q", name) + continue + } + if gotConfig != wantConfig { + t.Errorf("BuildCRDLabelCache()[%q] = %+v, want %+v", name, gotConfig, wantConfig) + } + } + }) + } +} + +func TestGetConfig(t *testing.T) { + cache := CRDLabelCache{ + "keystoneapis.keystone.openstack.org": { + Enabled: true, + RestoreOrder: RestoreOrder30, + Category: CategoryControlPlane, + }, + "openstackdataplaneservices.dataplane.openstack.org": { + Enabled: true, + RestoreOrder: RestoreOrder60, + Category: CategoryDataPlane, + }, + } + + tests := []struct { + name string + crdName string + want Config + }{ + { + name: "existing CRD", + crdName: "keystoneapis.keystone.openstack.org", + want: Config{ + Enabled: true, + RestoreOrder: RestoreOrder30, + Category: CategoryControlPlane, + }, + }, + { + name: "non-existent CRD", + crdName: "unknown.example.com", + want: Config{ + Enabled: false, + }, + }, + { + name: "dataplane CRD", + crdName: "openstackdataplaneservices.dataplane.openstack.org", + want: Config{ + Enabled: true, + RestoreOrder: RestoreOrder60, + Category: CategoryDataPlane, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cache.GetConfig(tt.crdName) + if got != tt.want { + t.Errorf("GetConfig(%q) = %+v, want %+v", tt.crdName, got, tt.want) + } + }) + } +} diff --git a/modules/common/backup/labels.go b/modules/common/backup/labels.go new file mode 100644 index 00000000..b457de5c --- /dev/null +++ b/modules/common/backup/labels.go @@ -0,0 +1,246 @@ +/* +Copyright 2025 Red Hat + +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 backup provides utilities for backup and restore labeling +package backup + +import ( + "context" + "fmt" + "strings" + + k8s_corev1 "k8s.io/api/core/v1" + k8s_errors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +const ( + // BackupRestoreLabel is a CRD label: "true" means instances participate in backup/restore + BackupRestoreLabel = "backup.openstack.org/restore" + // BackupCategoryLabel is a CRD & instance label: "controlplane" or "dataplane" + BackupCategoryLabel = "backup.openstack.org/category" + // BackupRestoreOrderLabel is a CRD & instance label: "00"-"60" + BackupRestoreOrderLabel = "backup.openstack.org/restore-order" + + // BackupLabel is a resource instance label: "true" marks for backup + BackupLabel = "backup.openstack.org/backup" +) + +// LabelKeys returns all backup-related label/annotation keys. +// Used by applyAnnotationOverrides and can be used by controllers +// for event filtering (e.g. detecting annotation changes). +func LabelKeys() []string { + return []string{BackupLabel, BackupRestoreLabel, BackupRestoreOrderLabel} +} + +// GetBackupLabels returns labels to mark a resource for OADP backup selection. +// Use this for PVCs and other resources that need to be explicitly selected +// for backup (large storage volumes). Resources backed up by namespace +// (CRs, Secrets, ConfigMaps) do not need these labels. +func GetBackupLabels(category string) map[string]string { + labels := map[string]string{ + BackupLabel: "true", + } + if category != "" { + labels[BackupCategoryLabel] = category + } + return labels +} + +// GetRestoreLabels returns labels for controlling restore ordering. +// Use this for CRs, Secrets, ConfigMaps that are backed up by namespace +// but need ordered restore. For PVCs, combine with GetBackupLabels(). +func GetRestoreLabels(restoreOrder, category string) map[string]string { + labels := map[string]string{ + BackupRestoreLabel: "true", + BackupRestoreOrderLabel: restoreOrder, + } + if category != "" { + labels[BackupCategoryLabel] = category + } + return labels +} + +// GetRestoreLabelsWithOverrides returns restore labels with overrides. +// The overrides map (typically from CR annotations) can override the default +// restoreOrder and category. +func GetRestoreLabelsWithOverrides(defaultRestoreOrder string, overrides map[string]string) map[string]string { + labels := GetRestoreLabels(defaultRestoreOrder, "") + + // Check for user override of restore order + if order, ok := overrides[BackupRestoreOrderLabel]; ok { + labels[BackupRestoreOrderLabel] = order + } + + // Category override + if category, ok := overrides[BackupCategoryLabel]; ok { + labels[BackupCategoryLabel] = category + } + + return labels +} + +// ShouldBackup returns true if the resource is marked for backup +func ShouldBackup(labels map[string]string) bool { + return labels != nil && labels[BackupLabel] == "true" +} + +// EnsureBackupLabels sets backup/restore labels on a resource. It always +// writes the caller-provided default labels, then applies any annotation +// overrides on top. This means: +// - Operator defaults are always current (updated on operator upgrade) +// - User overrides via annotations take precedence +// - It's visible what was set by the operator vs what the user overrode +// +// defaultLabels should be built by the caller using GetBackupLabels() and/or +// GetRestoreLabels(). Returns true if labels were changed. +func EnsureBackupLabels(ctx context.Context, c client.Client, obj client.Object, defaultLabels map[string]string) (bool, error) { + origObj := obj.DeepCopyObject().(client.Object) + + labels := obj.GetLabels() + if labels == nil { + labels = make(map[string]string) + } + + // Step 1: Always set operator defaults (overwrite existing) + for k, v := range defaultLabels { + labels[k] = v + } + + // Step 2: Apply annotation overrides on top + ApplyAnnotationOverrides(obj.GetAnnotations(), labels) + + // Check if anything actually changed + origLabels := origObj.GetLabels() + changed := len(labels) != len(origLabels) + if !changed { + for k, v := range labels { + if origLabels[k] != v { + changed = true + break + } + } + } + if !changed { + return false, nil + } + + patch := client.MergeFrom(origObj) + obj.SetLabels(labels) + if err := c.Patch(ctx, obj, patch); err != nil { + return false, fmt.Errorf("patching backup labels on %s: %w", obj.GetName(), err) + } + return true, nil +} + +// GetCertSecretBackupLabels returns backup labels for a cert secret, respecting +// annotation overrides. It reads the cert secret (named "cert-") and +// checks for backup-related annotations. If found, they override the default labels. +// This ensures that cert-manager's SecretTemplate propagates the correct labels +// so that annotation overrides on the Secret are not reverted by cert-manager. +func GetCertSecretBackupLabels( + ctx context.Context, + c client.Client, + certName string, + namespace string, + defaultLabels map[string]string, +) (map[string]string, error) { + labels := make(map[string]string, len(defaultLabels)) + for k, v := range defaultLabels { + labels[k] = v + } + + // Check if the cert secret already exists and has annotation overrides + certSecretName := "cert-" + certName + certSecret := &k8s_corev1.Secret{} + if err := c.Get(ctx, types.NamespacedName{Name: certSecretName, Namespace: namespace}, certSecret); err != nil { + if !k8s_errors.IsNotFound(err) { + return nil, fmt.Errorf("failed to get cert secret %s/%s: %w", namespace, certSecretName, err) + } + // Secret doesn't exist yet (first reconcile) — use defaults + return labels, nil + } + + // Apply annotation overrides from the secret + ApplyAnnotationOverrides(certSecret.GetAnnotations(), labels) + return labels, nil +} + +// AnnotationChangedPredicate returns a predicate that only triggers +// for resources matching the given label selector when backup annotations change. +// This is useful for watching resources (e.g. cert secrets) where a user may +// add backup annotation overrides that need to be picked up by a controller. +func AnnotationChangedPredicate(labelSelector string) predicate.Predicate { + return predicate.Funcs{ + CreateFunc: func(e event.CreateEvent) bool { + return false + }, + DeleteFunc: func(e event.DeleteEvent) bool { + return false + }, + GenericFunc: func(e event.GenericEvent) bool { + return false + }, + UpdateFunc: func(e event.UpdateEvent) bool { + labels := e.ObjectNew.GetLabels() + if _, ok := labels[labelSelector]; !ok { + return false + } + + oldAnnotations := e.ObjectOld.GetAnnotations() + newAnnotations := e.ObjectNew.GetAnnotations() + for _, key := range LabelKeys() { + if oldAnnotations[key] != newAnnotations[key] { + return true + } + } + return false + }, + } +} + +// ApplyAnnotationOverrides checks for backup-related annotations on a resource +// and applies them as label overrides. Annotations allow users to override +// operator defaults: +// - backup.openstack.org/backup: "false" → exclude from backup +// - backup.openstack.org/restore: "false" → skip restore +// - backup.openstack.org/restore-order: "XX" → custom restore order (implies restore=true) +func ApplyAnnotationOverrides(annotations, labels map[string]string) { + if annotations == nil { + return + } + + for _, key := range LabelKeys() { + val, has := annotations[key] + if !has { + continue + } + normalized := strings.ToLower(val) + + switch key { + case BackupLabel: + labels[BackupLabel] = normalized + case BackupRestoreLabel: + labels[BackupRestoreLabel] = normalized + case BackupRestoreOrderLabel: + labels[BackupRestoreOrderLabel] = normalized + labels[BackupRestoreLabel] = "true" + } + } +} diff --git a/modules/common/backup/labels_test.go b/modules/common/backup/labels_test.go new file mode 100644 index 00000000..03b4852f --- /dev/null +++ b/modules/common/backup/labels_test.go @@ -0,0 +1,462 @@ +/* +Copyright 2025 Red Hat + +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 backup + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/event" +) + +func TestGetBackupLabels(t *testing.T) { + tests := []struct { + name string + category string + want map[string]string + }{ + { + name: "PVC with controlplane category", + category: CategoryControlPlane, + want: map[string]string{ + BackupLabel: "true", + BackupCategoryLabel: "controlplane", + }, + }, + { + name: "PVC with dataplane category", + category: CategoryDataPlane, + want: map[string]string{ + BackupLabel: "true", + BackupCategoryLabel: "dataplane", + }, + }, + { + name: "PVC without category", + category: "", + want: map[string]string{ + BackupLabel: "true", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetBackupLabels(tt.category) + if len(got) != len(tt.want) { + t.Errorf("GetBackupLabels() returned %d labels, want %d", len(got), len(tt.want)) + } + for k, v := range tt.want { + if got[k] != v { + t.Errorf("GetBackupLabels()[%q] = %q, want %q", k, got[k], v) + } + } + }) + } +} + +func TestGetRestoreLabels(t *testing.T) { + tests := []struct { + name string + restoreOrder string + category string + want map[string]string + }{ + { + name: "controlplane CR", + restoreOrder: RestoreOrder30, + category: CategoryControlPlane, + want: map[string]string{ + BackupRestoreLabel: "true", + BackupRestoreOrderLabel: "30", + BackupCategoryLabel: "controlplane", + }, + }, + { + name: "without category", + restoreOrder: RestoreOrder10, + category: "", + want: map[string]string{ + BackupRestoreLabel: "true", + BackupRestoreOrderLabel: "10", + }, + }, + { + name: "dataplane CR", + restoreOrder: RestoreOrder60, + category: CategoryDataPlane, + want: map[string]string{ + BackupRestoreLabel: "true", + BackupRestoreOrderLabel: "60", + BackupCategoryLabel: "dataplane", + }, + }, + { + name: "PVC restore order", + restoreOrder: RestoreOrder00, + category: CategoryControlPlane, + want: map[string]string{ + BackupRestoreLabel: "true", + BackupRestoreOrderLabel: "00", + BackupCategoryLabel: "controlplane", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetRestoreLabels(tt.restoreOrder, tt.category) + if len(got) != len(tt.want) { + t.Errorf("GetRestoreLabels() returned %d labels, want %d", len(got), len(tt.want)) + } + for k, v := range tt.want { + if got[k] != v { + t.Errorf("GetRestoreLabels()[%q] = %q, want %q", k, got[k], v) + } + } + }) + } +} + +func TestShouldBackup(t *testing.T) { + tests := []struct { + name string + labels map[string]string + want bool + }{ + { + name: "nil labels", + labels: nil, + want: false, + }, + { + name: "empty labels", + labels: map[string]string{}, + want: false, + }, + { + name: "backup label true", + labels: map[string]string{ + BackupLabel: "true", + }, + want: true, + }, + { + name: "backup label false", + labels: map[string]string{ + BackupLabel: "false", + }, + want: false, + }, + { + name: "no backup label", + labels: map[string]string{ + "other": "label", + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ShouldBackup(tt.labels); got != tt.want { + t.Errorf("ShouldBackup() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestLabelKeys(t *testing.T) { + keys := LabelKeys() + if len(keys) != 3 { + t.Errorf("LabelKeys() returned %d keys, want 3", len(keys)) + } + expected := map[string]bool{ + BackupLabel: true, + BackupRestoreLabel: true, + BackupRestoreOrderLabel: true, + } + for _, k := range keys { + if !expected[k] { + t.Errorf("LabelKeys() contains unexpected key %q", k) + } + } +} + +func TestApplyAnnotationOverrides(t *testing.T) { + tests := []struct { + name string + annotations map[string]string + labels map[string]string + want map[string]string + }{ + { + name: "nil annotations", + annotations: nil, + labels: map[string]string{BackupRestoreLabel: "true", BackupRestoreOrderLabel: "00"}, + want: map[string]string{BackupRestoreLabel: "true", BackupRestoreOrderLabel: "00"}, + }, + { + name: "no backup annotations", + annotations: map[string]string{"other": "value"}, + labels: map[string]string{BackupRestoreLabel: "true", BackupRestoreOrderLabel: "00"}, + want: map[string]string{BackupRestoreLabel: "true", BackupRestoreOrderLabel: "00"}, + }, + { + name: "override restore to false", + annotations: map[string]string{BackupRestoreLabel: "false"}, + labels: map[string]string{BackupRestoreLabel: "true", BackupRestoreOrderLabel: "00"}, + want: map[string]string{BackupRestoreLabel: "false", BackupRestoreOrderLabel: "00"}, + }, + { + name: "override restore order", + annotations: map[string]string{BackupRestoreOrderLabel: "20"}, + labels: map[string]string{BackupRestoreLabel: "true", BackupRestoreOrderLabel: "00"}, + want: map[string]string{BackupRestoreLabel: "true", BackupRestoreOrderLabel: "20"}, + }, + { + name: "restore order implies restore true", + annotations: map[string]string{BackupRestoreOrderLabel: "20"}, + labels: map[string]string{BackupRestoreLabel: "false", BackupRestoreOrderLabel: "00"}, + want: map[string]string{BackupRestoreLabel: "true", BackupRestoreOrderLabel: "20"}, + }, + { + name: "override backup to false", + annotations: map[string]string{BackupLabel: "false"}, + labels: map[string]string{BackupLabel: "true"}, + want: map[string]string{BackupLabel: "false"}, + }, + { + name: "case insensitive", + annotations: map[string]string{BackupRestoreLabel: "TRUE"}, + labels: map[string]string{BackupRestoreLabel: "false"}, + want: map[string]string{BackupRestoreLabel: "true"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + labels := make(map[string]string) + for k, v := range tt.labels { + labels[k] = v + } + ApplyAnnotationOverrides(tt.annotations, labels) + for k, v := range tt.want { + if labels[k] != v { + t.Errorf("applyAnnotationOverrides() labels[%q] = %q, want %q", k, labels[k], v) + } + } + }) + } +} + +func TestGetRestoreLabelsWithOverrides(t *testing.T) { + tests := []struct { + name string + defaultRestoreOrder string + overrides map[string]string + want map[string]string + }{ + { + name: "no overrides", + defaultRestoreOrder: RestoreOrder30, + overrides: map[string]string{}, + want: map[string]string{ + BackupRestoreLabel: "true", + BackupRestoreOrderLabel: "30", + }, + }, + { + name: "override restore order", + defaultRestoreOrder: RestoreOrder30, + overrides: map[string]string{ + BackupRestoreOrderLabel: "40", + }, + want: map[string]string{ + BackupRestoreLabel: "true", + BackupRestoreOrderLabel: "40", + }, + }, + { + name: "override category", + defaultRestoreOrder: RestoreOrder30, + overrides: map[string]string{ + BackupCategoryLabel: CategoryDataPlane, + }, + want: map[string]string{ + BackupRestoreLabel: "true", + BackupRestoreOrderLabel: "30", + BackupCategoryLabel: "dataplane", + }, + }, + { + name: "override both order and category", + defaultRestoreOrder: RestoreOrder30, + overrides: map[string]string{ + BackupRestoreOrderLabel: "50", + BackupCategoryLabel: CategoryDataPlane, + }, + want: map[string]string{ + BackupRestoreLabel: "true", + BackupRestoreOrderLabel: "50", + BackupCategoryLabel: "dataplane", + }, + }, + { + name: "nil overrides", + defaultRestoreOrder: RestoreOrder10, + overrides: nil, + want: map[string]string{ + BackupRestoreLabel: "true", + BackupRestoreOrderLabel: "10", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetRestoreLabelsWithOverrides(tt.defaultRestoreOrder, tt.overrides) + if len(got) != len(tt.want) { + t.Errorf("GetRestoreLabelsWithOverrides() returned %d labels, want %d", len(got), len(tt.want)) + } + for k, v := range tt.want { + if got[k] != v { + t.Errorf("GetRestoreLabelsWithOverrides()[%q] = %q, want %q", k, got[k], v) + } + } + }) + } +} + +func TestAnnotationChangedPredicate(t *testing.T) { + labelSelector := "service-cert" + p := AnnotationChangedPredicate(labelSelector) + + t.Run("create event returns false", func(t *testing.T) { + if p.Create(event.CreateEvent{}) { + t.Error("expected CreateEvent to return false") + } + }) + + t.Run("delete event returns false", func(t *testing.T) { + if p.Delete(event.DeleteEvent{}) { + t.Error("expected DeleteEvent to return false") + } + }) + + t.Run("generic event returns false", func(t *testing.T) { + if p.Generic(event.GenericEvent{}) { + t.Error("expected GenericEvent to return false") + } + }) + + t.Run("update without label selector returns false", func(t *testing.T) { + e := event.UpdateEvent{ + ObjectOld: &metav1.PartialObjectMetadata{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"other": "label"}}, + }, + ObjectNew: &metav1.PartialObjectMetadata{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"other": "label"}, + Annotations: map[string]string{BackupRestoreLabel: "true"}, + }, + }, + } + if p.Update(e) { + t.Error("expected update without label selector to return false") + } + }) + + t.Run("update with label selector but no annotation change returns false", func(t *testing.T) { + e := event.UpdateEvent{ + ObjectOld: &metav1.PartialObjectMetadata{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{labelSelector: ""}, + Annotations: map[string]string{BackupRestoreLabel: "false"}, + }, + }, + ObjectNew: &metav1.PartialObjectMetadata{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{labelSelector: ""}, + Annotations: map[string]string{BackupRestoreLabel: "false"}, + }, + }, + } + if p.Update(e) { + t.Error("expected update with no annotation change to return false") + } + }) + + t.Run("update with restore annotation change returns true", func(t *testing.T) { + e := event.UpdateEvent{ + ObjectOld: &metav1.PartialObjectMetadata{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{labelSelector: ""}, + Annotations: map[string]string{BackupRestoreLabel: "false"}, + }, + }, + ObjectNew: &metav1.PartialObjectMetadata{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{labelSelector: ""}, + Annotations: map[string]string{BackupRestoreLabel: "true"}, + }, + }, + } + if !p.Update(e) { + t.Error("expected update with restore annotation change to return true") + } + }) + + t.Run("update with restore-order annotation added returns true", func(t *testing.T) { + e := event.UpdateEvent{ + ObjectOld: &metav1.PartialObjectMetadata{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{labelSelector: ""}, + }, + }, + ObjectNew: &metav1.PartialObjectMetadata{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{labelSelector: ""}, + Annotations: map[string]string{BackupRestoreOrderLabel: "20"}, + }, + }, + } + if !p.Update(e) { + t.Error("expected update with restore-order annotation added to return true") + } + }) + + t.Run("update with non-backup annotation change returns false", func(t *testing.T) { + e := event.UpdateEvent{ + ObjectOld: &metav1.PartialObjectMetadata{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{labelSelector: ""}, + Annotations: map[string]string{"other": "old"}, + }, + }, + ObjectNew: &metav1.PartialObjectMetadata{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{labelSelector: ""}, + Annotations: map[string]string{"other": "new"}, + }, + }, + } + if p.Update(e) { + t.Error("expected update with non-backup annotation change to return false") + } + }) +} diff --git a/modules/common/backup/restore.go b/modules/common/backup/restore.go new file mode 100644 index 00000000..a64fdc71 --- /dev/null +++ b/modules/common/backup/restore.go @@ -0,0 +1,43 @@ +/* +Copyright 2025 Red Hat + +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 backup + +// Restore order constants (gaps of 10 allow insertion) +const ( + // RestoreOrder00 is PVCs - storage foundation + RestoreOrder00 = "00" + // RestoreOrder10 is NADs, Secrets, ConfigMaps - foundation resources + RestoreOrder10 = "10" + // RestoreOrder20 is OpenStackVersion - restored before ControlPlane + RestoreOrder20 = "20" + // RestoreOrder30 is OpenStackControlPlane - restored after Version + RestoreOrder30 = "30" + // RestoreOrder40 is backup config and user resources + RestoreOrder40 = "40" + // RestoreOrder50 is manual steps - database/RabbitMQ restore, resume deployment + RestoreOrder50 = "50" + // RestoreOrder60 is OpenStackDataPlaneNodeSet - restored after ControlPlane + RestoreOrder60 = "60" +) + +// Category constants for backup/restore scope +const ( + // CategoryControlPlane identifies control plane resources + CategoryControlPlane = "controlplane" + // CategoryDataPlane identifies data plane resources + CategoryDataPlane = "dataplane" +) diff --git a/modules/common/go.mod b/modules/common/go.mod index 3a09fa2f..22669ed2 100644 --- a/modules/common/go.mod +++ b/modules/common/go.mod @@ -13,6 +13,7 @@ require ( go.uber.org/zap v1.27.1 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.31.14 + k8s.io/apiextensions-apiserver v0.31.14 k8s.io/apimachinery v0.31.14 k8s.io/client-go v0.31.14 k8s.io/kubectl v0.31.14 @@ -77,7 +78,6 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - k8s.io/apiextensions-apiserver v0.31.14 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20240903163716-9e1beecbcb38 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect