Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Copy link
Copy Markdown

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

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",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

version below class floor - same as above - can't we set lower versions on cluster level now?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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
Expand Up @@ -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 {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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 {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oldObj is ignored on purpose?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reader.Get() collapses every error into “referenced PostgresClusterClass not found”. That will hide real issues like API/RBAC failures and return a misleading validation error. I’d special-case IsNotFound and surface other errors separately.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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.
Expand Down
Loading
Loading