Skip to content

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

Merged
merged 1 commit into from
Jul 28, 2025
Merged
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 @@ -136,8 +136,8 @@ spec:
"ldapsearchattribute", or "ldapsearchfilter" options with
"ldapprefix" or "ldapsuffix" options
rule: has(self.hba) || self.method != "ldap" || !has(self.options)
|| [["ldapprefix","ldapsuffix"], ["ldapbasedn","ldapbinddn","ldapbindpasswd","ldapsearchattribute","ldapsearchfilter"]].exists_one(a,
a.exists(k, k in self.options))
|| 2 > size([["ldapprefix","ldapsuffix"], ["ldapbasedn","ldapbinddn","ldapbindpasswd","ldapsearchattribute","ldapsearchfilter"]].filter(a,
Copy link
Member Author

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 using v1beta1 and v1 structs.

The unstructured value emitted options: {} which showed that exists_one is the wrong behavior:

  • With options: {} the field "has options" and has zero of ldapprefix, ldapbasedn, etc.
  • The correct behavior is "has options" and "at most one" of ldapprefix, ldapbasedn, etc.

a.exists(k, k in self.options)))
- message: the "radius" method requires "radiusservers" and
"radiussecrets" options
rule: has(self.hba) || self.method != "radius" || (has(self.options)
Expand Down Expand Up @@ -18767,8 +18767,8 @@ spec:
"ldapsearchattribute", or "ldapsearchfilter" options with
"ldapprefix" or "ldapsuffix" options
rule: has(self.hba) || self.method != "ldap" || !has(self.options)
|| [["ldapprefix","ldapsuffix"], ["ldapbasedn","ldapbinddn","ldapbindpasswd","ldapsearchattribute","ldapsearchfilter"]].exists_one(a,
a.exists(k, k in self.options))
|| 2 > size([["ldapprefix","ldapsuffix"], ["ldapbasedn","ldapbinddn","ldapbindpasswd","ldapsearchattribute","ldapsearchfilter"]].filter(a,
a.exists(k, k in self.options)))
- message: the "radius" method requires "radiusservers" and
"radiussecrets" options
rule: has(self.hba) || self.method != "radius" || (has(self.options)
Expand Down
22 changes: 22 additions & 0 deletions internal/testing/require/encoding.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"testing"

"gotest.tools/v3/assert"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"sigs.k8s.io/json"
"sigs.k8s.io/yaml"
)
Expand Down Expand Up @@ -37,3 +38,24 @@ func UnmarshalInto[Data ~string | ~[]byte, Destination *T, T any](
assert.NilError(t, err)
assert.NilError(t, errors.Join(strict...))
}

// UnmarshalIntoField parses input as YAML (or JSON) the same way as the Kubernetes API Server.
// The result goes into a (nested) field of output. It calls t.Fatal when something fails.
func UnmarshalIntoField[Data ~string | ~[]byte](
t testing.TB, output *unstructured.Unstructured, input Data, fields ...string,
) {
t.Helper()

if len(fields) == 0 {
t.Fatal("BUG: called without a destination")
}

if output.Object == nil {
output.Object = map[string]any{}
}

var value any
UnmarshalInto(t, &value, []byte(input))

assert.NilError(t, unstructured.SetNestedField(output.Object, value, fields...))
}
42 changes: 42 additions & 0 deletions internal/testing/require/encoding_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

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

📝 I found these links helpful

Also, for reference, a simple %v would yield

map[spec:map[nested:map[field:asdf]]]

}
})

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
Copy link
Contributor

Choose a reason for hiding this comment

The 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
Copy link
Member Author

Choose a reason for hiding this comment

The 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")
Copy link
Member Author

Choose a reason for hiding this comment

The 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
Copy link
Member Author

Choose a reason for hiding this comment

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

The options: {} case is what passed with v1beta1 and v1 structs but failed with unstructured. I suspect the empty object was silently being omitted, causing this case to match the one above it.

],
}`, "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))
})
})
}
Loading
Loading