-
Notifications
You must be signed in to change notification settings - Fork 130
Validate cross-referenced parameters between PostgresCluster and PostgresClusterClass #1858
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f27a666
0580d68
fd0611d
dd96323
213ab5b
d7e6ab3
a850bda
1d863e9
0c51361
96b28d3
7622554
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,10 +24,13 @@ import ( | |
| "testing" | ||
|
|
||
| admissionv1 "k8s.io/api/admission/v1" | ||
| "k8s.io/apimachinery/pkg/api/resource" | ||
| authenticationv1 "k8s.io/api/authentication/v1" | ||
| metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" | ||
| "k8s.io/apimachinery/pkg/runtime" | ||
| "k8s.io/apimachinery/pkg/types" | ||
| "k8s.io/utils/ptr" | ||
| "sigs.k8s.io/controller-runtime/pkg/client/fake" | ||
|
|
||
| enterpriseApi "github.com/splunk/splunk-operator/api/v4" | ||
| "github.com/splunk/splunk-operator/pkg/splunk/enterprise/validation" | ||
|
|
@@ -571,3 +574,235 @@ func TestPostgresClusterClassPgHBAUpdateIntegration(t *testing.T) { | |
| assert.Contains(t, resp.Result.Message, "unknown auth method") | ||
| }) | ||
| } | ||
|
|
||
| func newFakeReader(objects ...runtime.Object) *fake.ClientBuilder { | ||
| s := runtime.NewScheme() | ||
| enterpriseApi.AddToScheme(s) | ||
| b := fake.NewClientBuilder().WithScheme(s) | ||
| for _, obj := range objects { | ||
| b = b.WithRuntimeObjects(obj) | ||
| } | ||
| return b | ||
| } | ||
|
|
||
| func TestCrossResourceValidationIntegration(t *testing.T) { | ||
| prodClass := &enterpriseApi.PostgresClusterClass{ | ||
| ObjectMeta: metav1.ObjectMeta{Name: "prod"}, | ||
| Spec: enterpriseApi.PostgresClusterClassSpec{ | ||
| Provisioner: "postgresql.cnpg.io", | ||
| Config: &enterpriseApi.PostgresClusterClassConfig{ | ||
| Instances: ptr.To(int32(3)), | ||
| Storage: ptr.To(resource.MustParse("50Gi")), | ||
| PostgresVersion: ptr.To("17"), | ||
| ConnectionPoolerEnabled: ptr.To(false), | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
| fakeClient := newFakeReader(prodClass).Build() | ||
|
|
||
| server := validation.NewWebhookServer(validation.WebhookServerOptions{ | ||
| Port: 9443, | ||
| Validators: validation.DefaultValidators, | ||
| Client: fakeClient, | ||
| }) | ||
|
|
||
| tests := []struct { | ||
| name string | ||
| obj *enterpriseApi.PostgresCluster | ||
| wantAllowed bool | ||
| wantMessage string | ||
| }{ | ||
| { | ||
| name: "allowed - inherits all from class", | ||
| obj: &enterpriseApi.PostgresCluster{ | ||
| TypeMeta: metav1.TypeMeta{APIVersion: "enterprise.splunk.com/v4", Kind: "PostgresCluster"}, | ||
| ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, | ||
| Spec: enterpriseApi.PostgresClusterSpec{Class: "prod"}, | ||
| }, | ||
| wantAllowed: true, | ||
| }, | ||
| { | ||
| name: "rejected - class not found", | ||
| obj: &enterpriseApi.PostgresCluster{ | ||
| TypeMeta: metav1.TypeMeta{APIVersion: "enterprise.splunk.com/v4", Kind: "PostgresCluster"}, | ||
| ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, | ||
| Spec: enterpriseApi.PostgresClusterSpec{Class: "nonexistent"}, | ||
| }, | ||
| wantAllowed: false, | ||
| wantMessage: "PostgresClusterClass not found", | ||
| }, | ||
| { | ||
| name: "rejected - version below class floor", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For the compliance we prohibit setting lower version than in class |
||
| obj: &enterpriseApi.PostgresCluster{ | ||
| TypeMeta: metav1.TypeMeta{APIVersion: "enterprise.splunk.com/v4", Kind: "PostgresCluster"}, | ||
| ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, | ||
| Spec: enterpriseApi.PostgresClusterSpec{ | ||
| Class: "prod", | ||
| PostgresVersion: ptr.To("16"), | ||
| }, | ||
| }, | ||
| wantAllowed: false, | ||
| wantMessage: "postgresVersion cannot be lower than class default", | ||
| }, | ||
| { | ||
| name: "rejected - pooler enabled but class has no cnpg.connectionPooler", | ||
| obj: &enterpriseApi.PostgresCluster{ | ||
| TypeMeta: metav1.TypeMeta{APIVersion: "enterprise.splunk.com/v4", Kind: "PostgresCluster"}, | ||
| ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, | ||
| Spec: enterpriseApi.PostgresClusterSpec{ | ||
| Class: "prod", | ||
| ConnectionPoolerEnabled: ptr.To(true), | ||
| }, | ||
| }, | ||
| wantAllowed: false, | ||
| wantMessage: "connection pooler requires cnpg.connectionPooler configuration", | ||
| }, | ||
| { | ||
| name: "allowed - higher version", | ||
| obj: &enterpriseApi.PostgresCluster{ | ||
| TypeMeta: metav1.TypeMeta{APIVersion: "enterprise.splunk.com/v4", Kind: "PostgresCluster"}, | ||
| ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, | ||
| Spec: enterpriseApi.PostgresClusterSpec{ | ||
| Class: "prod", | ||
| PostgresVersion: ptr.To("18"), | ||
| }, | ||
| }, | ||
| wantAllowed: true, | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| ar := newPostgresClusterAdmissionReview(t, "uid-xref-"+tt.name, admissionv1.Create, tt.obj, nil) | ||
| resp := sendAdmissionReview(t, server, ar) | ||
|
|
||
| assert.Equal(t, tt.wantAllowed, resp.Allowed, "unexpected admission result") | ||
| if tt.wantMessage != "" { | ||
| require.NotNil(t, resp.Result) | ||
| assert.Contains(t, resp.Result.Message, tt.wantMessage) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestCrossResourceValidationUpdateIntegration(t *testing.T) { | ||
| prodClass := &enterpriseApi.PostgresClusterClass{ | ||
| ObjectMeta: metav1.ObjectMeta{Name: "prod"}, | ||
| Spec: enterpriseApi.PostgresClusterClassSpec{ | ||
| Provisioner: "postgresql.cnpg.io", | ||
| Config: &enterpriseApi.PostgresClusterClassConfig{ | ||
| Instances: ptr.To(int32(3)), | ||
| Storage: ptr.To(resource.MustParse("50Gi")), | ||
| PostgresVersion: ptr.To("17"), | ||
| ConnectionPoolerEnabled: ptr.To(false), | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
| fakeClient := newFakeReader(prodClass).Build() | ||
|
|
||
| server := validation.NewWebhookServer(validation.WebhookServerOptions{ | ||
| Port: 9443, | ||
| Validators: validation.DefaultValidators, | ||
| Client: fakeClient, | ||
| }) | ||
|
|
||
| oldObj := &enterpriseApi.PostgresCluster{ | ||
| TypeMeta: metav1.TypeMeta{APIVersion: "enterprise.splunk.com/v4", Kind: "PostgresCluster"}, | ||
| ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, | ||
| Spec: enterpriseApi.PostgresClusterSpec{ | ||
| Class: "prod", | ||
| PostgresVersion: ptr.To("17"), | ||
| }, | ||
| } | ||
|
|
||
| tests := []struct { | ||
| name string | ||
| newObj *enterpriseApi.PostgresCluster | ||
| wantAllowed bool | ||
| wantMessage string | ||
| }{ | ||
| { | ||
| name: "allowed - upgrade version", | ||
| newObj: &enterpriseApi.PostgresCluster{ | ||
| TypeMeta: metav1.TypeMeta{APIVersion: "enterprise.splunk.com/v4", Kind: "PostgresCluster"}, | ||
| ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, | ||
| Spec: enterpriseApi.PostgresClusterSpec{ | ||
| Class: "prod", | ||
| PostgresVersion: ptr.To("18"), | ||
| }, | ||
| }, | ||
| wantAllowed: true, | ||
| }, | ||
| { | ||
| name: "rejected - downgrade version below class floor", | ||
| newObj: &enterpriseApi.PostgresCluster{ | ||
| TypeMeta: metav1.TypeMeta{APIVersion: "enterprise.splunk.com/v4", Kind: "PostgresCluster"}, | ||
| ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, | ||
| Spec: enterpriseApi.PostgresClusterSpec{ | ||
| Class: "prod", | ||
| PostgresVersion: ptr.To("16"), | ||
| }, | ||
| }, | ||
| wantAllowed: false, | ||
| wantMessage: "postgresVersion cannot be lower than class default", | ||
| }, | ||
| { | ||
| name: "rejected - enable pooler without cnpg config", | ||
| newObj: &enterpriseApi.PostgresCluster{ | ||
| TypeMeta: metav1.TypeMeta{APIVersion: "enterprise.splunk.com/v4", Kind: "PostgresCluster"}, | ||
| ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, | ||
| Spec: enterpriseApi.PostgresClusterSpec{ | ||
| Class: "prod", | ||
| ConnectionPoolerEnabled: ptr.To(true), | ||
| }, | ||
| }, | ||
| wantAllowed: false, | ||
| wantMessage: "connection pooler requires cnpg.connectionPooler configuration", | ||
| }, | ||
| { | ||
| name: "allowed - no changes", | ||
| newObj: &enterpriseApi.PostgresCluster{ | ||
| TypeMeta: metav1.TypeMeta{APIVersion: "enterprise.splunk.com/v4", Kind: "PostgresCluster"}, | ||
| ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, | ||
| Spec: enterpriseApi.PostgresClusterSpec{ | ||
| Class: "prod", | ||
| PostgresVersion: ptr.To("17"), | ||
| }, | ||
| }, | ||
| wantAllowed: true, | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| ar := newPostgresClusterAdmissionReview(t, "uid-xref-update-"+tt.name, admissionv1.Update, tt.newObj, oldObj) | ||
| resp := sendAdmissionReview(t, server, ar) | ||
|
|
||
| assert.Equal(t, tt.wantAllowed, resp.Allowed, "unexpected admission result") | ||
| if tt.wantMessage != "" { | ||
| require.NotNil(t, resp.Result) | ||
| assert.Contains(t, resp.Result.Message, tt.wantMessage) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestCrossResourceValidationDisabledWithoutClient(t *testing.T) { | ||
| server := validation.NewWebhookServer(validation.WebhookServerOptions{ | ||
| Port: 9443, | ||
| Validators: validation.DefaultValidators, | ||
| }) | ||
|
|
||
| obj := &enterpriseApi.PostgresCluster{ | ||
| TypeMeta: metav1.TypeMeta{APIVersion: "enterprise.splunk.com/v4", Kind: "PostgresCluster"}, | ||
| ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, | ||
| Spec: enterpriseApi.PostgresClusterSpec{Class: "nonexistent"}, | ||
| } | ||
|
|
||
| ar := newPostgresClusterAdmissionReview(t, "uid-no-client", admissionv1.Create, obj, nil) | ||
| resp := sendAdmissionReview(t, server, ar) | ||
|
|
||
| assert.True(t, resp.Allowed, "without a client, cross-resource validation should be skipped") | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,32 +17,119 @@ limitations under the License. | |
| package webhook | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "strconv" | ||
|
|
||
| apierrors "k8s.io/apimachinery/pkg/api/errors" | ||
| "k8s.io/apimachinery/pkg/util/validation/field" | ||
| "sigs.k8s.io/controller-runtime/pkg/client" | ||
|
|
||
| enterpriseApi "github.com/splunk/splunk-operator/api/v4" | ||
| hba "github.com/splunk/splunk-operator/pkg/postgresql/cluster/core" | ||
| core "github.com/splunk/splunk-operator/pkg/postgresql/cluster/core" | ||
| ) | ||
|
|
||
| // ValidatePostgresClusterCreate validates a PostgresCluster on CREATE. | ||
| func ValidatePostgresClusterCreate(obj *enterpriseApi.PostgresCluster) field.ErrorList { | ||
| func ValidatePostgresClusterCreate(obj *enterpriseApi.PostgresCluster, reader client.Reader) field.ErrorList { | ||
| var allErrs field.ErrorList | ||
|
|
||
| if len(obj.Spec.PgHBA) > 0 { | ||
| pgHBAPath := field.NewPath("spec").Child("pgHBA") | ||
| for _, re := range hba.ValidateRules(obj.Spec.PgHBA) { | ||
| for _, re := range core.ValidateRules(obj.Spec.PgHBA) { | ||
| allErrs = append(allErrs, field.Invalid( | ||
| pgHBAPath.Index(re.Index), | ||
| obj.Spec.PgHBA[re.Index], | ||
| re.Message)) | ||
| } | ||
| } | ||
|
|
||
| if reader != nil { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. could you elab why we expect some reader to validate against class logic?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. removed |
||
| allErrs = append(allErrs, validateAgainstClass(obj, reader)...) | ||
| } | ||
|
|
||
| return allErrs | ||
| } | ||
|
|
||
| // ValidatePostgresClusterUpdate validates a PostgresCluster on UPDATE. | ||
| func ValidatePostgresClusterUpdate(obj, oldObj *enterpriseApi.PostgresCluster) field.ErrorList { | ||
| return ValidatePostgresClusterCreate(obj) | ||
| func ValidatePostgresClusterUpdate(obj, oldObj *enterpriseApi.PostgresCluster, reader client.Reader) field.ErrorList { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes, we don't care about oldObj |
||
| return ValidatePostgresClusterCreate(obj, reader) | ||
| } | ||
|
|
||
| func validateAgainstClass(obj *enterpriseApi.PostgresCluster, reader client.Reader) field.ErrorList { | ||
| var allErrs field.ErrorList | ||
|
|
||
| class := &enterpriseApi.PostgresClusterClass{} | ||
| if err := reader.Get(context.Background(), client.ObjectKey{Name: obj.Spec.Class}, class); err != nil { | ||
| classPath := field.NewPath("spec").Child("class") | ||
| if apierrors.IsNotFound(err) { | ||
| allErrs = append(allErrs, field.Invalid(classPath, obj.Spec.Class, | ||
| "referenced PostgresClusterClass not found")) | ||
| } else { | ||
| allErrs = append(allErrs, field.InternalError(classPath, | ||
| fmt.Errorf("failed to look up PostgresClusterClass %q: %w", obj.Spec.Class, err))) | ||
| } | ||
| return allErrs | ||
| } | ||
|
Comment on lines
+61
to
+72
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. removed API reader |
||
|
|
||
| merged, err := core.GetMergedConfig(class, obj) | ||
| if err != nil { | ||
| specPath := field.NewPath("spec") | ||
| if merged == nil || merged.Spec.Instances == nil { | ||
| allErrs = append(allErrs, field.Required(specPath.Child("instances"), | ||
| "must be set in PostgresCluster or PostgresClusterClass")) | ||
| } | ||
| if merged == nil || merged.Spec.PostgresVersion == nil { | ||
| allErrs = append(allErrs, field.Required(specPath.Child("postgresVersion"), | ||
| "must be set in PostgresCluster or PostgresClusterClass")) | ||
| } | ||
| if merged == nil || merged.Spec.Storage == nil { | ||
| allErrs = append(allErrs, field.Required(specPath.Child("storage"), | ||
| "must be set in PostgresCluster or PostgresClusterClass")) | ||
| } | ||
| return allErrs | ||
| } | ||
|
|
||
| if classConfig := class.Spec.Config; classConfig != nil { | ||
| // Class version acts as a minimum floor for compliance; clusters may override higher but not lower. | ||
| if obj.Spec.PostgresVersion != nil && classConfig.PostgresVersion != nil { | ||
| clusterMajor, clusterMinor := parseVersion(*obj.Spec.PostgresVersion) | ||
| classMajor, classMinor := parseVersion(*classConfig.PostgresVersion) | ||
| if clusterMajor > 0 && classMajor > 0 { | ||
| versionTooLow := clusterMajor < classMajor || | ||
| (clusterMajor == classMajor && classMinor >= 0 && clusterMinor >= 0 && clusterMinor < classMinor) | ||
| if versionTooLow { | ||
| allErrs = append(allErrs, field.Invalid( | ||
| field.NewPath("spec").Child("postgresVersion"), | ||
| *obj.Spec.PostgresVersion, | ||
| "postgresVersion cannot be lower than class default ("+*classConfig.PostgresVersion+")")) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| poolerEnabled := (obj.Spec.ConnectionPoolerEnabled != nil && *obj.Spec.ConnectionPoolerEnabled) || | ||
| (obj.Spec.ConnectionPoolerEnabled == nil && classConfig.ConnectionPoolerEnabled != nil && *classConfig.ConnectionPoolerEnabled) | ||
| if poolerEnabled && (class.Spec.CNPG == nil || class.Spec.CNPG.ConnectionPooler == nil) { | ||
| allErrs = append(allErrs, field.Invalid( | ||
| field.NewPath("spec").Child("connectionPoolerEnabled"), | ||
| true, | ||
| "connection pooler requires cnpg.connectionPooler configuration in PostgresClusterClass")) | ||
| } | ||
| } | ||
|
|
||
| _ = merged | ||
| return allErrs | ||
| } | ||
|
|
||
| func parseVersion(version string) (major, minor int) { | ||
| for i, ch := range version { | ||
| if ch == '.' { | ||
| major, _ = strconv.Atoi(version[:i]) | ||
| minor, _ = strconv.Atoi(version[i+1:]) | ||
| return major, minor | ||
| } | ||
| } | ||
| major, _ = strconv.Atoi(version) | ||
| return major, -1 | ||
|
Comment on lines
+123
to
+132
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need to check for minor at all? I mean, we are allowed to downgrade minor version, don't we?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. resolved, #1858 (comment), downgrading while being alligned with version set in class is allowed. |
||
| } | ||
|
|
||
| // GetPostgresClusterWarningsOnCreate returns warnings for PostgresCluster CREATE. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new tests cover the happy path well, but they mostly assert error count/field or Allowed. I’d add message assertions for the unit tests and one update-path integration case for the cross-resource checks, so semantic regressions in the comparison logic are easier to catch.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done