Skip to content

Commit 74dca92

Browse files
committed
cross cluster and class validation
1 parent 6bffab3 commit 74dca92

8 files changed

Lines changed: 568 additions & 16 deletions

File tree

cmd/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,7 @@ func main() {
348348
ReadTimeout: readTimeout,
349349
WriteTimeout: writeTimeout,
350350
Client: mgr.GetClient(),
351+
Reader: mgr.GetAPIReader(),
351352
})
352353

353354
if err := mgr.Add(manager.RunnableFunc(func(ctx context.Context) error {

pkg/postgresql/cluster/adapter/webhook/postgres_webhook_integration_test.go

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,12 @@ import (
2424
"testing"
2525

2626
admissionv1 "k8s.io/api/admission/v1"
27+
"k8s.io/apimachinery/pkg/api/resource"
2728
authenticationv1 "k8s.io/api/authentication/v1"
2829
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2930
"k8s.io/apimachinery/pkg/runtime"
3031
"k8s.io/apimachinery/pkg/types"
32+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
3133

3234
enterpriseApi "github.com/splunk/splunk-operator/api/v4"
3335
"github.com/splunk/splunk-operator/pkg/splunk/enterprise/validation"
@@ -557,3 +559,166 @@ func TestPostgresClusterClassPgHBAUpdateIntegration(t *testing.T) {
557559
assert.Contains(t, resp.Result.Message, "unknown auth method")
558560
})
559561
}
562+
563+
func ptrBool(b bool) *bool { return &b }
564+
func ptrString(s string) *string { return &s }
565+
func ptrInt32(i int32) *int32 { return &i }
566+
567+
func ptrQuantity(s string) *resource.Quantity {
568+
q := resource.MustParse(s)
569+
return &q
570+
}
571+
572+
func newFakeReader(objects ...runtime.Object) *fake.ClientBuilder {
573+
s := runtime.NewScheme()
574+
enterpriseApi.AddToScheme(s)
575+
b := fake.NewClientBuilder().WithScheme(s)
576+
for _, obj := range objects {
577+
b = b.WithRuntimeObjects(obj)
578+
}
579+
return b
580+
}
581+
582+
func TestCrossResourceValidationIntegration(t *testing.T) {
583+
prodClass := &enterpriseApi.PostgresClusterClass{
584+
ObjectMeta: metav1.ObjectMeta{Name: "prod"},
585+
Spec: enterpriseApi.PostgresClusterClassSpec{
586+
Provisioner: "postgresql.cnpg.io",
587+
Config: &enterpriseApi.PostgresClusterClassConfig{
588+
Instances: ptrInt32(3),
589+
Storage: ptrQuantity("50Gi"),
590+
PostgresVersion: ptrString("17"),
591+
ConnectionPoolerEnabled: ptrBool(false),
592+
},
593+
},
594+
}
595+
596+
reader := newFakeReader(prodClass).Build()
597+
598+
server := validation.NewWebhookServer(validation.WebhookServerOptions{
599+
Port: 9443,
600+
Validators: validation.DefaultValidators,
601+
Reader: reader,
602+
})
603+
604+
tests := []struct {
605+
name string
606+
obj *enterpriseApi.PostgresCluster
607+
wantAllowed bool
608+
wantMessage string
609+
}{
610+
{
611+
name: "allowed - inherits all from class",
612+
obj: &enterpriseApi.PostgresCluster{
613+
TypeMeta: metav1.TypeMeta{APIVersion: "enterprise.splunk.com/v4", Kind: "PostgresCluster"},
614+
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
615+
Spec: enterpriseApi.PostgresClusterSpec{Class: "prod"},
616+
},
617+
wantAllowed: true,
618+
},
619+
{
620+
name: "rejected - class not found",
621+
obj: &enterpriseApi.PostgresCluster{
622+
TypeMeta: metav1.TypeMeta{APIVersion: "enterprise.splunk.com/v4", Kind: "PostgresCluster"},
623+
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
624+
Spec: enterpriseApi.PostgresClusterSpec{Class: "nonexistent"},
625+
},
626+
wantAllowed: false,
627+
wantMessage: "PostgresClusterClass not found",
628+
},
629+
{
630+
name: "rejected - storage below class floor",
631+
obj: &enterpriseApi.PostgresCluster{
632+
TypeMeta: metav1.TypeMeta{APIVersion: "enterprise.splunk.com/v4", Kind: "PostgresCluster"},
633+
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
634+
Spec: enterpriseApi.PostgresClusterSpec{
635+
Class: "prod",
636+
Storage: ptrQuantity("10Gi"),
637+
},
638+
},
639+
wantAllowed: false,
640+
wantMessage: "storage cannot be lower than class default",
641+
},
642+
{
643+
name: "rejected - version below class floor",
644+
obj: &enterpriseApi.PostgresCluster{
645+
TypeMeta: metav1.TypeMeta{APIVersion: "enterprise.splunk.com/v4", Kind: "PostgresCluster"},
646+
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
647+
Spec: enterpriseApi.PostgresClusterSpec{
648+
Class: "prod",
649+
PostgresVersion: ptrString("16"),
650+
},
651+
},
652+
wantAllowed: false,
653+
wantMessage: "postgresVersion cannot be lower than class default",
654+
},
655+
{
656+
name: "rejected - pooler enabled when class disables",
657+
obj: &enterpriseApi.PostgresCluster{
658+
TypeMeta: metav1.TypeMeta{APIVersion: "enterprise.splunk.com/v4", Kind: "PostgresCluster"},
659+
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
660+
Spec: enterpriseApi.PostgresClusterSpec{
661+
Class: "prod",
662+
ConnectionPoolerEnabled: ptrBool(true),
663+
},
664+
},
665+
wantAllowed: false,
666+
wantMessage: "connectionPoolerEnabled cannot be enabled",
667+
},
668+
{
669+
name: "allowed - storage equal to class",
670+
obj: &enterpriseApi.PostgresCluster{
671+
TypeMeta: metav1.TypeMeta{APIVersion: "enterprise.splunk.com/v4", Kind: "PostgresCluster"},
672+
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
673+
Spec: enterpriseApi.PostgresClusterSpec{
674+
Class: "prod",
675+
Storage: ptrQuantity("50Gi"),
676+
},
677+
},
678+
wantAllowed: true,
679+
},
680+
{
681+
name: "allowed - higher version",
682+
obj: &enterpriseApi.PostgresCluster{
683+
TypeMeta: metav1.TypeMeta{APIVersion: "enterprise.splunk.com/v4", Kind: "PostgresCluster"},
684+
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
685+
Spec: enterpriseApi.PostgresClusterSpec{
686+
Class: "prod",
687+
PostgresVersion: ptrString("18"),
688+
},
689+
},
690+
wantAllowed: true,
691+
},
692+
}
693+
694+
for _, tt := range tests {
695+
t.Run(tt.name, func(t *testing.T) {
696+
ar := newPostgresClusterAdmissionReview(t, "uid-xref-"+tt.name, admissionv1.Create, tt.obj, nil)
697+
resp := sendAdmissionReview(t, server, ar)
698+
699+
assert.Equal(t, tt.wantAllowed, resp.Allowed, "unexpected admission result")
700+
if tt.wantMessage != "" {
701+
require.NotNil(t, resp.Result)
702+
assert.Contains(t, resp.Result.Message, tt.wantMessage)
703+
}
704+
})
705+
}
706+
}
707+
708+
func TestCrossResourceValidationDisabledWithoutReader(t *testing.T) {
709+
server := validation.NewWebhookServer(validation.WebhookServerOptions{
710+
Port: 9443,
711+
Validators: validation.DefaultValidators,
712+
})
713+
714+
obj := &enterpriseApi.PostgresCluster{
715+
TypeMeta: metav1.TypeMeta{APIVersion: "enterprise.splunk.com/v4", Kind: "PostgresCluster"},
716+
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
717+
Spec: enterpriseApi.PostgresClusterSpec{Class: "nonexistent"},
718+
}
719+
720+
ar := newPostgresClusterAdmissionReview(t, "uid-no-reader", admissionv1.Create, obj, nil)
721+
resp := sendAdmissionReview(t, server, ar)
722+
723+
assert.True(t, resp.Allowed, "without a reader, cross-resource validation should be skipped")
724+
}

pkg/postgresql/cluster/adapter/webhook/postgrescluster_validation.go

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,18 @@ limitations under the License.
1717
package webhook
1818

1919
import (
20+
"context"
21+
"strconv"
22+
2023
"k8s.io/apimachinery/pkg/util/validation/field"
24+
"sigs.k8s.io/controller-runtime/pkg/client"
2125

2226
enterpriseApi "github.com/splunk/splunk-operator/api/v4"
2327
hba "github.com/splunk/splunk-operator/pkg/postgresql/cluster/core"
2428
)
2529

2630
// ValidatePostgresClusterCreate validates a PostgresCluster on CREATE.
27-
func ValidatePostgresClusterCreate(obj *enterpriseApi.PostgresCluster) field.ErrorList {
31+
func ValidatePostgresClusterCreate(obj *enterpriseApi.PostgresCluster, reader client.Reader) field.ErrorList {
2832
var allErrs field.ErrorList
2933

3034
if len(obj.Spec.PgHBA) > 0 {
@@ -36,12 +40,106 @@ func ValidatePostgresClusterCreate(obj *enterpriseApi.PostgresCluster) field.Err
3640
}
3741
}
3842

43+
if reader != nil {
44+
allErrs = append(allErrs, validateAgainstClass(obj, reader)...)
45+
}
46+
3947
return allErrs
4048
}
4149

4250
// ValidatePostgresClusterUpdate validates a PostgresCluster on UPDATE.
43-
func ValidatePostgresClusterUpdate(obj, oldObj *enterpriseApi.PostgresCluster) field.ErrorList {
44-
return ValidatePostgresClusterCreate(obj)
51+
func ValidatePostgresClusterUpdate(obj, oldObj *enterpriseApi.PostgresCluster, reader client.Reader) field.ErrorList {
52+
return ValidatePostgresClusterCreate(obj, reader)
53+
}
54+
55+
func validateAgainstClass(obj *enterpriseApi.PostgresCluster, reader client.Reader) field.ErrorList {
56+
var allErrs field.ErrorList
57+
58+
class := &enterpriseApi.PostgresClusterClass{}
59+
if err := reader.Get(context.Background(), client.ObjectKey{Name: obj.Spec.Class}, class); err != nil {
60+
allErrs = append(allErrs, field.Invalid(
61+
field.NewPath("spec").Child("class"),
62+
obj.Spec.Class,
63+
"referenced PostgresClusterClass not found"))
64+
return allErrs
65+
}
66+
67+
classConfig := class.Spec.Config
68+
69+
mergedInstances := obj.Spec.Instances
70+
mergedVersion := obj.Spec.PostgresVersion
71+
mergedStorage := obj.Spec.Storage
72+
if classConfig != nil {
73+
if mergedInstances == nil {
74+
mergedInstances = classConfig.Instances
75+
}
76+
if mergedVersion == nil {
77+
mergedVersion = classConfig.PostgresVersion
78+
}
79+
if mergedStorage == nil {
80+
mergedStorage = classConfig.Storage
81+
}
82+
}
83+
specPath := field.NewPath("spec")
84+
if mergedInstances == nil {
85+
allErrs = append(allErrs, field.Required(specPath.Child("instances"),
86+
"must be set in PostgresCluster or PostgresClusterClass"))
87+
}
88+
if mergedVersion == nil {
89+
allErrs = append(allErrs, field.Required(specPath.Child("postgresVersion"),
90+
"must be set in PostgresCluster or PostgresClusterClass"))
91+
}
92+
if mergedStorage == nil {
93+
allErrs = append(allErrs, field.Required(specPath.Child("storage"),
94+
"must be set in PostgresCluster or PostgresClusterClass"))
95+
}
96+
97+
if classConfig == nil {
98+
return allErrs
99+
}
100+
101+
if obj.Spec.Storage != nil && classConfig.Storage != nil {
102+
if obj.Spec.Storage.Cmp(*classConfig.Storage) < 0 {
103+
allErrs = append(allErrs, field.Invalid(
104+
field.NewPath("spec").Child("storage"),
105+
obj.Spec.Storage.String(),
106+
"storage cannot be lower than class default ("+classConfig.Storage.String()+")"))
107+
}
108+
}
109+
110+
if obj.Spec.PostgresVersion != nil && classConfig.PostgresVersion != nil {
111+
clusterMajor := parseMajorVersion(*obj.Spec.PostgresVersion)
112+
classMajor := parseMajorVersion(*classConfig.PostgresVersion)
113+
if clusterMajor > 0 && classMajor > 0 && clusterMajor < classMajor {
114+
allErrs = append(allErrs, field.Invalid(
115+
field.NewPath("spec").Child("postgresVersion"),
116+
*obj.Spec.PostgresVersion,
117+
"postgresVersion cannot be lower than class default ("+*classConfig.PostgresVersion+")"))
118+
}
119+
}
120+
121+
if obj.Spec.ConnectionPoolerEnabled != nil && *obj.Spec.ConnectionPoolerEnabled {
122+
classDisabled := classConfig.ConnectionPoolerEnabled == nil || !*classConfig.ConnectionPoolerEnabled
123+
if classDisabled {
124+
allErrs = append(allErrs, field.Invalid(
125+
field.NewPath("spec").Child("connectionPoolerEnabled"),
126+
true,
127+
"connectionPoolerEnabled cannot be enabled when disabled in PostgresClusterClass"))
128+
}
129+
}
130+
131+
return allErrs
132+
}
133+
134+
func parseMajorVersion(version string) int {
135+
for i, ch := range version {
136+
if ch == '.' {
137+
v, _ := strconv.Atoi(version[:i])
138+
return v
139+
}
140+
}
141+
v, _ := strconv.Atoi(version)
142+
return v
45143
}
46144

47145
// GetPostgresClusterWarningsOnCreate returns warnings for PostgresCluster CREATE.

0 commit comments

Comments
 (0)