Skip to content
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

IPV6 APIVIP Handling #589

Closed
wants to merge 10 commits into from
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
2 changes: 2 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

## General

* Added support for IPv6 addresses in the Kubernetes 'apiVIP' field

## API

### Image Definition Changes
Expand Down
2 changes: 1 addition & 1 deletion docs/building-images.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ kubernetes:
* `network` - Required for multi-node clusters, optional for single-node clusters; Defines the network configuration
for bootstrapping a cluster.
* `apiVIP` - Required for multi-node clusters, optional for single-node clusters; Specifies the IP address which
will serve as the cluster LoadBalancer, backed by MetalLB.
will serve as the cluster LoadBalancer, backed by MetalLB. Supports IPv4 and IPv6 addresses.
* `apiHost` - Optional; Specifies the domain address for accessing the cluster.
* `nodes` - Required for multi-node clusters; Defines a list of all nodes that form the cluster.
* `hostname` - Required; Indicates the fully qualified domain name (FQDN) to identify the particular node on which
Expand Down
8 changes: 8 additions & 0 deletions pkg/combustion/kubernetes.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package combustion
import (
_ "embed"
"fmt"
"net/netip"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -316,11 +317,18 @@ func (c *Combustion) downloadRKE2Artefacts(ctx *image.Context, cluster *kubernet
}

func kubernetesVIPManifest(k *image.Kubernetes) (string, error) {
ip, err := netip.ParseAddr(k.Network.APIVIP)
if err != nil {
return "", fmt.Errorf("parsing kubernetes apiVIP address: %w", err)
}

manifest := struct {
APIAddress string
IsIPV4 bool
RKE2 bool
}{
APIAddress: k.Network.APIVIP,
IsIPV4: ip.Is4(),
RKE2: strings.Contains(k.Version, image.KubernetesDistroRKE2),
}

Expand Down
43 changes: 43 additions & 0 deletions pkg/combustion/kubernetes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -821,3 +821,46 @@ func TestConfigureKubernetes_SuccessfulRKE2ServerWithManifests(t *testing.T) {
assert.Contains(t, contents, "name: my-nginx")
assert.Contains(t, contents, "image: nginx:1.14.2")
}

func TestKubernetesVIPManifestValidIPV4(t *testing.T) {
Copy link

@mchiappero mchiappero Oct 24, 2024

Choose a reason for hiding this comment

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

Nit-picking, but I'd align these two names from "IPV4"/"IPV6" to IPv4 too, as the rest of the code/prints.

k8s := &image.Kubernetes{
Version: "v1.30.3+rke2r1",
Network: image.Network{
APIVIP: "192.168.1.1",
},
}

manifest, err := kubernetesVIPManifest(k8s)
require.NoError(t, err)

assert.Contains(t, manifest, "- 192.168.1.1/32")
assert.Contains(t, manifest, "- name: rke2-api")
}

func TestKubernetesVIPManifestValidIPV6(t *testing.T) {
k8s := &image.Kubernetes{
Version: "v1.30.3+k3s1",
Network: image.Network{
APIVIP: "fd12:3456:789a::21",
},
}

manifest, err := kubernetesVIPManifest(k8s)
require.NoError(t, err)

assert.Contains(t, manifest, "- fd12:3456:789a::21/128")
assert.Contains(t, manifest, "- name: k8s-api")
assert.NotContains(t, manifest, "rke2")
}

func TestKubernetesVIPManifestInvalidIP(t *testing.T) {
k8s := &image.Kubernetes{
Version: "v1.30.3+k3s1",
Network: image.Network{
APIVIP: "1111",
},
}

_, err := kubernetesVIPManifest(k8s)
require.ErrorContains(t, err, "parsing kubernetes apiVIP address: ParseAddr(\"1111\"): unable to parse IP")
}
6 changes: 5 additions & 1 deletion pkg/combustion/templates/k8s-vip.yaml.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ metadata:
namespace: metallb-system
spec:
addresses:
- {{ .APIAddress }}/32
{{- if .IsIPV4 }}
- {{ .APIAddress }}/32
{{- else }}
- {{ .APIAddress }}/128
{{- end }}
avoidBuggyIPs: true
serviceAllocation:
namespaces:
Expand Down
2 changes: 1 addition & 1 deletion pkg/combustion/templates/rke2-multi-node-installer.sh.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ systemctl enable kubernetes-resources-install.service
{{- end }}
fi

{{- if .apiHost }}
{{- if and .apiVIP .apiHost }}
atanasdinov marked this conversation as resolved.
Show resolved Hide resolved
echo "{{ .apiVIP }} {{ .apiHost }}" >> /etc/hosts
{{- end }}

atanasdinov marked this conversation as resolved.
Show resolved Hide resolved
Expand Down
49 changes: 43 additions & 6 deletions pkg/image/validation/kubernetes.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package validation
import (
"errors"
"fmt"
"net/netip"
"net/url"
"os"
"path/filepath"
Expand Down Expand Up @@ -32,6 +33,7 @@ func validateKubernetes(ctx *image.Context) []FailedValidation {
return failures
}

failures = append(failures, validateNetwork(&def.Kubernetes)...)
failures = append(failures, validateNodes(&def.Kubernetes)...)
failures = append(failures, validateManifestURLs(&def.Kubernetes)...)
failures = append(failures, validateHelm(&def.Kubernetes, combustion.HelmValuesPath(ctx), combustion.HelmCertsPath(ctx))...)
Expand All @@ -52,12 +54,6 @@ func validateNodes(k8s *image.Kubernetes) []FailedValidation {
return failures
}

if k8s.Network.APIVIP == "" {
failures = append(failures, FailedValidation{
UserMessage: "The 'apiVIP' field is required in the 'network' section when defining entries under 'nodes'.",
})
}

var nodeTypes []string
var nodeNames []string
var initialisers []*image.Node
Expand Down Expand Up @@ -117,6 +113,47 @@ func validateNodes(k8s *image.Kubernetes) []FailedValidation {
return failures
}

func validateNetwork(k8s *image.Kubernetes) []FailedValidation {
var failures []FailedValidation

if k8s.Network.APIVIP == "" {
if len(k8s.Nodes) > 1 {
failures = append(failures, FailedValidation{
UserMessage: "The 'apiVIP' field is required in the 'network' section for multi node clusters.",
})
}

return failures
}

parsedIP, err := netip.ParseAddr(k8s.Network.APIVIP)
if err != nil {
failures = append(failures, FailedValidation{
atanasdinov marked this conversation as resolved.
Show resolved Hide resolved
UserMessage: fmt.Sprintf("Invalid address value %q for field 'apiVIP'.", k8s.Network.APIVIP),
Error: err,
})

return failures
}

if !parsedIP.Is4() && !parsedIP.Is6() {
Copy link

@mchiappero mchiappero Oct 24, 2024

Choose a reason for hiding this comment

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

These two checks are redundant, when the address is parsed successfully it's either IPv4 or IPv6. And would also not pass the following IsGlobalUnicast() test anyway.

failures = append(failures, FailedValidation{
UserMessage: "Only IPv4 and IPv6 addresses are valid values for field 'apiVIP'.",
})

return failures
}

if !parsedIP.IsGlobalUnicast() {
msg := fmt.Sprintf("Invalid non-unicast cluster API address (%s) for field 'apiVIP'.", k8s.Network.APIVIP)
failures = append(failures, FailedValidation{
UserMessage: msg,
})
}

return failures
}

func validateManifestURLs(k8s *image.Kubernetes) []FailedValidation {
var failures []FailedValidation

Expand Down
131 changes: 111 additions & 20 deletions pkg/image/validation/kubernetes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import (

var validNetwork = image.Network{
APIHost: "host.com",
APIVIP: "127.0.0.1",
APIVIP: "192.168.1.1",
}

func TestValidateKubernetes(t *testing.T) {
Expand Down Expand Up @@ -78,7 +78,10 @@ func TestValidateKubernetes(t *testing.T) {
`failures all sections`: {
K8s: image.Kubernetes{
Version: "v1.30.3",
Network: validNetwork,
Network: image.Network{
APIHost: "host.com",
APIVIP: "127.0.0.1",
},
Nodes: []image.Node{
{
Type: image.KubernetesNodeTypeServer,
Expand Down Expand Up @@ -116,6 +119,7 @@ func TestValidateKubernetes(t *testing.T) {
"Helm chart 'name' field must be defined.",
"Helm repository 'name' field for \"apache-repo\" must match the 'repositoryName' field in at least one defined Helm chart.",
"Helm chart 'repositoryName' \"another-apache-repo\" for Helm chart \"\" does not match the name of any defined repository.",
"Invalid non-unicast cluster API address (127.0.0.1) for field 'apiVIP'.",
},
},
}
Expand Down Expand Up @@ -185,24 +189,6 @@ func TestValidateNodes(t *testing.T) {
Nodes: []image.Node{},
},
},
`with nodes - no network config`: {
K8s: image.Kubernetes{
Network: image.Network{},
Nodes: []image.Node{
{
Hostname: "host1",
Type: image.KubernetesNodeTypeServer,
},
{
Hostname: "host2",
Type: image.KubernetesNodeTypeAgent,
},
},
},
ExpectedFailedMessages: []string{
"The 'apiVIP' field is required in the 'network' section when defining entries under 'nodes'.",
},
},
`no hostname`: {
K8s: image.Kubernetes{
Network: validNetwork,
Expand Down Expand Up @@ -1079,3 +1065,108 @@ func TestValidateAdditionalArtifacts(t *testing.T) {
})
}
}

func TestValidateNetwork(t *testing.T) {
Copy link

@mchiappero mchiappero Oct 24, 2024

Choose a reason for hiding this comment

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

Hey Danial, glad to see that most of my code has been reused :)
I saw you re-wrote and simplified this function, which on the one hand is good, but ends having a much smaller coverage. The one I wrote was carefully calibrated around the IsGlobalUnicast() check (and internal sub-checks), so that the different cases of non-global unicast values were sufficiently tested. I think it make sense to include a couple of additional malformed IPs (e.g. 500.168.1.1), but I'd bring back the cases from my initial work as it's just a matter of copying and pasting.

tests := map[string]struct {
K8s image.Kubernetes
ExpectedFailedMessages []string
}{
`no network defined, no nodes defined`: {
K8s: image.Kubernetes{
Network: image.Network{},
},
},
`no network defined, nodes defined`: {
K8s: image.Kubernetes{
Network: image.Network{},
Nodes: []image.Node{
{
Hostname: "node1",
Type: "server",
Initialiser: false,
},
{
Hostname: "node2",
Type: "server",
Initialiser: false,
},
},
},
ExpectedFailedMessages: []string{
"The 'apiVIP' field is required in the 'network' section for multi node clusters.",
},
},
`valid ipv4`: {
K8s: image.Kubernetes{
Network: image.Network{
APIVIP: "192.168.1.1",
},
},
},
`valid ipv6`: {
K8s: image.Kubernetes{
Network: image.Network{
APIVIP: "fd12:3456:789a::21",
},
},
},
`invalid ipv4`: {
K8s: image.Kubernetes{
Network: image.Network{
APIVIP: "500.168.1.1",
},
},
ExpectedFailedMessages: []string{
"Invalid address value \"500.168.1.1\" for field 'apiVIP'.",
},
},
`non-unicast ipv4`: {
K8s: image.Kubernetes{
Network: image.Network{
APIVIP: "127.0.0.1",
},
},
ExpectedFailedMessages: []string{
"Invalid non-unicast cluster API address (127.0.0.1) for field 'apiVIP'.",
},
},
`invalid ipv6`: {
K8s: image.Kubernetes{
Network: image.Network{
APIVIP: "xxxx:3456:789a::21",
},
},
ExpectedFailedMessages: []string{
"Invalid address value \"xxxx:3456:789a::21\" for field 'apiVIP'.",
},
},
`non-unicast ipv6`: {
K8s: image.Kubernetes{
Network: image.Network{
APIVIP: "ff02::1",
},
},
ExpectedFailedMessages: []string{
"Invalid non-unicast cluster API address (ff02::1) for field 'apiVIP'.",
},
},
}

for name, test := range tests {
t.Run(name, func(t *testing.T) {
k := test.K8s
failures := validateNetwork(&k)
assert.Len(t, failures, len(test.ExpectedFailedMessages))

var foundMessages []string
for _, foundValidation := range failures {
foundMessages = append(foundMessages, foundValidation.UserMessage)
}

for _, expectedMessage := range test.ExpectedFailedMessages {
assert.Contains(t, foundMessages, expectedMessage)
}

})
}
}
Loading