From 6fe9a7a7afa6df3c08eb4f324a536f29875486fb Mon Sep 17 00:00:00 2001 From: Alexandre Havrileck Date: Mon, 4 Nov 2024 16:39:01 +0100 Subject: [PATCH] feat: wip --- PROJECT | 9 + .../v1alpha1/postgresqlpublication_types.go | 133 + .../v1alpha1/zz_generated.deepcopy.go | 169 + cmd/main.go | 23 +- ...l.easymile.com_postgresqlpublications.yaml | 155 + config/crd/kustomization.yaml | 3 + ..._in_postgresql_postgresqlpublications.yaml | 7 + ..._in_postgresql_postgresqlpublications.yaml | 16 + ...sql_postgresqlpublication_editor_role.yaml | 31 + ...sql_postgresqlpublication_viewer_role.yaml | 27 + config/rbac/role.yaml | 26 + config/samples/kustomization.yaml | 1 + ...gresql_v1alpha1_postgresqlpublication.yaml | 12 + go.mod | 13 +- go.sum | 27 +- .../postgres/create-publication-builder.go | 113 + .../postgresql/postgres/postgres.go | 5 + .../postgresql/postgres/publication.go | 191 + .../postgres/update-publication-builder.go | 100 + .../postgresqldatabase_controller.go | 35 + .../postgresqlpublication_controller.go | 521 +++ .../postgresqlpublication_controller_test.go | 3265 +++++++++++++++++ internal/controller/postgresql/suite_test.go | 264 +- 23 files changed, 5125 insertions(+), 21 deletions(-) create mode 100644 api/postgresql/v1alpha1/postgresqlpublication_types.go create mode 100644 config/crd/bases/postgresql.easymile.com_postgresqlpublications.yaml create mode 100644 config/crd/patches/cainjection_in_postgresql_postgresqlpublications.yaml create mode 100644 config/crd/patches/webhook_in_postgresql_postgresqlpublications.yaml create mode 100644 config/rbac/postgresql_postgresqlpublication_editor_role.yaml create mode 100644 config/rbac/postgresql_postgresqlpublication_viewer_role.yaml create mode 100644 config/samples/postgresql_v1alpha1_postgresqlpublication.yaml create mode 100644 internal/controller/postgresql/postgres/create-publication-builder.go create mode 100644 internal/controller/postgresql/postgres/publication.go create mode 100644 internal/controller/postgresql/postgres/update-publication-builder.go create mode 100644 internal/controller/postgresql/postgresqlpublication_controller.go create mode 100644 internal/controller/postgresql/postgresqlpublication_controller_test.go diff --git a/PROJECT b/PROJECT index 68952ec..93004f9 100644 --- a/PROJECT +++ b/PROJECT @@ -40,4 +40,13 @@ resources: kind: PostgresqlUserRole path: github.com/easymile/postgresql-operator/apis/postgresql/v1alpha1 version: v1alpha1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: easymile.com + group: postgresql + kind: PostgresqlPublication + path: github.com/easymile/postgresql-operator/api/postgresql/v1alpha1 + version: v1alpha1 version: "3" diff --git a/api/postgresql/v1alpha1/postgresqlpublication_types.go b/api/postgresql/v1alpha1/postgresqlpublication_types.go new file mode 100644 index 0000000..3f4c8b2 --- /dev/null +++ b/api/postgresql/v1alpha1/postgresqlpublication_types.go @@ -0,0 +1,133 @@ +/* +Copyright 2022. + +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 v1alpha1 + +import ( + "github.com/easymile/postgresql-operator/api/postgresql/common" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! +// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. + +// PostgresqlPublicationSpec defines the desired state of PostgresqlPublication. +type PostgresqlPublicationSpec struct { + // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster + // Important: Run "make" to regenerate code after modifying this file + + // Postgresql Database + // +required + // +kubebuilder:validation:Required + Database *common.CRLink `json:"database"` + // Postgresql Publication name + // +required + // +kubebuilder:validation:Required + Name string `json:"name"` + // Should drop database on Custom Resource deletion ? + // +optional + DropOnDelete bool `json:"dropOnDelete,omitempty"` + // Publication for all tables + // Note: This is mutually exclusive with "tablesInSchema" & "tables" + // +optional + AllTables bool `json:"allTables,omitempty"` + // Publication for tables in schema + // Note: this is a list of schema + // +optional + TablesInSchema []string `json:"tablesInSchema,omitempty"` + // Publication for selected tables + // +optional + Tables []*PostgresqlPublicationTable `json:"tables,omitempty"` + // Publication with parameters + // +optional + WithParameters *PostgresqlPublicationWith `json:"withParameters,omitempty"` +} + +type PostgresqlPublicationTable struct { + // Table name to use for publication + TableName string `json:"tableName"` + // Columns to export + Columns *[]string `json:"columns,omitempty"` + // Additional WHERE for table + AdditionalWhere *string `json:"where,omitempty"` +} + +type PostgresqlPublicationWith struct { + // Publish param + // See here: https://www.postgresql.org/docs/current/sql-createpublication.html#SQL-CREATEPUBLICATION-PARAMS-WITH-PUBLISH + Publish string `json:"publish"` + // Publish via partition root param + // See here: https://www.postgresql.org/docs/current/sql-createpublication.html#SQL-CREATEPUBLICATION-PARAMS-WITH-PUBLISH + PublishViaPartitionRoot *bool `json:"publishViaPartitionRoot,omitempty"` +} + +type PublicationStatusPhase string + +const PublicationNoPhase PublicationStatusPhase = "" +const PublicationFailedPhase PublicationStatusPhase = "Failed" +const PublicationCreatedPhase PublicationStatusPhase = "Created" + +// PostgresqlPublicationStatus defines the observed state of PostgresqlPublication. +type PostgresqlPublicationStatus struct { + // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster + // Important: Run "make" to regenerate code after modifying this file + + // Current phase of the operator + Phase PublicationStatusPhase `json:"phase"` + // Human-readable message indicating details about current operator phase or error. + // +optional + Message string `json:"message"` + // True if all resources are in a ready state and all work is done. + // +optional + Ready bool `json:"ready"` + // Created publication name + // +optional + Name string `json:"name,omitempty"` + // Marker for save + // +optional + AllTables *bool `json:"forAllTables,omitempty"` + // Resource Spec hash + // +optional + Hash string `json:"hash,omitempty"` +} + +//+kubebuilder:object:root=true +//+kubebuilder:subresource:status +//+kubebuilder:resource:path=postgresqlpublications,scope=Namespaced,shortName=pgpublication;pgpub +//+kubebuilder:printcolumn:name="Publication",type=string,description="Publication",JSONPath=".status.name" +//+kubebuilder:printcolumn:name="Phase",type=string,description="Status phase",JSONPath=".status.phase" + +// PostgresqlPublication is the Schema for the postgresqlpublications API. +type PostgresqlPublication struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec PostgresqlPublicationSpec `json:"spec,omitempty"` + Status PostgresqlPublicationStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// PostgresqlPublicationList contains a list of PostgresqlPublication. +type PostgresqlPublicationList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []PostgresqlPublication `json:"items"` +} + +func init() { + SchemeBuilder.Register(&PostgresqlPublication{}, &PostgresqlPublicationList{}) +} diff --git a/api/postgresql/v1alpha1/zz_generated.deepcopy.go b/api/postgresql/v1alpha1/zz_generated.deepcopy.go index abf5b5b..004b47c 100644 --- a/api/postgresql/v1alpha1/zz_generated.deepcopy.go +++ b/api/postgresql/v1alpha1/zz_generated.deepcopy.go @@ -261,6 +261,175 @@ func (in *PostgresqlEngineConfigurationStatus) DeepCopy() *PostgresqlEngineConfi return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresqlPublication) DeepCopyInto(out *PostgresqlPublication) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresqlPublication. +func (in *PostgresqlPublication) DeepCopy() *PostgresqlPublication { + if in == nil { + return nil + } + out := new(PostgresqlPublication) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PostgresqlPublication) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresqlPublicationList) DeepCopyInto(out *PostgresqlPublicationList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]PostgresqlPublication, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresqlPublicationList. +func (in *PostgresqlPublicationList) DeepCopy() *PostgresqlPublicationList { + if in == nil { + return nil + } + out := new(PostgresqlPublicationList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PostgresqlPublicationList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresqlPublicationSpec) DeepCopyInto(out *PostgresqlPublicationSpec) { + *out = *in + if in.Database != nil { + in, out := &in.Database, &out.Database + *out = new(common.CRLink) + **out = **in + } + if in.TablesInSchema != nil { + in, out := &in.TablesInSchema, &out.TablesInSchema + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Tables != nil { + in, out := &in.Tables, &out.Tables + *out = make([]*PostgresqlPublicationTable, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(PostgresqlPublicationTable) + (*in).DeepCopyInto(*out) + } + } + } + if in.WithParameters != nil { + in, out := &in.WithParameters, &out.WithParameters + *out = new(PostgresqlPublicationWith) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresqlPublicationSpec. +func (in *PostgresqlPublicationSpec) DeepCopy() *PostgresqlPublicationSpec { + if in == nil { + return nil + } + out := new(PostgresqlPublicationSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresqlPublicationStatus) DeepCopyInto(out *PostgresqlPublicationStatus) { + *out = *in + if in.AllTables != nil { + in, out := &in.AllTables, &out.AllTables + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresqlPublicationStatus. +func (in *PostgresqlPublicationStatus) DeepCopy() *PostgresqlPublicationStatus { + if in == nil { + return nil + } + out := new(PostgresqlPublicationStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresqlPublicationTable) DeepCopyInto(out *PostgresqlPublicationTable) { + *out = *in + if in.Columns != nil { + in, out := &in.Columns, &out.Columns + *out = new([]string) + if **in != nil { + in, out := *in, *out + *out = make([]string, len(*in)) + copy(*out, *in) + } + } + if in.AdditionalWhere != nil { + in, out := &in.AdditionalWhere, &out.AdditionalWhere + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresqlPublicationTable. +func (in *PostgresqlPublicationTable) DeepCopy() *PostgresqlPublicationTable { + if in == nil { + return nil + } + out := new(PostgresqlPublicationTable) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresqlPublicationWith) DeepCopyInto(out *PostgresqlPublicationWith) { + *out = *in + if in.PublishViaPartitionRoot != nil { + in, out := &in.PublishViaPartitionRoot, &out.PublishViaPartitionRoot + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresqlPublicationWith. +func (in *PostgresqlPublicationWith) DeepCopy() *PostgresqlPublicationWith { + if in == nil { + return nil + } + out := new(PostgresqlPublicationWith) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PostgresqlUserRole) DeepCopyInto(out *PostgresqlUserRole) { *out = *in diff --git a/cmd/main.go b/cmd/main.go index 0bb2f39..da4236e 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -34,9 +34,11 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" "sigs.k8s.io/controller-runtime/pkg/metrics" + "github.com/prometheus/client_golang/prometheus" + postgresqlv1alpha1 "github.com/easymile/postgresql-operator/api/postgresql/v1alpha1" + postgresqlcontroller "github.com/easymile/postgresql-operator/internal/controller/postgresql" postgresqlcontrollers "github.com/easymile/postgresql-operator/internal/controller/postgresql" - "github.com/prometheus/client_golang/prometheus" //+kubebuilder:scaffold:imports ) @@ -173,6 +175,25 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "PostgresqlUserRole") os.Exit(1) } + + if err = (&postgresqlcontroller.PostgresqlPublicationReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("postgresqlpublication-controller"), + Log: ctrl.Log.WithValues( + "controller", + "postgresqlpublication", + "controllerKind", + "PostgresqlPublication", + "controllerGroup", + "postgresql.easymile.com", + ), + ControllerRuntimeDetailedErrorTotal: controllerRuntimeDetailedErrorTotal, + ControllerName: "postgresqlpublication", + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "PostgresqlPublication") + os.Exit(1) + } //+kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/config/crd/bases/postgresql.easymile.com_postgresqlpublications.yaml b/config/crd/bases/postgresql.easymile.com_postgresqlpublications.yaml new file mode 100644 index 0000000..ed8ae8a --- /dev/null +++ b/config/crd/bases/postgresql.easymile.com_postgresqlpublications.yaml @@ -0,0 +1,155 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.14.0 + name: postgresqlpublications.postgresql.easymile.com +spec: + group: postgresql.easymile.com + names: + kind: PostgresqlPublication + listKind: PostgresqlPublicationList + plural: postgresqlpublications + shortNames: + - pgpublication + - pgpub + singular: postgresqlpublication + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Publication + jsonPath: .status.name + name: Publication + type: string + - description: Status phase + jsonPath: .status.phase + name: Phase + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: PostgresqlPublication is the Schema for the postgresqlpublications + API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: PostgresqlPublicationSpec defines the desired state of PostgresqlPublication. + properties: + allTables: + description: |- + Publication for all tables + Note: This is mutually exclusive with "tablesInSchema" & "tables" + type: boolean + database: + description: Postgresql Database + properties: + name: + description: Custom resource name + type: string + namespace: + description: Custom resource namespace + type: string + required: + - name + type: object + dropOnDelete: + description: Should drop database on Custom Resource deletion ? + type: boolean + name: + description: Postgresql Publication name + type: string + tables: + description: Publication for selected tables + items: + properties: + columns: + description: Columns to export + items: + type: string + type: array + tableName: + description: Table name to use for publication + type: string + where: + description: Additional WHERE for table + type: string + required: + - tableName + type: object + type: array + tablesInSchema: + description: |- + Publication for tables in schema + Note: this is a list of schema + items: + type: string + type: array + withParameters: + description: Publication with parameters + properties: + publish: + description: |- + Publish param + See here: https://www.postgresql.org/docs/current/sql-createpublication.html#SQL-CREATEPUBLICATION-PARAMS-WITH-PUBLISH + type: string + publishViaPartitionRoot: + description: |- + Publish via partition root param + See here: https://www.postgresql.org/docs/current/sql-createpublication.html#SQL-CREATEPUBLICATION-PARAMS-WITH-PUBLISH + type: boolean + required: + - publish + type: object + required: + - database + - name + type: object + status: + description: PostgresqlPublicationStatus defines the observed state of + PostgresqlPublication. + properties: + forAllTables: + description: Marker for save + type: boolean + hash: + description: Resource Spec hash + type: string + message: + description: Human-readable message indicating details about current + operator phase or error. + type: string + name: + description: Created publication name + type: string + phase: + description: Current phase of the operator + type: string + ready: + description: True if all resources are in a ready state and all work + is done. + type: boolean + required: + - phase + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index a09ccc9..677d86d 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -5,6 +5,7 @@ resources: - bases/postgresql.easymile.com_postgresqlengineconfigurations.yaml - bases/postgresql.easymile.com_postgresqldatabases.yaml - bases/postgresql.easymile.com_postgresqluserroles.yaml +- bases/postgresql.easymile.com_postgresqlpublications.yaml #+kubebuilder:scaffold:crdkustomizeresource patchesStrategicMerge: @@ -13,6 +14,7 @@ patchesStrategicMerge: #- patches/webhook_in_postgresqlengineconfigurations.yaml #- patches/webhook_in_postgresqldatabases.yaml #- patches/webhook_in_postgresqluserroles.yaml +#- path: patches/webhook_in_postgresqlpublications.yaml #+kubebuilder:scaffold:crdkustomizewebhookpatch # [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix. @@ -20,6 +22,7 @@ patchesStrategicMerge: #- patches/cainjection_in_postgresqlengineconfigurations.yaml #- patches/cainjection_in_postgresqldatabases.yaml #- patches/cainjection_in_postgresqluserroles.yaml +#- path: patches/cainjection_in_postgresqlpublications.yaml #+kubebuilder:scaffold:crdkustomizecainjectionpatch # the following config is for teaching kustomize how to do kustomization for CRDs. diff --git a/config/crd/patches/cainjection_in_postgresql_postgresqlpublications.yaml b/config/crd/patches/cainjection_in_postgresql_postgresqlpublications.yaml new file mode 100644 index 0000000..377aa38 --- /dev/null +++ b/config/crd/patches/cainjection_in_postgresql_postgresqlpublications.yaml @@ -0,0 +1,7 @@ +# The following patch adds a directive for certmanager to inject CA into the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + cert-manager.io/inject-ca-from: CERTIFICATE_NAMESPACE/CERTIFICATE_NAME + name: postgresqlpublications.postgresql.easymile.com diff --git a/config/crd/patches/webhook_in_postgresql_postgresqlpublications.yaml b/config/crd/patches/webhook_in_postgresql_postgresqlpublications.yaml new file mode 100644 index 0000000..2e44f29 --- /dev/null +++ b/config/crd/patches/webhook_in_postgresql_postgresqlpublications.yaml @@ -0,0 +1,16 @@ +# The following patch enables a conversion webhook for the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: postgresqlpublications.postgresql.easymile.com +spec: + conversion: + strategy: Webhook + webhook: + clientConfig: + service: + namespace: system + name: webhook-service + path: /convert + conversionReviewVersions: + - v1 diff --git a/config/rbac/postgresql_postgresqlpublication_editor_role.yaml b/config/rbac/postgresql_postgresqlpublication_editor_role.yaml new file mode 100644 index 0000000..627aa3e --- /dev/null +++ b/config/rbac/postgresql_postgresqlpublication_editor_role.yaml @@ -0,0 +1,31 @@ +# permissions for end users to edit postgresqlpublications. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: postgresqlpublication-editor-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: postgresql-operator + app.kubernetes.io/part-of: postgresql-operator + app.kubernetes.io/managed-by: kustomize + name: postgresqlpublication-editor-role +rules: +- apiGroups: + - postgresql.easymile.com + resources: + - postgresqlpublications + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - postgresql.easymile.com + resources: + - postgresqlpublications/status + verbs: + - get diff --git a/config/rbac/postgresql_postgresqlpublication_viewer_role.yaml b/config/rbac/postgresql_postgresqlpublication_viewer_role.yaml new file mode 100644 index 0000000..65d03c3 --- /dev/null +++ b/config/rbac/postgresql_postgresqlpublication_viewer_role.yaml @@ -0,0 +1,27 @@ +# permissions for end users to view postgresqlpublications. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: postgresqlpublication-viewer-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: postgresql-operator + app.kubernetes.io/part-of: postgresql-operator + app.kubernetes.io/managed-by: kustomize + name: postgresqlpublication-viewer-role +rules: +- apiGroups: + - postgresql.easymile.com + resources: + - postgresqlpublications + verbs: + - get + - list + - watch +- apiGroups: + - postgresql.easymile.com + resources: + - postgresqlpublications/status + verbs: + - get diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 4cb4d4d..efe92f3 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -75,6 +75,32 @@ rules: - get - patch - update +- apiGroups: + - postgresql.easymile.com + resources: + - postgresqlpublications + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - postgresql.easymile.com + resources: + - postgresqlpublications/finalizers + verbs: + - update +- apiGroups: + - postgresql.easymile.com + resources: + - postgresqlpublications/status + verbs: + - get + - patch + - update - apiGroups: - postgresql.easymile.com resources: diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 5bf0ea9..73b78ac 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -5,4 +5,5 @@ resources: - postgresql_v1alpha1_postgresqluser.yaml - postgresql_v1alpha2_postgresqluser.yaml - postgresql_v1alpha1_postgresqluserrole.yaml +- postgresql_v1alpha1_postgresqlpublication.yaml #+kubebuilder:scaffold:manifestskustomizesamples diff --git a/config/samples/postgresql_v1alpha1_postgresqlpublication.yaml b/config/samples/postgresql_v1alpha1_postgresqlpublication.yaml new file mode 100644 index 0000000..396c938 --- /dev/null +++ b/config/samples/postgresql_v1alpha1_postgresqlpublication.yaml @@ -0,0 +1,12 @@ +apiVersion: postgresql.easymile.com/v1alpha1 +kind: PostgresqlPublication +metadata: + labels: + app.kubernetes.io/name: postgresqlpublication + app.kubernetes.io/instance: postgresqlpublication-sample + app.kubernetes.io/part-of: postgresql-operator + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/created-by: postgresql-operator + name: postgresqlpublication-sample +spec: + # TODO(user): Add fields here diff --git a/go.mod b/go.mod index 7e99e20..8dd7293 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/onsi/ginkgo/v2 v2.9.5 github.com/onsi/gomega v1.27.7 github.com/prometheus/client_golang v1.15.1 + github.com/samber/lo v1.47.0 github.com/thoas/go-funk v0.9.3 k8s.io/api v0.27.2 k8s.io/apimachinery v0.27.2 @@ -31,7 +32,7 @@ require ( github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/gnostic v0.5.7-v3refs // indirect - github.com/google/go-cmp v0.5.9 // indirect + github.com/google/go-cmp v0.6.0 // indirect github.com/google/gofuzz v1.1.0 // indirect github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 // indirect github.com/google/uuid v1.3.0 // indirect @@ -51,13 +52,13 @@ require ( go.uber.org/atomic v1.7.0 // indirect go.uber.org/multierr v1.6.0 // indirect go.uber.org/zap v1.24.0 // indirect - golang.org/x/net v0.10.0 // indirect + golang.org/x/net v0.25.0 // indirect golang.org/x/oauth2 v0.5.0 // indirect - golang.org/x/sys v0.8.0 // indirect - golang.org/x/term v0.8.0 // indirect - golang.org/x/text v0.9.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/term v0.20.0 // indirect + golang.org/x/text v0.16.0 // indirect golang.org/x/time v0.3.0 // indirect - golang.org/x/tools v0.9.1 // indirect + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect gomodules.xyz/jsonpatch/v2 v2.3.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.30.0 // indirect diff --git a/go.sum b/go.sum index 1428225..982c582 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g= github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -123,6 +123,8 @@ github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI= github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc= +github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= @@ -162,7 +164,6 @@ golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHl golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -173,8 +174,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.5.0 h1:HuArIo48skDwlrvM3sEdHXElYslAMsf3KwRkkW4MC4s= golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I= @@ -194,16 +195,16 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -215,8 +216,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo= -golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/controller/postgresql/postgres/create-publication-builder.go b/internal/controller/postgresql/postgres/create-publication-builder.go new file mode 100644 index 0000000..1938f4a --- /dev/null +++ b/internal/controller/postgresql/postgres/create-publication-builder.go @@ -0,0 +1,113 @@ +package postgres + +import ( + "fmt" + "strings" +) + +type CreatePublicationBuilder struct { + name string + tablesPart string + allTables string + withPart string + tables []string + schemaList []string +} + +func NewCreatePublicationBuilder() *CreatePublicationBuilder { + return &CreatePublicationBuilder{} +} + +func (b *CreatePublicationBuilder) Build() { + if b.allTables != "" { + b.tablesPart = b.allTables + + return + } + + // Build + res := "FOR " + + // Check if tables are set + if len(b.tables) != 0 { + res += "TABLE " + strings.Join(b.tables, ", ") + } + + // Check if schema are set + if len(b.schemaList) != 0 { + // Check if tables were added + if len(b.tables) != 0 { + // Append + res += ", " + } + + res += "TABLES IN SCHEMA " + strings.Join(b.schemaList, ", ") + } + + // Save + b.tablesPart = res +} + +func (b *CreatePublicationBuilder) AddTable(name string, columns *[]string, additionalWhere *string) *CreatePublicationBuilder { + res := name + + // Manage columns + if columns != nil { + res += " (" + strings.Join(*columns, ", ") + ")" + } + + // Add where is set + if additionalWhere != nil { + res += " WHERE (" + *additionalWhere + ")" + } + + // Save + b.tables = append(b.tables, res) + + return b +} + +func (b *CreatePublicationBuilder) SetTablesInSchema(schemaList []string) *CreatePublicationBuilder { + b.schemaList = schemaList + + return b +} + +func (b *CreatePublicationBuilder) SetForAllTables() *CreatePublicationBuilder { + b.allTables = "FOR ALL TABLES" + + return b +} + +func (b *CreatePublicationBuilder) SetName(n string) *CreatePublicationBuilder { + b.name = n + + return b +} + +func (b *CreatePublicationBuilder) SetWith(publish string, publishViaPartitionRoot *bool) *CreatePublicationBuilder { + var with string + // Check if publish is set + if publish != "" { + with += "publish = '" + publish + "'" + } + // Check publish via partition root + if publishViaPartitionRoot != nil { + // Check if there is already a with set + if with != "" { + with += ", " + } + // Manage bool + with += "publish_via_partition_root = " + if *publishViaPartitionRoot { + with += "true" + } else { + with += "false" + } + } + + // Save + b.withPart = fmt.Sprintf("WITH (%s)", with) + + return b +} diff --git a/internal/controller/postgresql/postgres/postgres.go b/internal/controller/postgresql/postgres/postgres.go index db7cbcb..e4fcc63 100644 --- a/internal/controller/postgresql/postgres/postgres.go +++ b/internal/controller/postgresql/postgres/postgres.go @@ -46,6 +46,11 @@ type PG interface { //nolint:interfacebloat // This is needed ChangeTableOwner(db, table, owner string) error GetTypesInSchema(db, schema string) ([]string, error) ChangeTypeOwnerInSchema(db, schema, typeName, owner string) error + DropPublication(dbname, name string) error + RenamePublication(dbname, oldname, newname string) error + GetPublication(dbname, name string) (*PublicationResult, error) + CreatePublication(dbname string, builder *CreatePublicationBuilder) error + UpdatePublication(dbname, publicationName string, builder *UpdatePublicationBuilder) error GetUser() string GetHost() string GetPort() int diff --git a/internal/controller/postgresql/postgres/publication.go b/internal/controller/postgresql/postgres/publication.go new file mode 100644 index 0000000..1220e53 --- /dev/null +++ b/internal/controller/postgresql/postgres/publication.go @@ -0,0 +1,191 @@ +package postgres + +import ( + "errors" + "fmt" + + "github.com/lib/pq" +) + +const ( + CreatePublicationSQLTemplate = `CREATE PUBLICATION "%s" %s %s` + DropPublicationSQLTemplate = `DROP PUBLICATION "%s"` + AlterPublicationRenameSQLTemplate = `ALTER PUBLICATION "%s" RENAME TO "%s"` + AlterPublicationGeneralOperationSQLTemplate = `ALTER PUBLICATION "%s" SET %s` + GetPublicationSQLTemplate = `SELECT + puballtables, pubinsert, pubupdate, pubdelete, pubtruncate, pubviaroot +FROM pg_catalog.pg_publication +WHERE pubname = '%s';` +) + +type PublicationResult struct { + AllTables bool + Insert bool + Update bool + Delete bool + Truncate bool + PublicationViaRoot bool +} + +type PublicationTableDetail struct { + SchemaName string + TableName string + AdditionalWhere *string + Columns []string +} + +func (c *pg) UpdatePublication(dbname, publicationName string, builder *UpdatePublicationBuilder) (err error) { + // Connect to db + err = c.connect(dbname) + if err != nil { + return err + } + + // Build + builder.Build() + + tx, err := c.db.Begin() + if err != nil { + return err + } + + defer func() { + if err != nil { + err2 := tx.Rollback() + + err = errors.Join(err, err2) + } + }() + + // Manage with options + if builder.withPart != "" { + _, err = tx.Exec(fmt.Sprintf(AlterPublicationGeneralOperationSQLTemplate, publicationName, builder.withPart)) + if err != nil { + return err + } + } + + // Manage tables + if builder.tablesPart != "" { + _, err = tx.Exec(fmt.Sprintf(AlterPublicationGeneralOperationSQLTemplate, publicationName, builder.tablesPart)) + if err != nil { + return err + } + } + + // Check rename + // ? Note: this should be the last step + if builder.newName != "" { + // Rename have to be done + _, err = tx.Exec(fmt.Sprintf(AlterPublicationRenameSQLTemplate, publicationName, builder.newName)) + if err != nil { + return err + } + } + + // Commit + err = tx.Commit() + if err != nil { + return err + } + + // Default + return nil +} + +func (c *pg) CreatePublication(dbname string, builder *CreatePublicationBuilder) error { + // Connect to db + err := c.connect(dbname) + if err != nil { + return err + } + + // Build + builder.Build() + + _, err = c.db.Exec(fmt.Sprintf(CreatePublicationSQLTemplate, builder.name, builder.tablesPart, builder.withPart)) + if err != nil { + return err + } + + // Default + return nil +} + +func (c *pg) GetPublication(dbname, name string) (*PublicationResult, error) { + err := c.connect(dbname) + if err != nil { + return nil, err + } + + // Get rows + rows, err := c.db.Query(fmt.Sprintf(GetPublicationSQLTemplate, name)) + if err != nil { + return nil, err + } + + defer rows.Close() + + var res PublicationResult + + var foundOne bool + + for rows.Next() { + // Scan + err = rows.Scan(&res.AllTables, &res.Insert, &res.Update, &res.Delete, &res.Truncate, &res.PublicationViaRoot) + // Check error + if err != nil { + return nil, err + } + + // Update marker + foundOne = true + } + + // Rows error + err = rows.Err() + // Check error + if err != nil { + return nil, err + } + + // Check if found marker isn't set + if !foundOne { + return nil, nil + } + + return &res, nil +} + +func (c *pg) DropPublication(dbname, name string) error { + err := c.connect(dbname) + if err != nil { + return err + } + + _, err = c.db.Exec(fmt.Sprintf(DropPublicationSQLTemplate, name)) + // Error code 3D000 is returned if database doesn't exist + if err != nil { + // Try to cast error + pqErr, ok := err.(*pq.Error) + if !ok || pqErr.Code != "3D000" { + return err + } + } + + return nil +} + +func (c *pg) RenamePublication(dbname, oldname, newname string) error { + err := c.connect(dbname) + if err != nil { + return err + } + + _, err = c.db.Exec(fmt.Sprintf(AlterPublicationRenameSQLTemplate, oldname, newname)) + if err != nil { + return err + } + + return nil +} diff --git a/internal/controller/postgresql/postgres/update-publication-builder.go b/internal/controller/postgresql/postgres/update-publication-builder.go new file mode 100644 index 0000000..213a48b --- /dev/null +++ b/internal/controller/postgresql/postgres/update-publication-builder.go @@ -0,0 +1,100 @@ +package postgres + +import ( + "fmt" + "strings" +) + +type UpdatePublicationBuilder struct { + newName string + withPart string + tablesPart string + tables []string + schemaList []string +} + +func NewUpdatePublicationBuilder() *UpdatePublicationBuilder { + return &UpdatePublicationBuilder{} +} + +func (b *UpdatePublicationBuilder) Build() { + // Build + var res string + + // Check if tables are set + if len(b.tables) != 0 { + res += "TABLE " + strings.Join(b.tables, ", ") + } + + // Check if schema are set + if len(b.schemaList) != 0 { + // Check if tables were added + if len(b.tables) != 0 { + // Append + res += ", " + } + + res += "TABLES IN SCHEMA " + strings.Join(b.schemaList, ", ") + } + + // Save + b.tablesPart = res +} + +func (b *UpdatePublicationBuilder) AddSetTable(name string, columns *[]string, additionalWhere *string) *UpdatePublicationBuilder { + res := name + + // Manage columns + if columns != nil { + res += " (" + strings.Join(*columns, ", ") + ")" + } + + // Add where is set + if additionalWhere != nil { + res += " WHERE (" + *additionalWhere + ")" + } + + // Save + b.tables = append(b.tables, res) + + return b +} + +func (b *UpdatePublicationBuilder) SetTablesInSchema(schemaList []string) *UpdatePublicationBuilder { + b.schemaList = schemaList + + return b +} + +func (b *UpdatePublicationBuilder) RenameTo(newName string) *UpdatePublicationBuilder { + b.newName = newName + + return b +} + +func (b *UpdatePublicationBuilder) SetWith(publish string, publishViaPartitionRoot *bool) *UpdatePublicationBuilder { + var with string + // Check if publish is set + if publish != "" { + with += "publish = '" + publish + "'" + } + // Check publish via partition root + if publishViaPartitionRoot != nil { + // Check if there is already a with set + if with != "" { + with += ", " + } + // Manage bool + with += "publish_via_partition_root = " + if *publishViaPartitionRoot { + with += "true" + } else { + with += "false" + } + } + + // Save + b.withPart = fmt.Sprintf(" (%s)", with) + + return b +} diff --git a/internal/controller/postgresql/postgresqldatabase_controller.go b/internal/controller/postgresql/postgresqldatabase_controller.go index b19aea9..6b036f5 100644 --- a/internal/controller/postgresql/postgresqldatabase_controller.go +++ b/internal/controller/postgresql/postgresqldatabase_controller.go @@ -368,6 +368,19 @@ func (r *PostgresqlDatabaseReconciler) shouldDropDatabase( return false, err } + + // Check if there are user role linked resource linked to this + existingPublication, err := r.getAnyPublicationLinked(ctx, instance) + if err != nil { + return false, err + } + + if existingPublication != nil { + // Wait for children removal + err = fmt.Errorf("cannot remove resource because found publication %s in namespace %s linked to this resource and wait for deletion flag is enabled", existingPublication.Name, existingPublication.Namespace) + + return false, err + } } // Check if drop on delete flag is enabled @@ -379,6 +392,28 @@ func (r *PostgresqlDatabaseReconciler) shouldDropDatabase( return false, nil } +func (r *PostgresqlDatabaseReconciler) getAnyPublicationLinked( + ctx context.Context, + instance *postgresqlv1alpha1.PostgresqlDatabase, +) (*postgresqlv1alpha1.PostgresqlPublication, error) { + // Initialize postgres user list + list := postgresqlv1alpha1.PostgresqlPublicationList{} + // Requests for list of users + err := r.List(ctx, &list) + if err != nil { + return nil, err + } + // Loop over the list + for _, item := range list.Items { + // Check if db is linked to pgdatabase + if item.Spec.Database.Name == instance.Name && (item.Spec.Database.Namespace == instance.Namespace || item.Namespace == instance.Namespace) { + return &item, nil + } + } + + return nil, nil +} + func (r *PostgresqlDatabaseReconciler) getAnyUserRoleLinked( ctx context.Context, instance *postgresqlv1alpha1.PostgresqlDatabase, diff --git a/internal/controller/postgresql/postgresqlpublication_controller.go b/internal/controller/postgresql/postgresqlpublication_controller.go new file mode 100644 index 0000000..4cd5434 --- /dev/null +++ b/internal/controller/postgresql/postgresqlpublication_controller.go @@ -0,0 +1,521 @@ +/* +Copyright 2022. + +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 postgresql + +import ( + "context" + "reflect" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/easymile/postgresql-operator/api/postgresql/v1alpha1" + "github.com/easymile/postgresql-operator/internal/controller/config" + "github.com/easymile/postgresql-operator/internal/controller/postgresql/postgres" + "github.com/easymile/postgresql-operator/internal/controller/utils" + "github.com/go-logr/logr" + "github.com/prometheus/client_golang/prometheus" + "github.com/samber/lo" +) + +// PostgresqlPublicationReconciler reconciles a PostgresqlPublication object. +type PostgresqlPublicationReconciler struct { + Recorder record.EventRecorder + client.Client + Scheme *runtime.Scheme + ControllerRuntimeDetailedErrorTotal *prometheus.CounterVec + Log logr.Logger + ControllerName string +} + +//+kubebuilder:rbac:groups=postgresql.easymile.com,resources=postgresqlpublications,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=postgresql.easymile.com,resources=postgresqlpublications/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=postgresql.easymile.com,resources=postgresqlpublications/finalizers,verbs=update + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +// Reconcile function to compare the state specified by +// the PostgresqlPublication object against the actual cluster state, and then +// perform operations to make the cluster state reflect the state specified by +// the user. +// +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.15.0/pkg/reconcile +func (r *PostgresqlPublicationReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { //nolint:wsl // it is like that + // Issue with this logger: controller and controllerKind are incorrect + // Build another logger from upper to fix this. + // reqLogger := log.FromContext(ctx) + + reqLogger := r.Log.WithValues("Request.Namespace", req.Namespace, "Request.Name", req.Name) + + reqLogger.Info("Reconciling PostgresqlPublication") + + // Fetch the PostgresqlPublication instance + instance := &v1alpha1.PostgresqlPublication{} + err := r.Get(ctx, req.NamespacedName, instance) + + if err != nil { + if errors.IsNotFound(err) { + // Request object not found, could have been deleted after reconcile request. + // Owned objects are automatically garbage collected. For additional cleanup logic use finalizers. + // Return and don't requeue + return reconcile.Result{}, nil + } + // Error reading the object - requeue the request. + return reconcile.Result{}, err + } + + // Original patch + originalPatch := client.MergeFrom(instance.DeepCopy()) + + // Deletion case + if !instance.GetDeletionTimestamp().IsZero() { //nolint:wsl + // Deletion detected + + // Check if drop on delete is enabled + if instance.Spec.DropOnDelete { + // Delete publication + err = r.manageDropPublication(ctx, reqLogger, instance) + if err != nil { + return r.manageError(ctx, reqLogger, instance, originalPatch, err) + } + } + + // Remove finalizer + controllerutil.RemoveFinalizer(instance, config.Finalizer) + + // Update CR + err = r.Update(ctx, instance) + if err != nil { + return r.manageError(ctx, reqLogger, instance, originalPatch, err) + } + + reqLogger.Info("Successfully deleted") + // Stop reconcile + return reconcile.Result{}, nil + } + + // Creation / Update case + + // Validate + err = r.validate(instance) + // Check error + if err != nil { + return r.manageError(ctx, reqLogger, instance, originalPatch, err) + } + + // Try to find pg db CR + pgDB, err := utils.FindPgDatabaseFromLink(ctx, r.Client, instance.Spec.Database, instance.Namespace) + if err != nil { + return r.manageError(ctx, reqLogger, instance, originalPatch, err) + } + + // Check that postgres database is ready before continue but only if it is the first time + // If not, requeue event + if instance.Status.Phase == v1alpha1.PublicationNoPhase && !pgDB.Status.Ready { + reqLogger.Info("PostgresqlDatabase not ready, waiting for it") + r.Recorder.Event(instance, "Warning", "Processing", "Processing stopped because PostgresqlDatabase isn't ready. Waiting for it.") + + return ctrl.Result{}, nil + } + + // Try to find PostgresqlEngineConfiguration CR + pgEngCfg, err := utils.FindPgEngineCfg(ctx, r.Client, pgDB) + if err != nil { + return r.manageError(ctx, reqLogger, instance, originalPatch, err) + } + + // Check that postgres engine configuration is ready before continue but only if it is the first time + // If not, requeue event + if instance.Status.Phase == v1alpha1.PublicationNoPhase && !pgEngCfg.Status.Ready { + reqLogger.Info("PostgresqlEngineConfiguration not ready, waiting for it") + r.Recorder.Event(instance, "Warning", "Processing", "Processing stopped because PostgresqlEngineConfiguration isn't ready. Waiting for it.") + + return ctrl.Result{}, nil + } + + // Get secret linked to PostgresqlEngineConfiguration CR + secret, err := utils.FindSecretPgEngineCfg(ctx, r.Client, pgEngCfg) + if err != nil { + return r.manageError(ctx, reqLogger, instance, originalPatch, err) + } + + // Add finalizer, owners and default values + updated, err := r.updateInstance(ctx, instance) + // Check error + if err != nil { + return r.manageError(ctx, reqLogger, instance, originalPatch, err) + } + // Check if it has been updated in order to stop this reconcile loop here for the moment + if updated { + return ctrl.Result{}, nil + } + + // Create PG instance + pg := utils.CreatePgInstance(reqLogger, secret.Data, pgEngCfg) + + // Compute name to search + nameToSearch := instance.Status.Name + // Check + if nameToSearch == "" { + // ? This is done to recover the first creation with an existing publication with the same name + nameToSearch = instance.Spec.Name + } + + // Get publication + pubRes, err := pg.GetPublication(pgDB.Status.Database, nameToSearch) + // Check error + if err != nil { + return r.manageError(ctx, reqLogger, instance, originalPatch, err) + } + + // Calculate hash for status (this time is to update it in status) + hash, err := utils.CalculateHash(instance.Spec) + if err != nil { + return r.manageError(ctx, reqLogger, instance, originalPatch, errors.NewInternalError(err)) + } + + // Check if publication haven't been found + if pubRes == nil { + // Create case + reqLogger.Info("Publication creation case detected") + + err = r.manageCreate(instance, pg, pgDB) + // Check error + if err != nil { + return r.manageError(ctx, reqLogger, instance, originalPatch, err) + } + } else { + // Update case + reqLogger.Info("Publication update case detected") + + // Need to check if status hash is the same or not to force renew or not + if hash != instance.Status.Hash { + reqLogger.Info("Specs are different, update need to be done") + + err = r.manageUpdate(instance, pg, pgDB, pubRes, nameToSearch) + // Check error + if err != nil { + return r.manageError(ctx, reqLogger, instance, originalPatch, err) + } + } + } + + // Save name + instance.Status.Name = instance.Spec.Name + // Save hash in status + instance.Status.Hash = hash + // Save for all tables + instance.Status.AllTables = &instance.Spec.AllTables + + return r.manageSuccess(ctx, reqLogger, instance, originalPatch) +} + +func (*PostgresqlPublicationReconciler) manageUpdate( + instance *v1alpha1.PostgresqlPublication, + pg postgres.PG, + pgDB *v1alpha1.PostgresqlDatabase, + pubRes *postgres.PublicationResult, + currentPublicationName string, +) error { + // Check that publication in database and spec are aligned on "for all tables" as this cannot be changed + if pubRes.AllTables != instance.Spec.AllTables { + // Not aligned => Problem + return errors.NewBadRequest("publication in database and spec are out of sync for 'for all tables' and values must be aligned to continue") + } + + // Create builder + builder := postgres.NewUpdatePublicationBuilder() + + // Check if publication has to be renamed + if instance.Spec.Name != currentPublicationName { + builder = builder.RenameTo(instance.Spec.Name) + } + + // Add tables schema + builder = builder.SetTablesInSchema(instance.Spec.TablesInSchema) + + // Loop over tables + for _, t := range instance.Spec.Tables { + builder = builder.AddSetTable(t.TableName, t.Columns, t.AdditionalWhere) + } + + // Check if there are with options + if instance.Spec.WithParameters != nil { + // Change with + builder = builder.SetWith(instance.Spec.WithParameters.Publish, instance.Spec.WithParameters.PublishViaPartitionRoot) + } + + // Perform update + // ? Note: this will do an alter even if it is unnecessary + // ? Detecting real diff will be long and painful, perform an alter with what is asked will ensure that nothing can be changed + err := pg.UpdatePublication(pgDB.Status.Database, currentPublicationName, builder) + // Check error + if err != nil { + return err + } + + // Default + return nil +} + +func (*PostgresqlPublicationReconciler) manageCreate( + instance *v1alpha1.PostgresqlPublication, + pg postgres.PG, + pgDB *v1alpha1.PostgresqlDatabase, +) error { + // Save spec for easy use + spec := instance.Spec + + // Create builder + builder := postgres.NewCreatePublicationBuilder() + + // Add name & tables in schema + builder = builder.SetName(spec.Name).SetTablesInSchema(spec.TablesInSchema) + + // Check if all tables is enabled + if spec.AllTables { + builder = builder.SetForAllTables() + } + + // Check if with is set + if spec.WithParameters != nil { + // Manage with + builder = builder.SetWith(spec.WithParameters.Publish, spec.WithParameters.PublishViaPartitionRoot) + } + + // Manage tables + lo.ForEach(spec.Tables, func(table *v1alpha1.PostgresqlPublicationTable, _ int) { + builder = builder.AddTable(table.TableName, table.Columns, table.AdditionalWhere) + }) + + // Create publication + err := pg.CreatePublication(pgDB.Status.Database, builder) + // Check error + if err != nil { + return err + } + + // Default + return nil +} + +func (*PostgresqlPublicationReconciler) validate( + instance *v1alpha1.PostgresqlPublication, +) error { + // Save spec for easy use + spec := instance.Spec + // Save status for easy use + status := instance.Status + + // Check name + if spec.Name == "" { + return errors.NewBadRequest("name must have a value") + } + + // Init some vars + tablesInSchemaLength := len(spec.TablesInSchema) + tablesLength := len(spec.Tables) + + // check that something have been asked + if !spec.AllTables && tablesInSchemaLength == 0 && tablesLength == 0 { + return errors.NewBadRequest("nothing is selected for publication (no all tables, no tables in schema, no tables)") + } + + // Check all tables vs other case + if spec.AllTables && (tablesInSchemaLength != 0 || tablesLength != 0) { + return errors.NewBadRequest("all tables cannot be set with tables in schema or tables") + } + + // Check status and spec "for all tables" + if status.AllTables != nil && *status.AllTables != spec.AllTables { + return errors.NewBadRequest("cannot change all tables flag on an upgrade") + } + + // Check Tables in schema + _, found := lo.Find(spec.TablesInSchema, func(it string) bool { return it == "" }) + // Check + if found { + return errors.NewBadRequest("tables in schema cannot have empty schema listed") + } + + // Check tables + _, found = lo.Find(spec.Tables, func(it *v1alpha1.PostgresqlPublicationTable) bool { + // Check table name + if it.TableName == "" { + return true + } + + // Check columns + if it.Columns != nil { + // Check if there is an empty column + _, f := lo.Find(*it.Columns, func(it string) bool { return it == "" }) + // Check + if f { + return true + } + + // Check if it have a columns list and a schema list + if len(*it.Columns) != 0 && tablesInSchemaLength != 0 { + return true + } + } + + // Check additional where + if it.AdditionalWhere != nil && *it.AdditionalWhere == "" { + return true + } + + return false + }) + // Check + if found { + return errors.NewBadRequest("tables cannot have a columns list with an empty name or have a columns list with a table schema list enabled or an empty additional where") + } + + // Default + return nil +} + +func (r *PostgresqlPublicationReconciler) updateInstance( + ctx context.Context, + instance *v1alpha1.PostgresqlPublication, +) (bool, error) { + // Deep copy + oCopy := instance.DeepCopy() + + // Add finalizer + controllerutil.AddFinalizer(instance, config.Finalizer) + + // Check if update is needed + if !reflect.DeepEqual(oCopy.ObjectMeta, instance.ObjectMeta) { + return true, r.Update(ctx, instance) + } + + return false, nil +} + +func (r *PostgresqlPublicationReconciler) manageDropPublication( + ctx context.Context, + logger logr.Logger, + instance *v1alpha1.PostgresqlPublication, +) error { + // Get pg db + pgDB, err := utils.FindPgDatabaseFromLink(ctx, r.Client, instance.Spec.Database, instance.Namespace) + if err != nil && !errors.IsNotFound(err) { + return err + } + // In case of not found => Can't delete => skip + if errors.IsNotFound(err) { + logger.Error(err, "can't delete publication because PostgresDatabase didn't exists anymore") + + return nil + } + + // Try to find PostgresqlEngineConfiguration CR + pgEngCfg, err := utils.FindPgEngineCfg(ctx, r.Client, pgDB) + if err != nil && !errors.IsNotFound(err) { + return err + } + // In case of not found => Can't delete => skip + if errors.IsNotFound(err) { + logger.Error(err, "can't delete database because PostgresEngineConfiguration didn't exists anymore") + + return nil + } + + // Get secret linked to PostgresqlEngineConfiguration CR + secret, err := utils.FindSecretPgEngineCfg(ctx, r.Client, pgEngCfg) + if err != nil { + return err + } + + // Create PG instance + pg := utils.CreatePgInstance(logger, secret.Data, pgEngCfg) + + // Drop database + return pg.DropPublication(pgDB.Status.Database, instance.Spec.Name) +} + +func (r *PostgresqlPublicationReconciler) manageError( + ctx context.Context, + logger logr.Logger, + instance *v1alpha1.PostgresqlPublication, + originalPatch client.Patch, + issue error, +) (reconcile.Result, error) { + logger.Error(issue, "issue raised in reconcile") + // Add kubernetes event + r.Recorder.Event(instance, "Warning", "ProcessingError", issue.Error()) + + // Update status + instance.Status.Message = issue.Error() + instance.Status.Ready = false + instance.Status.Phase = v1alpha1.PublicationFailedPhase + + // Increase fail counter + r.ControllerRuntimeDetailedErrorTotal.WithLabelValues(r.ControllerName, instance.Namespace, instance.Name).Inc() + + // Patch status + err := r.Status().Patch(ctx, instance, originalPatch) + if err != nil { + logger.Error(err, "unable to update status") + } + + // Return error + return ctrl.Result{}, issue +} + +func (r *PostgresqlPublicationReconciler) manageSuccess( + ctx context.Context, + logger logr.Logger, + instance *v1alpha1.PostgresqlPublication, + originalPatch client.Patch, +) (reconcile.Result, error) { + // Update status + instance.Status.Message = "" + instance.Status.Ready = true + instance.Status.Phase = v1alpha1.PublicationCreatedPhase + + // Patch status + err := r.Status().Patch(ctx, instance, originalPatch) + if err != nil { + // Increase fail counter + r.ControllerRuntimeDetailedErrorTotal.WithLabelValues(r.ControllerName, instance.Namespace, instance.Name).Inc() + + logger.Error(err, "unable to update status") + + // Return error + return ctrl.Result{}, err + } + + logger.Info("Reconcile done") + + return reconcile.Result{}, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *PostgresqlPublicationReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&v1alpha1.PostgresqlPublication{}). + Complete(r) +} diff --git a/internal/controller/postgresql/postgresqlpublication_controller_test.go b/internal/controller/postgresql/postgresqlpublication_controller_test.go new file mode 100644 index 0000000..457631e --- /dev/null +++ b/internal/controller/postgresql/postgresqlpublication_controller_test.go @@ -0,0 +1,3265 @@ +package postgresql + +import ( + "errors" + gerrors "errors" + "fmt" + "time" + + "github.com/easymile/postgresql-operator/api/postgresql/common" + postgresqlv1alpha1 "github.com/easymile/postgresql-operator/api/postgresql/v1alpha1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + apimachineryErrors "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +var _ = Describe("PostgresqlPublication tests", func() { + AfterEach(cleanupFunction) + + Describe("Spec error", func() { + It("shouldn't accept input without any specs", func() { + err := k8sClient.Create(ctx, &postgresqlv1alpha1.PostgresqlPublication{ + ObjectMeta: v1.ObjectMeta{ + Name: pgurName, + Namespace: pgurNamespace, + }, + }) + + Expect(err).To(HaveOccurred()) + + // Cast error + stErr, ok := err.(*apimachineryErrors.StatusError) + + Expect(ok).To(BeTrue()) + + // Check that content is correct + causes := stErr.Status().Details.Causes + + Expect(causes).To(HaveLen(1)) + + // Search all fields + fields := map[string]bool{ + "spec.database": false, + } + + // Loop over all causes + for _, cause := range causes { + fields[cause.Field] = true + } + + // Check that all fields are found + for key, value := range fields { + if !value { + err := fmt.Errorf("%s found be found in error causes", key) + Expect(err).ToNot(HaveOccurred()) + } + } + }) + + It("should fail when nothing is provided", func() { + it := &postgresqlv1alpha1.PostgresqlPublication{ + ObjectMeta: v1.ObjectMeta{ + Name: pgpublicationName, + Namespace: pgpublicationNamespace, + }, + Spec: postgresqlv1alpha1.PostgresqlPublicationSpec{ + Database: &common.CRLink{ + Name: pgdbName, + Namespace: pgdbNamespace, + }, + }, + } + + // Create user + Expect(k8sClient.Create(ctx, it)).Should(Succeed()) + + item := &postgresqlv1alpha1.PostgresqlPublication{} + // Get updated user + Eventually( + func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: pgpublicationName, + Namespace: pgpublicationNamespace, + }, item) + // Check error + if err != nil { + return err + } + + // Check if status hasn't been updated + if item.Status.Phase == postgresqlv1alpha1.PublicationNoPhase { + return errors.New("pgpub hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + // Checks + Expect(item.Status.Ready).To(BeFalse()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationFailedPhase)) + Expect(item.Status.Message).To(Equal("name must have a value")) + }) + + It("should fail when no publication option is provided", func() { + it := &postgresqlv1alpha1.PostgresqlPublication{ + ObjectMeta: v1.ObjectMeta{ + Name: pgpublicationName, + Namespace: pgpublicationNamespace, + }, + Spec: postgresqlv1alpha1.PostgresqlPublicationSpec{ + Database: &common.CRLink{ + Name: pgdbName, + Namespace: pgdbNamespace, + }, + Name: pgpublicationPublicationName1, + }, + } + + // Create user + Expect(k8sClient.Create(ctx, it)).Should(Succeed()) + + item := &postgresqlv1alpha1.PostgresqlPublication{} + // Get updated user + Eventually( + func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: pgpublicationName, + Namespace: pgpublicationNamespace, + }, item) + // Check error + if err != nil { + return err + } + + // Check if status hasn't been updated + if item.Status.Phase == postgresqlv1alpha1.PublicationNoPhase { + return errors.New("pgpub hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + // Checks + Expect(item.Status.Ready).To(BeFalse()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationFailedPhase)) + Expect(item.Status.Message).To(Equal("nothing is selected for publication (no all tables, no tables in schema, no tables)")) + }) + + It("should fail when all tables and tables in schema are provided", func() { + it := &postgresqlv1alpha1.PostgresqlPublication{ + ObjectMeta: v1.ObjectMeta{ + Name: pgpublicationName, + Namespace: pgpublicationNamespace, + }, + Spec: postgresqlv1alpha1.PostgresqlPublicationSpec{ + Database: &common.CRLink{ + Name: pgdbName, + Namespace: pgdbNamespace, + }, + Name: pgpublicationPublicationName1, + AllTables: true, + TablesInSchema: []string{"fake"}, + }, + } + + // Create user + Expect(k8sClient.Create(ctx, it)).Should(Succeed()) + + item := &postgresqlv1alpha1.PostgresqlPublication{} + // Get updated user + Eventually( + func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: pgpublicationName, + Namespace: pgpublicationNamespace, + }, item) + // Check error + if err != nil { + return err + } + + // Check if status hasn't been updated + if item.Status.Phase == postgresqlv1alpha1.PublicationNoPhase { + return errors.New("pgpub hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + // Checks + Expect(item.Status.Ready).To(BeFalse()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationFailedPhase)) + Expect(item.Status.Message).To(Equal("all tables cannot be set with tables in schema or tables")) + }) + + It("should fail when all tables and tables are provided", func() { + it := &postgresqlv1alpha1.PostgresqlPublication{ + ObjectMeta: v1.ObjectMeta{ + Name: pgpublicationName, + Namespace: pgpublicationNamespace, + }, + Spec: postgresqlv1alpha1.PostgresqlPublicationSpec{ + Database: &common.CRLink{ + Name: pgdbName, + Namespace: pgdbNamespace, + }, + Name: pgpublicationPublicationName1, + AllTables: true, + Tables: []*postgresqlv1alpha1.PostgresqlPublicationTable{ + { + TableName: "fake", + }, + }, + }, + } + + // Create user + Expect(k8sClient.Create(ctx, it)).Should(Succeed()) + + item := &postgresqlv1alpha1.PostgresqlPublication{} + // Get updated user + Eventually( + func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: pgpublicationName, + Namespace: pgpublicationNamespace, + }, item) + // Check error + if err != nil { + return err + } + + // Check if status hasn't been updated + if item.Status.Phase == postgresqlv1alpha1.PublicationNoPhase { + return errors.New("pgpub hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + // Checks + Expect(item.Status.Ready).To(BeFalse()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationFailedPhase)) + Expect(item.Status.Message).To(Equal("all tables cannot be set with tables in schema or tables")) + }) + + It("should fail when tables in schema with a empty string is provided", func() { + it := &postgresqlv1alpha1.PostgresqlPublication{ + ObjectMeta: v1.ObjectMeta{ + Name: pgpublicationName, + Namespace: pgpublicationNamespace, + }, + Spec: postgresqlv1alpha1.PostgresqlPublicationSpec{ + Database: &common.CRLink{ + Name: pgdbName, + Namespace: pgdbNamespace, + }, + Name: pgpublicationPublicationName1, + TablesInSchema: []string{""}, + }, + } + + // Create user + Expect(k8sClient.Create(ctx, it)).Should(Succeed()) + + item := &postgresqlv1alpha1.PostgresqlPublication{} + // Get updated user + Eventually( + func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: pgpublicationName, + Namespace: pgpublicationNamespace, + }, item) + // Check error + if err != nil { + return err + } + + // Check if status hasn't been updated + if item.Status.Phase == postgresqlv1alpha1.PublicationNoPhase { + return errors.New("pgpub hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + // Checks + Expect(item.Status.Ready).To(BeFalse()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationFailedPhase)) + Expect(item.Status.Message).To(Equal("tables in schema cannot have empty schema listed")) + }) + + It("should fail when tables with a empty string as table name is provided", func() { + it := &postgresqlv1alpha1.PostgresqlPublication{ + ObjectMeta: v1.ObjectMeta{ + Name: pgpublicationName, + Namespace: pgpublicationNamespace, + }, + Spec: postgresqlv1alpha1.PostgresqlPublicationSpec{ + Database: &common.CRLink{ + Name: pgdbName, + Namespace: pgdbNamespace, + }, + Name: pgpublicationPublicationName1, + Tables: []*postgresqlv1alpha1.PostgresqlPublicationTable{ + { + TableName: "", + }, + }, + }, + } + + // Create user + Expect(k8sClient.Create(ctx, it)).Should(Succeed()) + + item := &postgresqlv1alpha1.PostgresqlPublication{} + // Get updated user + Eventually( + func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: pgpublicationName, + Namespace: pgpublicationNamespace, + }, item) + // Check error + if err != nil { + return err + } + + // Check if status hasn't been updated + if item.Status.Phase == postgresqlv1alpha1.PublicationNoPhase { + return errors.New("pgpub hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + // Checks + Expect(item.Status.Ready).To(BeFalse()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationFailedPhase)) + Expect(item.Status.Message).To(Equal("tables cannot have a columns list with an empty name or have a columns list with a table schema list enabled or an empty additional where")) + }) + + It("should fail when tables with a empty string in columns is provided", func() { + it := &postgresqlv1alpha1.PostgresqlPublication{ + ObjectMeta: v1.ObjectMeta{ + Name: pgpublicationName, + Namespace: pgpublicationNamespace, + }, + Spec: postgresqlv1alpha1.PostgresqlPublicationSpec{ + Database: &common.CRLink{ + Name: pgdbName, + Namespace: pgdbNamespace, + }, + Name: pgpublicationPublicationName1, + Tables: []*postgresqlv1alpha1.PostgresqlPublicationTable{ + { + TableName: "fake", + Columns: &[]string{""}, + }, + }, + }, + } + + // Create user + Expect(k8sClient.Create(ctx, it)).Should(Succeed()) + + item := &postgresqlv1alpha1.PostgresqlPublication{} + // Get updated user + Eventually( + func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: pgpublicationName, + Namespace: pgpublicationNamespace, + }, item) + // Check error + if err != nil { + return err + } + + // Check if status hasn't been updated + if item.Status.Phase == postgresqlv1alpha1.PublicationNoPhase { + return errors.New("pgpub hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + // Checks + Expect(item.Status.Ready).To(BeFalse()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationFailedPhase)) + Expect(item.Status.Message).To(Equal("tables cannot have a columns list with an empty name or have a columns list with a table schema list enabled or an empty additional where")) + }) + + It("should fail when tables with a empty string in additional where is provided", func() { + it := &postgresqlv1alpha1.PostgresqlPublication{ + ObjectMeta: v1.ObjectMeta{ + Name: pgpublicationName, + Namespace: pgpublicationNamespace, + }, + Spec: postgresqlv1alpha1.PostgresqlPublicationSpec{ + Database: &common.CRLink{ + Name: pgdbName, + Namespace: pgdbNamespace, + }, + Name: pgpublicationPublicationName1, + Tables: []*postgresqlv1alpha1.PostgresqlPublicationTable{ + { + TableName: "fake", + AdditionalWhere: starAny(""), + }, + }, + }, + } + + // Create user + Expect(k8sClient.Create(ctx, it)).Should(Succeed()) + + item := &postgresqlv1alpha1.PostgresqlPublication{} + // Get updated user + Eventually( + func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: pgpublicationName, + Namespace: pgpublicationNamespace, + }, item) + // Check error + if err != nil { + return err + } + + // Check if status hasn't been updated + if item.Status.Phase == postgresqlv1alpha1.PublicationNoPhase { + return errors.New("pgpub hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + // Checks + Expect(item.Status.Ready).To(BeFalse()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationFailedPhase)) + Expect(item.Status.Message).To(Equal("tables cannot have a columns list with an empty name or have a columns list with a table schema list enabled or an empty additional where")) + }) + + It("should fail when tables with columns and tables in schema are provided", func() { + it := &postgresqlv1alpha1.PostgresqlPublication{ + ObjectMeta: v1.ObjectMeta{ + Name: pgpublicationName, + Namespace: pgpublicationNamespace, + }, + Spec: postgresqlv1alpha1.PostgresqlPublicationSpec{ + Database: &common.CRLink{ + Name: pgdbName, + Namespace: pgdbNamespace, + }, + Name: pgpublicationPublicationName1, + TablesInSchema: []string{"fake1"}, + Tables: []*postgresqlv1alpha1.PostgresqlPublicationTable{ + { + TableName: "fake2", + Columns: &[]string{"id"}, + }, + }, + }, + } + + // Create user + Expect(k8sClient.Create(ctx, it)).Should(Succeed()) + + item := &postgresqlv1alpha1.PostgresqlPublication{} + // Get updated user + Eventually( + func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: pgpublicationName, + Namespace: pgpublicationNamespace, + }, item) + // Check error + if err != nil { + return err + } + + // Check if status hasn't been updated + if item.Status.Phase == postgresqlv1alpha1.PublicationNoPhase { + return errors.New("pgpub hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + // Checks + Expect(item.Status.Ready).To(BeFalse()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationFailedPhase)) + Expect(item.Status.Message).To(Equal("tables cannot have a columns list with an empty name or have a columns list with a table schema list enabled or an empty additional where")) + }) + }) + + Describe("Creation", func() { + Describe("For all tables", func() { + It("should be ok without any tables", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{AllTables: true}) + + // Checks + Expect(item.Status.Ready).To(BeTrue()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + Expect(item.Status.Message).To(Equal("")) + Expect(item.Status.AllTables).To(Equal(starAny(true))) + Expect(item.Status.Hash).NotTo(Equal("")) + Expect(item.Status.Name).To(Equal(pgpublicationPublicationName1)) + + data, err := getPublication(item.Status.Name) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(Equal(&PublicationResult{ + AllTables: true, + Insert: true, + Update: true, + Delete: true, + Truncate: true, + PublicationViaRoot: false, + })) + + // Get details + details, err := getPublicationTableDetails(item.Status.Name) + if Expect(err).NotTo(HaveOccurred()) { + Expect(details).To(HaveLen(0)) + } + } + }) + + It("should be ok with tables", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{AllTables: true}) + + // Checks + Expect(item.Status.Ready).To(BeTrue()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + Expect(item.Status.Message).To(Equal("")) + Expect(item.Status.AllTables).To(Equal(starAny(true))) + Expect(item.Status.Hash).NotTo(Equal("")) + Expect(item.Status.Name).To(Equal(pgpublicationPublicationName1)) + + data, err := getPublication(item.Status.Name) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(Equal(&PublicationResult{ + AllTables: true, + Insert: true, + Update: true, + Delete: true, + Truncate: true, + PublicationViaRoot: false, + })) + + // Get details + details, err := getPublicationTableDetails(item.Status.Name) + if Expect(err).NotTo(HaveOccurred()) { + Expect(details).To(HaveLen(2)) + Expect(details).To(Equal([]*PublicationTableDetail{ + { + SchemaName: "public", + TableName: "fake", + Columns: []string{"id", "nb", "nb2"}, + AdditionalWhere: nil, + }, + { + SchemaName: "public", + TableName: "fake2", + Columns: []string{"id", "test"}, + AdditionalWhere: nil, + }, + })) + } + } + }) + + It("should be ok with pg with options", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + AllTables: true, + WithParameters: &postgresqlv1alpha1.PostgresqlPublicationWith{ + Publish: "truncate", + }, + }) + + // Checks + Expect(item.Status.Ready).To(BeTrue()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + Expect(item.Status.Message).To(Equal("")) + Expect(item.Status.AllTables).To(Equal(starAny(true))) + Expect(item.Status.Hash).NotTo(Equal("")) + Expect(item.Status.Name).To(Equal(pgpublicationPublicationName1)) + + data, err := getPublication(item.Status.Name) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(Equal(&PublicationResult{ + AllTables: true, + Insert: false, + Update: false, + Delete: false, + Truncate: true, + PublicationViaRoot: false, + })) + + // Get details + details, err := getPublicationTableDetails(item.Status.Name) + if Expect(err).NotTo(HaveOccurred()) { + Expect(details).To(HaveLen(2)) + Expect(details).To(Equal([]*PublicationTableDetail{ + { + SchemaName: "public", + TableName: "fake", + Columns: []string{"id", "nb", "nb2"}, + AdditionalWhere: nil, + }, + { + SchemaName: "public", + TableName: "fake2", + Columns: []string{"id", "test"}, + AdditionalWhere: nil, + }, + })) + } + } + }) + + It("should be ok with pg with options and via partition root", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + AllTables: true, + WithParameters: &postgresqlv1alpha1.PostgresqlPublicationWith{ + Publish: "truncate", + PublishViaPartitionRoot: starAny(true), + }, + }) + + // Checks + Expect(item.Status.Ready).To(BeTrue()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + Expect(item.Status.Message).To(Equal("")) + Expect(item.Status.AllTables).To(Equal(starAny(true))) + Expect(item.Status.Hash).NotTo(Equal("")) + Expect(item.Status.Name).To(Equal(pgpublicationPublicationName1)) + + data, err := getPublication(item.Status.Name) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(Equal(&PublicationResult{ + AllTables: true, + Insert: false, + Update: false, + Delete: false, + Truncate: true, + PublicationViaRoot: true, + })) + + // Get details + details, err := getPublicationTableDetails(item.Status.Name) + if Expect(err).NotTo(HaveOccurred()) { + Expect(details).To(HaveLen(2)) + Expect(details).To(Equal([]*PublicationTableDetail{ + { + SchemaName: "public", + TableName: "fake", + Columns: []string{"id", "nb", "nb2"}, + AdditionalWhere: nil, + }, + { + SchemaName: "public", + TableName: "fake2", + Columns: []string{"id", "test"}, + AdditionalWhere: nil, + }, + })) + } + } + }) + }) + + Describe("For tables in schema", func() { + It("should be ok without any tables", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{TablesInSchema: []string{"public"}}) + + // Checks + Expect(item.Status.Ready).To(BeTrue()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + Expect(item.Status.Message).To(Equal("")) + Expect(item.Status.AllTables).To(Equal(starAny(false))) + Expect(item.Status.Hash).NotTo(Equal("")) + Expect(item.Status.Name).To(Equal(pgpublicationPublicationName1)) + + data, err := getPublication(item.Status.Name) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(Equal(&PublicationResult{ + AllTables: false, + Insert: true, + Update: true, + Delete: true, + Truncate: true, + PublicationViaRoot: false, + })) + + // Get details + details, err := getPublicationTableDetails(item.Status.Name) + if Expect(err).NotTo(HaveOccurred()) { + Expect(details).To(HaveLen(0)) + } + } + }) + + It("should be ok with tables", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{TablesInSchema: []string{"public"}}) + + // Checks + Expect(item.Status.Ready).To(BeTrue()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + Expect(item.Status.Message).To(Equal("")) + Expect(item.Status.AllTables).To(Equal(starAny(false))) + Expect(item.Status.Hash).NotTo(Equal("")) + Expect(item.Status.Name).To(Equal(pgpublicationPublicationName1)) + + data, err := getPublication(item.Status.Name) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(Equal(&PublicationResult{ + AllTables: false, + Insert: true, + Update: true, + Delete: true, + Truncate: true, + PublicationViaRoot: false, + })) + + // Get details + details, err := getPublicationTableDetails(item.Status.Name) + if Expect(err).NotTo(HaveOccurred()) { + Expect(details).To(HaveLen(2)) + Expect(details).To(Equal([]*PublicationTableDetail{ + { + SchemaName: "public", + TableName: "fake", + Columns: []string{"id", "nb", "nb2"}, + AdditionalWhere: nil, + }, + { + SchemaName: "public", + TableName: "fake2", + Columns: []string{"id", "test"}, + AdditionalWhere: nil, + }, + })) + } + } + }) + + It("should be ok with pg with options", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + TablesInSchema: []string{"public"}, + WithParameters: &postgresqlv1alpha1.PostgresqlPublicationWith{ + Publish: "truncate", + }, + }) + + // Checks + Expect(item.Status.Ready).To(BeTrue()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + Expect(item.Status.Message).To(Equal("")) + Expect(item.Status.AllTables).To(Equal(starAny(false))) + Expect(item.Status.Hash).NotTo(Equal("")) + Expect(item.Status.Name).To(Equal(pgpublicationPublicationName1)) + + data, err := getPublication(item.Status.Name) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(Equal(&PublicationResult{ + AllTables: false, + Insert: false, + Update: false, + Delete: false, + Truncate: true, + PublicationViaRoot: false, + })) + + // Get details + details, err := getPublicationTableDetails(item.Status.Name) + if Expect(err).NotTo(HaveOccurred()) { + Expect(details).To(HaveLen(2)) + Expect(details).To(Equal([]*PublicationTableDetail{ + { + SchemaName: "public", + TableName: "fake", + Columns: []string{"id", "nb", "nb2"}, + AdditionalWhere: nil, + }, + { + SchemaName: "public", + TableName: "fake2", + Columns: []string{"id", "test"}, + AdditionalWhere: nil, + }, + })) + } + } + }) + + It("should be ok with pg with options and via partition root", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + TablesInSchema: []string{"public"}, + WithParameters: &postgresqlv1alpha1.PostgresqlPublicationWith{ + Publish: "truncate", + PublishViaPartitionRoot: starAny(true), + }, + }) + + // Checks + Expect(item.Status.Ready).To(BeTrue()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + Expect(item.Status.Message).To(Equal("")) + Expect(item.Status.AllTables).To(Equal(starAny(false))) + Expect(item.Status.Hash).NotTo(Equal("")) + Expect(item.Status.Name).To(Equal(pgpublicationPublicationName1)) + + data, err := getPublication(item.Status.Name) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(Equal(&PublicationResult{ + AllTables: false, + Insert: false, + Update: false, + Delete: false, + Truncate: true, + PublicationViaRoot: true, + })) + + // Get details + details, err := getPublicationTableDetails(item.Status.Name) + if Expect(err).NotTo(HaveOccurred()) { + Expect(details).To(HaveLen(2)) + Expect(details).To(Equal([]*PublicationTableDetail{ + { + SchemaName: "public", + TableName: "fake", + Columns: []string{"id", "nb", "nb2"}, + AdditionalWhere: nil, + }, + { + SchemaName: "public", + TableName: "fake2", + Columns: []string{"id", "test"}, + AdditionalWhere: nil, + }, + })) + } + } + }) + }) + + Describe("For specific tables", func() { + It("should fail without any tables", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + Tables: []*postgresqlv1alpha1.PostgresqlPublicationTable{ + {TableName: "fake"}, + }, + }) + + // Checks + Expect(item.Status.Ready).To(BeFalse()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationFailedPhase)) + Expect(item.Status.Message).To(Equal(`pq: relation "fake" does not exist`)) + Expect(item.Status.AllTables).To(BeNil()) + Expect(item.Status.Hash).To(Equal("")) + Expect(item.Status.Name).To(Equal("")) + + data, err := getPublication(item.Status.Name) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(BeNil()) + } + }) + + It("should be ok with tables with all columns", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + Tables: []*postgresqlv1alpha1.PostgresqlPublicationTable{ + {TableName: "fake"}, + }, + }) + + // Checks + Expect(item.Status.Ready).To(BeTrue()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + Expect(item.Status.Message).To(Equal("")) + Expect(item.Status.AllTables).To(Equal(starAny(false))) + Expect(item.Status.Hash).NotTo(Equal("")) + Expect(item.Status.Name).To(Equal(pgpublicationPublicationName1)) + + data, err := getPublication(item.Status.Name) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(Equal(&PublicationResult{ + AllTables: false, + Insert: true, + Update: true, + Delete: true, + Truncate: true, + PublicationViaRoot: false, + })) + + // Get details + details, err := getPublicationTableDetails(item.Status.Name) + if Expect(err).NotTo(HaveOccurred()) { + Expect(details).To(HaveLen(1)) + Expect(details).To(Equal([]*PublicationTableDetail{ + { + SchemaName: "public", + TableName: "fake", + Columns: []string{"id", "nb", "nb2"}, + AdditionalWhere: nil, + }, + })) + } + } + }) + + It("should be ok with tables selected columns", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + Tables: []*postgresqlv1alpha1.PostgresqlPublicationTable{ + {TableName: "fake", Columns: &[]string{"id", "nb2"}}, + }, + }) + + // Checks + Expect(item.Status.Ready).To(BeTrue()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + Expect(item.Status.Message).To(Equal("")) + Expect(item.Status.AllTables).To(Equal(starAny(false))) + Expect(item.Status.Hash).NotTo(Equal("")) + Expect(item.Status.Name).To(Equal(pgpublicationPublicationName1)) + + data, err := getPublication(item.Status.Name) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(Equal(&PublicationResult{ + AllTables: false, + Insert: true, + Update: true, + Delete: true, + Truncate: true, + PublicationViaRoot: false, + })) + + // Get details + details, err := getPublicationTableDetails(item.Status.Name) + if Expect(err).NotTo(HaveOccurred()) { + Expect(details).To(HaveLen(1)) + Expect(details).To(Equal([]*PublicationTableDetail{ + { + SchemaName: "public", + TableName: "fake", + Columns: []string{"id", "nb2"}, + AdditionalWhere: nil, + }, + })) + } + } + }) + + It("should be ok with additional where and all columns", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + Tables: []*postgresqlv1alpha1.PostgresqlPublicationTable{ + {TableName: "fake", AdditionalWhere: starAny(`'id' = 'value'`)}, + }, + }) + + // Checks + Expect(item.Status.Ready).To(BeTrue()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + Expect(item.Status.Message).To(Equal("")) + Expect(item.Status.AllTables).To(Equal(starAny(false))) + Expect(item.Status.Hash).NotTo(Equal("")) + Expect(item.Status.Name).To(Equal(pgpublicationPublicationName1)) + + data, err := getPublication(item.Status.Name) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(Equal(&PublicationResult{ + AllTables: false, + Insert: true, + Update: true, + Delete: true, + Truncate: true, + PublicationViaRoot: false, + })) + + // Get details + details, err := getPublicationTableDetails(item.Status.Name) + if Expect(err).NotTo(HaveOccurred()) { + Expect(details).To(HaveLen(1)) + Expect(details).To(Equal([]*PublicationTableDetail{ + { + SchemaName: "public", + TableName: "fake", + Columns: []string{"id", "nb", "nb2"}, + AdditionalWhere: starAny(`('id'::text = 'value'::text)`), + }, + })) + } + } + }) + + It("should be ok with additional where and specific columns", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + Tables: []*postgresqlv1alpha1.PostgresqlPublicationTable{ + {TableName: "fake", Columns: &[]string{"id", "nb2"}, AdditionalWhere: starAny(`'id' = 'value'`)}, + }, + }) + + // Checks + Expect(item.Status.Ready).To(BeTrue()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + Expect(item.Status.Message).To(Equal("")) + Expect(item.Status.AllTables).To(Equal(starAny(false))) + Expect(item.Status.Hash).NotTo(Equal("")) + Expect(item.Status.Name).To(Equal(pgpublicationPublicationName1)) + + data, err := getPublication(item.Status.Name) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(Equal(&PublicationResult{ + AllTables: false, + Insert: true, + Update: true, + Delete: true, + Truncate: true, + PublicationViaRoot: false, + })) + + // Get details + details, err := getPublicationTableDetails(item.Status.Name) + if Expect(err).NotTo(HaveOccurred()) { + Expect(details).To(HaveLen(1)) + Expect(details).To(Equal([]*PublicationTableDetail{ + { + SchemaName: "public", + TableName: "fake", + Columns: []string{"id", "nb2"}, + AdditionalWhere: starAny(`('id'::text = 'value'::text)`), + }, + })) + } + } + }) + + It("should be ok with pg with options", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + Tables: []*postgresqlv1alpha1.PostgresqlPublicationTable{ + {TableName: "fake"}, + }, + WithParameters: &postgresqlv1alpha1.PostgresqlPublicationWith{ + Publish: "truncate", + }, + }) + + // Checks + Expect(item.Status.Ready).To(BeTrue()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + Expect(item.Status.Message).To(Equal("")) + Expect(item.Status.AllTables).To(Equal(starAny(false))) + Expect(item.Status.Hash).NotTo(Equal("")) + Expect(item.Status.Name).To(Equal(pgpublicationPublicationName1)) + + data, err := getPublication(item.Status.Name) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(Equal(&PublicationResult{ + AllTables: false, + Insert: false, + Update: false, + Delete: false, + Truncate: true, + PublicationViaRoot: false, + })) + + // Get details + details, err := getPublicationTableDetails(item.Status.Name) + if Expect(err).NotTo(HaveOccurred()) { + Expect(details).To(HaveLen(1)) + Expect(details).To(Equal([]*PublicationTableDetail{ + { + SchemaName: "public", + TableName: "fake", + Columns: []string{"id", "nb", "nb2"}, + AdditionalWhere: nil, + }, + })) + } + } + }) + + It("should be ok with pg with options and via partition root", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + Tables: []*postgresqlv1alpha1.PostgresqlPublicationTable{ + {TableName: "fake"}, + }, + WithParameters: &postgresqlv1alpha1.PostgresqlPublicationWith{ + Publish: "truncate", + PublishViaPartitionRoot: starAny(true), + }, + }) + + // Checks + Expect(item.Status.Ready).To(BeTrue()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + Expect(item.Status.Message).To(Equal("")) + Expect(item.Status.AllTables).To(Equal(starAny(false))) + Expect(item.Status.Hash).NotTo(Equal("")) + Expect(item.Status.Name).To(Equal(pgpublicationPublicationName1)) + + data, err := getPublication(item.Status.Name) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(Equal(&PublicationResult{ + AllTables: false, + Insert: false, + Update: false, + Delete: false, + Truncate: true, + PublicationViaRoot: true, + })) + + // Get details + details, err := getPublicationTableDetails(item.Status.Name) + if Expect(err).NotTo(HaveOccurred()) { + Expect(details).To(HaveLen(1)) + Expect(details).To(Equal([]*PublicationTableDetail{ + { + SchemaName: "public", + TableName: "fake", + Columns: []string{"id", "nb", "nb2"}, + AdditionalWhere: nil, + }, + })) + } + } + }) + }) + }) + + Describe("Update", func() { + Describe("For all tables", func() { + It("should fail to change for a table schema list", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + AllTables: true, + }) + + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + + // Update + item.Spec.AllTables = false + item.Spec.TablesInSchema = []string{"public"} + // Update + err := k8sClient.Update(ctx, item) + Expect(err).NotTo(HaveOccurred()) + + updatedItem := &postgresqlv1alpha1.PostgresqlPublication{} + + Eventually( + func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: item.Name, + Namespace: item.Namespace, + }, updatedItem) + // Check error + if err != nil { + return err + } + + // Check if status hasn't been updated + if updatedItem.Status.Phase == item.Status.Phase { + return gerrors.New("hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + // Checks + Expect(updatedItem.Status.Ready).To(BeFalse()) + Expect(updatedItem.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationFailedPhase)) + Expect(updatedItem.Status.Message).To(Equal(`cannot change all tables flag on an upgrade`)) + Expect(*updatedItem.Status.AllTables).To(BeTrue()) + Expect(updatedItem.Status.Hash).NotTo(Equal("")) + Expect(updatedItem.Status.Name).To(Equal(item.Status.Name)) + }) + + It("should fail to change for a table specific", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + AllTables: true, + }) + + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + + // Update + item.Spec.AllTables = false + item.Spec.Tables = []*postgresqlv1alpha1.PostgresqlPublicationTable{ + {TableName: "fake"}, + } + // Update + err := k8sClient.Update(ctx, item) + Expect(err).NotTo(HaveOccurred()) + + updatedItem := &postgresqlv1alpha1.PostgresqlPublication{} + + Eventually( + func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: item.Name, + Namespace: item.Namespace, + }, updatedItem) + // Check error + if err != nil { + return err + } + + // Check if status hasn't been updated + if updatedItem.Status.Phase == item.Status.Phase { + return gerrors.New("hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + // Checks + Expect(updatedItem.Status.Ready).To(BeFalse()) + Expect(updatedItem.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationFailedPhase)) + Expect(updatedItem.Status.Message).To(Equal(`cannot change all tables flag on an upgrade`)) + Expect(updatedItem.Status.AllTables).To(Equal(starAny(true))) + Expect(updatedItem.Status.Hash).NotTo(Equal("")) + Expect(updatedItem.Status.Name).To(Equal(item.Status.Name)) + }) + + It("should be ok to change pg with option", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{AllTables: true}) + + // Save hash + hash := item.Status.Hash + + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + + // Update + item.Spec.WithParameters = &postgresqlv1alpha1.PostgresqlPublicationWith{ + Publish: "truncate", + } + // Update + err = k8sClient.Update(ctx, item) + Expect(err).NotTo(HaveOccurred()) + + updatedItem := &postgresqlv1alpha1.PostgresqlPublication{} + + Eventually( + func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: item.Name, + Namespace: item.Namespace, + }, updatedItem) + // Check error + if err != nil { + return err + } + + // Check if status hasn't been updated + if updatedItem.Status.Hash == hash { + return gerrors.New("hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + // Checks + Expect(updatedItem.Status.Ready).To(BeTrue()) + Expect(updatedItem.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + Expect(updatedItem.Status.Message).To(Equal("")) + Expect(updatedItem.Status.AllTables).To(Equal(starAny(true))) + Expect(updatedItem.Status.Hash).NotTo(Equal("")) + Expect(updatedItem.Status.Hash).NotTo(Equal(item.Status.Hash)) + Expect(updatedItem.Status.Name).To(Equal(pgpublicationPublicationName1)) + + data, err := getPublication(item.Status.Name) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(Equal(&PublicationResult{ + AllTables: true, + Insert: false, + Update: false, + Delete: false, + Truncate: true, + PublicationViaRoot: false, + })) + + // Get details + details, err := getPublicationTableDetails(item.Status.Name) + if Expect(err).NotTo(HaveOccurred()) { + Expect(details).To(HaveLen(2)) + Expect(details).To(Equal([]*PublicationTableDetail{ + { + SchemaName: "public", + TableName: "fake", + Columns: []string{"id", "nb", "nb2"}, + AdditionalWhere: nil, + }, + { + SchemaName: "public", + TableName: "fake2", + Columns: []string{"id", "test"}, + AdditionalWhere: nil, + }, + })) + } + } + }) + + It("should be ok to rename", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + AllTables: true, + }) + + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + + // Save + hash := item.Status.Hash + // Build new name + oldName := item.Spec.Name + newName := oldName + "rename" + // Update + item.Spec.Name = newName + // Update + err = k8sClient.Update(ctx, item) + Expect(err).NotTo(HaveOccurred()) + + updatedItem := &postgresqlv1alpha1.PostgresqlPublication{} + + Eventually( + func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: item.Name, + Namespace: item.Namespace, + }, updatedItem) + // Check error + if err != nil { + return err + } + + // Check if status hasn't been updated + if updatedItem.Status.Hash == hash { + return gerrors.New("hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + // Checks + Expect(updatedItem.Status.Ready).To(BeTrue()) + Expect(updatedItem.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + Expect(updatedItem.Status.Message).To(Equal("")) + Expect(*updatedItem.Status.AllTables).To(BeTrue()) + Expect(updatedItem.Status.Hash).NotTo(Equal("")) + Expect(updatedItem.Status.Name).NotTo(Equal(item.Status.Name)) + Expect(updatedItem.Status.Name).To(Equal(newName)) + + oldData, err := getPublication(oldName) + Expect(err).NotTo(HaveOccurred()) + Expect(oldData).To(BeNil()) + + data, err := getPublication(newName) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(Equal(&PublicationResult{ + AllTables: true, + Insert: true, + Update: true, + Delete: true, + Truncate: true, + PublicationViaRoot: false, + })) + + // Get details + details, err := getPublicationTableDetails(newName) + if Expect(err).NotTo(HaveOccurred()) { + Expect(details).To(HaveLen(2)) + Expect(details).To(Equal([]*PublicationTableDetail{ + { + SchemaName: "public", + TableName: "fake", + Columns: []string{"id", "nb", "nb2"}, + AdditionalWhere: nil, + }, + { + SchemaName: "public", + TableName: "fake2", + Columns: []string{"id", "test"}, + AdditionalWhere: nil, + }, + })) + } + } + }) + }) + + Describe("For tables in schema", func() { + It("should fail to change to a for all tables", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{TablesInSchema: []string{"public"}}) + + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + + // Update + item.Spec.AllTables = true + item.Spec.TablesInSchema = []string{} + // Update + err = k8sClient.Update(ctx, item) + Expect(err).NotTo(HaveOccurred()) + + updatedItem := &postgresqlv1alpha1.PostgresqlPublication{} + + Eventually( + func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: item.Name, + Namespace: item.Namespace, + }, updatedItem) + // Check error + if err != nil { + return err + } + + // Check if status hasn't been updated + if updatedItem.Status.Phase == item.Status.Phase { + return gerrors.New("hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + // Checks + Expect(updatedItem.Status.Ready).To(BeFalse()) + Expect(updatedItem.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationFailedPhase)) + Expect(updatedItem.Status.Message).To(Equal(`cannot change all tables flag on an upgrade`)) + Expect(updatedItem.Status.AllTables).To(Equal(starAny(false))) + Expect(updatedItem.Status.Hash).NotTo(Equal("")) + Expect(updatedItem.Status.Name).To(Equal(pgpublicationPublicationName1)) + }) + + It("should be ok to change for a table specific with specific columns", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + TablesInSchema: []string{"public"}, + }) + + // Save hash + hash := item.Status.Hash + + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + + // Update + item.Spec.TablesInSchema = []string{} + item.Spec.Tables = []*postgresqlv1alpha1.PostgresqlPublicationTable{ + {TableName: "fake", Columns: &[]string{"id", "nb2"}}, + } + // Update + err = k8sClient.Update(ctx, item) + Expect(err).NotTo(HaveOccurred()) + + updatedItem := &postgresqlv1alpha1.PostgresqlPublication{} + + Eventually( + func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: item.Name, + Namespace: item.Namespace, + }, updatedItem) + // Check error + if err != nil { + return err + } + + // Check if status hasn't been updated + if updatedItem.Status.Hash == hash { + return gerrors.New("hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + // Checks + Expect(updatedItem.Status.Ready).To(BeTrue()) + Expect(updatedItem.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + Expect(updatedItem.Status.Message).To(Equal("")) + Expect(updatedItem.Status.AllTables).To(Equal(starAny(false))) + Expect(updatedItem.Status.Hash).NotTo(Equal("")) + Expect(updatedItem.Status.Hash).NotTo(Equal(item.Status.Hash)) + Expect(updatedItem.Status.Name).To(Equal(pgpublicationPublicationName1)) + + data, err := getPublication(item.Status.Name) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(Equal(&PublicationResult{ + AllTables: false, + Insert: true, + Update: true, + Delete: true, + Truncate: true, + PublicationViaRoot: false, + })) + + // Get details + details, err := getPublicationTableDetails(item.Status.Name) + if Expect(err).NotTo(HaveOccurred()) { + Expect(details).To(HaveLen(1)) + Expect(details).To(Equal([]*PublicationTableDetail{ + { + SchemaName: "public", + TableName: "fake", + Columns: []string{"id", "nb2"}, + AdditionalWhere: nil, + }, + })) + } + } + }) + + It("should be ok to change pg with option", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + TablesInSchema: []string{"public"}, + }) + + // Save hash + hash := item.Status.Hash + + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + + // Update + item.Spec.WithParameters = &postgresqlv1alpha1.PostgresqlPublicationWith{ + Publish: "truncate", + } + // Update + err = k8sClient.Update(ctx, item) + Expect(err).NotTo(HaveOccurred()) + + updatedItem := &postgresqlv1alpha1.PostgresqlPublication{} + + Eventually( + func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: item.Name, + Namespace: item.Namespace, + }, updatedItem) + // Check error + if err != nil { + return err + } + + // Check if status hasn't been updated + if updatedItem.Status.Hash == hash { + return gerrors.New("hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + // Checks + Expect(updatedItem.Status.Ready).To(BeTrue()) + Expect(updatedItem.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + Expect(updatedItem.Status.Message).To(Equal("")) + Expect(updatedItem.Status.AllTables).To(Equal(starAny(false))) + Expect(updatedItem.Status.Hash).NotTo(Equal("")) + Expect(updatedItem.Status.Hash).NotTo(Equal(item.Status.Hash)) + Expect(updatedItem.Status.Name).To(Equal(pgpublicationPublicationName1)) + + data, err := getPublication(item.Status.Name) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(Equal(&PublicationResult{ + AllTables: false, + Insert: false, + Update: false, + Delete: false, + Truncate: true, + PublicationViaRoot: false, + })) + + // Get details + details, err := getPublicationTableDetails(item.Status.Name) + if Expect(err).NotTo(HaveOccurred()) { + Expect(details).To(HaveLen(2)) + Expect(details).To(Equal([]*PublicationTableDetail{ + { + SchemaName: "public", + TableName: "fake", + Columns: []string{"id", "nb", "nb2"}, + AdditionalWhere: nil, + }, + { + SchemaName: "public", + TableName: "fake2", + Columns: []string{"id", "test"}, + AdditionalWhere: nil, + }, + })) + } + } + }) + + It("should be ok to rename", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + TablesInSchema: []string{"public"}, + }) + + // Save hash + hash := item.Status.Hash + + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + + // Build new name + oldName := item.Spec.Name + newName := oldName + "rename" + // Update + item.Spec.Name = newName + // Update + err = k8sClient.Update(ctx, item) + Expect(err).NotTo(HaveOccurred()) + + updatedItem := &postgresqlv1alpha1.PostgresqlPublication{} + + Eventually( + func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: item.Name, + Namespace: item.Namespace, + }, updatedItem) + // Check error + if err != nil { + return err + } + + // Check if status hasn't been updated + if updatedItem.Status.Hash == hash { + return gerrors.New("hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + // Checks + Expect(updatedItem.Status.Ready).To(BeTrue()) + Expect(updatedItem.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + Expect(updatedItem.Status.Message).To(Equal("")) + Expect(updatedItem.Status.AllTables).To(Equal(starAny(false))) + Expect(updatedItem.Status.Hash).NotTo(Equal("")) + Expect(updatedItem.Status.Hash).NotTo(Equal(item.Status.Hash)) + Expect(updatedItem.Status.Name).NotTo(Equal(item.Status.Name)) + Expect(updatedItem.Status.Name).To(Equal(newName)) + + oldData, err := getPublication(oldName) + Expect(err).NotTo(HaveOccurred()) + Expect(oldData).To(BeNil()) + + data, err := getPublication(newName) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(Equal(&PublicationResult{ + AllTables: false, + Insert: true, + Update: true, + Delete: true, + Truncate: true, + PublicationViaRoot: false, + })) + + // Get details + details, err := getPublicationTableDetails(updatedItem.Status.Name) + if Expect(err).NotTo(HaveOccurred()) { + Expect(details).To(HaveLen(2)) + Expect(details).To(Equal([]*PublicationTableDetail{ + { + SchemaName: "public", + TableName: "fake", + Columns: []string{"id", "nb", "nb2"}, + AdditionalWhere: nil, + }, + { + SchemaName: "public", + TableName: "fake2", + Columns: []string{"id", "test"}, + AdditionalWhere: nil, + }, + })) + } + } + }) + }) + + Describe("For specific tables", func() { + It("should fail to change to a for all tables", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + Tables: []*postgresqlv1alpha1.PostgresqlPublicationTable{{TableName: "fake"}}, + }) + + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + + // Update + item.Spec.AllTables = true + item.Spec.Tables = []*postgresqlv1alpha1.PostgresqlPublicationTable{} + // Update + err = k8sClient.Update(ctx, item) + Expect(err).NotTo(HaveOccurred()) + + updatedItem := &postgresqlv1alpha1.PostgresqlPublication{} + + Eventually( + func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: item.Name, + Namespace: item.Namespace, + }, updatedItem) + // Check error + if err != nil { + return err + } + + // Check if status hasn't been updated + if updatedItem.Status.Phase == item.Status.Phase { + return gerrors.New("hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + // Checks + Expect(updatedItem.Status.Ready).To(BeFalse()) + Expect(updatedItem.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationFailedPhase)) + Expect(updatedItem.Status.Message).To(Equal(`cannot change all tables flag on an upgrade`)) + Expect(updatedItem.Status.AllTables).To(Equal(starAny(false))) + Expect(updatedItem.Status.Hash).NotTo(Equal("")) + Expect(updatedItem.Status.Name).To(Equal(pgpublicationPublicationName1)) + }) + + It("should be ok to change to a schema list", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + Tables: []*postgresqlv1alpha1.PostgresqlPublicationTable{{TableName: "fake", Columns: &[]string{"id", "nb2"}}}, + }) + + // Save hash + hash := item.Status.Hash + + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + + // Update + item.Spec.TablesInSchema = []string{"public"} + item.Spec.Tables = []*postgresqlv1alpha1.PostgresqlPublicationTable{} + // Update + err = k8sClient.Update(ctx, item) + Expect(err).NotTo(HaveOccurred()) + + updatedItem := &postgresqlv1alpha1.PostgresqlPublication{} + + Eventually( + func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: item.Name, + Namespace: item.Namespace, + }, updatedItem) + // Check error + if err != nil { + return err + } + + // Check if status hasn't been updated + if updatedItem.Status.Hash == hash { + return gerrors.New("hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + // Checks + Expect(updatedItem.Status.Ready).To(BeTrue()) + Expect(updatedItem.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + Expect(updatedItem.Status.Message).To(Equal("")) + Expect(updatedItem.Status.AllTables).To(Equal(starAny(false))) + Expect(updatedItem.Status.Hash).NotTo(Equal("")) + Expect(updatedItem.Status.Hash).NotTo(Equal(item.Status.Hash)) + Expect(updatedItem.Status.Name).To(Equal(pgpublicationPublicationName1)) + + data, err := getPublication(item.Status.Name) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(Equal(&PublicationResult{ + AllTables: false, + Insert: true, + Update: true, + Delete: true, + Truncate: true, + PublicationViaRoot: false, + })) + + // Get details + details, err := getPublicationTableDetails(item.Status.Name) + if Expect(err).NotTo(HaveOccurred()) { + Expect(details).To(HaveLen(2)) + Expect(details).To(Equal([]*PublicationTableDetail{ + { + SchemaName: "public", + TableName: "fake", + Columns: []string{"id", "nb", "nb2"}, + AdditionalWhere: nil, + }, + { + SchemaName: "public", + TableName: "fake2", + Columns: []string{"id", "test"}, + AdditionalWhere: nil, + }, + })) + } + } + }) + + It("should be ok to change remove table", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + Tables: []*postgresqlv1alpha1.PostgresqlPublicationTable{ + {TableName: "fake", Columns: &[]string{"id", "nb2"}}, + {TableName: "fake2", Columns: &[]string{"id", "test"}}, + }, + }) + + // Save hash + hash := item.Status.Hash + + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + + // Update + item.Spec.Tables = []*postgresqlv1alpha1.PostgresqlPublicationTable{ + {TableName: "fake", Columns: &[]string{"id", "nb2"}}, + } + // Update + err = k8sClient.Update(ctx, item) + Expect(err).NotTo(HaveOccurred()) + + updatedItem := &postgresqlv1alpha1.PostgresqlPublication{} + + Eventually( + func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: item.Name, + Namespace: item.Namespace, + }, updatedItem) + // Check error + if err != nil { + return err + } + + // Check if status hasn't been updated + if updatedItem.Status.Hash == hash { + return gerrors.New("hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + // Checks + Expect(updatedItem.Status.Ready).To(BeTrue()) + Expect(updatedItem.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + Expect(updatedItem.Status.Message).To(Equal("")) + Expect(updatedItem.Status.AllTables).To(Equal(starAny(false))) + Expect(updatedItem.Status.Hash).NotTo(Equal("")) + Expect(updatedItem.Status.Hash).NotTo(Equal(item.Status.Hash)) + Expect(updatedItem.Status.Name).To(Equal(pgpublicationPublicationName1)) + + data, err := getPublication(item.Status.Name) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(Equal(&PublicationResult{ + AllTables: false, + Insert: true, + Update: true, + Delete: true, + Truncate: true, + PublicationViaRoot: false, + })) + + // Get details + details, err := getPublicationTableDetails(item.Status.Name) + if Expect(err).NotTo(HaveOccurred()) { + Expect(details).To(HaveLen(1)) + Expect(details).To(Equal([]*PublicationTableDetail{ + { + SchemaName: "public", + TableName: "fake", + Columns: []string{"id", "nb2"}, + AdditionalWhere: nil, + }, + })) + } + } + }) + + It("should be ok to change add table", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + Tables: []*postgresqlv1alpha1.PostgresqlPublicationTable{ + {TableName: "fake", Columns: &[]string{"id", "nb2"}}, + }, + }) + + // Save hash + hash := item.Status.Hash + + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + + // Update + item.Spec.Tables = []*postgresqlv1alpha1.PostgresqlPublicationTable{ + {TableName: "fake", Columns: &[]string{"id", "nb2"}}, + {TableName: "fake2", Columns: &[]string{"id", "test"}}, + } + // Update + err = k8sClient.Update(ctx, item) + Expect(err).NotTo(HaveOccurred()) + + updatedItem := &postgresqlv1alpha1.PostgresqlPublication{} + + Eventually( + func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: item.Name, + Namespace: item.Namespace, + }, updatedItem) + // Check error + if err != nil { + return err + } + + // Check if status hasn't been updated + if updatedItem.Status.Hash == hash { + return gerrors.New("hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + // Checks + Expect(updatedItem.Status.Ready).To(BeTrue()) + Expect(updatedItem.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + Expect(updatedItem.Status.Message).To(Equal("")) + Expect(updatedItem.Status.AllTables).To(Equal(starAny(false))) + Expect(updatedItem.Status.Hash).NotTo(Equal("")) + Expect(updatedItem.Status.Hash).NotTo(Equal(item.Status.Hash)) + Expect(updatedItem.Status.Name).To(Equal(pgpublicationPublicationName1)) + + data, err := getPublication(item.Status.Name) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(Equal(&PublicationResult{ + AllTables: false, + Insert: true, + Update: true, + Delete: true, + Truncate: true, + PublicationViaRoot: false, + })) + + // Get details + details, err := getPublicationTableDetails(item.Status.Name) + if Expect(err).NotTo(HaveOccurred()) { + Expect(details).To(HaveLen(2)) + Expect(details).To(Equal([]*PublicationTableDetail{ + { + SchemaName: "public", + TableName: "fake", + Columns: []string{"id", "nb2"}, + AdditionalWhere: nil, + }, + { + SchemaName: "public", + TableName: "fake2", + Columns: []string{"id", "test"}, + AdditionalWhere: nil, + }, + })) + } + } + }) + + It("should be ok to change remove columns", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + Tables: []*postgresqlv1alpha1.PostgresqlPublicationTable{ + {TableName: "fake", Columns: &[]string{"id", "nb2"}}, + }, + }) + + // Save hash + hash := item.Status.Hash + + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + + // Update + item.Spec.Tables = []*postgresqlv1alpha1.PostgresqlPublicationTable{ + {TableName: "fake", Columns: &[]string{"id"}}, + } + // Update + err = k8sClient.Update(ctx, item) + Expect(err).NotTo(HaveOccurred()) + + updatedItem := &postgresqlv1alpha1.PostgresqlPublication{} + + Eventually( + func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: item.Name, + Namespace: item.Namespace, + }, updatedItem) + // Check error + if err != nil { + return err + } + + // Check if status hasn't been updated + if updatedItem.Status.Hash == hash { + return gerrors.New("hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + // Checks + Expect(updatedItem.Status.Ready).To(BeTrue()) + Expect(updatedItem.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + Expect(updatedItem.Status.Message).To(Equal("")) + Expect(updatedItem.Status.AllTables).To(Equal(starAny(false))) + Expect(updatedItem.Status.Hash).NotTo(Equal("")) + Expect(updatedItem.Status.Hash).NotTo(Equal(item.Status.Hash)) + Expect(updatedItem.Status.Name).To(Equal(pgpublicationPublicationName1)) + + data, err := getPublication(item.Status.Name) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(Equal(&PublicationResult{ + AllTables: false, + Insert: true, + Update: true, + Delete: true, + Truncate: true, + PublicationViaRoot: false, + })) + + // Get details + details, err := getPublicationTableDetails(item.Status.Name) + if Expect(err).NotTo(HaveOccurred()) { + Expect(details).To(HaveLen(1)) + Expect(details).To(Equal([]*PublicationTableDetail{ + { + SchemaName: "public", + TableName: "fake", + Columns: []string{"id"}, + AdditionalWhere: nil, + }, + })) + } + } + }) + + It("should be ok to change add columns", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + Tables: []*postgresqlv1alpha1.PostgresqlPublicationTable{ + {TableName: "fake", Columns: &[]string{"id"}}, + }, + }) + + // Save hash + hash := item.Status.Hash + + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + + // Update + item.Spec.Tables = []*postgresqlv1alpha1.PostgresqlPublicationTable{ + {TableName: "fake", Columns: &[]string{"id", "nb"}}, + } + // Update + err = k8sClient.Update(ctx, item) + Expect(err).NotTo(HaveOccurred()) + + updatedItem := &postgresqlv1alpha1.PostgresqlPublication{} + + Eventually( + func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: item.Name, + Namespace: item.Namespace, + }, updatedItem) + // Check error + if err != nil { + return err + } + + // Check if status hasn't been updated + if updatedItem.Status.Hash == hash { + return gerrors.New("hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + // Checks + Expect(updatedItem.Status.Ready).To(BeTrue()) + Expect(updatedItem.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + Expect(updatedItem.Status.Message).To(Equal("")) + Expect(updatedItem.Status.AllTables).To(Equal(starAny(false))) + Expect(updatedItem.Status.Hash).NotTo(Equal("")) + Expect(updatedItem.Status.Hash).NotTo(Equal(item.Status.Hash)) + Expect(updatedItem.Status.Name).To(Equal(pgpublicationPublicationName1)) + + data, err := getPublication(item.Status.Name) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(Equal(&PublicationResult{ + AllTables: false, + Insert: true, + Update: true, + Delete: true, + Truncate: true, + PublicationViaRoot: false, + })) + + // Get details + details, err := getPublicationTableDetails(item.Status.Name) + if Expect(err).NotTo(HaveOccurred()) { + Expect(details).To(HaveLen(1)) + Expect(details).To(Equal([]*PublicationTableDetail{ + { + SchemaName: "public", + TableName: "fake", + Columns: []string{"id", "nb"}, + AdditionalWhere: nil, + }, + })) + } + } + }) + + It("should be ok to change add additional where", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + Tables: []*postgresqlv1alpha1.PostgresqlPublicationTable{ + {TableName: "fake", Columns: &[]string{"id"}}, + }, + }) + + // Save hash + hash := item.Status.Hash + + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + + // Update + item.Spec.Tables = []*postgresqlv1alpha1.PostgresqlPublicationTable{ + {TableName: "fake", Columns: &[]string{"id"}, AdditionalWhere: starAny("'id' = 'value'")}, + } + // Update + err = k8sClient.Update(ctx, item) + Expect(err).NotTo(HaveOccurred()) + + updatedItem := &postgresqlv1alpha1.PostgresqlPublication{} + + Eventually( + func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: item.Name, + Namespace: item.Namespace, + }, updatedItem) + // Check error + if err != nil { + return err + } + + // Check if status hasn't been updated + if updatedItem.Status.Hash == hash { + return gerrors.New("hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + // Checks + Expect(updatedItem.Status.Ready).To(BeTrue()) + Expect(updatedItem.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + Expect(updatedItem.Status.Message).To(Equal("")) + Expect(updatedItem.Status.AllTables).To(Equal(starAny(false))) + Expect(updatedItem.Status.Hash).NotTo(Equal("")) + Expect(updatedItem.Status.Hash).NotTo(Equal(item.Status.Hash)) + Expect(updatedItem.Status.Name).To(Equal(pgpublicationPublicationName1)) + + data, err := getPublication(item.Status.Name) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(Equal(&PublicationResult{ + AllTables: false, + Insert: true, + Update: true, + Delete: true, + Truncate: true, + PublicationViaRoot: false, + })) + + // Get details + details, err := getPublicationTableDetails(item.Status.Name) + if Expect(err).NotTo(HaveOccurred()) { + Expect(details).To(HaveLen(1)) + Expect(details).To(Equal([]*PublicationTableDetail{ + { + SchemaName: "public", + TableName: "fake", + Columns: []string{"id"}, + AdditionalWhere: starAny(`('id'::text = 'value'::text)`), + }, + })) + } + } + }) + + It("should be ok to change remove additional where", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + Tables: []*postgresqlv1alpha1.PostgresqlPublicationTable{ + {TableName: "fake", Columns: &[]string{"id"}, AdditionalWhere: starAny("'id' = 'value'")}, + }, + }) + + // Save hash + hash := item.Status.Hash + + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + + // Update + item.Spec.Tables = []*postgresqlv1alpha1.PostgresqlPublicationTable{ + {TableName: "fake", Columns: &[]string{"id"}}, + } + // Update + err = k8sClient.Update(ctx, item) + Expect(err).NotTo(HaveOccurred()) + + updatedItem := &postgresqlv1alpha1.PostgresqlPublication{} + + Eventually( + func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: item.Name, + Namespace: item.Namespace, + }, updatedItem) + // Check error + if err != nil { + return err + } + + // Check if status hasn't been updated + if updatedItem.Status.Hash == hash { + return gerrors.New("hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + // Checks + Expect(updatedItem.Status.Ready).To(BeTrue()) + Expect(updatedItem.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + Expect(updatedItem.Status.Message).To(Equal("")) + Expect(updatedItem.Status.AllTables).To(Equal(starAny(false))) + Expect(updatedItem.Status.Hash).NotTo(Equal("")) + Expect(updatedItem.Status.Hash).NotTo(Equal(item.Status.Hash)) + Expect(updatedItem.Status.Name).To(Equal(pgpublicationPublicationName1)) + + data, err := getPublication(item.Status.Name) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(Equal(&PublicationResult{ + AllTables: false, + Insert: true, + Update: true, + Delete: true, + Truncate: true, + PublicationViaRoot: false, + })) + + // Get details + details, err := getPublicationTableDetails(item.Status.Name) + if Expect(err).NotTo(HaveOccurred()) { + Expect(details).To(HaveLen(1)) + Expect(details).To(Equal([]*PublicationTableDetail{ + { + SchemaName: "public", + TableName: "fake", + Columns: []string{"id"}, + AdditionalWhere: nil, + }, + })) + } + } + }) + + It("should be ok to change change additional where", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + Tables: []*postgresqlv1alpha1.PostgresqlPublicationTable{ + {TableName: "fake", Columns: &[]string{"id"}, AdditionalWhere: starAny("'id' = 'value2'")}, + }, + }) + + // Save hash + hash := item.Status.Hash + + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + + // Update + item.Spec.Tables = []*postgresqlv1alpha1.PostgresqlPublicationTable{ + {TableName: "fake", Columns: &[]string{"id"}, AdditionalWhere: starAny("'id' = 'value'")}, + } + // Update + err = k8sClient.Update(ctx, item) + Expect(err).NotTo(HaveOccurred()) + + updatedItem := &postgresqlv1alpha1.PostgresqlPublication{} + + Eventually( + func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: item.Name, + Namespace: item.Namespace, + }, updatedItem) + // Check error + if err != nil { + return err + } + + // Check if status hasn't been updated + if updatedItem.Status.Hash == hash { + return gerrors.New("hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + // Checks + Expect(updatedItem.Status.Ready).To(BeTrue()) + Expect(updatedItem.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + Expect(updatedItem.Status.Message).To(Equal("")) + Expect(updatedItem.Status.AllTables).To(Equal(starAny(false))) + Expect(updatedItem.Status.Hash).NotTo(Equal("")) + Expect(updatedItem.Status.Hash).NotTo(Equal(item.Status.Hash)) + Expect(updatedItem.Status.Name).To(Equal(pgpublicationPublicationName1)) + + data, err := getPublication(item.Status.Name) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(Equal(&PublicationResult{ + AllTables: false, + Insert: true, + Update: true, + Delete: true, + Truncate: true, + PublicationViaRoot: false, + })) + + // Get details + details, err := getPublicationTableDetails(item.Status.Name) + if Expect(err).NotTo(HaveOccurred()) { + Expect(details).To(HaveLen(1)) + Expect(details).To(Equal([]*PublicationTableDetail{ + { + SchemaName: "public", + TableName: "fake", + Columns: []string{"id"}, + AdditionalWhere: starAny(`('id'::text = 'value'::text)`), + }, + })) + } + } + }) + + It("should be ok to change pg with option", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + Tables: []*postgresqlv1alpha1.PostgresqlPublicationTable{ + {TableName: "fake", Columns: &[]string{"id"}}, + }, + }) + + // Save hash + hash := item.Status.Hash + + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + + // Update + item.Spec.WithParameters = &postgresqlv1alpha1.PostgresqlPublicationWith{ + Publish: "truncate", + } + // Update + err = k8sClient.Update(ctx, item) + Expect(err).NotTo(HaveOccurred()) + + updatedItem := &postgresqlv1alpha1.PostgresqlPublication{} + + Eventually( + func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: item.Name, + Namespace: item.Namespace, + }, updatedItem) + // Check error + if err != nil { + return err + } + + // Check if status hasn't been updated + if updatedItem.Status.Hash == hash { + return gerrors.New("hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + // Checks + Expect(updatedItem.Status.Ready).To(BeTrue()) + Expect(updatedItem.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + Expect(updatedItem.Status.Message).To(Equal("")) + Expect(updatedItem.Status.AllTables).To(Equal(starAny(false))) + Expect(updatedItem.Status.Hash).NotTo(Equal("")) + Expect(updatedItem.Status.Hash).NotTo(Equal(item.Status.Hash)) + Expect(updatedItem.Status.Name).To(Equal(pgpublicationPublicationName1)) + + data, err := getPublication(item.Status.Name) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(Equal(&PublicationResult{ + AllTables: false, + Insert: false, + Update: false, + Delete: false, + Truncate: true, + PublicationViaRoot: false, + })) + + // Get details + details, err := getPublicationTableDetails(item.Status.Name) + if Expect(err).NotTo(HaveOccurred()) { + Expect(details).To(HaveLen(1)) + Expect(details).To(Equal([]*PublicationTableDetail{ + { + SchemaName: "public", + TableName: "fake", + Columns: []string{"id"}, + AdditionalWhere: nil, + }, + })) + } + } + }) + + It("should be ok to rename", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + Tables: []*postgresqlv1alpha1.PostgresqlPublicationTable{ + {TableName: "fake", Columns: &[]string{"id"}}, + }, + }) + + // Save hash + hash := item.Status.Hash + + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + + // Build new name + oldName := item.Spec.Name + newName := oldName + "rename" + // Update + item.Spec.Name = newName + // Update + err = k8sClient.Update(ctx, item) + Expect(err).NotTo(HaveOccurred()) + + updatedItem := &postgresqlv1alpha1.PostgresqlPublication{} + + Eventually( + func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: item.Name, + Namespace: item.Namespace, + }, updatedItem) + // Check error + if err != nil { + return err + } + + // Check if status hasn't been updated + if updatedItem.Status.Hash == hash { + return gerrors.New("hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + // Checks + Expect(updatedItem.Status.Ready).To(BeTrue()) + Expect(updatedItem.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + Expect(updatedItem.Status.Message).To(Equal("")) + Expect(updatedItem.Status.AllTables).To(Equal(starAny(false))) + Expect(updatedItem.Status.Hash).NotTo(Equal("")) + Expect(updatedItem.Status.Hash).NotTo(Equal(item.Status.Hash)) + Expect(updatedItem.Status.Name).NotTo(Equal(item.Status.Name)) + Expect(updatedItem.Status.Name).To(Equal(newName)) + + oldData, err := getPublication(oldName) + Expect(err).NotTo(HaveOccurred()) + Expect(oldData).To(BeNil()) + + data, err := getPublication(newName) + + if Expect(err).NotTo(HaveOccurred()) { + // Assert + Expect(data).To(Equal(&PublicationResult{ + AllTables: false, + Insert: true, + Update: true, + Delete: true, + Truncate: true, + PublicationViaRoot: false, + })) + + // Get details + details, err := getPublicationTableDetails(updatedItem.Status.Name) + if Expect(err).NotTo(HaveOccurred()) { + Expect(details).To(HaveLen(1)) + Expect(details).To(Equal([]*PublicationTableDetail{ + { + SchemaName: "public", + TableName: "fake", + Columns: []string{"id"}, + AdditionalWhere: nil, + }, + })) + } + } + }) + }) + }) + + Describe("Deletion", func() { + Describe("For all tables", func() { + It("should be ok to delete a publication with drop on delete", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + AllTables: true, + DropOnDelete: true, + }) + + // Checks + Expect(item.Status.Ready).To(BeTrue()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + + // Delete object + err := k8sClient.Delete(ctx, item) + Expect(err).NotTo(HaveOccurred()) + + Eventually( + func() error { + data, err := getPublication(item.Status.Name) + if err != nil { + return err + } + + if data != nil { + return errors.New("hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + data, err := getPublication(item.Status.Name) + Expect(err).NotTo(HaveOccurred()) + Expect(data).To(BeNil()) + }) + + It("should be ok to ignore a publication without drop on delete", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + AllTables: true, + DropOnDelete: false, + }) + + // Checks + Expect(item.Status.Ready).To(BeTrue()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + + // Delete object + err := k8sClient.Delete(ctx, item) + Expect(err).NotTo(HaveOccurred()) + + // Here as we cannot ensure that this have been ignored by operator programmatically, just sleep + time.Sleep(time.Second) + + data, err := getPublication(item.Status.Name) + Expect(err).NotTo(HaveOccurred()) + Expect(data).To(Equal(&PublicationResult{ + AllTables: true, + Insert: true, + Update: true, + Delete: true, + Truncate: true, + PublicationViaRoot: false, + })) + }) + }) + + Describe("For tables in schema", func() { + It("should be ok to delete a publication with drop on delete", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + DropOnDelete: true, + TablesInSchema: []string{"public"}, + }) + + // Checks + Expect(item.Status.Ready).To(BeTrue()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + + // Delete object + err = k8sClient.Delete(ctx, item) + Expect(err).NotTo(HaveOccurred()) + + Eventually( + func() error { + data, err := getPublication(item.Status.Name) + if err != nil { + return err + } + + if data != nil { + return errors.New("hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + data, err := getPublication(item.Status.Name) + Expect(err).NotTo(HaveOccurred()) + Expect(data).To(BeNil()) + }) + + It("should be ok to ignore a publication without drop on delete", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + DropOnDelete: false, + TablesInSchema: []string{"public"}, + }) + + // Checks + Expect(item.Status.Ready).To(BeTrue()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + + // Delete object + err = k8sClient.Delete(ctx, item) + Expect(err).NotTo(HaveOccurred()) + + // Here as we cannot ensure that this have been ignored by operator programmatically, just sleep + time.Sleep(time.Second) + + data, err := getPublication(item.Status.Name) + Expect(err).NotTo(HaveOccurred()) + Expect(data).To(Equal(&PublicationResult{ + AllTables: false, + Insert: true, + Update: true, + Delete: true, + Truncate: true, + PublicationViaRoot: false, + })) + }) + }) + + Describe("For specific tables", func() { + It("should be ok to delete a publication with drop on delete", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + DropOnDelete: true, + Tables: []*postgresqlv1alpha1.PostgresqlPublicationTable{{TableName: "fake"}}, + }) + + // Checks + Expect(item.Status.Ready).To(BeTrue()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + + // Delete object + err = k8sClient.Delete(ctx, item) + Expect(err).NotTo(HaveOccurred()) + + Eventually( + func() error { + data, err := getPublication(item.Status.Name) + if err != nil { + return err + } + + if data != nil { + return errors.New("hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + data, err := getPublication(item.Status.Name) + Expect(err).NotTo(HaveOccurred()) + Expect(data).To(BeNil()) + }) + + It("should be ok to ignore a publication without drop on delete", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + // Create tables + err := create2KnownTablesWithColumnsInPublicSchema() + Expect(err).NotTo(HaveOccurred()) + + // Setup a pg publication + item := setupPGPublicationWithPartialSpec(postgresqlv1alpha1.PostgresqlPublicationSpec{ + DropOnDelete: false, + Tables: []*postgresqlv1alpha1.PostgresqlPublicationTable{{TableName: "fake"}}, + }) + + // Checks + Expect(item.Status.Ready).To(BeTrue()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.PublicationCreatedPhase)) + + // Delete object + err = k8sClient.Delete(ctx, item) + Expect(err).NotTo(HaveOccurred()) + + // Here as we cannot ensure that this have been ignored by operator programmatically, just sleep + time.Sleep(time.Second) + + data, err := getPublication(item.Status.Name) + Expect(err).NotTo(HaveOccurred()) + Expect(data).To(Equal(&PublicationResult{ + AllTables: false, + Insert: true, + Update: true, + Delete: true, + Truncate: true, + PublicationViaRoot: false, + })) + }) + }) + }) + + Describe("Reconcile", func() { + It("should be ok to reconcile an existing table schema list publication to a table list", func() { Fail("TODO") }) + + It("should be ok to reconcile an existing table list publication to a tables in schema list", func() { Fail("TODO") }) + + It("should fail to reconcile an existing for all tables publication to a table list", func() { Fail("TODO") }) + + It("should fail to reconcile an existing for all tables publication to a tables in schema list", func() { Fail("TODO") }) + }) +}) diff --git a/internal/controller/postgresql/suite_test.go b/internal/controller/postgresql/suite_test.go index 4240bf0..a10a3b3 100644 --- a/internal/controller/postgresql/suite_test.go +++ b/internal/controller/postgresql/suite_test.go @@ -63,6 +63,9 @@ var ctx context.Context var cancel context.CancelFunc var generalEventuallyTimeout = 60 * time.Second var generalEventuallyInterval = time.Second +var pgpublicationNamespace = "pgpub-ns" +var pgpublicationName = "pgpub-object" +var pgpublicationPublicationName1 = "pub1" var pgecNamespace = "pgec-ns" var pgecName = "pgec-object" var pgecSecretName = "pgec-secret" @@ -173,6 +176,15 @@ var _ = BeforeSuite(func(_ context.Context) { ControllerName: "postgresqluserrole", }).SetupWithManager(k8sManager)).ToNot(HaveOccurred()) + Expect((&PostgresqlPublicationReconciler{ + Client: k8sClient, + Log: logf.Log.WithName("controllers"), + Recorder: k8sManager.GetEventRecorderFor("controller"), + Scheme: scheme.Scheme, + ControllerRuntimeDetailedErrorTotal: controllerRuntimeDetailedErrorTotal, + ControllerName: "postgresqlpublication", + }).SetupWithManager(k8sManager)).ToNot(HaveOccurred()) + go func() { defer GinkgoRecover() err = k8sManager.Start(ctx) @@ -202,6 +214,12 @@ var _ = BeforeSuite(func(_ context.Context) { Name: pgurNamespace, }, })).ToNot(HaveOccurred()) + + Expect(k8sClient.Create(ctx, &corev1.Namespace{ + ObjectMeta: v1.ObjectMeta{ + Name: pgpublicationNamespace, + }, + })).ToNot(HaveOccurred()) }, NodeTimeout(60*time.Second)) var _ = AfterSuite(func() { @@ -219,6 +237,10 @@ var _ = AfterSuite(func() { } }) +func starAny[T any](s T) *T { + return &s +} + func cleanupFunction() { for k, _ := range dbConns { disconnectConnFromKey(k) @@ -230,6 +252,7 @@ func cleanupFunction() { err = deleteSecret(ctx, k8sClient, pgecSecretName, pgecNamespace) Expect(err).ToNot(HaveOccurred()) + Expect(deletePGPublication(ctx, k8sClient, pgpublicationName, pgpublicationNamespace)).ToNot(HaveOccurred()) Expect(deletePGUR(ctx, k8sClient, pgurName, pgurNamespace)).ToNot(HaveOccurred()) Expect(deletePGDB(ctx, k8sClient, pgdbName, pgdbNamespace)).ToNot(HaveOccurred()) Expect(deletePGDB(ctx, k8sClient, pgdbName2, pgdbNamespace)).ToNot(HaveOccurred()) @@ -281,7 +304,7 @@ func deleteObject( ctx context.Context, cl client.Client, name, namespace string, - obj controllerutil.Object, + obj client.Object, ) error { // Get item err := cl.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, obj) @@ -623,6 +646,57 @@ func setupSavePGURInternal(it *postgresqlv1alpha1.PostgresqlUserRole) *postgresq return it } +func setupPGPublicationWithPartialSpec(partialSpec postgresqlv1alpha1.PostgresqlPublicationSpec) *postgresqlv1alpha1.PostgresqlPublication { + it := &postgresqlv1alpha1.PostgresqlPublication{ + ObjectMeta: v1.ObjectMeta{ + Name: pgpublicationName, + Namespace: pgpublicationNamespace, + }, + Spec: postgresqlv1alpha1.PostgresqlPublicationSpec{ + Database: &common.CRLink{Name: pgdbName, Namespace: pgdbNamespace}, + Name: pgpublicationPublicationName1, + AllTables: partialSpec.AllTables, + TablesInSchema: partialSpec.TablesInSchema, + Tables: partialSpec.Tables, + WithParameters: partialSpec.WithParameters, + DropOnDelete: partialSpec.DropOnDelete, + }, + } + + return setupSavePGPublicationInternal(it) +} + +func setupSavePGPublicationInternal(it *postgresqlv1alpha1.PostgresqlPublication) *postgresqlv1alpha1.PostgresqlPublication { + // Create user + Expect(k8sClient.Create(ctx, it)).Should(Succeed()) + + // Get updated user + Eventually( + func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: it.Name, + Namespace: it.Namespace, + }, it) + // Check error + if err != nil { + return err + } + + // Check if status hasn't been updated + if it.Status.Phase == postgresqlv1alpha1.PublicationNoPhase { + return gerrors.New("pgpub hasn't been updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + return it +} + func setupPGEC( checkInterval string, waitLinkedResourcesDeletion bool, @@ -847,6 +921,13 @@ func deletePGUR(ctx context.Context, cl client.Client, name, namespace string) e return deleteObject(ctx, cl, name, namespace, st) } +func deletePGPublication(ctx context.Context, cl client.Client, name, namespace string) error { + // Create structure + st := &postgresqlv1alpha1.PostgresqlPublication{} + // Delete + return deleteObject(ctx, cl, name, namespace, st) +} + func deleteSQLDBs(name string) error { // Query template GetAllCreatedSQLDBTemplate := "SELECT datname FROM pg_database WHERE datname LIKE '%" + name + "%';" @@ -1127,6 +1208,64 @@ func createTableInSchemaAsAdmin(schema, table string) error { return nil } +func createColumnInTable(table, columnName, columnType string) error { + tmpl := `ALTER TABLE IF EXISTS %s ADD COLUMN %s %s;` + + // Connect + db, err := sql.Open("postgres", postgresUrlToDB) + // Check error + if err != nil { + return err + } + + defer func() error { + return db.Close() + }() + + _, err = db.Exec(fmt.Sprintf(tmpl, table, columnName, columnType)) + if err != nil { + return err + } + + return nil +} + +func create2KnownTablesWithColumnsInPublicSchema() error { + err := createTableInSchemaAsAdmin("public", "fake") + if err != nil { + return err + } + + err = createTableInSchemaAsAdmin("public", "fake2") + if err != nil { + return err + } + + err = createColumnInTable("public.fake", "id", "text") + if err != nil { + return err + } + err = createColumnInTable("public.fake", "nb", "integer") + if err != nil { + return err + } + err = createColumnInTable("public.fake", "nb2", "integer") + if err != nil { + return err + } + + err = createColumnInTable("public.fake2", "id", "text") + if err != nil { + return err + } + err = createColumnInTable("public.fake2", "test", "integer") + if err != nil { + return err + } + + return nil +} + // Here we are considering that type cannot be in another schema just for test. // This is easier for test cases. func getTypeOwner(dbName, typeName string) (string, error) { @@ -1189,6 +1328,129 @@ func createTypeInSchemaAsAdmin(schema, typeName string) error { return nil } +type PublicationResult struct { + AllTables bool + Insert bool + Update bool + Delete bool + Truncate bool + PublicationViaRoot bool +} + +func getPublication(name string) (*PublicationResult, error) { + // Connect + db, err := sql.Open("postgres", postgresUrlToDB) + // Check error + if err != nil { + return nil, err + } + + defer func() error { + return db.Close() + }() + + // Get rows + rows, err := db.Query(fmt.Sprintf(`SELECT + puballtables, pubinsert, pubupdate, pubdelete, pubtruncate, pubviaroot +FROM pg_catalog.pg_publication +WHERE pubname = '%s';`, name)) + if err != nil { + return nil, err + } + + defer rows.Close() + + var res PublicationResult + + var foundOne bool + + for rows.Next() { + // Scan + err = rows.Scan(&res.AllTables, &res.Insert, &res.Update, &res.Delete, &res.Truncate, &res.PublicationViaRoot) + // Check error + if err != nil { + return nil, err + } + + // Update marker + foundOne = true + } + + // Rows error + err = rows.Err() + // Check error + if err != nil { + return nil, err + } + + // Check if found marker isn't set + if !foundOne { + return nil, nil + } + + return &res, nil +} + +type PublicationTableDetail struct { + SchemaName string + TableName string + Columns []string + AdditionalWhere *string +} + +func getPublicationTableDetails(name string) ([]*PublicationTableDetail, error) { + // Connect + db, err := sql.Open("postgres", postgresUrlToDB) + // Check error + if err != nil { + return nil, err + } + + defer func() error { + return db.Close() + }() + + // Get rows + rows, err := db.Query(fmt.Sprintf(`SELECT + schemaname, tablename, attnames, rowfilter +FROM pg_catalog.pg_publication_tables +WHERE pubname = '%s';`, name)) + if err != nil { + return nil, err + } + + defer rows.Close() + + res := make([]*PublicationTableDetail, 0) + + for rows.Next() { + var it PublicationTableDetail + var pqSA pq.StringArray + // Scan + err = rows.Scan(&it.SchemaName, &it.TableName, &pqSA, &it.AdditionalWhere) + // Check error + if err != nil { + return nil, err + } + // Save + // ? Note: getting a list of string from pg imply a decode + // ? See issue: https://github.com/cockroachdb/cockroach/issues/39770#issuecomment-576170805 + it.Columns = pqSA + + // Save value + res = append(res, &it) + } + + // Rows error + err = rows.Err() + // Check error + if err != nil { + return nil, err + } + + return res, nil +} + func checkRoleInSQLDb(role string) { roleExists, roleErr := isSQLRoleExists(role) Expect(roleErr).ToNot(HaveOccurred())