From e0d242e4810445eb742787ece0f2483da2e0776c Mon Sep 17 00:00:00 2001 From: Ankit Mahajan Date: Mon, 6 Jul 2026 19:25:05 +0530 Subject: [PATCH] installer: fix NetworkDeviceSpec CRD incorrectly using format: ipv6 The NetworkDeviceSpec gateway, ipAddrs, and nameservers fields had both +kubebuilder:validation:Format=ipv4 and Format=ipv6 markers. Since controller-gen only keeps the last marker, the generated CRD schema used format: ipv6, causing valid IPv4 addresses to be rejected. Remove the format markers from these fields so both IPv4 and IPv6 addresses are accepted. Fixes: https://github.com/openshift/installer/issues/10377 Signed-off-by: Ankit Mahajan --- .../install.openshift.io_installconfigs.yaml | 16 +++- pkg/types/vsphere/platform.go | 11 +-- pkg/types/vsphere/validation/crd_test.go | 94 +++++++++++++++++++ 3 files changed, 112 insertions(+), 9 deletions(-) create mode 100644 pkg/types/vsphere/validation/crd_test.go diff --git a/data/data/install.openshift.io_installconfigs.yaml b/data/data/install.openshift.io_installconfigs.yaml index 9ffb030ec83..cac1061d304 100644 --- a/data/data/install.openshift.io_installconfigs.yaml +++ b/data/data/install.openshift.io_installconfigs.yaml @@ -8666,28 +8666,38 @@ spec: description: |- gateway is an IPv4 or IPv6 address which represents the subnet gateway, for example, 192.168.1.1. - format: ipv6 type: string + x-kubernetes-validations: + - message: gateway must be a valid IPv4 or IPv6 address + rule: self == '' || isIP(self) ipAddrs: description: |- ipAddrs is a list of one or more IPv4 and/or IPv6 addresses and CIDR to assign to this device, for example, 192.168.1.100/24. IP addresses provided via ipAddrs are intended to allow explicit assignment of a machine's IP address. example: 2001:DB8:0000:0000:244:17FF:FEB6:D37D/64 - format: ipv6 items: type: string + maxItems: 10 type: array + x-kubernetes-validations: + - message: each ipAddrs entry must be a valid CIDR (e.g. + 192.168.1.100/24) + rule: self.all(x, isCIDR(x)) nameservers: description: |- nameservers is a list of IPv4 and/or IPv6 addresses used as DNS nameservers, for example, 8.8.8.8. a nameserver is not provided by a fulfilled IPAddressClaim. If DHCP is not the source of IP addresses for this network device, nameservers should include a valid nameserver. example: 8.8.8.8 - format: ipv6 items: type: string + maxItems: 3 type: array + x-kubernetes-validations: + - message: each nameserver must be a valid IPv4 or IPv6 + address + rule: self.all(x, isIP(x)) required: - ipAddrs type: object diff --git a/pkg/types/vsphere/platform.go b/pkg/types/vsphere/platform.go index f3872746196..294104a62bf 100644 --- a/pkg/types/vsphere/platform.go +++ b/pkg/types/vsphere/platform.go @@ -331,15 +331,14 @@ type Host struct { type NetworkDeviceSpec struct { // gateway is an IPv4 or IPv6 address which represents the subnet gateway, // for example, 192.168.1.1. - // +kubebuilder:validation:Format=ipv4 - // +kubebuilder:validation:Format=ipv6 + // +kubebuilder:validation:XValidation:rule="self == '' || isIP(self)",message="gateway must be a valid IPv4 or IPv6 address" Gateway string `json:"gateway,omitempty"` // ipAddrs is a list of one or more IPv4 and/or IPv6 addresses and CIDR to assign to // this device, for example, 192.168.1.100/24. IP addresses provided via ipAddrs are // intended to allow explicit assignment of a machine's IP address. - // +kubebuilder:validation:Format=ipv4 - // +kubebuilder:validation:Format=ipv6 + // +kubebuilder:validation:XValidation:rule="self.all(x, isCIDR(x))",message="each ipAddrs entry must be a valid CIDR (e.g. 192.168.1.100/24)" + // +kubebuilder:validation:MaxItems=10 // +kubebuilder:example=`192.168.1.100/24` // +kubebuilder:example=`2001:DB8:0000:0000:244:17FF:FEB6:D37D/64` // +kubebuilder:validation:Required @@ -348,8 +347,8 @@ type NetworkDeviceSpec struct { // nameservers is a list of IPv4 and/or IPv6 addresses used as DNS nameservers, for example, // 8.8.8.8. a nameserver is not provided by a fulfilled IPAddressClaim. If DHCP is not the // source of IP addresses for this network device, nameservers should include a valid nameserver. - // +kubebuilder:validation:Format=ipv4 - // +kubebuilder:validation:Format=ipv6 + // +kubebuilder:validation:XValidation:rule="self.all(x, isIP(x))",message="each nameserver must be a valid IPv4 or IPv6 address" + // +kubebuilder:validation:MaxItems=3 // +kubebuilder:example=`8.8.8.8` Nameservers []string `json:"nameservers,omitempty"` } diff --git a/pkg/types/vsphere/validation/crd_test.go b/pkg/types/vsphere/validation/crd_test.go new file mode 100644 index 00000000000..53b95a63e5d --- /dev/null +++ b/pkg/types/vsphere/validation/crd_test.go @@ -0,0 +1,94 @@ +package validation + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "sigs.k8s.io/yaml" +) + +func loadCRD(t *testing.T) *apiextensionsv1.CustomResourceDefinition { + t.Helper() + data, err := os.ReadFile("../../../../data/data/install.openshift.io_installconfigs.yaml") + if err != nil { + t.Fatalf("failed to load CRD: %v", err) + } + var crd apiextensionsv1.CustomResourceDefinition + if err := yaml.Unmarshal(data, &crd); err != nil { + t.Fatalf("failed to unmarshal CRD: %v", err) + } + return &crd +} + +func networkDeviceSchema(t *testing.T, crd *apiextensionsv1.CustomResourceDefinition) map[string]apiextensionsv1.JSONSchemaProps { + t.Helper() + + schema := crd.Spec.Versions[0].Schema.OpenAPIV3Schema + platform, ok := schema.Properties["platform"] + if !ok { + t.Fatal("missing platform property in CRD schema") + } + vsphere, ok := platform.Properties["vsphere"] + if !ok { + t.Fatal("missing vsphere property in CRD schema") + } + hosts, ok := vsphere.Properties["hosts"] + if !ok { + t.Fatal("missing hosts property in CRD schema") + } + if hosts.Items == nil || hosts.Items.Schema == nil { + t.Fatal("hosts items schema is nil") + } + netDev, ok := hosts.Items.Schema.Properties["networkDevice"] + if !ok { + t.Fatal("missing networkDevice property in CRD schema") + } + return netDev.Properties +} + +func TestCRDNetworkDeviceSpec(t *testing.T) { + crd := loadCRD(t) + props := networkDeviceSchema(t, crd) + + t.Run("gateway", func(t *testing.T) { + gw, ok := props["gateway"] + if !assert.True(t, ok, "gateway property must exist") { + return + } + assert.Empty(t, gw.Format, "gateway must not have a format constraint") + if assert.Len(t, gw.XValidations, 1, "gateway must have exactly 1 CEL validation rule") { + assert.Equal(t, "self == '' || isIP(self)", gw.XValidations[0].Rule) + assert.NotEmpty(t, gw.XValidations[0].Message) + } + }) + + t.Run("ipAddrs", func(t *testing.T) { + ip, ok := props["ipAddrs"] + if !assert.True(t, ok, "ipAddrs property must exist") { + return + } + assert.Empty(t, ip.Format, "ipAddrs must not have a format constraint") + if assert.Len(t, ip.XValidations, 1, "ipAddrs must have exactly 1 CEL validation rule") { + assert.Equal(t, "self.all(x, isCIDR(x))", ip.XValidations[0].Rule) + } + if assert.NotNil(t, ip.MaxItems, "ipAddrs must have maxItems set") { + assert.Equal(t, int64(10), *ip.MaxItems) + } + }) + + t.Run("nameservers", func(t *testing.T) { + ns, ok := props["nameservers"] + if !assert.True(t, ok, "nameservers property must exist") { + return + } + assert.Empty(t, ns.Format, "nameservers must not have a format constraint") + if assert.Len(t, ns.XValidations, 1, "nameservers must have exactly 1 CEL validation rule") { + assert.Equal(t, "self.all(x, isIP(x))", ns.XValidations[0].Rule) + } + if assert.NotNil(t, ns.MaxItems, "nameservers must have maxItems set") { + assert.Equal(t, int64(3), *ns.MaxItems) + } + }) +}