-
Notifications
You must be signed in to change notification settings - Fork 622
Share PostgresCluster validation tests across API versions #4220
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
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 |
---|---|---|
|
@@ -8,10 +8,14 @@ import ( | |
"reflect" | ||
"testing" | ||
|
||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" | ||
|
||
"github.com/crunchydata/postgres-operator/internal/testing/require" | ||
) | ||
|
||
func TestUnmarshalInto(t *testing.T) { | ||
t.Parallel() | ||
|
||
for _, tt := range []struct { | ||
input string | ||
expected any | ||
|
@@ -39,3 +43,41 @@ func TestUnmarshalInto(t *testing.T) { | |
} | ||
} | ||
} | ||
|
||
func TestUnmarshalIntoField(t *testing.T) { | ||
t.Parallel() | ||
|
||
var u unstructured.Unstructured | ||
|
||
t.Run("NestedString", func(t *testing.T) { | ||
u.Object = nil | ||
require.UnmarshalIntoField(t, &u, `asdf`, "spec", "nested", "field") | ||
|
||
if !reflect.DeepEqual(u.Object, map[string]any{ | ||
"spec": map[string]any{ | ||
"nested": map[string]any{ | ||
"field": "asdf", | ||
}, | ||
}, | ||
}) { | ||
t.Fatalf("got %[1]T(%#[1]v)", u.Object) | ||
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. 📝 I found these links helpful
Also, for reference, a simple
|
||
} | ||
}) | ||
|
||
t.Run("Numeric", func(t *testing.T) { | ||
u.Object = nil | ||
require.UnmarshalIntoField(t, &u, `99`, "one") | ||
require.UnmarshalIntoField(t, &u, `5.7`, "two") | ||
|
||
// Kubernetes distinguishes between integral and fractional numbers. | ||
if !reflect.DeepEqual(u.Object, map[string]any{ | ||
"one": int64(99), | ||
"two": float64(5.7), | ||
}) { | ||
t.Fatalf("got %[1]T(%#[1]v)", u.Object) | ||
} | ||
}) | ||
|
||
// Correctly fails with: BUG: called without a destination | ||
// require.UnmarshalIntoField(t, &u, `true`) | ||
Comment on lines
+81
to
+82
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. Leftover? |
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,272 @@ | ||
// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. | ||
// | ||
// SPDX-License-Identifier: Apache-2.0 | ||
|
||
package validation | ||
|
||
import ( | ||
"fmt" | ||
"testing" | ||
|
||
"gotest.tools/v3/assert" | ||
apierrors "k8s.io/apimachinery/pkg/api/errors" | ||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" | ||
"sigs.k8s.io/controller-runtime/pkg/client" | ||
"sigs.k8s.io/yaml" | ||
|
||
"github.com/crunchydata/postgres-operator/internal/testing/cmp" | ||
"github.com/crunchydata/postgres-operator/internal/testing/require" | ||
v1 "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1" | ||
"github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" | ||
) | ||
|
||
func TestPostgresAuthenticationV1beta1(t *testing.T) { | ||
ctx := t.Context() | ||
cc := require.Kubernetes(t) | ||
t.Parallel() | ||
|
||
namespace := require.Namespace(t, cc) | ||
base := v1beta1.NewPostgresCluster() | ||
|
||
// required fields | ||
require.UnmarshalInto(t, &base.Spec, `{ | ||
postgresVersion: 16, | ||
instances: [{ | ||
dataVolumeClaimSpec: { | ||
accessModes: [ReadWriteOnce], | ||
resources: { requests: { storage: 1Mi } }, | ||
}, | ||
}], | ||
}`) | ||
|
||
base.Namespace = namespace.Name | ||
base.Name = "postgres-authentication-rules" | ||
|
||
assert.NilError(t, cc.Create(ctx, base.DeepCopy(), client.DryRunAll), | ||
"expected this base cluster to be valid") | ||
|
||
var u unstructured.Unstructured | ||
require.UnmarshalInto(t, &u, require.Value(yaml.Marshal(base))) | ||
assert.Equal(t, u.GetAPIVersion(), "postgres-operator.crunchydata.com/v1beta1") | ||
|
||
testPostgresAuthenticationCommon(t, cc, u) | ||
Comment on lines
+48
to
+52
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. This is the pattern I used to share tests between v1beta1 and v1. Notice L82-84 of this same file. |
||
} | ||
|
||
func TestPostgresAuthenticationV1(t *testing.T) { | ||
ctx := t.Context() | ||
cc := require.KubernetesAtLeast(t, "1.30") | ||
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. These tests pass with v1 on K8s before 1.30, but the plan is for v1 to be 1.30+. |
||
t.Parallel() | ||
|
||
namespace := require.Namespace(t, cc) | ||
base := v1.NewPostgresCluster() | ||
|
||
// required fields | ||
require.UnmarshalInto(t, &base.Spec, `{ | ||
postgresVersion: 16, | ||
instances: [{ | ||
dataVolumeClaimSpec: { | ||
accessModes: [ReadWriteOnce], | ||
resources: { requests: { storage: 1Mi } }, | ||
}, | ||
}], | ||
}`) | ||
|
||
base.Namespace = namespace.Name | ||
base.Name = "postgres-authentication-rules" | ||
|
||
assert.NilError(t, cc.Create(ctx, base.DeepCopy(), client.DryRunAll), | ||
"expected this base cluster to be valid") | ||
|
||
var u unstructured.Unstructured | ||
require.UnmarshalInto(t, &u, require.Value(yaml.Marshal(base))) | ||
assert.Equal(t, u.GetAPIVersion(), "postgres-operator.crunchydata.com/v1") | ||
|
||
testPostgresAuthenticationCommon(t, cc, u) | ||
} | ||
|
||
func testPostgresAuthenticationCommon(t *testing.T, cc client.Client, base unstructured.Unstructured) { | ||
ctx := t.Context() | ||
|
||
t.Run("OneTopLevel", func(t *testing.T) { | ||
cluster := base.DeepCopy() | ||
require.UnmarshalIntoField(t, cluster, `{ | ||
rules: [ | ||
{ connection: host, hba: anything }, | ||
{ users: [alice, bob], hba: anything }, | ||
], | ||
}`, "spec", "authentication") | ||
|
||
err := cc.Create(ctx, cluster, client.DryRunAll) | ||
assert.Assert(t, apierrors.IsInvalid(err)) | ||
|
||
status := require.StatusError(t, err) | ||
assert.Assert(t, status.Details != nil) | ||
assert.Assert(t, cmp.Len(status.Details.Causes, 2)) | ||
|
||
for i, cause := range status.Details.Causes { | ||
assert.Equal(t, cause.Field, fmt.Sprintf("spec.authentication.rules[%d]", i)) | ||
assert.Assert(t, cmp.Contains(cause.Message, "cannot be combined")) | ||
} | ||
}) | ||
|
||
t.Run("NoInclude", func(t *testing.T) { | ||
cluster := base.DeepCopy() | ||
require.UnmarshalIntoField(t, cluster, `{ | ||
rules: [ | ||
{ hba: 'include "/etc/passwd"' }, | ||
{ hba: ' include_dir /tmp' }, | ||
{ hba: 'include_if_exists postgresql.auto.conf' }, | ||
], | ||
}`, "spec", "authentication") | ||
|
||
err := cc.Create(ctx, cluster, client.DryRunAll) | ||
assert.Assert(t, apierrors.IsInvalid(err)) | ||
|
||
status := require.StatusError(t, err) | ||
assert.Assert(t, status.Details != nil) | ||
assert.Assert(t, cmp.Len(status.Details.Causes, 3)) | ||
|
||
for i, cause := range status.Details.Causes { | ||
assert.Equal(t, cause.Field, fmt.Sprintf("spec.authentication.rules[%d].hba", i)) | ||
assert.Assert(t, cmp.Contains(cause.Message, "cannot include")) | ||
} | ||
}) | ||
|
||
t.Run("NoStructuredTrust", func(t *testing.T) { | ||
cluster := base.DeepCopy() | ||
require.UnmarshalIntoField(t, cluster, `{ | ||
rules: [ | ||
{ connection: local, method: trust }, | ||
{ connection: hostssl, method: trust }, | ||
{ connection: hostgssenc, method: trust }, | ||
], | ||
}`, "spec", "authentication") | ||
|
||
err := cc.Create(ctx, cluster, client.DryRunAll) | ||
assert.Assert(t, apierrors.IsInvalid(err)) | ||
|
||
status := require.StatusError(t, err) | ||
assert.Assert(t, status.Details != nil) | ||
assert.Assert(t, cmp.Len(status.Details.Causes, 3)) | ||
|
||
for i, cause := range status.Details.Causes { | ||
assert.Equal(t, cause.Field, fmt.Sprintf("spec.authentication.rules[%d].method", i)) | ||
assert.Assert(t, cmp.Contains(cause.Message, "unsafe")) | ||
} | ||
}) | ||
|
||
t.Run("LDAP", func(t *testing.T) { | ||
t.Run("Required", func(t *testing.T) { | ||
cluster := base.DeepCopy() | ||
require.UnmarshalIntoField(t, cluster, `{ | ||
rules: [ | ||
{ connection: hostssl, method: ldap }, | ||
{ connection: hostssl, method: ldap, options: {} }, | ||
{ connection: hostssl, method: ldap, options: { ldapbinddn: any } }, | ||
Comment on lines
+163
to
+165
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. The |
||
], | ||
}`, "spec", "authentication") | ||
|
||
err := cc.Create(ctx, cluster, client.DryRunAll) | ||
assert.Assert(t, apierrors.IsInvalid(err)) | ||
|
||
status := require.StatusError(t, err) | ||
assert.Assert(t, status.Details != nil) | ||
assert.Assert(t, cmp.Len(status.Details.Causes, 3)) | ||
|
||
for i, cause := range status.Details.Causes { | ||
assert.Equal(t, cause.Field, fmt.Sprintf("spec.authentication.rules[%d]", i), "%#v", cause) | ||
assert.Assert(t, cmp.Contains(cause.Message, `"ldap" method requires`)) | ||
} | ||
|
||
// These are valid. | ||
|
||
unstructured.RemoveNestedField(cluster.Object, "spec", "authentication") | ||
require.UnmarshalIntoField(t, cluster, `{ | ||
rules: [ | ||
{ connection: hostssl, method: ldap, options: { ldapbasedn: any } }, | ||
{ connection: hostssl, method: ldap, options: { ldapprefix: any } }, | ||
{ connection: hostssl, method: ldap, options: { ldapsuffix: any } }, | ||
], | ||
}`, "spec", "authentication") | ||
assert.NilError(t, cc.Create(ctx, cluster, client.DryRunAll)) | ||
}) | ||
|
||
t.Run("Mixed", func(t *testing.T) { | ||
// Some options cannot be combined with others. | ||
|
||
cluster := base.DeepCopy() | ||
require.UnmarshalIntoField(t, cluster, `{ | ||
rules: [ | ||
{ connection: hostssl, method: ldap, options: { ldapbinddn: any, ldapprefix: other } }, | ||
{ connection: hostssl, method: ldap, options: { ldapbasedn: any, ldapsuffix: other } }, | ||
], | ||
}`, "spec", "authentication") | ||
|
||
err := cc.Create(ctx, cluster, client.DryRunAll) | ||
assert.Assert(t, apierrors.IsInvalid(err)) | ||
|
||
status := require.StatusError(t, err) | ||
assert.Assert(t, status.Details != nil) | ||
assert.Assert(t, cmp.Len(status.Details.Causes, 2)) | ||
|
||
for i, cause := range status.Details.Causes { | ||
assert.Equal(t, cause.Field, fmt.Sprintf("spec.authentication.rules[%d]", i), "%#v", cause) | ||
assert.Assert(t, cmp.Regexp(`cannot use .+? options with .+? options`, cause.Message)) | ||
} | ||
|
||
// These combinations are allowed. | ||
|
||
unstructured.RemoveNestedField(cluster.Object, "spec", "authentication") | ||
require.UnmarshalIntoField(t, cluster, `{ | ||
rules: [ | ||
{ connection: hostssl, method: ldap, options: { ldapprefix: one, ldapsuffix: two } }, | ||
{ connection: hostssl, method: ldap, options: { ldapbasedn: one, ldapbinddn: two } }, | ||
{ connection: hostssl, method: ldap, options: { | ||
ldapbasedn: one, ldapsearchattribute: two, ldapsearchfilter: three, | ||
} }, | ||
], | ||
}`, "spec", "authentication") | ||
assert.NilError(t, cc.Create(ctx, cluster, client.DryRunAll)) | ||
}) | ||
}) | ||
|
||
t.Run("RADIUS", func(t *testing.T) { | ||
t.Run("Required", func(t *testing.T) { | ||
cluster := base.DeepCopy() | ||
require.UnmarshalIntoField(t, cluster, `{ | ||
rules: [ | ||
{ connection: hostssl, method: radius }, | ||
{ connection: hostssl, method: radius, options: {} }, | ||
{ connection: hostssl, method: radius, options: { radiusidentifiers: any } }, | ||
{ connection: hostssl, method: radius, options: { radiusservers: any } }, | ||
{ connection: hostssl, method: radius, options: { radiussecrets: any } }, | ||
], | ||
}`, "spec", "authentication") | ||
|
||
err := cc.Create(ctx, cluster, client.DryRunAll) | ||
assert.Assert(t, apierrors.IsInvalid(err)) | ||
|
||
status := require.StatusError(t, err) | ||
assert.Assert(t, status.Details != nil) | ||
assert.Assert(t, cmp.Len(status.Details.Causes, 5)) | ||
|
||
for i, cause := range status.Details.Causes { | ||
assert.Equal(t, cause.Field, fmt.Sprintf("spec.authentication.rules[%d]", i), "%#v", cause) | ||
assert.Assert(t, cmp.Contains(cause.Message, `"radius" method requires`)) | ||
} | ||
|
||
// These are valid. | ||
|
||
unstructured.RemoveNestedField(cluster.Object, "spec", "authentication") | ||
require.UnmarshalIntoField(t, cluster, `{ | ||
rules: [ | ||
{ connection: hostssl, method: radius, options: { radiusservers: one, radiussecrets: two } }, | ||
{ connection: hostssl, method: radius, options: { | ||
radiusservers: one, radiussecrets: two, radiusports: three, | ||
} }, | ||
], | ||
}`, "spec", "authentication") | ||
assert.NilError(t, cc.Create(ctx, cluster, client.DryRunAll)) | ||
}) | ||
}) | ||
} |
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
!has(self.option)
portion was being tested twice when usingv1beta1
andv1
structs.The unstructured value emitted
options: {}
which showed thatexists_one
is the wrong behavior:options: {}
the field "has options" and has zero of ldapprefix, ldapbasedn, etc.