From 0f854133187690ed9b380d2f3ca2f124c5a95d22 Mon Sep 17 00:00:00 2001 From: Artiom Diomin Date: Thu, 9 Apr 2026 10:58:59 +0300 Subject: [PATCH] Add dnsServiceIP override for cluster DNS settings Support `clusterNetwork.dnsServiceIP` in API versions and conversions, validate the value, and use it for kubelet/OSM when NodeLocalDNS is disabled. Fall back to the default 10th service-subnet IP when unset. Signed-off-by: Artiom Diomin --- .../deployment-controller.yaml | 4 +- docs/api_reference/v1beta2.en.md | 3 +- docs/api_reference/v1beta3.en.md | 3 +- pkg/addons/applier.go | 14 ++++++- pkg/apis/kubeone/helpers.go | 30 +++++++++++++ pkg/apis/kubeone/types.go | 5 +++ pkg/apis/kubeone/v1beta2/types.go | 5 +++ .../v1beta2/zz_generated.conversion.go | 2 + pkg/apis/kubeone/v1beta3/types.go | 5 +++ .../v1beta3/zz_generated.conversion.go | 2 + pkg/apis/kubeone/validation/validation.go | 13 ++++++ .../kubeone/validation/validation_test.go | 42 +++++++++++++++++++ pkg/templates/kubernetesconfigs/kubelet.go | 2 + 13 files changed, 123 insertions(+), 7 deletions(-) diff --git a/addons/operating-system-manager/deployment-controller.yaml b/addons/operating-system-manager/deployment-controller.yaml index c9859bc7b..9897954d2 100644 --- a/addons/operating-system-manager/deployment-controller.yaml +++ b/addons/operating-system-manager/deployment-controller.yaml @@ -46,9 +46,7 @@ spec: - -log-format=json # json or console - -health-probe-address=0.0.0.0:8085 - -metrics-address=0.0.0.0:8080 - {{ if .Config.Features.NodeLocalDNS.Deploy -}} - - -cluster-dns={{ .Resources.NodeLocalDNSVirtualIP }} - {{ end -}} + - -cluster-dns={{ .ClusterDNSIP }} - -namespace=kube-system - -container-runtime={{ .Config.ContainerRuntime }} - -pause-image={{ .InternalImages.Get "PauseImage" }} diff --git a/docs/api_reference/v1beta2.en.md b/docs/api_reference/v1beta2.en.md index 9e91f79b2..b60924ba7 100644 --- a/docs/api_reference/v1beta2.en.md +++ b/docs/api_reference/v1beta2.en.md @@ -1,6 +1,6 @@ +++ title = "v1beta2 API Reference" -date = 2026-04-03T16:57:31+03:00 +date = 2026-04-15T10:59:17+03:00 weight = 11 +++ ## v1beta2 @@ -242,6 +242,7 @@ ClusterNetworkConfig describes the cluster network | ipFamily | IPFamily allows specifying IP family of a cluster. Valid values are IPv4 \| IPv6 \| IPv4+IPv6 \| IPv6+IPv4. | IPFamily | false | | nodeCIDRMaskSizeIPv4 | NodeCIDRMaskSizeIPv4 is the mask size used to address the nodes within provided IPv4 Pods CIDR. It has to be larger than the provided IPv4 Pods CIDR. Defaults to 24. | *int | false | | nodeCIDRMaskSizeIPv6 | NodeCIDRMaskSizeIPv6 is the mask size used to address the nodes within provided IPv6 Pods CIDR. It has to be larger than the provided IPv6 Pods CIDR. Defaults to 64. | *int | false | +| dnsServiceIP | DNSServiceIP is an optional override for the cluster DNS service IP. If not set, it defaults to the 10th IP of the serviceSubnet (e.g. 10.96.0.10 for 10.96.0.0/12). When nodeLocalDNS is enabled, OSM always uses the NodeLocalDNS virtual IP regardless of this field. | string | false | [Back to Group](#v1beta2) diff --git a/docs/api_reference/v1beta3.en.md b/docs/api_reference/v1beta3.en.md index 647b798cf..75760968c 100644 --- a/docs/api_reference/v1beta3.en.md +++ b/docs/api_reference/v1beta3.en.md @@ -1,6 +1,6 @@ +++ title = "v1beta3 API Reference" -date = 2026-04-03T16:57:31+03:00 +date = 2026-04-15T10:59:17+03:00 weight = 11 +++ ## v1beta3 @@ -255,6 +255,7 @@ ClusterNetworkConfig describes the cluster network | ipFamily | IPFamily allows specifying IP family of a cluster. Valid values are IPv4 \| IPv6 \| IPv4+IPv6 \| IPv6+IPv4. | IPFamily | false | | nodeCIDRMaskSizeIPv4 | NodeCIDRMaskSizeIPv4 is the mask size used to address the nodes within provided IPv4 Pods CIDR. It has to be larger than the provided IPv4 Pods CIDR. Defaults to 24. | *int | false | | nodeCIDRMaskSizeIPv6 | NodeCIDRMaskSizeIPv6 is the mask size used to address the nodes within provided IPv6 Pods CIDR. It has to be larger than the provided IPv6 Pods CIDR. Defaults to 64. | *int | false | +| dnsServiceIP | DNSServiceIP is an optional override for the cluster DNS service IP. If not set, it defaults to the 10th IP of the serviceSubnet (e.g. 10.96.0.10 for 10.96.0.0/12). When nodeLocalDNS is enabled, OSM always uses the NodeLocalDNS virtual IP regardless of this field. | string | false | [Back to Group](#v1beta3) diff --git a/pkg/addons/applier.go b/pkg/addons/applier.go index f63d1a9b6..9ba5bfa1b 100644 --- a/pkg/addons/applier.go +++ b/pkg/addons/applier.go @@ -90,6 +90,7 @@ type templateData struct { InternalImages *internalImages Resources map[string]string Params map[string]string + ClusterDNSIP string } type registryCredentialsContainer struct { @@ -185,6 +186,14 @@ func newAddonsApplier(s *state.State) (*applier, error) { } } + clusterDNSIP := resources.NodeLocalDNSVirtualIP + if !s.Cluster.Features.NodeLocalDNS.Deploy { + clusterDNSIP, err = s.Cluster.ClusterNetwork.EffectiveDNSServiceIP() + if err != nil { + return nil, fail.Runtime(err, "computing cluster DNS service IP") + } + } + data := templateData{ Config: s.Cluster, Certificates: map[string]string{ @@ -211,8 +220,9 @@ func newAddonsApplier(s *state.State) (*applier, error) { pauseImage: s.PauseImage, resolver: s.Images.Get, }, - Resources: resources.All(), - Params: map[string]string{}, + Resources: resources.All(), + Params: map[string]string{}, + ClusterDNSIP: clusterDNSIP, } if !s.LiveCluster.IsProvisioned() { diff --git a/pkg/apis/kubeone/helpers.go b/pkg/apis/kubeone/helpers.go index 4be79a2eb..12fbba55f 100644 --- a/pkg/apis/kubeone/helpers.go +++ b/pkg/apis/kubeone/helpers.go @@ -18,8 +18,10 @@ package kubeone import ( "bytes" + "encoding/binary" "fmt" "math/rand" + "net" "net/url" "path/filepath" "sort" @@ -547,3 +549,31 @@ func (v VersionConfig) KubernetesMajorMinorVersion() string { return fmt.Sprintf("v%d.%d", kubeSemVer.Major(), kubeSemVer.Minor()) } + +// EffectiveDNSServiceIP returns the cluster DNS service IP to use for kubelet and OSM. +// If DNSServiceIP is explicitly set, it is returned as-is. +// Otherwise the 10th IP in the ServiceSubnet is computed (e.g. 10.96.0.10 for 10.96.0.0/12), +// matching the CoreDNS service IP assigned by kubeadm. +func (c ClusterNetworkConfig) EffectiveDNSServiceIP() (string, error) { + if c.DNSServiceIP != "" { + return c.DNSServiceIP, nil + } + + return nthIPInSubnet(c.ServiceSubnet, 10) +} + +// nthIPInSubnet returns the n-th IP address within the given CIDR subnet. +func nthIPInSubnet(cidr string, n uint32) (string, error) { + _, network, err := net.ParseCIDR(cidr) + if err != nil { + return "", fmt.Errorf("parsing subnet %q: %w", cidr, err) + } + + ip := network.IP.To4() + addr := binary.BigEndian.Uint32(ip) + addr += n + result := make(net.IP, 4) + binary.BigEndian.PutUint32(result, addr) + + return result.String(), nil +} diff --git a/pkg/apis/kubeone/types.go b/pkg/apis/kubeone/types.go index 157714676..1194bfe8a 100644 --- a/pkg/apis/kubeone/types.go +++ b/pkg/apis/kubeone/types.go @@ -675,6 +675,11 @@ type ClusterNetworkConfig struct { // NodeCIDRMaskSizeIPv6 is the mask size used to address the nodes within provided IPv6 Pods CIDR. It has to be larger than the provided IPv6 Pods CIDR. Defaults to 64. NodeCIDRMaskSizeIPv6 *int `json:"nodeCIDRMaskSizeIPv6,omitempty"` + + // DNSServiceIP is an optional override for the cluster DNS service IP. + // If not set, it defaults to the 10th IP of the serviceSubnet (e.g. 10.96.0.10 for 10.96.0.0/12). + // When nodeLocalDNS is enabled, OSM always uses the NodeLocalDNS virtual IP regardless of this field. + DNSServiceIP string `json:"dnsServiceIP,omitempty"` } // IPFamily allows specifying IP family of a cluster. diff --git a/pkg/apis/kubeone/v1beta2/types.go b/pkg/apis/kubeone/v1beta2/types.go index 5ed36e254..060287ddd 100644 --- a/pkg/apis/kubeone/v1beta2/types.go +++ b/pkg/apis/kubeone/v1beta2/types.go @@ -680,6 +680,11 @@ type ClusterNetworkConfig struct { // NodeCIDRMaskSizeIPv6 is the mask size used to address the nodes within provided IPv6 Pods CIDR. It has to be larger than the provided IPv6 Pods CIDR. Defaults to 64. NodeCIDRMaskSizeIPv6 *int `json:"nodeCIDRMaskSizeIPv6,omitempty"` + + // DNSServiceIP is an optional override for the cluster DNS service IP. + // If not set, it defaults to the 10th IP of the serviceSubnet (e.g. 10.96.0.10 for 10.96.0.0/12). + // When nodeLocalDNS is enabled, OSM always uses the NodeLocalDNS virtual IP regardless of this field. + DNSServiceIP string `json:"dnsServiceIP,omitempty"` } // IPFamily allows specifying IP family of a cluster. diff --git a/pkg/apis/kubeone/v1beta2/zz_generated.conversion.go b/pkg/apis/kubeone/v1beta2/zz_generated.conversion.go index 55805caaf..9ea1fad0a 100644 --- a/pkg/apis/kubeone/v1beta2/zz_generated.conversion.go +++ b/pkg/apis/kubeone/v1beta2/zz_generated.conversion.go @@ -1121,6 +1121,7 @@ func autoConvert_v1beta2_ClusterNetworkConfig_To_kubeone_ClusterNetworkConfig(in out.IPFamily = kubeone.IPFamily(in.IPFamily) out.NodeCIDRMaskSizeIPv4 = (*int)(unsafe.Pointer(in.NodeCIDRMaskSizeIPv4)) out.NodeCIDRMaskSizeIPv6 = (*int)(unsafe.Pointer(in.NodeCIDRMaskSizeIPv6)) + out.DNSServiceIP = in.DNSServiceIP return nil } @@ -1149,6 +1150,7 @@ func autoConvert_kubeone_ClusterNetworkConfig_To_v1beta2_ClusterNetworkConfig(in out.IPFamily = IPFamily(in.IPFamily) out.NodeCIDRMaskSizeIPv4 = (*int)(unsafe.Pointer(in.NodeCIDRMaskSizeIPv4)) out.NodeCIDRMaskSizeIPv6 = (*int)(unsafe.Pointer(in.NodeCIDRMaskSizeIPv6)) + out.DNSServiceIP = in.DNSServiceIP return nil } diff --git a/pkg/apis/kubeone/v1beta3/types.go b/pkg/apis/kubeone/v1beta3/types.go index aaa2ad709..9cc6b2b4b 100644 --- a/pkg/apis/kubeone/v1beta3/types.go +++ b/pkg/apis/kubeone/v1beta3/types.go @@ -672,6 +672,11 @@ type ClusterNetworkConfig struct { // NodeCIDRMaskSizeIPv6 is the mask size used to address the nodes within provided IPv6 Pods CIDR. It has to be larger than the provided IPv6 Pods CIDR. Defaults to 64. NodeCIDRMaskSizeIPv6 *int `json:"nodeCIDRMaskSizeIPv6,omitempty"` + + // DNSServiceIP is an optional override for the cluster DNS service IP. + // If not set, it defaults to the 10th IP of the serviceSubnet (e.g. 10.96.0.10 for 10.96.0.0/12). + // When nodeLocalDNS is enabled, OSM always uses the NodeLocalDNS virtual IP regardless of this field. + DNSServiceIP string `json:"dnsServiceIP,omitempty"` } // IPFamily allows specifying IP family of a cluster. diff --git a/pkg/apis/kubeone/v1beta3/zz_generated.conversion.go b/pkg/apis/kubeone/v1beta3/zz_generated.conversion.go index 3bb300fa4..23ae7c239 100644 --- a/pkg/apis/kubeone/v1beta3/zz_generated.conversion.go +++ b/pkg/apis/kubeone/v1beta3/zz_generated.conversion.go @@ -1176,6 +1176,7 @@ func autoConvert_v1beta3_ClusterNetworkConfig_To_kubeone_ClusterNetworkConfig(in out.IPFamily = kubeone.IPFamily(in.IPFamily) out.NodeCIDRMaskSizeIPv4 = (*int)(unsafe.Pointer(in.NodeCIDRMaskSizeIPv4)) out.NodeCIDRMaskSizeIPv6 = (*int)(unsafe.Pointer(in.NodeCIDRMaskSizeIPv6)) + out.DNSServiceIP = in.DNSServiceIP return nil } @@ -1204,6 +1205,7 @@ func autoConvert_kubeone_ClusterNetworkConfig_To_v1beta3_ClusterNetworkConfig(in out.IPFamily = IPFamily(in.IPFamily) out.NodeCIDRMaskSizeIPv4 = (*int)(unsafe.Pointer(in.NodeCIDRMaskSizeIPv4)) out.NodeCIDRMaskSizeIPv6 = (*int)(unsafe.Pointer(in.NodeCIDRMaskSizeIPv6)) + out.DNSServiceIP = in.DNSServiceIP return nil } diff --git a/pkg/apis/kubeone/validation/validation.go b/pkg/apis/kubeone/validation/validation.go index d816692db..a1b626944 100644 --- a/pkg/apis/kubeone/validation/validation.go +++ b/pkg/apis/kubeone/validation/validation.go @@ -431,6 +431,19 @@ func ValidateClusterNetworkConfig(c kubeoneapi.ClusterNetworkConfig, prov kubeon allErrs = append(allErrs, ValidateKubeProxy(c.KubeProxy, fldPath.Child("kubeProxy"))...) } + if c.DNSServiceIP != "" { + ip := net.ParseIP(c.DNSServiceIP) + if ip == nil { + allErrs = append(allErrs, field.Invalid(fldPath.Child("dnsServiceIP"), c.DNSServiceIP, "must be a valid IP address")) + } else if c.ServiceSubnet != "" { + _, serviceSubnet, err := net.ParseCIDR(c.ServiceSubnet) + if err == nil && !serviceSubnet.Contains(ip) { + allErrs = append(allErrs, field.Invalid(fldPath.Child("dnsServiceIP"), c.DNSServiceIP, + fmt.Sprintf("must be within the service subnet %q", c.ServiceSubnet))) + } + } + } + return allErrs } diff --git a/pkg/apis/kubeone/validation/validation_test.go b/pkg/apis/kubeone/validation/validation_test.go index 9e2e44382..47dee8341 100644 --- a/pkg/apis/kubeone/validation/validation_test.go +++ b/pkg/apis/kubeone/validation/validation_test.go @@ -1646,6 +1646,48 @@ func TestValidateClusterNetworkConfig(t *testing.T) { }, expectedError: true, }, + { + name: "valid dnsServiceIP within service subnet", + clusterNetworkConfig: kubeoneapi.ClusterNetworkConfig{ + PodSubnet: "192.168.1.0/16", + ServiceSubnet: "10.96.0.0/12", + IPFamily: kubeoneapi.IPFamilyIPv4, + NodeCIDRMaskSizeIPv4: new(24), + DNSServiceIP: "10.96.0.100", + }, + provider: kubeoneapi.CloudProviderSpec{ + None: &kubeoneapi.NoneSpec{}, + }, + expectedError: false, + }, + { + name: "invalid dnsServiceIP not within service subnet", + clusterNetworkConfig: kubeoneapi.ClusterNetworkConfig{ + PodSubnet: "192.168.1.0/16", + ServiceSubnet: "10.96.0.0/12", + IPFamily: kubeoneapi.IPFamilyIPv4, + NodeCIDRMaskSizeIPv4: new(24), + DNSServiceIP: "192.168.1.1", + }, + provider: kubeoneapi.CloudProviderSpec{ + None: &kubeoneapi.NoneSpec{}, + }, + expectedError: true, + }, + { + name: "invalid dnsServiceIP not a valid IP", + clusterNetworkConfig: kubeoneapi.ClusterNetworkConfig{ + PodSubnet: "192.168.1.0/16", + ServiceSubnet: "10.96.0.0/12", + IPFamily: kubeoneapi.IPFamilyIPv4, + NodeCIDRMaskSizeIPv4: new(24), + DNSServiceIP: "not-an-ip", + }, + provider: kubeoneapi.CloudProviderSpec{ + None: &kubeoneapi.NoneSpec{}, + }, + expectedError: true, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/pkg/templates/kubernetesconfigs/kubelet.go b/pkg/templates/kubernetesconfigs/kubelet.go index 82a3f6c06..46a6d9afa 100644 --- a/pkg/templates/kubernetesconfigs/kubelet.go +++ b/pkg/templates/kubernetesconfigs/kubelet.go @@ -61,6 +61,8 @@ func NewKubeletConfiguration(cluster *kubeoneapi.KubeOneCluster, featureGates ma if cluster.Features.NodeLocalDNS.Deploy { kubeletConfig.ClusterDNS = []string{resources.NodeLocalDNSVirtualIP} + } else if dnsip, err := cluster.ClusterNetwork.EffectiveDNSServiceIP(); err == nil && dnsip != "" { + kubeletConfig.ClusterDNS = []string{dnsip} } return dropFields(kubeletConfig,