diff --git a/config/crd/bases/pxc.percona.com_perconaxtradbclusterrestores.yaml b/config/crd/bases/pxc.percona.com_perconaxtradbclusterrestores.yaml index c7bf16ca12..3d2cfdec0c 100644 --- a/config/crd/bases/pxc.percona.com_perconaxtradbclusterrestores.yaml +++ b/config/crd/bases/pxc.percona.com_perconaxtradbclusterrestores.yaml @@ -551,8 +551,25 @@ spec: gtid: type: string type: + enum: + - latest + - date + - transaction + - skip type: string type: object + x-kubernetes-validations: + - message: 'Date is required for type ''date'' and should be in format + YYYY-MM-DD HH:MM:SS with valid ranges (MM: 01-12, DD: 01-31, HH: + 00-23, MM/SS: 00-59)' + rule: self.type != 'date' || (has(self.date) && self.date.matches('^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01]) + ([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$')) + - message: GTID is required for types 'transaction' and 'skip' + rule: (self.type != 'transaction' && self.type != 'skip') || (has(self.gtid) + && size(self.gtid) > 0) + - message: Date and GTID should not be set when type is 'latest' + rule: self.type != 'latest' || ((!has(self.date) || size(self.date) + == 0) && (!has(self.gtid) || size(self.gtid) == 0)) pxcCluster: type: string resources: diff --git a/deploy/bundle.yaml b/deploy/bundle.yaml index a7ea48a2f8..1c403faf52 100644 --- a/deploy/bundle.yaml +++ b/deploy/bundle.yaml @@ -940,8 +940,25 @@ spec: gtid: type: string type: + enum: + - latest + - date + - transaction + - skip type: string type: object + x-kubernetes-validations: + - message: 'Date is required for type ''date'' and should be in format + YYYY-MM-DD HH:MM:SS with valid ranges (MM: 01-12, DD: 01-31, HH: + 00-23, MM/SS: 00-59)' + rule: self.type != 'date' || (has(self.date) && self.date.matches('^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01]) + ([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$')) + - message: GTID is required for types 'transaction' and 'skip' + rule: (self.type != 'transaction' && self.type != 'skip') || (has(self.gtid) + && size(self.gtid) > 0) + - message: Date and GTID should not be set when type is 'latest' + rule: self.type != 'latest' || ((!has(self.date) || size(self.date) + == 0) && (!has(self.gtid) || size(self.gtid) == 0)) pxcCluster: type: string resources: diff --git a/deploy/crd.yaml b/deploy/crd.yaml index 49c5e10cd6..003baac910 100644 --- a/deploy/crd.yaml +++ b/deploy/crd.yaml @@ -940,8 +940,25 @@ spec: gtid: type: string type: + enum: + - latest + - date + - transaction + - skip type: string type: object + x-kubernetes-validations: + - message: 'Date is required for type ''date'' and should be in format + YYYY-MM-DD HH:MM:SS with valid ranges (MM: 01-12, DD: 01-31, HH: + 00-23, MM/SS: 00-59)' + rule: self.type != 'date' || (has(self.date) && self.date.matches('^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01]) + ([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$')) + - message: GTID is required for types 'transaction' and 'skip' + rule: (self.type != 'transaction' && self.type != 'skip') || (has(self.gtid) + && size(self.gtid) > 0) + - message: Date and GTID should not be set when type is 'latest' + rule: self.type != 'latest' || ((!has(self.date) || size(self.date) + == 0) && (!has(self.gtid) || size(self.gtid) == 0)) pxcCluster: type: string resources: diff --git a/deploy/cw-bundle.yaml b/deploy/cw-bundle.yaml index 1910fad6d2..1015176430 100644 --- a/deploy/cw-bundle.yaml +++ b/deploy/cw-bundle.yaml @@ -940,8 +940,25 @@ spec: gtid: type: string type: + enum: + - latest + - date + - transaction + - skip type: string type: object + x-kubernetes-validations: + - message: 'Date is required for type ''date'' and should be in format + YYYY-MM-DD HH:MM:SS with valid ranges (MM: 01-12, DD: 01-31, HH: + 00-23, MM/SS: 00-59)' + rule: self.type != 'date' || (has(self.date) && self.date.matches('^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01]) + ([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$')) + - message: GTID is required for types 'transaction' and 'skip' + rule: (self.type != 'transaction' && self.type != 'skip') || (has(self.gtid) + && size(self.gtid) > 0) + - message: Date and GTID should not be set when type is 'latest' + rule: self.type != 'latest' || ((!has(self.date) || size(self.date) + == 0) && (!has(self.gtid) || size(self.gtid) == 0)) pxcCluster: type: string resources: diff --git a/pkg/apis/pxc/v1/pxc_prestore_types.go b/pkg/apis/pxc/v1/pxc_prestore_types.go index 2d81b2dd4a..ade9e21c58 100644 --- a/pkg/apis/pxc/v1/pxc_prestore_types.go +++ b/pkg/apis/pxc/v1/pxc_prestore_types.go @@ -2,7 +2,6 @@ package v1 import ( "errors" - corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -29,11 +28,15 @@ type PerconaXtraDBClusterRestoreStatus struct { Unsafe UnsafeFlags `json:"unsafeFlags,omitempty"` } +// +kubebuilder:validation:XValidation:rule="self.type != 'date' || (has(self.date) && self.date.matches('^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01]) ([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$'))",message="Date is required for type 'date' and should be in format YYYY-MM-DD HH:MM:SS with valid ranges (MM: 01-12, DD: 01-31, HH: 00-23, MM/SS: 00-59)" +// +kubebuilder:validation:XValidation:rule="(self.type != 'transaction' && self.type != 'skip') || (has(self.gtid) && size(self.gtid) > 0)",message="GTID is required for types 'transaction' and 'skip'" +// +kubebuilder:validation:XValidation:rule="self.type != 'latest' || ((!has(self.date) || size(self.date) == 0) && (!has(self.gtid) || size(self.gtid) == 0))",message="Date and GTID should not be set when type is 'latest'" type PITR struct { BackupSource *PXCBackupStatus `json:"backupSource"` - Type string `json:"type"` - Date string `json:"date"` - GTID string `json:"gtid"` + // +kubebuilder:validation:Enum={latest,date,transaction,skip} + Type string `json:"type"` + Date string `json:"date"` + GTID string `json:"gtid"` } // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object diff --git a/pkg/controller/pxcrestore/pitr_validation_test.go b/pkg/controller/pxcrestore/pitr_validation_test.go new file mode 100644 index 0000000000..21b690baee --- /dev/null +++ b/pkg/controller/pxcrestore/pitr_validation_test.go @@ -0,0 +1,111 @@ +package pxcrestore + +import ( + "context" + "os" + "path/filepath" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + "github.com/percona/percona-xtradb-cluster-operator/pkg/apis" + pxcv1 "github.com/percona/percona-xtradb-cluster-operator/pkg/apis/pxc/v1" +) + +var ( + cfg *rest.Config + k8sClient client.Client + testEnv *envtest.Environment +) + +func TestPxcrestore(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "PerconaXtraDBClusterRestore Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + Expect(os.Setenv("WATCH_NAMESPACE", "default")).NotTo(HaveOccurred()) + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: true, + } + + var err error + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + err = apis.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + Expect(os.Unsetenv("WATCH_NAMESPACE")).NotTo(HaveOccurred()) + Expect(testEnv.Stop()).NotTo(HaveOccurred()) +}) + +var _ = Describe("PerconaXtraDBClusterRestore PITR CRD validation", Ordered, func() { + ctx := context.Background() + const ns = "pitr-validation" + + BeforeAll(func() { + Expect(k8sClient.Create(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: ns}, + })).To(Succeed()) + }) + + AfterAll(func() { + _ = k8sClient.Delete(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: ns}}) + }) + + newRestore := func(name string, pitr *pxcv1.PITR) *pxcv1.PerconaXtraDBClusterRestore { + return &pxcv1.PerconaXtraDBClusterRestore{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, + Spec: pxcv1.PerconaXtraDBClusterRestoreSpec{ + PXCCluster: "cluster1", + BackupName: "backup1", + PITR: pitr, + }, + } + } + + DescribeTable("valid PITR configurations", + func(name string, pitr *pxcv1.PITR) { + Expect(k8sClient.Create(ctx, newRestore(name, pitr))).To(Succeed()) + }, + Entry("type latest", "valid-latest", &pxcv1.PITR{Type: "latest"}), + Entry("type date with valid format", "valid-date", &pxcv1.PITR{Type: "date", Date: "2024-01-15 12:30:00"}), + Entry("type transaction with gtid", "valid-transaction", &pxcv1.PITR{Type: "transaction", GTID: "abc123:1-10"}), + ) + + DescribeTable("invalid PITR configurations", + func(name string, pitr *pxcv1.PITR, errMsg string) { + err := k8sClient.Create(ctx, newRestore(name, pitr)) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(errMsg)) + }, + Entry("type date with empty date", "invalid-date-empty", &pxcv1.PITR{Type: "date"}, "Date is required"), + Entry("type date with wrong format", "invalid-date-format", &pxcv1.PITR{Type: "date", Date: "15-01-2024 12:30:00"}, "format YYYY-MM-DD"), + Entry("type latest with date set", "invalid-latest-date", &pxcv1.PITR{Type: "latest", Date: "2024-01-15 12:30:00"}, "Date and GTID should not be set"), + Entry("type transaction without gtid", "invalid-transaction-no-gtid", &pxcv1.PITR{Type: "transaction"}, "GTID is required"), + Entry("unknown type", "invalid-unknown-type", &pxcv1.PITR{Type: "unknown"}, "Unsupported value"), + ) +})